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,485 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo, useState } from "react";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
7
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
8
|
+
import { AlertCircle, Check, Copy, FileCode2, Loader2, Download, Palette } from "lucide-react";
|
|
9
|
+
import { type ThemeId, NITRO_ALL_THEMES } from "./theme-engine";
|
|
10
|
+
import {
|
|
11
|
+
exportTokens,
|
|
12
|
+
downloadFile,
|
|
13
|
+
copyToClipboard,
|
|
14
|
+
generateAllThemesCSS,
|
|
15
|
+
getThemeTokens,
|
|
16
|
+
type ExportFormat,
|
|
17
|
+
} from "./token-export-utils";
|
|
18
|
+
|
|
19
|
+
type CopyStatus = "idle" | "copying" | "copied" | "error";
|
|
20
|
+
type TabValue = "preview" | "code" | "tokens";
|
|
21
|
+
|
|
22
|
+
interface StudioCodePanelProps {
|
|
23
|
+
themeId?: ThemeId;
|
|
24
|
+
colorMode?: "dark" | "light";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function StudioCodePanel({ themeId = "theme-nitro-midnight-blurple", colorMode = "dark" }: StudioCodePanelProps) {
|
|
28
|
+
const [tab, setTab] = useState<TabValue>("preview");
|
|
29
|
+
const [copyState, setCopyState] = useState<Record<string, CopyStatus>>({});
|
|
30
|
+
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>("css");
|
|
31
|
+
|
|
32
|
+
const theme = useMemo(() => NITRO_ALL_THEMES.find((t) => t.id === themeId), [themeId]);
|
|
33
|
+
const tokens = useMemo(() => getThemeTokens(themeId), [themeId]);
|
|
34
|
+
|
|
35
|
+
const exportResult = useMemo(() => {
|
|
36
|
+
return exportTokens({ format: selectedFormat, themeId, colorMode });
|
|
37
|
+
}, [selectedFormat, themeId, colorMode]);
|
|
38
|
+
|
|
39
|
+
const allThemesExport = useMemo(() => generateAllThemesCSS(), []);
|
|
40
|
+
|
|
41
|
+
const snippets = useMemo(() => {
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
id: "root-layout",
|
|
45
|
+
label: "Root Layout",
|
|
46
|
+
path: "app/layout.tsx",
|
|
47
|
+
description: "Set default theme and wrap app with ThemeProvider.",
|
|
48
|
+
code: `import type { Metadata } from "next";
|
|
49
|
+
import "@/app/globals.css";
|
|
50
|
+
import { ThemeProvider } from "@/components/theme/theme-context";
|
|
51
|
+
|
|
52
|
+
export const metadata: Metadata = {
|
|
53
|
+
title: "Gradient Forge",
|
|
54
|
+
description: "A production-ready gradient theming system for shadcn components.",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
58
|
+
return (
|
|
59
|
+
<html
|
|
60
|
+
lang="en"
|
|
61
|
+
className="${colorMode} ${themeId}"
|
|
62
|
+
data-theme="${themeId}"
|
|
63
|
+
data-color-mode="${colorMode}"
|
|
64
|
+
suppressHydrationWarning
|
|
65
|
+
>
|
|
66
|
+
<body>
|
|
67
|
+
<ThemeProvider defaultTheme="${themeId}" defaultColorMode="${colorMode}">
|
|
68
|
+
{children}
|
|
69
|
+
</ThemeProvider>
|
|
70
|
+
</body>
|
|
71
|
+
</html>
|
|
72
|
+
);
|
|
73
|
+
}`,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: "theme-engine",
|
|
77
|
+
label: "Theme Engine",
|
|
78
|
+
path: "components/theme/theme-engine.ts",
|
|
79
|
+
description: "Add and manage your theme presets.",
|
|
80
|
+
code: `export const NITRO_PUBLIC_THEMES = [
|
|
81
|
+
{
|
|
82
|
+
id: "${themeId}",
|
|
83
|
+
label: "${theme?.label ?? "Theme"}",
|
|
84
|
+
preview: "${theme?.preview ?? ""}",
|
|
85
|
+
},
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
export type ThemeId = (typeof NITRO_PUBLIC_THEMES)[number]["id"];`,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "globals",
|
|
92
|
+
label: "Global Tokens",
|
|
93
|
+
path: "app/globals.css",
|
|
94
|
+
description: "Define surface and app gradient tokens for all components.",
|
|
95
|
+
code: generateThemeCSS(themeId, tokens),
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: "theme-context",
|
|
99
|
+
label: "Theme Context",
|
|
100
|
+
path: "components/theme/theme-context.tsx",
|
|
101
|
+
description: "Client provider that applies theme and color mode state.",
|
|
102
|
+
code: generateThemeContextCode(themeId, colorMode),
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
}, [themeId, colorMode, theme, tokens]);
|
|
106
|
+
|
|
107
|
+
const [activeSnippetId, setActiveSnippetId] = useState(snippets[0].id);
|
|
108
|
+
const activeSnippet = useMemo(
|
|
109
|
+
() => snippets.find((snippet) => snippet.id === activeSnippetId) ?? snippets[0],
|
|
110
|
+
[activeSnippetId, snippets]
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const handleCopy = async (id: string, content: string) => {
|
|
114
|
+
setCopyState((prev) => ({ ...prev, [id]: "copying" }));
|
|
115
|
+
const success = await copyToClipboard(content);
|
|
116
|
+
setCopyState((prev) => ({ ...prev, [id]: success ? "copied" : "error" }));
|
|
117
|
+
window.setTimeout(() => {
|
|
118
|
+
setCopyState((prev) => ({ ...prev, [id]: "idle" }));
|
|
119
|
+
}, 1400);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleDownload = (result: typeof exportResult) => {
|
|
123
|
+
downloadFile(result);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const copyLabel = (status: CopyStatus) => {
|
|
127
|
+
if (status === "copying") return "Copying...";
|
|
128
|
+
if (status === "copied") return "Copied";
|
|
129
|
+
if (status === "error") return "Copy failed";
|
|
130
|
+
return "Copy code";
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const setupPackState = copyState["setup-pack"] ?? "idle";
|
|
134
|
+
const activeSnippetState = copyState[activeSnippet.id] ?? "idle";
|
|
135
|
+
const tokensState = copyState["tokens"] ?? "idle";
|
|
136
|
+
const allThemesState = copyState["all-themes"] ?? "idle";
|
|
137
|
+
|
|
138
|
+
const requiredSnippetIds = ["root-layout", "theme-engine", "theme-context", "globals"] as const;
|
|
139
|
+
|
|
140
|
+
const getSetupPack = () => {
|
|
141
|
+
return requiredSnippetIds
|
|
142
|
+
.map((id) => snippets.find((snippet) => snippet.id === id))
|
|
143
|
+
.filter((snippet): snippet is typeof snippets[0] => Boolean(snippet))
|
|
144
|
+
.map((snippet) => `// ${snippet.path}\n${snippet.code}`)
|
|
145
|
+
.join("\n\n");
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<Card className="border-border/50 bg-background/60">
|
|
150
|
+
<CardHeader className="space-y-4">
|
|
151
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
152
|
+
<div>
|
|
153
|
+
<CardTitle>Token Export</CardTitle>
|
|
154
|
+
<p className="mt-1 text-sm text-muted-foreground">
|
|
155
|
+
Preview install guidance or open full code snippets with copy actions.
|
|
156
|
+
{theme && (
|
|
157
|
+
<span className="block mt-1 font-medium text-foreground">
|
|
158
|
+
Current theme: {theme.label}
|
|
159
|
+
</span>
|
|
160
|
+
)}
|
|
161
|
+
</p>
|
|
162
|
+
</div>
|
|
163
|
+
<Badge variant="glass" className="gap-1">
|
|
164
|
+
<FileCode2 className="h-3 w-3" /> Dev Ready
|
|
165
|
+
</Badge>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
168
|
+
<Button
|
|
169
|
+
variant={tab === "preview" ? "default" : "ghost"}
|
|
170
|
+
size="sm"
|
|
171
|
+
onClick={() => setTab("preview")}
|
|
172
|
+
>
|
|
173
|
+
Preview
|
|
174
|
+
</Button>
|
|
175
|
+
<Button
|
|
176
|
+
variant={tab === "code" ? "default" : "ghost"}
|
|
177
|
+
size="sm"
|
|
178
|
+
onClick={() => setTab("code")}
|
|
179
|
+
>
|
|
180
|
+
Code
|
|
181
|
+
</Button>
|
|
182
|
+
<Button
|
|
183
|
+
variant={tab === "tokens" ? "default" : "ghost"}
|
|
184
|
+
size="sm"
|
|
185
|
+
onClick={() => setTab("tokens")}
|
|
186
|
+
>
|
|
187
|
+
<Palette className="h-3.5 w-3.5 mr-1" />
|
|
188
|
+
Tokens
|
|
189
|
+
</Button>
|
|
190
|
+
</div>
|
|
191
|
+
</CardHeader>
|
|
192
|
+
<CardContent>
|
|
193
|
+
{tab === "preview" ? (
|
|
194
|
+
<div className="rounded-2xl border border-border/40 bg-background/50 p-5">
|
|
195
|
+
<h3 className="text-lg font-semibold">Copy-ready setup</h3>
|
|
196
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
197
|
+
Add data-theme attributes to your app root, import global tokens, and use ThemeProvider to keep
|
|
198
|
+
theme state synced across shadcn components.
|
|
199
|
+
</p>
|
|
200
|
+
|
|
201
|
+
{/* Theme Preview */}
|
|
202
|
+
{theme && (
|
|
203
|
+
<div className="mt-4 p-4 rounded-xl bg-background/70 border border-border/40">
|
|
204
|
+
<div className="flex items-center gap-3">
|
|
205
|
+
<div
|
|
206
|
+
className="w-16 h-16 rounded-xl shadow-lg"
|
|
207
|
+
style={{ background: theme.preview }}
|
|
208
|
+
/>
|
|
209
|
+
<div>
|
|
210
|
+
<p className="font-medium">{theme.label}</p>
|
|
211
|
+
<p className="text-xs text-muted-foreground font-mono">{theme.id}</p>
|
|
212
|
+
<p className="text-xs text-muted-foreground mt-1 capitalize">{colorMode} mode</p>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
219
|
+
{requiredSnippetIds.map((id) => {
|
|
220
|
+
const snippet = snippets.find((item) => item.id === id);
|
|
221
|
+
if (!snippet) return null;
|
|
222
|
+
return (
|
|
223
|
+
<Badge key={id} variant="outline">
|
|
224
|
+
{snippet.path}
|
|
225
|
+
</Badge>
|
|
226
|
+
);
|
|
227
|
+
})}
|
|
228
|
+
</div>
|
|
229
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
230
|
+
<Button variant="outline" size="sm" asChild>
|
|
231
|
+
<a href="/docs">Open docs</a>
|
|
232
|
+
</Button>
|
|
233
|
+
<Button
|
|
234
|
+
variant="secondary"
|
|
235
|
+
size="sm"
|
|
236
|
+
onClick={() => handleCopy("setup-pack", getSetupPack())}
|
|
237
|
+
disabled={setupPackState === "copying"}
|
|
238
|
+
>
|
|
239
|
+
{setupPackState === "copying" ? (
|
|
240
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
241
|
+
) : setupPackState === "copied" ? (
|
|
242
|
+
<Check className="h-4 w-4" />
|
|
243
|
+
) : setupPackState === "error" ? (
|
|
244
|
+
<AlertCircle className="h-4 w-4" />
|
|
245
|
+
) : (
|
|
246
|
+
<Copy className="h-4 w-4" />
|
|
247
|
+
)}
|
|
248
|
+
{copyLabel(setupPackState)}
|
|
249
|
+
</Button>
|
|
250
|
+
<Button variant="ghost" size="sm" onClick={() => setTab("code")}>
|
|
251
|
+
Open code snippets
|
|
252
|
+
</Button>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
) : tab === "code" ? (
|
|
256
|
+
<div className="space-y-4">
|
|
257
|
+
<div className="flex flex-wrap gap-2">
|
|
258
|
+
{snippets.map((snippet) => (
|
|
259
|
+
<Button
|
|
260
|
+
key={snippet.id}
|
|
261
|
+
size="sm"
|
|
262
|
+
variant={activeSnippetId === snippet.id ? "default" : "ghost"}
|
|
263
|
+
onClick={() => setActiveSnippetId(snippet.id)}
|
|
264
|
+
>
|
|
265
|
+
{snippet.label}
|
|
266
|
+
</Button>
|
|
267
|
+
))}
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
<div className="rounded-2xl border border-border/40 bg-background/50 p-4">
|
|
271
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
272
|
+
<div>
|
|
273
|
+
<p className="break-all text-sm font-semibold">{activeSnippet.path}</p>
|
|
274
|
+
<p className="text-xs text-muted-foreground">{activeSnippet.description}</p>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="flex gap-2">
|
|
277
|
+
<Button
|
|
278
|
+
variant="outline"
|
|
279
|
+
size="sm"
|
|
280
|
+
onClick={() => handleCopy(activeSnippet.id, activeSnippet.code)}
|
|
281
|
+
disabled={activeSnippetState === "copying"}
|
|
282
|
+
>
|
|
283
|
+
{activeSnippetState === "copying" ? (
|
|
284
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
285
|
+
) : activeSnippetState === "copied" ? (
|
|
286
|
+
<Check className="h-4 w-4" />
|
|
287
|
+
) : activeSnippetState === "error" ? (
|
|
288
|
+
<AlertCircle className="h-4 w-4" />
|
|
289
|
+
) : (
|
|
290
|
+
<Copy className="h-4 w-4" />
|
|
291
|
+
)}
|
|
292
|
+
{copyLabel(activeSnippetState)}
|
|
293
|
+
</Button>
|
|
294
|
+
<Button
|
|
295
|
+
variant="secondary"
|
|
296
|
+
size="sm"
|
|
297
|
+
onClick={() => handleCopy("setup-pack", getSetupPack())}
|
|
298
|
+
disabled={setupPackState === "copying"}
|
|
299
|
+
>
|
|
300
|
+
{setupPackState === "copying" ? (
|
|
301
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
302
|
+
) : setupPackState === "copied" ? (
|
|
303
|
+
<Check className="h-4 w-4" />
|
|
304
|
+
) : setupPackState === "error" ? (
|
|
305
|
+
<AlertCircle className="h-4 w-4" />
|
|
306
|
+
) : (
|
|
307
|
+
<Copy className="h-4 w-4" />
|
|
308
|
+
)}
|
|
309
|
+
Copy All
|
|
310
|
+
</Button>
|
|
311
|
+
</div>
|
|
312
|
+
</div>
|
|
313
|
+
<pre className="mt-4 max-h-[360px] overflow-auto rounded-2xl border border-border/40 bg-black/80 p-4 text-xs text-white/90">
|
|
314
|
+
<code>{activeSnippet.code}</code>
|
|
315
|
+
</pre>
|
|
316
|
+
</div>
|
|
317
|
+
</div>
|
|
318
|
+
) : (
|
|
319
|
+
<div className="space-y-4">
|
|
320
|
+
{/* Token Export Formats */}
|
|
321
|
+
<div className="flex flex-wrap gap-2">
|
|
322
|
+
{[
|
|
323
|
+
{ value: "css" as ExportFormat, label: "CSS" },
|
|
324
|
+
{ value: "json" as ExportFormat, label: "JSON" },
|
|
325
|
+
{ value: "tailwind" as ExportFormat, label: "Tailwind" },
|
|
326
|
+
{ value: "w3c-tokens" as ExportFormat, label: "W3C" },
|
|
327
|
+
].map((format) => (
|
|
328
|
+
<Button
|
|
329
|
+
key={format.value}
|
|
330
|
+
size="sm"
|
|
331
|
+
variant={selectedFormat === format.value ? "default" : "ghost"}
|
|
332
|
+
onClick={() => setSelectedFormat(format.value)}
|
|
333
|
+
>
|
|
334
|
+
{format.label}
|
|
335
|
+
</Button>
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
|
|
339
|
+
{/* Export Preview */}
|
|
340
|
+
<div className="rounded-2xl border border-border/40 bg-background/50 p-4">
|
|
341
|
+
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
|
|
342
|
+
<div>
|
|
343
|
+
<p className="text-sm font-semibold">{exportResult.filename}</p>
|
|
344
|
+
<p className="text-xs text-muted-foreground">
|
|
345
|
+
{theme?.label} theme in {selectedFormat.toUpperCase()} format
|
|
346
|
+
</p>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="flex gap-2">
|
|
349
|
+
<Button
|
|
350
|
+
variant="outline"
|
|
351
|
+
size="sm"
|
|
352
|
+
onClick={() => handleCopy("tokens", exportResult.content)}
|
|
353
|
+
disabled={tokensState === "copying"}
|
|
354
|
+
>
|
|
355
|
+
{tokensState === "copying" ? (
|
|
356
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
357
|
+
) : tokensState === "copied" ? (
|
|
358
|
+
<Check className="h-4 w-4" />
|
|
359
|
+
) : tokensState === "error" ? (
|
|
360
|
+
<AlertCircle className="h-4 w-4" />
|
|
361
|
+
) : (
|
|
362
|
+
<Copy className="h-4 w-4" />
|
|
363
|
+
)}
|
|
364
|
+
{copyLabel(tokensState)}
|
|
365
|
+
</Button>
|
|
366
|
+
<Button
|
|
367
|
+
variant="default"
|
|
368
|
+
size="sm"
|
|
369
|
+
onClick={() => handleDownload(exportResult)}
|
|
370
|
+
>
|
|
371
|
+
<Download className="h-4 w-4 mr-1" />
|
|
372
|
+
Download
|
|
373
|
+
</Button>
|
|
374
|
+
</div>
|
|
375
|
+
</div>
|
|
376
|
+
<pre className="max-h-[300px] overflow-auto rounded-2xl border border-border/40 bg-black/80 p-4 text-xs text-white/90">
|
|
377
|
+
<code>{exportResult.content}</code>
|
|
378
|
+
</pre>
|
|
379
|
+
</div>
|
|
380
|
+
|
|
381
|
+
{/* All Themes Export */}
|
|
382
|
+
<div className="rounded-2xl border border-border/40 bg-background/50 p-4">
|
|
383
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
384
|
+
<div>
|
|
385
|
+
<p className="text-sm font-semibold">All Themes</p>
|
|
386
|
+
<p className="text-xs text-muted-foreground">
|
|
387
|
+
Export all {NITRO_ALL_THEMES.length} themes at once
|
|
388
|
+
</p>
|
|
389
|
+
</div>
|
|
390
|
+
<div className="flex gap-2">
|
|
391
|
+
<Button
|
|
392
|
+
variant="outline"
|
|
393
|
+
size="sm"
|
|
394
|
+
onClick={() => handleCopy("all-themes", allThemesExport.content)}
|
|
395
|
+
disabled={allThemesState === "copying"}
|
|
396
|
+
>
|
|
397
|
+
{allThemesState === "copying" ? (
|
|
398
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
399
|
+
) : allThemesState === "copied" ? (
|
|
400
|
+
<Check className="h-4 w-4" />
|
|
401
|
+
) : allThemesState === "error" ? (
|
|
402
|
+
<AlertCircle className="h-4 w-4" />
|
|
403
|
+
) : (
|
|
404
|
+
<Copy className="h-4 w-4" />
|
|
405
|
+
)}
|
|
406
|
+
{copyLabel(allThemesState)}
|
|
407
|
+
</Button>
|
|
408
|
+
<Button
|
|
409
|
+
variant="secondary"
|
|
410
|
+
size="sm"
|
|
411
|
+
onClick={() => handleDownload(allThemesExport)}
|
|
412
|
+
>
|
|
413
|
+
<Download className="h-4 w-4 mr-1" />
|
|
414
|
+
Download All
|
|
415
|
+
</Button>
|
|
416
|
+
</div>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
)}
|
|
421
|
+
</CardContent>
|
|
422
|
+
</Card>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Helper functions
|
|
427
|
+
function generateThemeCSS(themeId: string, tokens: Record<string, string>): string {
|
|
428
|
+
const cssVars = Object.entries(tokens)
|
|
429
|
+
.filter(([key]) => key !== "--app-surface-tint")
|
|
430
|
+
.map(([key, value]) => ` ${key}: ${value};`)
|
|
431
|
+
.join("\n");
|
|
432
|
+
|
|
433
|
+
return `.${themeId} {
|
|
434
|
+
${cssVars}
|
|
435
|
+
--app-surface-tint: ${tokens["--app-surface-tint"] ?? "transparent"};
|
|
436
|
+
--app-gradient:
|
|
437
|
+
radial-gradient(1050px 560px at -10% -20%, hsl(var(--primary) / 0.3), transparent 60%),
|
|
438
|
+
radial-gradient(920px 520px at 112% 2%, hsl(var(--accent) / 0.22), transparent 58%),
|
|
439
|
+
linear-gradient(160deg, hsl(var(--background)) 0%, hsl(var(--background)) 50%, hsl(var(--background)) 100%);
|
|
440
|
+
}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function generateThemeContextCode(themeId: string, colorMode: string): string {
|
|
444
|
+
return `"use client";
|
|
445
|
+
|
|
446
|
+
import { createContext, useContext, useState } from "react";
|
|
447
|
+
import { applyTheme } from "@/components/theme/theme-engine";
|
|
448
|
+
|
|
449
|
+
const ThemeContext = createContext(null);
|
|
450
|
+
|
|
451
|
+
export function ThemeProvider({
|
|
452
|
+
children,
|
|
453
|
+
defaultTheme = "${themeId}",
|
|
454
|
+
defaultColorMode = "${colorMode}"
|
|
455
|
+
}: {
|
|
456
|
+
children: React.ReactNode;
|
|
457
|
+
defaultTheme?: string;
|
|
458
|
+
defaultColorMode?: "dark" | "light";
|
|
459
|
+
}) {
|
|
460
|
+
const [themeId, setThemeId] = useState(defaultTheme);
|
|
461
|
+
const [colorMode, setColorMode] = useState<"dark" | "light">(defaultColorMode);
|
|
462
|
+
|
|
463
|
+
const updateTheme = (nextTheme: string) => {
|
|
464
|
+
setThemeId(nextTheme);
|
|
465
|
+
applyTheme(nextTheme, colorMode);
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const updateMode = (nextMode: "dark" | "light") => {
|
|
469
|
+
setColorMode(nextMode);
|
|
470
|
+
applyTheme(themeId, nextMode);
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
return (
|
|
474
|
+
<ThemeContext.Provider value={{ themeId, colorMode, setThemeId: updateTheme, setColorMode: updateMode }}>
|
|
475
|
+
{children}
|
|
476
|
+
</ThemeContext.Provider>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export const useThemeContext = () => {
|
|
481
|
+
const context = useContext(ThemeContext);
|
|
482
|
+
if (!context) throw new Error("useThemeContext must be used within ThemeProvider");
|
|
483
|
+
return context;
|
|
484
|
+
};`;
|
|
485
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
publicThemeIdSet,
|
|
16
|
+
publicThemeIds,
|
|
17
|
+
readTourProgress,
|
|
18
|
+
} from "@/components/theme/theme-engine";
|
|
19
|
+
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
20
|
+
|
|
21
|
+
type ThemeContextValue = {
|
|
22
|
+
themeId: ThemeId;
|
|
23
|
+
colorMode: ColorMode;
|
|
24
|
+
availableThemes: typeof NITRO_ALL_THEMES;
|
|
25
|
+
viewedThemeIds: string[];
|
|
26
|
+
memoryLaneUnlocked: boolean;
|
|
27
|
+
remainingForUnlock: number;
|
|
28
|
+
setThemeId: (themeId: ThemeId) => void;
|
|
29
|
+
setColorMode: (mode: ColorMode) => void;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
33
|
+
|
|
34
|
+
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
|
|
35
|
+
const [themeId, setThemeIdState] = useState<ThemeId>(defaultTheme);
|
|
36
|
+
const [colorMode, setColorModeState] = useState<ColorMode>(defaultColorMode);
|
|
37
|
+
const [viewedThemeIds, setViewedThemeIds] = useState<string[]>([]);
|
|
38
|
+
const [memoryLaneUnlocked, setMemoryLaneUnlocked] = useState(false);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const storedTheme = getStoredTheme();
|
|
42
|
+
const storedMode = getStoredColorMode();
|
|
43
|
+
const tour = readTourProgress();
|
|
44
|
+
|
|
45
|
+
setThemeIdState(storedTheme);
|
|
46
|
+
setColorModeState(storedMode);
|
|
47
|
+
setViewedThemeIds(tour.viewedThemeIds);
|
|
48
|
+
setMemoryLaneUnlocked(tour.memoryLaneUnlocked);
|
|
49
|
+
applyTheme(storedTheme, storedMode);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
applyTheme(themeId, colorMode);
|
|
54
|
+
}, [themeId, colorMode]);
|
|
55
|
+
|
|
56
|
+
const setThemeId = (nextTheme: ThemeId) => {
|
|
57
|
+
const isPublic = publicThemeIdSet.has(nextTheme);
|
|
58
|
+
const nextViewedThemeIds = isPublic
|
|
59
|
+
? Array.from(new Set([...viewedThemeIds, nextTheme]))
|
|
60
|
+
: viewedThemeIds;
|
|
61
|
+
|
|
62
|
+
const hasCompletedTour = publicThemeIds.every((id) =>
|
|
63
|
+
nextViewedThemeIds.includes(id),
|
|
64
|
+
);
|
|
65
|
+
const nextMemoryLaneUnlocked = memoryLaneUnlocked || hasCompletedTour;
|
|
66
|
+
|
|
67
|
+
setThemeIdState(nextTheme);
|
|
68
|
+
setViewedThemeIds(nextViewedThemeIds);
|
|
69
|
+
setMemoryLaneUnlocked(nextMemoryLaneUnlocked);
|
|
70
|
+
persistTourProgress({
|
|
71
|
+
viewedThemeIds: nextViewedThemeIds,
|
|
72
|
+
memoryLaneUnlocked: nextMemoryLaneUnlocked,
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const setColorMode = (mode: ColorMode) => {
|
|
77
|
+
setColorModeState(mode);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const availableThemes = useMemo(
|
|
81
|
+
() => (memoryLaneUnlocked ? NITRO_ALL_THEMES : NITRO_PUBLIC_THEMES),
|
|
82
|
+
[memoryLaneUnlocked],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const remainingForUnlock = useMemo(() => {
|
|
86
|
+
const remaining = publicThemeIds.length - viewedThemeIds.length;
|
|
87
|
+
return Math.max(0, remaining);
|
|
88
|
+
}, [viewedThemeIds]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (themeId === MEMORY_LANE_THEME.id && !memoryLaneUnlocked) {
|
|
92
|
+
setMemoryLaneUnlocked(true);
|
|
93
|
+
}
|
|
94
|
+
}, [themeId, memoryLaneUnlocked]);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<ThemeContext.Provider
|
|
98
|
+
value={{
|
|
99
|
+
themeId,
|
|
100
|
+
colorMode,
|
|
101
|
+
availableThemes: availableThemes as typeof NITRO_ALL_THEMES,
|
|
102
|
+
viewedThemeIds,
|
|
103
|
+
memoryLaneUnlocked,
|
|
104
|
+
remainingForUnlock,
|
|
105
|
+
setThemeId,
|
|
106
|
+
setColorMode,
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
{children}
|
|
110
|
+
</ThemeContext.Provider>
|
|
111
|
+
);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const useThemeContext = () => {
|
|
115
|
+
const ctx = useContext(ThemeContext);
|
|
116
|
+
if (!ctx) {
|
|
117
|
+
throw new Error("useThemeContext must be used within ThemeProvider");
|
|
118
|
+
}
|
|
119
|
+
return ctx;
|
|
120
|
+
};
|