diff --git a/languages/de.json b/languages/de.json index fbb6e08..7bc8a4b 100644 --- a/languages/de.json +++ b/languages/de.json @@ -197,6 +197,8 @@ "appearance.syncHint": "Designs und Sprachen aus /themes und /languages im Projektverzeichnis laden", "appearance.sync": "Synchronisieren", "appearance.synced": "Synchronisiert", + "appearance.localOnly": "Nur lokal", + "appearance.localOverride": "Überschreibt Remote", "settings.advanced": "Erweitert", "settings.devMode": "Entwickleroptionen", @@ -234,6 +236,7 @@ "settings.version": "Version", "settings.stack": "Technologie", "settings.configPath": "Konfiguration", + "settings.openConfigFolder": "Konfigurationsordner öffnen", "release.title": "Release-Details", "release.preRelease": "Vorabversion", diff --git a/src/main/AssetManager.ts b/src/main/AssetManager.ts index ad5fa83..068b10a 100644 --- a/src/main/AssetManager.ts +++ b/src/main/AssetManager.ts @@ -5,8 +5,12 @@ import https from 'https'; import { GITHUB_CONFIG } from './shared/config/GitHub.config'; import { BUILTIN_THEME, THEME_GITHUB_PATH } from './shared/config/Theme.config'; import { ENGLISH } from './shared/config/DefaultLanguage.config'; -import type { ThemeDefinition, LocalThemeState } from './shared/types/Theme.types'; -import type { LanguageDefinition, LocalLanguageState } from './shared/types/Language.types'; +import type { ThemeDefinition, LocalThemeState, ThemePreview } from './shared/types/Theme.types'; +import type { + LanguageDefinition, + LocalLanguageState, + LanguagePreview, +} from './shared/types/Language.types'; function dataDir(): string { return app.getPath('userData'); @@ -93,6 +97,53 @@ export async function fetchRemoteThemes(): Promise<{ } } +export async function fetchRemoteThemePreviews(): Promise<{ + ok: boolean; + themes?: ThemePreview[]; + error?: string; +}> { + try { + const listing = await httpsGetJson(contentsUrl(THEME_GITHUB_PATH)); + if (!Array.isArray(listing)) return { ok: false, error: 'Themes folder not found' }; + const themes: ThemePreview[] = []; + for (const f of (listing as Array<{ name: string }>).filter((f) => f.name.endsWith('.json'))) { + try { + const theme = (await httpsGetJson(rawUrl(THEME_GITHUB_PATH, f.name))) as ThemeDefinition; + if (theme.id && theme.name && theme.colors) { + themes.push({ + id: theme.id, + name: theme.name, + filename: f.name, + previewColors: { + accent: theme.colors.accent, + 'base-900': theme.colors['base-900'], + 'surface-raised': theme.colors['surface-raised'], + 'text-primary': theme.colors['text-primary'], + }, + }); + } + } catch { + /* skip */ + } + } + return { ok: true, themes }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +export async function fetchRemoteThemeByFile( + filename: string +): Promise<{ ok: boolean; theme?: ThemeDefinition; error?: string }> { + try { + const theme = (await httpsGetJson(rawUrl(THEME_GITHUB_PATH, filename))) as ThemeDefinition; + if (theme.id && theme.name && theme.colors) return { ok: true, theme }; + return { ok: false, error: 'Invalid theme' }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + // ─── Languages ──────────────────────────────────────────────────────────────── const LANG_FILE = 'language-state.json'; @@ -142,6 +193,47 @@ export async function fetchRemoteLanguages(): Promise<{ } } +export async function fetchRemoteLanguagePreviews(): Promise<{ + ok: boolean; + languages?: LanguagePreview[]; + error?: string; +}> { + try { + const listing = await httpsGetJson(contentsUrl(GITHUB_CONFIG.languagesPath)); + if (!Array.isArray(listing)) return { ok: false, error: 'Languages folder not found' }; + const languages: LanguagePreview[] = []; + for (const f of (listing as Array<{ name: string }>).filter((f) => f.name.endsWith('.json'))) { + try { + const lang = (await httpsGetJson( + rawUrl(GITHUB_CONFIG.languagesPath, f.name) + )) as LanguageDefinition; + if (lang.id && lang.name) { + languages.push({ id: lang.id, name: lang.name, filename: f.name }); + } + } catch { + /* skip */ + } + } + return { ok: true, languages }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + +export async function fetchRemoteLanguageByFile( + filename: string +): Promise<{ ok: boolean; language?: LanguageDefinition; error?: string }> { + try { + const lang = (await httpsGetJson( + rawUrl(GITHUB_CONFIG.languagesPath, filename) + )) as LanguageDefinition; + if (lang.id && lang.name && lang.strings) return { ok: true, language: lang }; + return { ok: false, error: 'Invalid language' }; + } catch (e) { + return { ok: false, error: String(e) }; + } +} + // ─── Dev mode: load from local project directories ──────────────────────────── function projectRoot(): string { diff --git a/src/main/ipc/Asset.ipc.ts b/src/main/ipc/Asset.ipc.ts index f407005..6b83e5c 100644 --- a/src/main/ipc/Asset.ipc.ts +++ b/src/main/ipc/Asset.ipc.ts @@ -3,9 +3,13 @@ import { getActiveTheme, setActiveTheme, fetchRemoteThemes, + fetchRemoteThemePreviews, + fetchRemoteThemeByFile, getActiveLanguage, setActiveLanguage, fetchRemoteLanguages, + fetchRemoteLanguagePreviews, + fetchRemoteLanguageByFile, loadDevAssets, } from '../AssetManager'; import type { ThemeDefinition } from '../shared/types/Theme.types'; @@ -28,6 +32,16 @@ export const AssetIPC = { channel: 'asset:fetchThemes', handler: () => fetchRemoteThemes(), }, + fetchThemePreviews: { + type: 'invoke', + channel: 'asset:themePreviews', + handler: () => fetchRemoteThemePreviews(), + }, + fetchThemeByFile: { + type: 'invoke', + channel: 'asset:themeByFile', + handler: (_e: any, filename: string) => fetchRemoteThemeByFile(filename), + }, // Languages getActiveLanguage: { @@ -45,6 +59,16 @@ export const AssetIPC = { channel: 'asset:fetchLangs', handler: () => fetchRemoteLanguages(), }, + fetchLanguagePreviews: { + type: 'invoke', + channel: 'asset:langPreviews', + handler: () => fetchRemoteLanguagePreviews(), + }, + fetchLanguageByFile: { + type: 'invoke', + channel: 'asset:langByFile', + handler: (_e: any, filename: string) => fetchRemoteLanguageByFile(filename), + }, // Dev loadDevAssets: { diff --git a/src/main/ipc/System.ipc.ts b/src/main/ipc/System.ipc.ts index 5c88b69..74a8744 100644 --- a/src/main/ipc/System.ipc.ts +++ b/src/main/ipc/System.ipc.ts @@ -1,4 +1,4 @@ -import { dialog, shell } from 'electron'; +import { app, dialog, shell } from 'electron'; import { restApiServer } from '../RestAPI'; import type { RouteMap } from '../IPCController'; import type { AppSettings, JRCEnvironment } from '../shared/types/App.types'; @@ -65,4 +65,10 @@ export const SystemIPC = { channel: 'shell:openExternal', handler: (_e: any, url: string) => shell.openExternal(url), }, + + openConfigFolder: { + type: 'invoke', + channel: 'shell:openConfigFolder', + handler: () => shell.openPath(app.getPath('userData')), + }, } satisfies RouteMap; diff --git a/src/main/shared/config/DefaultLanguage.config.ts b/src/main/shared/config/DefaultLanguage.config.ts index 298e5f4..83a3271 100644 --- a/src/main/shared/config/DefaultLanguage.config.ts +++ b/src/main/shared/config/DefaultLanguage.config.ts @@ -219,6 +219,8 @@ const ENGLISH_STRINGS = { 'Load themes and languages from /themes and /languages in the project root', 'appearance.sync': 'Sync', 'appearance.synced': 'Synced', + 'appearance.localOnly': 'Local only', + 'appearance.localOverride': 'Overrides remote', // Settings: Advanced 'settings.advanced': 'Advanced', @@ -259,6 +261,7 @@ const ENGLISH_STRINGS = { 'settings.version': 'Version', 'settings.stack': 'Stack', 'settings.configPath': 'Config', + 'settings.openConfigFolder': 'Open config folder', // Release modal 'release.title': 'Release Details', diff --git a/src/main/shared/types/Language.types.ts b/src/main/shared/types/Language.types.ts index 480cdf4..626ed5a 100644 --- a/src/main/shared/types/Language.types.ts +++ b/src/main/shared/types/Language.types.ts @@ -10,3 +10,9 @@ export interface LocalLanguageState { activeLanguageId: string; activeLanguage: LanguageDefinition; } + +export interface LanguagePreview { + id: string; + name: string; + filename: string; +} diff --git a/src/main/shared/types/Theme.types.ts b/src/main/shared/types/Theme.types.ts index 3751bf2..4ff4688 100644 --- a/src/main/shared/types/Theme.types.ts +++ b/src/main/shared/types/Theme.types.ts @@ -26,3 +26,15 @@ export interface LocalThemeState { activeThemeId: string; activeTheme: ThemeDefinition; } + +export type ThemePreviewColors = Pick< + ThemeColors, + 'accent' | 'base-900' | 'surface-raised' | 'text-primary' +>; + +export interface ThemePreview { + id: string; + name: string; + filename: string; + previewColors: ThemePreviewColors; +} diff --git a/src/renderer/components/common/ContextMenu.tsx b/src/renderer/components/common/ContextMenu.tsx index ff20808..45db283 100644 --- a/src/renderer/components/common/ContextMenu.tsx +++ b/src/renderer/components/common/ContextMenu.tsx @@ -28,9 +28,11 @@ export function ContextMenu({ x, y, items, onClose }: Props) { }; document.addEventListener('mousedown', handleOutside); document.addEventListener('keydown', handleKey); + document.addEventListener('scroll', onClose, true); return () => { document.removeEventListener('mousedown', handleOutside); document.removeEventListener('keydown', handleKey); + document.removeEventListener('scroll', onClose, true); }; }, [onClose]); diff --git a/src/renderer/components/settings/VersionChecker.tsx b/src/renderer/components/settings/VersionChecker.tsx index 46cb8eb..b1fe7f0 100644 --- a/src/renderer/components/settings/VersionChecker.tsx +++ b/src/renderer/components/settings/VersionChecker.tsx @@ -4,7 +4,7 @@ import { Button } from '../common/Button'; import { Tooltip } from '../common/Tooltip'; import { ReleaseModal } from './ReleaseModal'; import { VscCheck, VscWarning, VscSync, VscCircleSlash } from 'react-icons/vsc'; -import { GitHubRelease } from '../../../../main/shared/types/GitHub.types'; +import { GitHubRelease } from '../../../main/shared/types/GitHub.types'; interface Props { currentVersion: string; diff --git a/src/renderer/components/settings/sections/AboutSection.tsx b/src/renderer/components/settings/sections/AboutSection.tsx index 3798129..5527c6f 100644 --- a/src/renderer/components/settings/sections/AboutSection.tsx +++ b/src/renderer/components/settings/sections/AboutSection.tsx @@ -1,7 +1,9 @@ import React from 'react'; import { useTranslation } from '../../../i18n/I18nProvider'; import { Section, Row } from '../SettingsRow'; +import { Tooltip } from '../../common/Tooltip'; import { VersionChecker } from '../VersionChecker'; +import { VscFolderOpened } from 'react-icons/vsc'; import { version } from '../../../../../package.json'; export function AboutSection() { @@ -13,7 +15,17 @@ export function AboutSection() { Electron · React · TypeScript - %APPDATA%\java-runner-client +
+ %APPDATA%\java-runner-client + + + +
); diff --git a/src/renderer/components/settings/sections/AppearanceSection.tsx b/src/renderer/components/settings/sections/AppearanceSection.tsx index 68778b2..1d49d8c 100644 --- a/src/renderer/components/settings/sections/AppearanceSection.tsx +++ b/src/renderer/components/settings/sections/AppearanceSection.tsx @@ -1,17 +1,26 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Section } from '../SettingsRow'; import { useTheme } from '../../../hooks/ThemeProvider'; import { useTranslation } from '../../../i18n/I18nProvider'; -import { VscSync, VscCheck } from 'react-icons/vsc'; -import type { ThemeDefinition } from '../../../../main/shared/types/Theme.types'; -import type { LanguageDefinition } from '../../../../main/shared/types/Language.types'; +import { Tooltip } from '../../common/Tooltip'; +import { VscSync, VscCheck, VscBeaker, VscArrowSwap } from 'react-icons/vsc'; +import type { + ThemeDefinition, + ThemePreview, + ThemePreviewColors, +} from '../../../../main/shared/types/Theme.types'; +import type { + LanguageDefinition, + LanguagePreview, +} from '../../../../main/shared/types/Language.types'; import { ENGLISH } from '../../../../main/shared/config/DefaultLanguage.config'; +import { BUILTIN_THEME } from '../../../../main/shared/config/Theme.config'; import type { JRCEnvironment } from '../../../../main/shared/types/App.types'; type FetchState = 'idle' | 'loading' | 'done' | 'error'; -const THEME_SESSION_KEY = 'jrc:session-themes'; -const LANG_SESSION_KEY = 'jrc:session-langs'; +const THEME_SESSION_KEY = 'jrc:theme-previews'; +const LANG_SESSION_KEY = 'jrc:lang-previews'; function readSession(key: string): T[] { try { @@ -25,68 +34,155 @@ function writeSession(key: string, items: T[]): void { sessionStorage.setItem(key, JSON.stringify(items)); } +// ─── List item types ───────────────────────────────────────────────────────── + +interface ThemeItem { + id: string; + name: string; + previewColors: ThemePreviewColors; + hasRemote: boolean; + hasLocal: boolean; + filename?: string; + fullTheme?: ThemeDefinition; +} + +interface LangItem { + id: string; + name: string; + hasRemote: boolean; + hasLocal: boolean; + filename?: string; + fullLang?: LanguageDefinition; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + export function AppearanceSection() { const { theme, setTheme } = useTheme(); const { language, t, setLanguage } = useTranslation(); - const [sessionThemes, setSessionThemes] = useState(() => - readSession(THEME_SESSION_KEY) + const [themePreviews, setThemePreviews] = useState(() => + readSession(THEME_SESSION_KEY) + ); + const [langPreviews, setLangPreviews] = useState(() => + readSession(LANG_SESSION_KEY) ); - const [sessionLangs, setSessionLangs] = useState(() => - readSession(LANG_SESSION_KEY) + + const [themesFetch, setThemesFetch] = useState(() => + readSession(THEME_SESSION_KEY).length > 0 ? 'done' : 'idle' ); - const [themeFetch, setThemeFetch] = useState('idle'); - const [langFetch, setLangFetch] = useState('idle'); + const [langsFetch, setLangsFetch] = useState(() => + readSession(LANG_SESSION_KEY).length > 0 ? 'done' : 'idle' + ); + + const [loadingId, setLoadingId] = useState(null); + const [isDev, setIsDev] = useState(false); + const [devThemes, setDevThemes] = useState([]); + const [devLangs, setDevLangs] = useState([]); 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()); }