([]);
const [devSynced, setDevSynced] = useState(false);
+ // ─── Effects ─────────────────────────────────────────────────────────────
+
useEffect(() => {
window.env.get().then((env: JRCEnvironment) => setIsDev(env.type === 'dev'));
}, []);
- const fetchThemes = async () => {
- sessionStorage.removeItem(THEME_SESSION_KEY);
- setSessionThemes([]);
- setThemeFetch('loading');
- const res = await window.api.fetchRemoteThemes();
+ // Auto-fetch previews on mount if not cached in session
+ useEffect(() => {
+ if (readSession(THEME_SESSION_KEY).length === 0) fetchThemePreviews();
+ if (readSession(LANG_SESSION_KEY).length === 0) fetchLangPreviews();
+ }, []);
+
+ // Auto-load dev assets when dev mode detected
+ useEffect(() => {
+ if (isDev) syncDevAssets();
+ }, [isDev]);
+
+ // ─── Fetch handlers ──────────────────────────────────────────────────────
+
+ const fetchThemePreviews = async () => {
+ setThemesFetch('loading');
+ const res = await window.api.fetchThemePreviews();
if (res.ok && res.themes) {
writeSession(THEME_SESSION_KEY, res.themes);
- setSessionThemes(res.themes);
- setThemeFetch('done');
+ setThemePreviews(res.themes);
+ setThemesFetch('done');
} else {
- setThemeFetch('error');
+ setThemesFetch('error');
}
};
- const fetchLangs = async () => {
- sessionStorage.removeItem(LANG_SESSION_KEY);
- setSessionLangs([]);
- setLangFetch('loading');
- const res = await window.api.fetchRemoteLanguages();
+ const fetchLangPreviews = async () => {
+ setLangsFetch('loading');
+ const res = await window.api.fetchLanguagePreviews();
if (res.ok && res.languages) {
writeSession(LANG_SESSION_KEY, res.languages);
- setSessionLangs(res.languages);
- setLangFetch('done');
+ setLangPreviews(res.languages);
+ setLangsFetch('done');
} else {
- setLangFetch('error');
+ setLangsFetch('error');
}
};
- const handleDevSync = async () => {
+ const refreshThemes = () => {
+ sessionStorage.removeItem(THEME_SESSION_KEY);
+ setThemePreviews([]);
+ fetchThemePreviews();
+ };
+
+ const refreshLangs = () => {
+ sessionStorage.removeItem(LANG_SESSION_KEY);
+ setLangPreviews([]);
+ fetchLangPreviews();
+ };
+
+ const syncDevAssets = async () => {
const assets = await window.api.loadDevAssets();
- const newThemes = mergeById(sessionThemes, assets.themes, (th) => th.id);
- writeSession(THEME_SESSION_KEY, newThemes);
- setSessionThemes(newThemes);
- const newLangs = mergeById(sessionLangs, assets.languages, (l) => l.id);
- writeSession(LANG_SESSION_KEY, newLangs);
- setSessionLangs(newLangs);
+ setDevThemes(assets.themes);
+ setDevLangs(assets.languages);
+ };
+
+ const handleDevSync = async () => {
+ await syncDevAssets();
setDevSynced(true);
setTimeout(() => setDevSynced(false), 2000);
};
- // Active theme/lang always shown first; session entries fill the rest
- const allThemes = [theme, ...sessionThemes.filter((th) => th.id !== theme.id)];
- const allLangs = mergeById([language, ENGLISH], sessionLangs, (l) => l.id);
+ // ─── Selection handlers ──────────────────────────────────────────────────
+
+ const selectTheme = async (item: ThemeItem) => {
+ if (item.id === theme.id || loadingId) return;
+ if (item.fullTheme) {
+ setTheme(item.fullTheme);
+ return;
+ }
+ if (!item.filename) return;
+ setLoadingId(item.id);
+ const res = await window.api.fetchThemeByFile(item.filename);
+ setLoadingId(null);
+ if (res.ok && res.theme) setTheme(res.theme);
+ };
+
+ const selectLang = async (item: LangItem) => {
+ if (item.id === language.id || loadingId) return;
+ if (item.fullLang) {
+ setLanguage(item.fullLang);
+ return;
+ }
+ if (!item.filename) return;
+ setLoadingId(item.id);
+ const res = await window.api.fetchLanguageByFile(item.filename);
+ setLoadingId(null);
+ if (res.ok && res.language) setLanguage(res.language);
+ };
+
+ // ─── Build lists ─────────────────────────────────────────────────────────
+
+ const themeItems = buildThemeList(theme, themePreviews, isDev ? devThemes : []);
+ const langItems = buildLangList(language, langPreviews, isDev ? devLangs : []);
+
+ // ─── Render ──────────────────────────────────────────────────────────────
return (
<>
@@ -94,27 +190,30 @@ export function AppearanceSection() {
{t('settings.themeHint')}
- {themeFetch === 'error' && (
+ {themesFetch === 'error' && (
{t('appearance.fetchThemesFailed')}
)}
- {allThemes.map((th) => (
+ {themeItems.map((item) => (
))}
+ {themesFetch === 'loading' && themePreviews.length === 0 && (
+
+ {t('general.loading')}
+
+ )}
@@ -137,33 +247,47 @@ export function AppearanceSection() {
{t('settings.languageHint')}
- {langFetch === 'error' && (
+ {langsFetch === 'error' && (
{t('appearance.fetchLangsFailed')}
)}
- {allLangs.map((l) => (
+ {langItems.map((item) => (
))}
+ {langsFetch === 'loading' && langPreviews.length === 0 && (
+
+ {t('general.loading')}
+
+ )}
@@ -182,15 +306,185 @@ export function AppearanceSection() {
{devSynced ? t('appearance.synced') : t('appearance.sync')}
+
+
+ {t('appearance.localOnly')}
+
+
+ {t('appearance.localOverride')}
+
+
)}
>
);
}
-function mergeById(base: T[], override: T[], getId: (item: T) => string): T[] {
- const map = new Map();
- for (const item of base) map.set(getId(item), item);
- for (const item of override) map.set(getId(item), item);
- return Array.from(map.values());
+// ─── Dev badge component ──────────────────────────────────────────────────────
+
+function DevBadge({ hasLocal, hasRemote }: { hasLocal: boolean; hasRemote: boolean }) {
+ const { t } = useTranslation();
+ if (hasLocal && hasRemote) {
+ return (
+
+
+
+
+
+ );
+ }
+ if (hasLocal && !hasRemote) {
+ return (
+
+
+
+
+
+ );
+ }
+ return null;
+}
+
+// ─── List builders ────────────────────────────────────────────────────────────
+
+function previewColorsFromTheme(th: ThemeDefinition): ThemePreviewColors {
+ return {
+ accent: th.colors.accent,
+ 'base-900': th.colors['base-900'],
+ 'surface-raised': th.colors['surface-raised'],
+ 'text-primary': th.colors['text-primary'],
+ };
+}
+
+function buildThemeList(
+ active: ThemeDefinition,
+ remotePreviews: ThemePreview[],
+ devThemes: ThemeDefinition[]
+): ThemeItem[] {
+ const items = new Map();
+
+ // Active theme always first
+ items.set(active.id, {
+ id: active.id,
+ name: active.name,
+ previewColors: previewColorsFromTheme(active),
+ hasRemote: false,
+ hasLocal: false,
+ fullTheme: active,
+ });
+
+ // Builtin theme always available (like English for languages)
+ if (!items.has(BUILTIN_THEME.id)) {
+ items.set(BUILTIN_THEME.id, {
+ id: BUILTIN_THEME.id,
+ name: BUILTIN_THEME.name,
+ previewColors: previewColorsFromTheme(BUILTIN_THEME),
+ hasRemote: false,
+ hasLocal: false,
+ fullTheme: BUILTIN_THEME,
+ });
+ }
+
+ // Remote previews
+ for (const p of remotePreviews) {
+ const existing = items.get(p.id);
+ if (existing) {
+ existing.hasRemote = true;
+ existing.filename = p.filename;
+ } else {
+ items.set(p.id, {
+ id: p.id,
+ name: p.name,
+ previewColors: p.previewColors,
+ hasRemote: true,
+ hasLocal: false,
+ filename: p.filename,
+ });
+ }
+ }
+
+ // Dev-local themes
+ for (const dt of devThemes) {
+ const existing = items.get(dt.id);
+ if (existing) {
+ existing.hasLocal = true;
+ existing.fullTheme = dt;
+ existing.previewColors = previewColorsFromTheme(dt);
+ } else {
+ items.set(dt.id, {
+ id: dt.id,
+ name: dt.name,
+ previewColors: previewColorsFromTheme(dt),
+ hasRemote: false,
+ hasLocal: true,
+ fullTheme: dt,
+ });
+ }
+ }
+
+ return Array.from(items.values());
+}
+
+function buildLangList(
+ active: LanguageDefinition,
+ remotePreviews: LanguagePreview[],
+ devLangs: LanguageDefinition[]
+): LangItem[] {
+ const items = new Map();
+
+ // Active language always first
+ items.set(active.id, {
+ id: active.id,
+ name: active.name,
+ hasRemote: false,
+ hasLocal: false,
+ fullLang: active,
+ });
+
+ // English always available
+ if (!items.has(ENGLISH.id)) {
+ items.set(ENGLISH.id, {
+ id: ENGLISH.id,
+ name: ENGLISH.name,
+ hasRemote: false,
+ hasLocal: false,
+ fullLang: ENGLISH,
+ });
+ }
+
+ // Remote previews
+ for (const p of remotePreviews) {
+ const existing = items.get(p.id);
+ if (existing) {
+ existing.hasRemote = true;
+ existing.filename = p.filename;
+ } else {
+ items.set(p.id, {
+ id: p.id,
+ name: p.name,
+ hasRemote: true,
+ hasLocal: false,
+ filename: p.filename,
+ });
+ }
+ }
+
+ // Dev-local languages
+ for (const dl of devLangs) {
+ const existing = items.get(dl.id);
+ if (existing) {
+ existing.hasLocal = true;
+ existing.fullLang = dl;
+ } else {
+ items.set(dl.id, {
+ id: dl.id,
+ name: dl.name,
+ hasRemote: false,
+ hasLocal: true,
+ fullLang: dl,
+ });
+ }
+ }
+
+ return Array.from(items.values());
}