Compare commits
9 Commits
7b6531c41a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aa85540e6 | |||
| 64ee8f8603 | |||
| 64770ddbcd | |||
| 370f93e1f7 | |||
| 5a7a080c61 | |||
| a3afa63717 | |||
| aa7ee3f19e | |||
| e1ed111c55 | |||
| 24544eb680 |
Generated
+25
@@ -39,6 +39,7 @@
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"axios": "^1.14.0",
|
||||
"axios-retry": "^4.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -3769,6 +3770,18 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios-retry": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz",
|
||||
"integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"is-retry-allowed": "^2.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"axios": "0.x || 1.x"
|
||||
}
|
||||
},
|
||||
"node_modules/bail": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||
@@ -5695,6 +5708,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-retry-allowed": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz",
|
||||
"integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"axios": "^1.14.0",
|
||||
"axios-retry": "^4.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,5 @@
|
||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { BrowserRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
@@ -17,6 +16,7 @@ import Projects from "./pages/Projects";
|
||||
import Technologies from "./pages/Technologies";
|
||||
import { QueryProvider } from "./provider/QueryProvider";
|
||||
import OverviewPage from "./components/admin/dashboards/OverviewPage";
|
||||
import { ThemeProvider } from "./provider/ThemeProvider";
|
||||
|
||||
function AnimatedRoutes() {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
// src/api/axiosInstance.ts
|
||||
|
||||
import axios from "axios";
|
||||
import axiosRetry from "axios-retry";
|
||||
|
||||
import { API_URL } from "./config";
|
||||
|
||||
import {
|
||||
requestInterceptor,
|
||||
responseSuccessInterceptor,
|
||||
responseErrorInterceptor,
|
||||
} from "./interceptors";
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 5000,
|
||||
baseURL: API_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
requestInterceptor
|
||||
);
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
responseSuccessInterceptor,
|
||||
responseErrorInterceptor
|
||||
);
|
||||
|
||||
axiosRetry(axiosInstance, {
|
||||
retries: 3,
|
||||
retryDelay: axiosRetry.exponentialDelay,
|
||||
|
||||
retryCondition: (error) =>
|
||||
axiosRetry.isNetworkOrIdempotentRequestError(
|
||||
error
|
||||
) ||
|
||||
error.response?.status === 429,
|
||||
});
|
||||
|
||||
export default axiosInstance;
|
||||
@@ -0,0 +1,9 @@
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
|
||||
if (!apiUrl) {
|
||||
throw new Error(
|
||||
"Missing VITE_API_URL environment variable"
|
||||
);
|
||||
}
|
||||
|
||||
export const API_URL = apiUrl;
|
||||
@@ -0,0 +1,38 @@
|
||||
// src/api/errors.ts
|
||||
|
||||
import axios from "axios";
|
||||
import { ApiError } from "./types";
|
||||
|
||||
export function normalizeApiError(error: unknown): ApiError {
|
||||
if (!axios.isAxiosError(error)) {
|
||||
return {
|
||||
status: 0,
|
||||
message: "Unexpected error occurred",
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === "ECONNABORTED") {
|
||||
return {
|
||||
status: 408,
|
||||
message: "Request timeout",
|
||||
};
|
||||
}
|
||||
|
||||
if (!error.response) {
|
||||
return {
|
||||
status: 0,
|
||||
message: "Network error",
|
||||
};
|
||||
}
|
||||
|
||||
const { status, data } = error.response;
|
||||
|
||||
return {
|
||||
status,
|
||||
message:
|
||||
data?.message ||
|
||||
data?.error ||
|
||||
"Something went wrong",
|
||||
code: data?.code,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getAccessToken } from "@/lib/auth";
|
||||
import { AxiosResponse, InternalAxiosRequestConfig } from "axios";
|
||||
import { normalizeApiError } from "./errors";
|
||||
|
||||
export function requestInterceptor(config: InternalAxiosRequestConfig) {
|
||||
const token = getAccessToken();
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export const responseSuccessInterceptor = (response: AxiosResponse) => response;
|
||||
|
||||
export function responseErrorInterceptor(error: unknown) {
|
||||
const normalizedError = normalizeApiError(error);
|
||||
|
||||
return Promise.reject(normalizedError);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface ApiError {
|
||||
status: number;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Moon, PanelLeftClose, PanelLeftOpen, Sun } from "lucide-react";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
useDeleteProject,
|
||||
useProjects,
|
||||
useUpdateProject,
|
||||
} from "@/hooks/queires/useProjects";
|
||||
} from "@/hooks/queries/useProjects";
|
||||
import { useState } from "react";
|
||||
|
||||
import { T_projects } from "@/types/projects.type";
|
||||
@@ -13,7 +13,7 @@ export default function ManageProject() {
|
||||
const { data: projectsData, isLoading } = useProjects();
|
||||
const updateMutation = useUpdateProject();
|
||||
const deleteMutation = useDeleteProject();
|
||||
console.log("project data", projectsData?.data.data.result)
|
||||
|
||||
const [selectedProject, setSelectedProject] = useState<T_projects | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBlogs } from "@/hooks/queires/useBlogs";
|
||||
import { useBlogs } from "@/hooks/queries/useBlogs";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Calendar, Clock, User } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
@@ -34,7 +34,6 @@ export default function BlogSection() {
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/5 to-transparent" />
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logo from "@/assets/logo.webp";
|
||||
import { motion } from "framer-motion";
|
||||
import { Facebook, Github, Gitlab, Instagram, Linkedin, Mail, Twitter } from "lucide-react";
|
||||
import { Facebook, Gitlab, Instagram, Linkedin } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const quickLinks = [
|
||||
@@ -24,7 +24,11 @@ const socials = [
|
||||
href: "https://www.linkedin.com/company/techzaa",
|
||||
label: "LinkedIn",
|
||||
},
|
||||
{ icon: Facebook, href: "https://www.facebook.com/techzaaalpha", label: "Facebook" },
|
||||
{
|
||||
icon: Facebook,
|
||||
href: "https://www.facebook.com/techzaaalpha",
|
||||
label: "Facebook",
|
||||
},
|
||||
{ icon: Gitlab, href: "https://gitlab.techzaa.tech", label: "Gitlab" },
|
||||
{ icon: Instagram, href: "#", label: "Instagram" },
|
||||
];
|
||||
@@ -32,16 +36,16 @@ const socials = [
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="relative overflow-hidden bg-secondary/50 py-20">
|
||||
{/* Animated gradient divider */}
|
||||
|
||||
<div className="absolute top-0 left-0 right-0 h-1 gradient-primary-animated" />
|
||||
|
||||
{/* Background decoration */}
|
||||
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-primary/10 rounded-full blur-[150px]" />
|
||||
<div className="absolute top-0 right-0 w-72 h-72 bg-neon-purple/10 rounded-full blur-[120px]" />
|
||||
</div>
|
||||
|
||||
{/* Circuit pattern background */}
|
||||
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<svg
|
||||
className="w-full h-full"
|
||||
@@ -70,7 +74,7 @@ export default function Footer() {
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-12 mb-16">
|
||||
{/* Brand */}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -84,11 +88,9 @@ export default function Footer() {
|
||||
Transforming ideas into powerful digital solutions. We build the
|
||||
future with technology.
|
||||
</p>
|
||||
|
||||
|
||||
</motion.div>
|
||||
|
||||
{/* Quick Links */}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -111,7 +113,7 @@ export default function Footer() {
|
||||
</ul>
|
||||
</motion.div>
|
||||
|
||||
{/* Services */}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
@@ -157,7 +159,7 @@ export default function Footer() {
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Rangpur, Bangladesh
|
||||
Rangpur, Bangladesh
|
||||
<br />
|
||||
techzaa.alpha@gmail.com
|
||||
<br />
|
||||
@@ -179,13 +181,22 @@ export default function Footer() {
|
||||
© {new Date().getFullYear()} TechZaa. All rights reserved.
|
||||
</p>
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||
<Link to="/privacy" className="hover:text-primary transition-colors">
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link to="/privacy" className="hover:text-primary transition-colors">
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link to="/privacy" className="hover:text-primary transition-colors">
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
Cookie Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logo from "@/assets/logo.webp";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Menu, Moon, Sun, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -28,7 +28,6 @@ export default function Navbar() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { mode, accent, setAccent, toggleMode } = useTheme();
|
||||
|
||||
// Handle scroll effect
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
@@ -37,7 +36,6 @@ export default function Navbar() {
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
// Close mobile menu on resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
@@ -59,14 +57,11 @@ export default function Navbar() {
|
||||
}`}
|
||||
>
|
||||
<div className="container mx-auto px-4 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<img src={logo} alt="TechZaa" className="h-16 w-auto" />
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden lg:flex items-center gap-8">
|
||||
{/* Nav Links */}
|
||||
<ul className="flex items-center gap-6">
|
||||
{navItems.map((item, index) => (
|
||||
<motion.li
|
||||
@@ -86,9 +81,7 @@ export default function Navbar() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Theme Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Accent Color Picker */}
|
||||
<div className="flex items-center gap-1.5 rounded-full glass p-1">
|
||||
{accentColors.map((color) => (
|
||||
<motion.button
|
||||
@@ -106,7 +99,6 @@ export default function Navbar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Dark/Light Mode Toggle */}
|
||||
<motion.button
|
||||
onClick={toggleMode}
|
||||
className="p-1.5 rounded-full neon-glow transition-all"
|
||||
@@ -140,7 +132,6 @@ export default function Navbar() {
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
@@ -155,7 +146,6 @@ export default function Navbar() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<motion.button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="lg:hidden p-2 rounded-lg glass"
|
||||
@@ -187,7 +177,6 @@ export default function Navbar() {
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<AnimatePresence>
|
||||
{isMobileMenuOpen && (
|
||||
<motion.div
|
||||
@@ -198,7 +187,6 @@ export default function Navbar() {
|
||||
className="lg:hidden glass-strong mt-2 mx-4 rounded-2xl overflow-hidden"
|
||||
>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Nav Links */}
|
||||
<ul className="space-y-4">
|
||||
{navItems.map((item, index) => (
|
||||
<motion.li
|
||||
@@ -218,9 +206,7 @@ export default function Navbar() {
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Theme Controls */}
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
{/* Accent Colors */}
|
||||
<div className="flex items-center gap-2">
|
||||
{accentColors.map((color) => (
|
||||
<button
|
||||
@@ -236,7 +222,6 @@ export default function Navbar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className="p-2 rounded-full glass"
|
||||
@@ -250,7 +235,6 @@ export default function Navbar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsMobileMenuOpen(false);
|
||||
@@ -266,7 +250,6 @@ export default function Navbar() {
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
|
||||
{/* Contact Modal */}
|
||||
<ContactModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProjects } from "@/hooks/queires/useProjects";
|
||||
import { useProjects } from "@/hooks/queries/useProjects";
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
@@ -7,21 +7,14 @@ import { Link } from "react-router-dom";
|
||||
import PremiumBadge from "../shared/PremiumBadge";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
|
||||
const gradientColors = [
|
||||
"from-neon-blue/90",
|
||||
"from-neon-purple/90",
|
||||
"from-neon-green/90",
|
||||
"from-neon-pink/90",
|
||||
];
|
||||
|
||||
export default function ProjectsSection() {
|
||||
const { data: projectsData } = useProjects({
|
||||
fields: "category, title, image, liveUrl",
|
||||
limit: 6,
|
||||
});
|
||||
console.log(projectsData?.data.data);
|
||||
|
||||
const projects = projectsData?.data.data || [];
|
||||
console.log("project from homepage", projects);
|
||||
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const isInView = useInView(ref, { once: true, margin: "-100px" });
|
||||
|
||||
@@ -31,7 +24,6 @@ export default function ProjectsSection() {
|
||||
ref={ref}
|
||||
className="py-24 relative overflow-hidden bg-secondary/30"
|
||||
>
|
||||
{/* Background Pattern */}
|
||||
<div className="absolute inset-0 opacity-5">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
@@ -43,7 +35,6 @@ export default function ProjectsSection() {
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
@@ -62,7 +53,6 @@ export default function ProjectsSection() {
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8 mb-16">
|
||||
{projects.map((project, index) => {
|
||||
return (
|
||||
@@ -76,7 +66,6 @@ export default function ProjectsSection() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* See All Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { Github, Linkedin, Twitter } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { Autoplay, Pagination } from "swiper/modules";
|
||||
import { Swiper, SwiperSlide } from "swiper/react";
|
||||
|
||||
import { useTeam } from "@/hooks/queires/useTeam";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTeam } from "@/hooks/queries/useTeam";
|
||||
import PremiumBadge from "../shared/PremiumBadge";
|
||||
|
||||
export default function TeamSection() {
|
||||
@@ -19,30 +17,30 @@ export default function TeamSection() {
|
||||
|
||||
return (
|
||||
<section id="team" ref={ref} className="pt-20 1relative overflow-hidden">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-0 right-1/4 w-72 h-72 bg-neon-purple/10 rounded-full blur-[120px]" />
|
||||
<div className="absolute bottom-0 left-1/4 w-64 h-64 bg-primary/10 rounded-full blur-xl" />
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 relative z-10">
|
||||
{/* Section Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<PremiumBadge text="Our experts"/>
|
||||
<PremiumBadge text="Our experts" />
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4">
|
||||
Meet Our <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent-foreground">Team</span>
|
||||
Meet Our{" "}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-accent-foreground">
|
||||
Team
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto text-lg">
|
||||
A passionate team of innovators, designers, and developers.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Team Slider Container */}
|
||||
<div className="w-full">
|
||||
<Swiper
|
||||
direction={"horizontal"}
|
||||
@@ -84,7 +82,6 @@ export default function TeamSection() {
|
||||
className="absolute inset-0 w-full h-full object-cover object-top transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold mb-1 group-hover:text-primary transition-colors">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useReviews } from "@/hooks/queires/useReviews";
|
||||
import { useReviews } from "@/hooks/queries/useReviews";
|
||||
import Autoplay from "embla-carousel-autoplay";
|
||||
import useEmblaCarousel from "embla-carousel-react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
})}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };
|
||||
@@ -1,63 +1,4 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { ThemeContextType } from "@/types/theme.interface";
|
||||
import { createContext } from "react";
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
type AccentTheme = 'blue' | 'purple' | 'green';
|
||||
|
||||
interface ThemeContextType {
|
||||
mode: ThemeMode;
|
||||
accent: AccentTheme;
|
||||
setMode: (mode: ThemeMode) => void;
|
||||
setAccent: (accent: AccentTheme) => void;
|
||||
toggleMode: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setModeState] = useState<ThemeMode>('dark');
|
||||
const [accent, setAccentState] = useState<AccentTheme>('blue');
|
||||
|
||||
// Initialize theme from localStorage
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem('techzaa-mode') as ThemeMode;
|
||||
const savedAccent = localStorage.getItem('techzaa-accent') as AccentTheme;
|
||||
|
||||
if (savedMode) setModeState(savedMode);
|
||||
if (savedAccent) setAccentState(savedAccent);
|
||||
}, []);
|
||||
|
||||
// Apply theme classes to document
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
// Apply dark/light mode
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(mode);
|
||||
|
||||
// Apply accent theme
|
||||
root.classList.remove('theme-blue', 'theme-purple', 'theme-green');
|
||||
root.classList.add(`theme-${accent}`);
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('techzaa-mode', mode);
|
||||
localStorage.setItem('techzaa-accent', accent);
|
||||
}, [mode, accent]);
|
||||
|
||||
const setMode = (newMode: ThemeMode) => setModeState(newMode);
|
||||
const setAccent = (newAccent: AccentTheme) => setAccentState(newAccent);
|
||||
const toggleMode = () => setModeState(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ mode, accent, setMode, setAccent, toggleMode }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
export const ThemeContext = createContext<ThemeContextType | null>(null);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ThemeContext } from "@/contexts/ThemeContext";
|
||||
import { ThemeContextType } from "@/types/theme.interface";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useTheme(): ThemeContextType {
|
||||
const context = useContext(ThemeContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useTheme must be used within a ThemeProvider"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function getAccessToken() {
|
||||
return localStorage.getItem("accessToken");
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Calendar, Clock, Search, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useBlogs } from "./../hooks/queires/useBlogs";
|
||||
import { useBlogs } from "../hooks/queries/useBlogs";
|
||||
|
||||
const categories = [
|
||||
"All",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import PageTransition from "@/components/home/PageTransition";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getRelatedPosts } from "@/data/blogData";
|
||||
import { useBlogById } from "@/hooks/queires/useBlogs";
|
||||
import { useBlogById } from "@/hooks/queries/useBlogs";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -16,11 +16,9 @@ import ReactMarkdown from "react-markdown";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function BlogArticle() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data } = useBlogById(id);
|
||||
|
||||
const post = data?.data.data;
|
||||
const navigate = useNavigate();
|
||||
// const post = getPostBySlug(id || '');
|
||||
@@ -337,24 +335,24 @@ export default function BlogArticle() {
|
||||
<p className="text-muted-foreground mb-4">{post.author.bio}</p>
|
||||
<div className="flex gap-3 justify-center md:justify-start">
|
||||
{post.author.twitter && (
|
||||
<Link
|
||||
to={`https://twitter.com/${post.author.twitter}`}
|
||||
<a
|
||||
href={`https://twitter.com/${post.author.twitter}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-full glass hover:neon-glow transition-all"
|
||||
>
|
||||
<Twitter className="w-5 h-5" />
|
||||
</Link>
|
||||
</a>
|
||||
)}
|
||||
{post.author.linkedin && (
|
||||
<Link
|
||||
to={`https://linkedin.com/in/${post.author.linkedin}`}
|
||||
<a
|
||||
href={`https://linkedin.com/in/${post.author.linkedin}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-full glass hover:neon-glow transition-all"
|
||||
>
|
||||
<Linkedin className="w-5 h-5" />
|
||||
</Link>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const NotFound = () => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||
<div className="text-center">
|
||||
<h1 className="mb-4 text-4xl font-bold">404</h1>
|
||||
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
|
||||
<p className="mb-4 text-xl text-muted-foreground">
|
||||
Oops! Page not found.
|
||||
</p>
|
||||
<Link to="/" className="text-primary underline hover:text-primary/90">
|
||||
Return to Home
|
||||
</Link>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import PageTransition from "@/components/home/PageTransition";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProjectById } from "@/hooks/queires/useProjects";
|
||||
import { useProjectById } from "@/hooks/queries/useProjects";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import {
|
||||
AlertCircle,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import PageTransition from "@/components/home/PageTransition";
|
||||
import { ProjectCard } from "@/components/home/ProjectCard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProjects } from "@/hooks/queires/useProjects";
|
||||
import { useProjects } from "@/hooks/queries/useProjects";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -77,7 +77,6 @@ export default function Projects() {
|
||||
<div className="min-h-screen bg-background text-foreground antialiased selection:bg-primary/20">
|
||||
<section className="pt-32 pb-12 relative overflow-hidden border-b border-border/40">
|
||||
<div className="container mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
|
||||
{/* Back Arrow Wrapper - Kept clean on left side */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: shouldReduceMotion ? 0 : -10 }}
|
||||
@@ -98,7 +97,6 @@ export default function Projects() {
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Core Header Content Module - Flex-centered alignment */}
|
||||
<div className="w-full flex flex-col items-center justify-center text-center">
|
||||
<div className="max-w-3xl mb-12">
|
||||
<motion.h1
|
||||
@@ -166,7 +164,6 @@ export default function Projects() {
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -268,4 +265,4 @@ export default function Projects() {
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ThemeContext } from "@/contexts/ThemeContext";
|
||||
import { ThemeMode, AccentTheme } from "@/types/theme.interface";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
mode: "techzaa-mode",
|
||||
accent: "techzaa-accent",
|
||||
} as const;
|
||||
|
||||
const isValidMode = (value: unknown): value is ThemeMode =>
|
||||
value === "light" || value === "dark";
|
||||
|
||||
const isValidAccent = (value: unknown): value is AccentTheme =>
|
||||
value === "blue" || value === "purple" || value === "green";
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mode, setMode] = useState<ThemeMode>(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.mode);
|
||||
|
||||
return isValidMode(stored) ? stored : "dark";
|
||||
});
|
||||
|
||||
const [accent, setAccent] = useState<AccentTheme>(() => {
|
||||
if (typeof window === "undefined") return "blue";
|
||||
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.accent);
|
||||
|
||||
return isValidAccent(stored) ? stored : "blue";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const root = document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(mode);
|
||||
|
||||
root.classList.remove("theme-blue", "theme-purple", "theme-green");
|
||||
root.classList.add(`theme-${accent}`);
|
||||
|
||||
localStorage.setItem(STORAGE_KEYS.mode, mode);
|
||||
localStorage.setItem(STORAGE_KEYS.accent, accent);
|
||||
}, [mode, accent]);
|
||||
|
||||
const toggleMode = useCallback(() => {
|
||||
setMode((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
mode,
|
||||
accent,
|
||||
setMode,
|
||||
setAccent,
|
||||
toggleMode,
|
||||
}),
|
||||
[mode, accent, toggleMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ThemeMode = "light" | "dark";
|
||||
export type AccentTheme = "blue" | "purple" | "green";
|
||||
|
||||
export interface ThemeContextType {
|
||||
mode: ThemeMode;
|
||||
accent: AccentTheme;
|
||||
setMode: React.Dispatch<React.SetStateAction<ThemeMode>>;
|
||||
setAccent: React.Dispatch<React.SetStateAction<AccentTheme>>;
|
||||
toggleMode: () => void;
|
||||
}
|
||||
Reference in New Issue
Block a user