gradient-forge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.json +3 -0
- package/.github/FUNDING.yml +2 -0
- package/README.md +140 -0
- package/app/docs/page.tsx +417 -0
- package/app/gallery/page.tsx +398 -0
- package/app/globals.css +1155 -0
- package/app/layout.tsx +36 -0
- package/app/page.tsx +600 -0
- package/app/showcase/page.tsx +730 -0
- package/app/studio/page.tsx +1310 -0
- package/cli/index.mjs +1141 -0
- package/cli/templates/theme-context.tsx +120 -0
- package/cli/templates/theme-engine.ts +237 -0
- package/cli/templates/themes.css +512 -0
- package/components/site/component-showcase.tsx +623 -0
- package/components/site/site-data.ts +103 -0
- package/components/site/site-header.tsx +270 -0
- package/components/templates/blog.tsx +198 -0
- package/components/templates/components-showcase.tsx +298 -0
- package/components/templates/dashboard.tsx +246 -0
- package/components/templates/ecommerce.tsx +199 -0
- package/components/templates/mail.tsx +275 -0
- package/components/templates/saas-landing.tsx +169 -0
- package/components/theme/studio-code-panel.tsx +485 -0
- package/components/theme/theme-context.tsx +120 -0
- package/components/theme/theme-engine.ts +237 -0
- package/components/theme/theme-exporter.tsx +369 -0
- package/components/theme/theme-panel.tsx +268 -0
- package/components/theme/token-export-utils.ts +1211 -0
- package/components/ui/animated.tsx +55 -0
- package/components/ui/avatar.tsx +38 -0
- package/components/ui/badge.tsx +32 -0
- package/components/ui/button.tsx +65 -0
- package/components/ui/card.tsx +56 -0
- package/components/ui/checkbox.tsx +19 -0
- package/components/ui/command-palette.tsx +245 -0
- package/components/ui/gsap-animated.tsx +436 -0
- package/components/ui/input.tsx +17 -0
- package/components/ui/select.tsx +176 -0
- package/components/ui/skeleton.tsx +102 -0
- package/components/ui/switch.tsx +43 -0
- package/components/ui/tabs.tsx +115 -0
- package/components/ui/toast.tsx +119 -0
- package/gradient-forge/theme-context.tsx +119 -0
- package/gradient-forge/theme-engine.ts +236 -0
- package/gradient-forge/themes.css +556 -0
- package/lib/animations.ts +50 -0
- package/lib/gsap.ts +426 -0
- package/lib/utils.ts +6 -0
- package/next-env.d.ts +6 -0
- package/next.config.mjs +6 -0
- package/package.json +53 -0
- package/postcss.config.mjs +5 -0
- package/tailwind.config.ts +15 -0
- package/tsconfig.json +43 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
|
|
3
|
+
interface SkeletonProps {
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Skeleton({ className }: SkeletonProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
className={cn(
|
|
11
|
+
"animate-pulse rounded-xl bg-muted/50",
|
|
12
|
+
className
|
|
13
|
+
)}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function CardSkeleton({ className }: SkeletonProps) {
|
|
19
|
+
return (
|
|
20
|
+
<div className={cn("rounded-3xl border border-border/50 bg-background/60 p-6", className)}>
|
|
21
|
+
<Skeleton className="h-6 w-1/3 mb-4" />
|
|
22
|
+
<Skeleton className="h-4 w-full mb-2" />
|
|
23
|
+
<Skeleton className="h-4 w-2/3 mb-6" />
|
|
24
|
+
<div className="flex gap-2">
|
|
25
|
+
<Skeleton className="h-10 w-24" />
|
|
26
|
+
<Skeleton className="h-10 w-24" />
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ThemeCardSkeleton() {
|
|
33
|
+
return (
|
|
34
|
+
<div className="rounded-3xl border border-border/50 bg-background/60 p-4">
|
|
35
|
+
<Skeleton className="h-32 w-full rounded-2xl mb-4" />
|
|
36
|
+
<Skeleton className="h-5 w-3/4 mb-2" />
|
|
37
|
+
<Skeleton className="h-4 w-1/2" />
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ComponentShowcaseSkeleton() {
|
|
43
|
+
return (
|
|
44
|
+
<div className="rounded-3xl border border-border/50 bg-background/60 p-8">
|
|
45
|
+
<div className="flex items-center gap-4 mb-8">
|
|
46
|
+
<Skeleton className="h-12 w-12 rounded-2xl" />
|
|
47
|
+
<div>
|
|
48
|
+
<Skeleton className="h-6 w-32 mb-2" />
|
|
49
|
+
<Skeleton className="h-4 w-48" />
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
53
|
+
<Skeleton className="h-20 w-full" />
|
|
54
|
+
<Skeleton className="h-20 w-full" />
|
|
55
|
+
<Skeleton className="h-20 w-full" />
|
|
56
|
+
<Skeleton className="h-20 w-full" />
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function GalleryGridSkeleton({ count = 8 }: { count?: number }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
65
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
66
|
+
<ThemeCardSkeleton key={i} />
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function TextSkeleton({ lines = 3 }: { lines?: number }) {
|
|
73
|
+
return (
|
|
74
|
+
<div className="space-y-2">
|
|
75
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
76
|
+
<Skeleton
|
|
77
|
+
key={i}
|
|
78
|
+
className={cn(
|
|
79
|
+
"h-4",
|
|
80
|
+
i === lines - 1 ? "w-2/3" : "w-full"
|
|
81
|
+
)}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function HeroSkeleton() {
|
|
89
|
+
return (
|
|
90
|
+
<div className="relative overflow-hidden rounded-3xl border border-border/50 bg-background/60 p-12">
|
|
91
|
+
<div className="max-w-3xl">
|
|
92
|
+
<Skeleton className="h-12 w-3/4 mb-6" />
|
|
93
|
+
<Skeleton className="h-6 w-full mb-4" />
|
|
94
|
+
<Skeleton className="h-6 w-2/3 mb-8" />
|
|
95
|
+
<div className="flex gap-4">
|
|
96
|
+
<Skeleton className="h-12 w-32" />
|
|
97
|
+
<Skeleton className="h-12 w-32" />
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
type SwitchProps = {
|
|
5
|
+
checked?: boolean;
|
|
6
|
+
defaultChecked?: boolean;
|
|
7
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
8
|
+
label?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function Switch({ checked, defaultChecked = false, onCheckedChange, label }: SwitchProps) {
|
|
12
|
+
const [internalChecked, setInternalChecked] = React.useState(defaultChecked);
|
|
13
|
+
const isControlled = checked !== undefined;
|
|
14
|
+
const isChecked = isControlled ? checked : internalChecked;
|
|
15
|
+
|
|
16
|
+
const handleClick = () => {
|
|
17
|
+
const newValue = !isChecked;
|
|
18
|
+
if (!isControlled) {
|
|
19
|
+
setInternalChecked(newValue);
|
|
20
|
+
}
|
|
21
|
+
onCheckedChange?.(newValue);
|
|
22
|
+
};
|
|
23
|
+
return (
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
role="switch"
|
|
27
|
+
aria-checked={isChecked}
|
|
28
|
+
aria-label={label}
|
|
29
|
+
onClick={handleClick}
|
|
30
|
+
className={cn(
|
|
31
|
+
"relative inline-flex h-7 w-12 items-center rounded-full border border-border/60 bg-background/40 transition-all",
|
|
32
|
+
isChecked && "bg-primary/30 border-primary/50",
|
|
33
|
+
)}
|
|
34
|
+
>
|
|
35
|
+
<span
|
|
36
|
+
className={cn(
|
|
37
|
+
"h-5 w-5 translate-x-1 rounded-full bg-foreground/80 shadow transition-all",
|
|
38
|
+
isChecked && "translate-x-6 bg-primary",
|
|
39
|
+
)}
|
|
40
|
+
/>
|
|
41
|
+
</button>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "@/lib/utils";
|
|
3
|
+
|
|
4
|
+
interface TabsProps {
|
|
5
|
+
value?: string;
|
|
6
|
+
defaultValue?: string;
|
|
7
|
+
onValueChange?: (value: string) => void;
|
|
8
|
+
children: React.ReactNode;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface TabsListProps {
|
|
13
|
+
children: React.ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TabsTriggerProps {
|
|
18
|
+
value: string;
|
|
19
|
+
children: React.ReactNode;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface TabsContentProps {
|
|
24
|
+
value: string;
|
|
25
|
+
children: React.ReactNode;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TabsContext = React.createContext<{
|
|
30
|
+
value: string;
|
|
31
|
+
onValueChange: (value: string) => void;
|
|
32
|
+
} | null>(null);
|
|
33
|
+
|
|
34
|
+
function useTabs() {
|
|
35
|
+
const context = React.useContext(TabsContext);
|
|
36
|
+
if (!context) {
|
|
37
|
+
throw new Error("Tabs components must be used within a Tabs provider");
|
|
38
|
+
}
|
|
39
|
+
return context;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function Tabs({ value, defaultValue, onValueChange, children, className }: TabsProps) {
|
|
43
|
+
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
|
|
44
|
+
|
|
45
|
+
const isControlled = value !== undefined;
|
|
46
|
+
const currentValue = isControlled ? value : internalValue;
|
|
47
|
+
|
|
48
|
+
const handleValueChange = React.useCallback((newValue: string) => {
|
|
49
|
+
if (!isControlled) {
|
|
50
|
+
setInternalValue(newValue);
|
|
51
|
+
}
|
|
52
|
+
onValueChange?.(newValue);
|
|
53
|
+
}, [isControlled, onValueChange]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<TabsContext.Provider value={{ value: currentValue, onValueChange: handleValueChange }}>
|
|
57
|
+
<div className={cn("w-full", className)}>{children}</div>
|
|
58
|
+
</TabsContext.Provider>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function TabsList({ children, className }: TabsListProps) {
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
className={cn(
|
|
66
|
+
"inline-flex h-10 items-center justify-center rounded-full bg-muted/50 p-1 text-muted-foreground",
|
|
67
|
+
className
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function TabsTrigger({ value, children, className }: TabsTriggerProps) {
|
|
76
|
+
const { value: selectedValue, onValueChange } = useTabs();
|
|
77
|
+
const isSelected = selectedValue === value;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
role="tab"
|
|
83
|
+
aria-selected={isSelected}
|
|
84
|
+
onClick={() => onValueChange(value)}
|
|
85
|
+
className={cn(
|
|
86
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
87
|
+
isSelected
|
|
88
|
+
? "bg-background text-foreground shadow-sm"
|
|
89
|
+
: "hover:bg-muted/70 hover:text-foreground",
|
|
90
|
+
className
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</button>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function TabsContent({ value, children, className }: TabsContentProps) {
|
|
99
|
+
const { value: selectedValue } = useTabs();
|
|
100
|
+
const isSelected = selectedValue === value;
|
|
101
|
+
|
|
102
|
+
if (!isSelected) return null;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
role="tabpanel"
|
|
107
|
+
className={cn(
|
|
108
|
+
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
109
|
+
className
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
|
4
|
+
import { motion, AnimatePresence } from "framer-motion";
|
|
5
|
+
import { Check, X, AlertCircle, Info } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
type ToastType = "success" | "error" | "warning" | "info";
|
|
8
|
+
|
|
9
|
+
interface Toast {
|
|
10
|
+
id: string;
|
|
11
|
+
message: string;
|
|
12
|
+
type: ToastType;
|
|
13
|
+
duration?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ToastContextType {
|
|
17
|
+
showToast: (message: string, type?: ToastType, duration?: number) => void;
|
|
18
|
+
removeToast: (id: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ToastContext = createContext<ToastContextType | null>(null);
|
|
22
|
+
|
|
23
|
+
export function useToast() {
|
|
24
|
+
const context = useContext(ToastContext);
|
|
25
|
+
if (!context) {
|
|
26
|
+
throw new Error("useToast must be used within ToastProvider");
|
|
27
|
+
}
|
|
28
|
+
return context;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
32
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
33
|
+
|
|
34
|
+
const removeToast = useCallback((id: string) => {
|
|
35
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
36
|
+
}, []);
|
|
37
|
+
|
|
38
|
+
const showToast = useCallback((message: string, type: ToastType = "info", duration = 3000) => {
|
|
39
|
+
const id = Math.random().toString(36).substring(2, 9);
|
|
40
|
+
const toast: Toast = { id, message, type, duration };
|
|
41
|
+
|
|
42
|
+
setToasts((prev) => [...prev, toast]);
|
|
43
|
+
|
|
44
|
+
if (duration > 0) {
|
|
45
|
+
setTimeout(() => removeToast(id), duration);
|
|
46
|
+
}
|
|
47
|
+
}, [removeToast]);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<ToastContext.Provider value={{ showToast, removeToast }}>
|
|
51
|
+
{children}
|
|
52
|
+
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
|
53
|
+
</ToastContext.Provider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function ToastContainer({
|
|
58
|
+
toasts,
|
|
59
|
+
removeToast
|
|
60
|
+
}: {
|
|
61
|
+
toasts: Toast[];
|
|
62
|
+
removeToast: (id: string) => void;
|
|
63
|
+
}) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
|
66
|
+
<AnimatePresence mode="popLayout">
|
|
67
|
+
{toasts.map((toast) => (
|
|
68
|
+
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
|
69
|
+
))}
|
|
70
|
+
</AnimatePresence>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ToastItem({
|
|
76
|
+
toast,
|
|
77
|
+
onClose
|
|
78
|
+
}: {
|
|
79
|
+
toast: Toast;
|
|
80
|
+
onClose: () => void;
|
|
81
|
+
}) {
|
|
82
|
+
const icons = {
|
|
83
|
+
success: <Check className="h-4 w-4 text-emerald-500" />,
|
|
84
|
+
error: <X className="h-4 w-4 text-red-500" />,
|
|
85
|
+
warning: <AlertCircle className="h-4 w-4 text-amber-500" />,
|
|
86
|
+
info: <Info className="h-4 w-4 text-blue-500" />,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const borders = {
|
|
90
|
+
success: "border-emerald-500/30",
|
|
91
|
+
error: "border-red-500/30",
|
|
92
|
+
warning: "border-amber-500/30",
|
|
93
|
+
info: "border-blue-500/30",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<motion.div
|
|
98
|
+
layout
|
|
99
|
+
initial={{ opacity: 0, x: 50, scale: 0.9 }}
|
|
100
|
+
animate={{ opacity: 1, x: 0, scale: 1 }}
|
|
101
|
+
exit={{ opacity: 0, x: 50, scale: 0.9 }}
|
|
102
|
+
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
|
103
|
+
className={`
|
|
104
|
+
flex items-center gap-3 px-4 py-3 rounded-2xl
|
|
105
|
+
bg-background/80 backdrop-blur-xl border ${borders[toast.type]}
|
|
106
|
+
shadow-lg shadow-black/5 min-w-[300px] max-w-[400px]
|
|
107
|
+
`}
|
|
108
|
+
>
|
|
109
|
+
{icons[toast.type]}
|
|
110
|
+
<p className="text-sm font-medium flex-1">{toast.message}</p>
|
|
111
|
+
<button
|
|
112
|
+
onClick={onClose}
|
|
113
|
+
className="p-1 rounded-full hover:bg-muted/50 transition-colors"
|
|
114
|
+
>
|
|
115
|
+
<X className="h-3 w-3 text-muted-foreground" />
|
|
116
|
+
</button>
|
|
117
|
+
</motion.div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ColorMode,
|
|
5
|
+
type ThemeId,
|
|
6
|
+
MEMORY_LANE_THEME,
|
|
7
|
+
NITRO_ALL_THEMES,
|
|
8
|
+
NITRO_PUBLIC_THEMES,
|
|
9
|
+
applyTheme,
|
|
10
|
+
defaultColorMode,
|
|
11
|
+
defaultTheme,
|
|
12
|
+
getStoredColorMode,
|
|
13
|
+
getStoredTheme,
|
|
14
|
+
persistTourProgress,
|
|
15
|
+
publicThemeIds,
|
|
16
|
+
readTourProgress,
|
|
17
|
+
} from "./theme-engine";
|
|
18
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
19
|
+
|
|
20
|
+
type ThemeContextValue = {
|
|
21
|
+
themeId: ThemeId;
|
|
22
|
+
colorMode: ColorMode;
|
|
23
|
+
availableThemes: typeof NITRO_ALL_THEMES;
|
|
24
|
+
viewedThemeIds: string[];
|
|
25
|
+
memoryLaneUnlocked: boolean;
|
|
26
|
+
remainingForUnlock: number;
|
|
27
|
+
setThemeId: (themeId: ThemeId) => void;
|
|
28
|
+
setColorMode: (mode: ColorMode) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
|
|
34
|
+
const [themeId, setThemeIdState] = useState<ThemeId>(defaultTheme);
|
|
35
|
+
const [colorMode, setColorModeState] = useState<ColorMode>(defaultColorMode);
|
|
36
|
+
const [viewedThemeIds, setViewedThemeIds] = useState<string[]>([]);
|
|
37
|
+
const [memoryLaneUnlocked, setMemoryLaneUnlocked] = useState(false);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const storedTheme = getStoredTheme();
|
|
41
|
+
const storedMode = getStoredColorMode();
|
|
42
|
+
const tour = readTourProgress();
|
|
43
|
+
|
|
44
|
+
setThemeIdState(storedTheme);
|
|
45
|
+
setColorModeState(storedMode);
|
|
46
|
+
setViewedThemeIds(tour.viewedThemeIds);
|
|
47
|
+
setMemoryLaneUnlocked(tour.memoryLaneUnlocked);
|
|
48
|
+
applyTheme(storedTheme, storedMode);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
applyTheme(themeId, colorMode);
|
|
53
|
+
}, [themeId, colorMode]);
|
|
54
|
+
|
|
55
|
+
const setThemeId = (nextTheme: ThemeId) => {
|
|
56
|
+
const isPublic = publicThemeIds.includes(nextTheme);
|
|
57
|
+
const nextViewedThemeIds = isPublic
|
|
58
|
+
? Array.from(new Set([...viewedThemeIds, nextTheme]))
|
|
59
|
+
: viewedThemeIds;
|
|
60
|
+
|
|
61
|
+
const hasCompletedTour = publicThemeIds.every((id) =>
|
|
62
|
+
nextViewedThemeIds.includes(id),
|
|
63
|
+
);
|
|
64
|
+
const nextMemoryLaneUnlocked = memoryLaneUnlocked || hasCompletedTour;
|
|
65
|
+
|
|
66
|
+
setThemeIdState(nextTheme);
|
|
67
|
+
setViewedThemeIds(nextViewedThemeIds);
|
|
68
|
+
setMemoryLaneUnlocked(nextMemoryLaneUnlocked);
|
|
69
|
+
persistTourProgress({
|
|
70
|
+
viewedThemeIds: nextViewedThemeIds,
|
|
71
|
+
memoryLaneUnlocked: nextMemoryLaneUnlocked,
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const setColorMode = (mode: ColorMode) => {
|
|
76
|
+
setColorModeState(mode);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const availableThemes = useMemo(
|
|
80
|
+
() => (memoryLaneUnlocked ? NITRO_ALL_THEMES : NITRO_PUBLIC_THEMES),
|
|
81
|
+
[memoryLaneUnlocked],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const remainingForUnlock = useMemo(() => {
|
|
85
|
+
const remaining = publicThemeIds.length - viewedThemeIds.length;
|
|
86
|
+
return Math.max(0, remaining);
|
|
87
|
+
}, [viewedThemeIds]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (themeId === MEMORY_LANE_THEME.id && !memoryLaneUnlocked) {
|
|
91
|
+
setMemoryLaneUnlocked(true);
|
|
92
|
+
}
|
|
93
|
+
}, [themeId, memoryLaneUnlocked]);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<ThemeContext.Provider
|
|
97
|
+
value={{
|
|
98
|
+
themeId,
|
|
99
|
+
colorMode,
|
|
100
|
+
availableThemes: availableThemes as typeof NITRO_ALL_THEMES,
|
|
101
|
+
viewedThemeIds,
|
|
102
|
+
memoryLaneUnlocked,
|
|
103
|
+
remainingForUnlock,
|
|
104
|
+
setThemeId,
|
|
105
|
+
setColorMode,
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</ThemeContext.Provider>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const useThemeContext = () => {
|
|
114
|
+
const ctx = useContext(ThemeContext);
|
|
115
|
+
if (!ctx) {
|
|
116
|
+
throw new Error("useThemeContext must be used within ThemeProvider");
|
|
117
|
+
}
|
|
118
|
+
return ctx;
|
|
119
|
+
};
|