Skip to content

Commit 765e650

Browse files
committed
Implement Advanced Theme Customization System
1 parent 635376c commit 765e650

9 files changed

Lines changed: 519 additions & 75 deletions

File tree

package-lock.json

Lines changed: 82 additions & 75 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
22
import { Geist, Geist_Mono } from 'next/font/google';
33
import './globals.css';
44
import { ThemeProvider } from '@/lib/theme-provider';
5+
import DynamicTheming from '@/components/theme/DynamicTheming';
56
import { OfflineModeProvider } from './context/OfflineModeContext';
67
import { I18nProvider } from '@/hooks/useInternationalization';
78
import { InternationalizationEngine } from '@/components/i18n/InternationalizationEngine';
@@ -43,6 +44,7 @@ export default function RootLayout({
4344
<InternationalizationEngine>
4445
<CulturalAdaptationManager>
4546
<ThemeProvider>
47+
<DynamicTheming />
4648
<AccessibilityProvider pageLabel="TeachLink — main application">
4749
<OfflineModeProvider>
4850
<PWAManager />

src/app/theme-demo/page.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use client';
2+
3+
import ThemeCustomizer from '@/components/theme/ThemeCustomizer';
4+
import ThemePresets from '@/components/theme/ThemePresets';
5+
import ThemeExporter from '@/components/theme/ThemeExporter';
6+
7+
export default function ThemeDemoPage() {
8+
return (
9+
<main className="p-6 space-y-6">
10+
<h1 className="text-2xl font-bold">Theme Demo</h1>
11+
<div className="grid md:grid-cols-3 gap-6">
12+
<div>
13+
<ThemeCustomizer />
14+
</div>
15+
<div>
16+
<ThemePresets />
17+
</div>
18+
<div>
19+
<ThemeExporter />
20+
</div>
21+
</div>
22+
</main>
23+
);
24+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useThemeCustomization } from '@/hooks/useThemeCustomization';
5+
6+
export default function DynamicTheming() {
7+
// Hook applies theme to root when state changes.
8+
useThemeCustomization();
9+
10+
// This component only ensures the hook runs and the theme is applied at runtime.
11+
return null;
12+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useThemeCustomization } from '@/hooks/useThemeCustomization';
5+
import { DEFAULT_THEME } from '@/utils/themeUtils';
6+
7+
export default function ThemeCustomizer() {
8+
const { theme, setTheme, reset, exportTheme, importTheme } = useThemeCustomization();
9+
const [local, setLocal] = React.useState(theme);
10+
11+
React.useEffect(() => setLocal(theme), [theme]);
12+
13+
function updateColor(key: keyof typeof theme.colors, value: string) {
14+
setLocal((prev) => ({ ...prev, colors: { ...prev.colors, [key]: value } }));
15+
}
16+
17+
function apply() {
18+
setTheme(local);
19+
}
20+
21+
async function handleImportFile(e: React.ChangeEvent<HTMLInputElement>) {
22+
const f = e.target.files?.[0];
23+
if (!f) return;
24+
const txt = await f.text();
25+
const res = importTheme(txt);
26+
if (!res.ok) {
27+
// simple alert for now
28+
alert('Failed to import theme: ' + (res.error || 'invalid'));
29+
}
30+
}
31+
32+
return (
33+
<div className="p-4 border rounded-md bg-white">
34+
<h2 className="text-lg font-semibold mb-2">Theme Customizer</h2>
35+
<div className="grid grid-cols-2 gap-3">
36+
<label className="flex flex-col text-sm">
37+
Primary
38+
<input
39+
type="color"
40+
value={local.colors.primary}
41+
onChange={(e) => updateColor('primary', e.target.value)}
42+
/>
43+
</label>
44+
<label className="flex flex-col text-sm">
45+
Accent
46+
<input
47+
type="color"
48+
value={local.colors.accent}
49+
onChange={(e) => updateColor('accent', e.target.value)}
50+
/>
51+
</label>
52+
<label className="flex flex-col text-sm">
53+
Background
54+
<input
55+
type="color"
56+
value={local.colors.background}
57+
onChange={(e) => updateColor('background', e.target.value)}
58+
/>
59+
</label>
60+
<label className="flex flex-col text-sm">
61+
Foreground
62+
<input
63+
type="color"
64+
value={local.colors.foreground}
65+
onChange={(e) => updateColor('foreground', e.target.value)}
66+
/>
67+
</label>
68+
</div>
69+
70+
<div className="mt-4 flex gap-2">
71+
<button onClick={apply} className="px-3 py-1 rounded bg-blue-600 text-white">
72+
Apply
73+
</button>
74+
<button onClick={() => setTheme(DEFAULT_THEME as any)} className="px-3 py-1 rounded border">
75+
Reset
76+
</button>
77+
<button
78+
onClick={() => {
79+
const json = exportTheme();
80+
navigator.clipboard?.writeText(json);
81+
alert('Theme JSON copied to clipboard');
82+
}}
83+
className="px-3 py-1 rounded border"
84+
>
85+
Copy JSON
86+
</button>
87+
<label className="px-3 py-1 rounded border cursor-pointer">
88+
Import
89+
<input
90+
type="file"
91+
accept="application/json"
92+
onChange={handleImportFile}
93+
className="hidden"
94+
/>
95+
</label>
96+
</div>
97+
98+
<div className="mt-4">
99+
<div
100+
className="p-3 rounded"
101+
style={{
102+
background: 'var(--background)',
103+
color: 'var(--foreground)',
104+
border: '1px solid rgba(0,0,0,0.06)',
105+
}}
106+
>
107+
<strong>Live preview</strong>
108+
<p className="mt-2">
109+
Primary color sample:{' '}
110+
<span style={{ color: 'var(--color-primary)' }}>{local.colors.primary}</span>
111+
</p>
112+
</div>
113+
</div>
114+
</div>
115+
);
116+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useThemeCustomization } from '@/hooks/useThemeCustomization';
5+
6+
export default function ThemeExporter() {
7+
const { exportTheme, importTheme } = useThemeCustomization();
8+
9+
function downloadTheme() {
10+
const json = exportTheme();
11+
const blob = new Blob([json], { type: 'application/json' });
12+
const url = URL.createObjectURL(blob);
13+
const a = document.createElement('a');
14+
a.href = url;
15+
a.download = 'teachlink-theme.json';
16+
a.click();
17+
URL.revokeObjectURL(url);
18+
}
19+
20+
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
21+
const f = e.target.files?.[0];
22+
if (!f) return;
23+
const txt = await f.text();
24+
const res = importTheme(txt);
25+
if (!res.ok) alert('Import failed: ' + (res.error || 'invalid'));
26+
}
27+
28+
return (
29+
<div className="space-y-2">
30+
<button onClick={downloadTheme} className="px-3 py-1 rounded bg-green-600 text-white">
31+
Export Theme
32+
</button>
33+
<label className="px-3 py-1 rounded border cursor-pointer inline-block">
34+
Import Theme
35+
<input type="file" accept="application/json" onChange={handleFile} className="hidden" />
36+
</label>
37+
</div>
38+
);
39+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { useThemeCustomization } from '@/hooks/useThemeCustomization';
5+
6+
export default function ThemePresets() {
7+
const { presets, applyPreset } = useThemeCustomization();
8+
9+
return (
10+
<div className="space-y-2">
11+
<h3 className="text-sm font-semibold">Presets</h3>
12+
<div className="flex flex-wrap gap-2">
13+
{presets.map((p) => (
14+
<button
15+
key={p.name}
16+
onClick={() => applyPreset(p.name || '')}
17+
className="flex items-center gap-2 px-3 py-1 rounded-md border hover:shadow-sm"
18+
aria-label={`Apply preset ${p.name}`}
19+
>
20+
<span
21+
className="w-6 h-6 rounded-sm"
22+
style={{
23+
background: p.colors.primary,
24+
boxShadow: `inset 0 0 0 2px ${p.colors.accent}`,
25+
}}
26+
/>
27+
<span className="text-sm">{p.name}</span>
28+
</button>
29+
))}
30+
</div>
31+
</div>
32+
);
33+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import {
5+
DEFAULT_THEME,
6+
PRESET_THEMES,
7+
themeToCSSVars,
8+
validateTheme,
9+
type ThemeShape,
10+
} from '@/utils/themeUtils';
11+
12+
const STORAGE_KEY = 'teachlink:theme';
13+
const BROADCAST_CHANNEL = 'teachlink-theme';
14+
15+
function applyThemeToRoot(theme: ThemeShape) {
16+
const vars = themeToCSSVars(theme);
17+
const root = document.documentElement;
18+
Object.entries(vars).forEach(([k, v]) => root.style.setProperty(k, v));
19+
}
20+
21+
export function useThemeCustomization() {
22+
const [theme, setThemeState] = React.useState<ThemeShape>(DEFAULT_THEME);
23+
24+
// BroadcastChannel for cross-tab sync
25+
const bcRef = React.useRef<BroadcastChannel | null>(null);
26+
27+
React.useEffect(() => {
28+
// load persisted theme
29+
try {
30+
const raw = localStorage.getItem(STORAGE_KEY);
31+
if (raw) {
32+
const parsed = JSON.parse(raw);
33+
if (validateTheme(parsed)) {
34+
setThemeState(parsed);
35+
applyThemeToRoot(parsed);
36+
}
37+
} else {
38+
// apply default
39+
applyThemeToRoot(DEFAULT_THEME);
40+
}
41+
} catch (e) {
42+
// ignore parse errors and fallback
43+
applyThemeToRoot(DEFAULT_THEME);
44+
}
45+
46+
if ('BroadcastChannel' in window) {
47+
bcRef.current = new BroadcastChannel(BROADCAST_CHANNEL);
48+
bcRef.current.onmessage = (ev) => {
49+
try {
50+
const data = ev.data;
51+
if (validateTheme(data)) {
52+
setThemeState(data);
53+
applyThemeToRoot(data);
54+
}
55+
} catch (e) {
56+
// ignore
57+
}
58+
};
59+
}
60+
61+
return () => {
62+
if (bcRef.current) bcRef.current.close();
63+
};
64+
// eslint-disable-next-line react-hooks/exhaustive-deps
65+
}, []);
66+
67+
const setTheme = React.useCallback((next: ThemeShape) => {
68+
setThemeState(next);
69+
try {
70+
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
71+
} catch (e) {
72+
// ignore
73+
}
74+
applyThemeToRoot(next);
75+
if (bcRef.current) {
76+
try {
77+
bcRef.current.postMessage(next);
78+
} catch (e) {}
79+
}
80+
}, []);
81+
82+
const reset = React.useCallback(() => setTheme(DEFAULT_THEME), [setTheme]);
83+
84+
const applyPreset = React.useCallback(
85+
(name: string) => {
86+
const p = PRESET_THEMES.find((t) => t.name === name);
87+
if (p) setTheme(p);
88+
},
89+
[setTheme],
90+
);
91+
92+
const exportTheme = React.useCallback(() => {
93+
return JSON.stringify(theme, null, 2);
94+
}, [theme]);
95+
96+
const importTheme = React.useCallback(
97+
(payload: string) => {
98+
try {
99+
const parsed = JSON.parse(payload);
100+
if (validateTheme(parsed)) {
101+
setTheme(parsed);
102+
return { ok: true };
103+
}
104+
return { ok: false, error: 'Invalid theme shape' };
105+
} catch (e: any) {
106+
return { ok: false, error: e?.message || String(e) };
107+
}
108+
},
109+
[setTheme],
110+
);
111+
112+
return {
113+
theme,
114+
setTheme,
115+
reset,
116+
presets: PRESET_THEMES,
117+
applyPreset,
118+
exportTheme,
119+
importTheme,
120+
} as const;
121+
}

0 commit comments

Comments
 (0)