feat: Theme engine
big changes
This commit is contained in:
@@ -39,6 +39,13 @@ import {
|
|||||||
getWindowIconPath,
|
getWindowIconPath,
|
||||||
updateCloseToTraySetting
|
updateCloseToTraySetting
|
||||||
} from '../window/create-window';
|
} from '../window/create-window';
|
||||||
|
import {
|
||||||
|
deleteSavedTheme,
|
||||||
|
getSavedThemesPath,
|
||||||
|
listSavedThemes,
|
||||||
|
readSavedTheme,
|
||||||
|
writeSavedTheme
|
||||||
|
} from '../theme-library';
|
||||||
|
|
||||||
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -325,6 +332,15 @@ export function setupSystemHandlers(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
|
||||||
|
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
|
||||||
|
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
|
||||||
|
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
|
||||||
|
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
|
||||||
|
return await writeSavedTheme(fileName, text);
|
||||||
|
});
|
||||||
|
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
|
||||||
|
return await deleteSavedTheme(fileName);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ export interface WindowStateSnapshot {
|
|||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SavedThemeFileDescriptor {
|
||||||
|
fileName: string;
|
||||||
|
modifiedAt: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
function readLinuxDisplayServer(): string {
|
function readLinuxDisplayServer(): string {
|
||||||
if (process.platform !== 'linux') {
|
if (process.platform !== 'linux') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
@@ -134,6 +140,11 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
getSavedThemesPath: () => Promise<string>;
|
||||||
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
|
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||||
|
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||||
consumePendingDeepLink: () => Promise<string | null>;
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
getDesktopSettings: () => Promise<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
@@ -230,6 +241,11 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||||
|
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
|
||||||
|
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
|
||||||
|
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
|
||||||
|
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
|
||||||
|
deleteSavedTheme: (fileName) => ipcRenderer.invoke('delete-saved-theme', fileName),
|
||||||
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
|
||||||
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
|
||||||
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
|
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
|
||||||
|
|||||||
92
electron/theme-library.ts
Normal file
92
electron/theme-library.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import * as fsp from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface SavedThemeFileDescriptor {
|
||||||
|
fileName: string;
|
||||||
|
modifiedAt: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SAVED_THEME_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
|
||||||
|
|
||||||
|
function resolveSavedThemesPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'themes');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSavedThemesPath(): Promise<string> {
|
||||||
|
const themesPath = resolveSavedThemesPath();
|
||||||
|
|
||||||
|
await fsp.mkdir(themesPath, { recursive: true });
|
||||||
|
|
||||||
|
return themesPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSavedThemeFileName(fileName: string): string {
|
||||||
|
const normalized = typeof fileName === 'string'
|
||||||
|
? fileName.trim()
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!SAVED_THEME_FILE_NAME_PATTERN.test(normalized) || normalized.includes('..')) {
|
||||||
|
throw new Error('Invalid saved theme file name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSavedThemeFilePath(fileName: string): Promise<string> {
|
||||||
|
const themesPath = await ensureSavedThemesPath();
|
||||||
|
|
||||||
|
return path.join(themesPath, assertSavedThemeFileName(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSavedThemesPath(): Promise<string> {
|
||||||
|
return await ensureSavedThemesPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
|
||||||
|
const themesPath = await ensureSavedThemesPath();
|
||||||
|
const entries = await fsp.readdir(themesPath, { withFileTypes: true });
|
||||||
|
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
|
||||||
|
|
||||||
|
const descriptors = await Promise.all(files.map(async (entry) => {
|
||||||
|
const filePath = path.join(themesPath, entry.name);
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileName: entry.name,
|
||||||
|
modifiedAt: Math.round(stats.mtimeMs),
|
||||||
|
path: filePath
|
||||||
|
} satisfies SavedThemeFileDescriptor;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return descriptors.sort((left, right) => right.modifiedAt - left.modifiedAt || left.fileName.localeCompare(right.fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readSavedTheme(fileName: string): Promise<string> {
|
||||||
|
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||||
|
|
||||||
|
return await fsp.readFile(filePath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeSavedTheme(fileName: string, text: string): Promise<boolean> {
|
||||||
|
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||||
|
|
||||||
|
await fsp.writeFile(filePath, text, 'utf8');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSavedTheme(fileName: string): Promise<boolean> {
|
||||||
|
const filePath = await resolveSavedThemeFilePath(fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsp.unlink(filePath);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code === 'ENOENT') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
184
package-lock.json
generated
184
package-lock.json
generated
@@ -14,6 +14,12 @@
|
|||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.41.0",
|
||||||
"@ng-icons/core": "^33.0.0",
|
"@ng-icons/core": "^33.0.0",
|
||||||
"@ng-icons/lucide": "^33.0.0",
|
"@ng-icons/lucide": "^33.0.0",
|
||||||
"@ngrx/effects": "^21.0.1",
|
"@ngrx/effects": "^21.0.1",
|
||||||
@@ -27,6 +33,7 @@
|
|||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
@@ -2697,6 +2704,109 @@
|
|||||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
|
||||||
|
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
|
||||||
|
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-json": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@lezer/json": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||||
|
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
|
||||||
|
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.35.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
|
||||||
|
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/theme-one-dark": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.41.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
|
||||||
|
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@develar/schema-utils": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -5672,6 +5782,41 @@
|
|||||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/json": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
|
||||||
|
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@listr2/prompt-adapter-inquirer": {
|
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
||||||
@@ -5865,6 +6010,12 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@mermaid-js/parser": {
|
"node_modules/@mermaid-js/parser": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
||||||
@@ -14138,6 +14289,21 @@
|
|||||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -14766,6 +14932,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cron-parser": {
|
"node_modules/cron-parser": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
@@ -27782,6 +27954,12 @@
|
|||||||
"webpack": "^5.0.0"
|
"webpack": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stylehacks": {
|
"node_modules/stylehacks": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
||||||
@@ -30374,6 +30552,12 @@
|
|||||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/wait-on": {
|
"node_modules/wait-on": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||||
|
|||||||
@@ -60,6 +60,12 @@
|
|||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.0.0",
|
||||||
|
"@codemirror/commands": "^6.10.3",
|
||||||
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
|
"@codemirror/language": "^6.12.3",
|
||||||
|
"@codemirror/state": "^6.6.0",
|
||||||
|
"@codemirror/theme-one-dark": "^6.1.3",
|
||||||
|
"@codemirror/view": "^6.41.0",
|
||||||
"@ng-icons/core": "^33.0.0",
|
"@ng-icons/core": "^33.0.0",
|
||||||
"@ng-icons/lucide": "^33.0.0",
|
"@ng-icons/lucide": "^33.0.0",
|
||||||
"@ngrx/effects": "^21.0.1",
|
"@ngrx/effects": "^21.0.1",
|
||||||
@@ -73,6 +79,7 @@
|
|||||||
"auto-launch": "^5.0.6",
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
|
|||||||
Binary file not shown.
@@ -97,7 +97,7 @@
|
|||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "1MB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "2.1MB"
|
"maximumError": "2.15MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
@@ -1,60 +1,139 @@
|
|||||||
<div class="workspace-bright-theme relative flex h-screen overflow-hidden bg-background text-foreground">
|
<div
|
||||||
<!-- Global left servers rail always visible -->
|
appThemeNode="appRoot"
|
||||||
<aside class="w-16 flex-shrink-0 bg-transparent">
|
class="workspace-bright-theme relative h-screen overflow-hidden bg-background text-foreground"
|
||||||
<app-servers-rail class="h-full" />
|
>
|
||||||
</aside>
|
<div
|
||||||
<main class="relative min-w-0 flex-1 overflow-hidden bg-background">
|
class="grid h-full min-h-0 min-w-0 overflow-hidden"
|
||||||
<!-- Custom draggable title bar -->
|
[ngStyle]="appShellLayoutStyles()"
|
||||||
<app-title-bar />
|
>
|
||||||
|
<aside
|
||||||
|
appThemeNode="serversRail"
|
||||||
|
class="min-h-0 overflow-hidden bg-transparent"
|
||||||
|
[class.hidden]="isThemeStudioFullscreen()"
|
||||||
|
[ngStyle]="serversRailLayoutStyles()"
|
||||||
|
>
|
||||||
|
<app-servers-rail class="block h-full" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
@if (desktopUpdateState().restartRequired) {
|
<main
|
||||||
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
|
appThemeNode="appWorkspace"
|
||||||
<div class="pointer-events-auto mx-auto max-w-4xl rounded-md border border-border bg-card p-4 shadow-sm">
|
class="relative flex min-h-0 min-w-0 flex-col overflow-hidden bg-background"
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
[ngStyle]="appWorkspaceShellStyles()"
|
||||||
<div>
|
>
|
||||||
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
|
<app-title-bar class="block shrink-0" />
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
|
||||||
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="relative min-h-0 flex-1 overflow-hidden">
|
||||||
<button
|
@if (isThemeStudioFullscreen()) {
|
||||||
type="button"
|
<div class="theme-studio-fullscreen-shell absolute inset-0 overflow-y-auto overflow-x-hidden bg-background">
|
||||||
(click)="openUpdatesSettings()"
|
@if (themeStudioFullscreenComponent()) {
|
||||||
class="inline-flex items-center rounded-md border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
<ng-container *ngComponentOutlet="themeStudioFullscreenComponent()"></ng-container>
|
||||||
>
|
} @else {
|
||||||
Update settings
|
<div class="flex h-full items-center justify-center px-6 text-sm text-muted-foreground">Loading Theme Studio…</div>
|
||||||
</button>
|
}
|
||||||
|
</div>
|
||||||
|
} @else { @if (desktopUpdateState().restartRequired) {
|
||||||
|
<div class="pointer-events-none absolute inset-x-0 top-0 z-20 px-4 pt-4">
|
||||||
|
<div class="pointer-events-auto mx-auto max-w-4xl rounded-md border border-border bg-card p-4 shadow-sm">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="flex flex-wrap gap-2">
|
||||||
type="button"
|
<button
|
||||||
(click)="restartToApplyUpdate()"
|
type="button"
|
||||||
class="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
(click)="openUpdatesSettings()"
|
||||||
>
|
class="inline-flex items-center rounded-md border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
Restart now
|
>
|
||||||
</button>
|
Update settings
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="restartToApplyUpdate()"
|
||||||
|
class="inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Restart now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="absolute inset-0 overflow-auto">
|
||||||
|
<router-outlet />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
<!-- Content area fills below the title bar without global scroll -->
|
@if (isThemeStudioFullscreen()) {
|
||||||
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
|
<div
|
||||||
<router-outlet />
|
#themeStudioControlsRef
|
||||||
</div>
|
class="pointer-events-none absolute z-[80]"
|
||||||
</main>
|
[ngStyle]="themeStudioControlsPositionStyles()"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-auto flex items-center gap-2 rounded-full border border-border/80 bg-card/95 px-2 py-2 shadow-2xl backdrop-blur">
|
||||||
|
<div
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground select-none cursor-grab"
|
||||||
|
[class.cursor-grabbing]="isDraggingThemeStudioControls()"
|
||||||
|
(pointerdown)="startThemeStudioControlsDrag($event, themeStudioControlsRef)"
|
||||||
|
>
|
||||||
|
Theme Studio
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="minimizeThemeStudio()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Minimize
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="closeThemeStudio()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @if (isThemeStudioMinimized()) {
|
||||||
|
<div class="pointer-events-none absolute bottom-4 right-4 z-[80]">
|
||||||
|
<div class="pointer-events-auto flex items-center gap-3 rounded-2xl border border-border/80 bg-card/95 px-3 py-3 shadow-2xl backdrop-blur">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
|
||||||
|
<p class="mt-1 text-sm font-medium text-foreground">Minimized</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="reopenThemeStudio()"
|
||||||
|
class="rounded-full bg-primary px-3 py-1.5 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Re-open
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="dismissMinimizedThemeStudio()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @if (!isThemeStudioFullscreen()) {
|
||||||
<app-floating-voice-controls />
|
<app-floating-voice-controls />
|
||||||
|
}
|
||||||
|
<app-settings-modal />
|
||||||
|
<app-screen-share-source-picker />
|
||||||
|
<app-debug-console [showLauncher]="false" />
|
||||||
|
<app-theme-picker-overlay />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unified Settings Modal -->
|
|
||||||
<app-settings-modal />
|
|
||||||
|
|
||||||
<!-- Shared Screen Share Source Picker -->
|
|
||||||
<app-screen-share-source-picker />
|
|
||||||
|
|
||||||
<!-- Shared Debug Console -->
|
|
||||||
<app-debug-console [showLauncher]="false" />
|
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import {
|
|||||||
Component,
|
Component,
|
||||||
OnInit,
|
OnInit,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
HostListener
|
HostListener,
|
||||||
|
signal,
|
||||||
|
Type
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
Router,
|
Router,
|
||||||
@@ -37,6 +41,11 @@ import {
|
|||||||
STORAGE_KEY_CURRENT_USER_ID,
|
STORAGE_KEY_CURRENT_USER_ID,
|
||||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||||
} from './core/constants';
|
} from './core/constants';
|
||||||
|
import {
|
||||||
|
ThemeNodeDirective,
|
||||||
|
ThemePickerOverlayComponent,
|
||||||
|
ThemeService
|
||||||
|
} from './domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -48,12 +57,17 @@ import {
|
|||||||
FloatingVoiceControlsComponent,
|
FloatingVoiceControlsComponent,
|
||||||
SettingsModalComponent,
|
SettingsModalComponent,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareSourcePickerComponent
|
ScreenShareSourcePickerComponent,
|
||||||
|
ThemeNodeDirective,
|
||||||
|
ThemePickerOverlayComponent
|
||||||
],
|
],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss'
|
styleUrl: './app.scss'
|
||||||
})
|
})
|
||||||
export class App implements OnInit, OnDestroy {
|
export class App implements OnInit, OnDestroy {
|
||||||
|
private static readonly THEME_STUDIO_CONTROLS_MARGIN = 16;
|
||||||
|
private static readonly TITLE_BAR_HEIGHT = 40;
|
||||||
|
|
||||||
store = inject(Store);
|
store = inject(Store);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
desktopUpdates = inject(DesktopAppUpdateService);
|
desktopUpdates = inject(DesktopAppUpdateService);
|
||||||
@@ -65,17 +79,120 @@ export class App implements OnInit, OnDestroy {
|
|||||||
private notifications = inject(NotificationsFacade);
|
private notifications = inject(NotificationsFacade);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
private timeSync = inject(TimeSyncService);
|
private timeSync = inject(TimeSyncService);
|
||||||
|
private theme = inject(ThemeService);
|
||||||
private voiceSession = inject(VoiceSessionFacade);
|
private voiceSession = inject(VoiceSessionFacade);
|
||||||
private externalLinks = inject(ExternalLinkService);
|
private externalLinks = inject(ExternalLinkService);
|
||||||
private electronBridge = inject(ElectronBridgeService);
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
private deepLinkCleanup: (() => void) | null = null;
|
private deepLinkCleanup: (() => void) | null = null;
|
||||||
|
private themeStudioControlsDragOffset: { x: number; y: number } | null = null;
|
||||||
|
private themeStudioControlsBounds: { width: number; height: number } | null = null;
|
||||||
|
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||||
|
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||||
|
readonly isDraggingThemeStudioControls = signal(false);
|
||||||
|
|
||||||
|
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||||
|
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||||
|
readonly appWorkspaceLayoutStyles = computed(() => this.theme.getLayoutItemStyles('appWorkspace'));
|
||||||
|
readonly isThemeStudioFullscreen = computed(() => {
|
||||||
|
return this.settingsModal.isOpen()
|
||||||
|
&& this.settingsModal.activePage() === 'theme'
|
||||||
|
&& this.settingsModal.themeStudioFullscreen();
|
||||||
|
});
|
||||||
|
readonly isThemeStudioMinimized = computed(() => {
|
||||||
|
return this.settingsModal.activePage() === 'theme'
|
||||||
|
&& this.settingsModal.themeStudioMinimized();
|
||||||
|
});
|
||||||
|
readonly appWorkspaceShellStyles = computed(() => {
|
||||||
|
const workspaceStyles = this.appWorkspaceLayoutStyles();
|
||||||
|
|
||||||
|
if (!this.isThemeStudioFullscreen()) {
|
||||||
|
return workspaceStyles;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...workspaceStyles,
|
||||||
|
gridColumn: '1 / -1',
|
||||||
|
gridRow: '1 / -1'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
readonly themeStudioControlsPositionStyles = computed(() => {
|
||||||
|
const position = this.themeStudioControlsPosition();
|
||||||
|
|
||||||
|
if (!position) {
|
||||||
|
return {
|
||||||
|
right: `${App.THEME_STUDIO_CONTROLS_MARGIN}px`,
|
||||||
|
bottom: `${App.THEME_STUDIO_CONTROLS_MARGIN}px`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${position.x}px`,
|
||||||
|
top: `${position.y}px`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
if (!this.isThemeStudioFullscreen() || this.themeStudioFullscreenComponent()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void import('./domains/theme/feature/settings/theme-settings.component')
|
||||||
|
.then((module) => {
|
||||||
|
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (this.isThemeStudioFullscreen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDraggingThemeStudioControls.set(false);
|
||||||
|
this.themeStudioControlsDragOffset = null;
|
||||||
|
this.themeStudioControlsBounds = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
this.externalLinks.handleClick(evt);
|
this.externalLinks.handleClick(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown', ['$event'])
|
||||||
|
onGlobalKeyDown(evt: KeyboardEvent): void {
|
||||||
|
this.theme.handleGlobalShortcut(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:pointermove', ['$event'])
|
||||||
|
onDocumentPointerMove(event: PointerEvent): void {
|
||||||
|
if (!this.isDraggingThemeStudioControls() || !this.themeStudioControlsDragOffset || !this.themeStudioControlsBounds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.themeStudioControlsPosition.set(this.clampThemeStudioControlsPosition(
|
||||||
|
event.clientX - this.themeStudioControlsDragOffset.x,
|
||||||
|
event.clientY - this.themeStudioControlsDragOffset.y,
|
||||||
|
this.themeStudioControlsBounds.width,
|
||||||
|
this.themeStudioControlsBounds.height
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:pointerup')
|
||||||
|
@HostListener('document:pointercancel')
|
||||||
|
onDocumentPointerEnd(): void {
|
||||||
|
if (!this.isDraggingThemeStudioControls()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDraggingThemeStudioControls.set(false);
|
||||||
|
this.themeStudioControlsDragOffset = null;
|
||||||
|
this.themeStudioControlsBounds = null;
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
|
this.theme.initialize();
|
||||||
|
|
||||||
void this.desktopUpdates.initialize();
|
void this.desktopUpdates.initialize();
|
||||||
|
|
||||||
await this.databaseService.initialize();
|
await this.databaseService.initialize();
|
||||||
@@ -143,6 +260,45 @@ export class App implements OnInit, OnDestroy {
|
|||||||
this.settingsModal.open('updates');
|
this.settingsModal.open('updates');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startThemeStudioControlsDrag(event: PointerEvent, controlsElement: HTMLElement): void {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = controlsElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
this.themeStudioControlsBounds = {
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height
|
||||||
|
};
|
||||||
|
this.themeStudioControlsDragOffset = {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top
|
||||||
|
};
|
||||||
|
this.themeStudioControlsPosition.set({
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top
|
||||||
|
});
|
||||||
|
this.isDraggingThemeStudioControls.set(true);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
reopenThemeStudio(): void {
|
||||||
|
this.settingsModal.restoreMinimizedThemeStudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
minimizeThemeStudio(): void {
|
||||||
|
this.settingsModal.minimizeThemeStudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissMinimizedThemeStudio(): void {
|
||||||
|
this.settingsModal.dismissMinimizedThemeStudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeThemeStudio(): void {
|
||||||
|
this.settingsModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
async refreshDesktopUpdateContext(): Promise<void> {
|
async refreshDesktopUpdateContext(): Promise<void> {
|
||||||
await this.desktopUpdates.refreshServerContext();
|
await this.desktopUpdates.refreshServerContext();
|
||||||
}
|
}
|
||||||
@@ -151,6 +307,18 @@ export class App implements OnInit, OnDestroy {
|
|||||||
await this.desktopUpdates.restartToApplyUpdate();
|
await this.desktopUpdates.restartToApplyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clampThemeStudioControlsPosition(x: number, y: number, width: number, height: number): { x: number; y: number } {
|
||||||
|
const minX = App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||||
|
const minY = App.TITLE_BAR_HEIGHT + App.THEME_STUDIO_CONTROLS_MARGIN;
|
||||||
|
const maxX = Math.max(minX, window.innerWidth - width - App.THEME_STUDIO_CONTROLS_MARGIN);
|
||||||
|
const maxY = Math.max(minY, window.innerHeight - height - App.THEME_STUDIO_CONTROLS_MARGIN);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(minX, x), maxX),
|
||||||
|
y: Math.min(Math.max(minY, y), maxY)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async setupDesktopDeepLinks(): Promise<void> {
|
private async setupDesktopDeepLinks(): Promise<void> {
|
||||||
const electronApi = this.electronBridge.getApi();
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
|||||||
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
||||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||||
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
|
||||||
|
export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active';
|
||||||
|
export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
|
||||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||||
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||||
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export interface WindowStateSnapshot {
|
|||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SavedThemeFileDescriptor {
|
||||||
|
fileName: string;
|
||||||
|
modifiedAt: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronCommand {
|
export interface ElectronCommand {
|
||||||
type: string;
|
type: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
@@ -143,6 +149,11 @@ export interface ElectronApi {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
|
getSavedThemesPath: () => Promise<string>;
|
||||||
|
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
|
||||||
|
readSavedTheme: (fileName: string) => Promise<string>;
|
||||||
|
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
|
||||||
|
deleteSavedTheme: (fileName: string) => Promise<boolean>;
|
||||||
consumePendingDeepLink: () => Promise<string | null>;
|
consumePendingDeepLink: () => Promise<string | null>;
|
||||||
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
||||||
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, signal } from '@angular/core';
|
|||||||
|
|
||||||
export type SettingsPage =
|
export type SettingsPage =
|
||||||
| 'general'
|
| 'general'
|
||||||
|
| 'theme'
|
||||||
| 'network'
|
| 'network'
|
||||||
| 'notifications'
|
| 'notifications'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
@@ -17,18 +18,59 @@ export class SettingsModalService {
|
|||||||
readonly isOpen = signal(false);
|
readonly isOpen = signal(false);
|
||||||
readonly activePage = signal<SettingsPage>('general');
|
readonly activePage = signal<SettingsPage>('general');
|
||||||
readonly targetServerId = signal<string | null>(null);
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
readonly themeStudioFullscreen = signal(false);
|
||||||
|
readonly themeStudioMinimized = signal(false);
|
||||||
|
|
||||||
open(page: SettingsPage = 'general', serverId?: string): void {
|
open(page: SettingsPage = 'general', serverId?: string): void {
|
||||||
|
this.themeStudioFullscreen.set(false);
|
||||||
|
this.themeStudioMinimized.set(false);
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
this.targetServerId.set(serverId ?? null);
|
this.targetServerId.set(serverId ?? null);
|
||||||
this.isOpen.set(true);
|
this.isOpen.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
this.themeStudioFullscreen.set(false);
|
||||||
|
this.themeStudioMinimized.set(false);
|
||||||
this.isOpen.set(false);
|
this.isOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(page: SettingsPage): void {
|
navigate(page: SettingsPage): void {
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
|
|
||||||
|
if (page !== 'theme') {
|
||||||
|
this.themeStudioFullscreen.set(false);
|
||||||
|
this.themeStudioMinimized.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setThemeStudioFullscreen(isFullscreen: boolean): void {
|
||||||
|
this.themeStudioFullscreen.set(isFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleThemeStudioFullscreen(): void {
|
||||||
|
this.themeStudioFullscreen.update((isFullscreen) => !isFullscreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
openThemeStudio(): void {
|
||||||
|
this.activePage.set('theme');
|
||||||
|
this.themeStudioMinimized.set(false);
|
||||||
|
this.isOpen.set(true);
|
||||||
|
this.themeStudioFullscreen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
minimizeThemeStudio(): void {
|
||||||
|
this.activePage.set('theme');
|
||||||
|
this.themeStudioFullscreen.set(false);
|
||||||
|
this.themeStudioMinimized.set(true);
|
||||||
|
this.isOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreMinimizedThemeStudio(): void {
|
||||||
|
this.openThemeStudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissMinimizedThemeStudio(): void {
|
||||||
|
this.themeStudioMinimized.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ infrastructure adapters and UI.
|
|||||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||||
|
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
|
||||||
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
| **voice-connection** | Voice activity detection, bitrate profiles, in-channel camera transport | `VoiceConnectionFacade` |
|
||||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsModalService,
|
||||||
|
type SettingsPage
|
||||||
|
} from '../../../core/services/settings-modal.service';
|
||||||
|
import { ThemeRegistryService } from './theme-registry.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ElementPickerService {
|
||||||
|
private readonly documentRef = inject(DOCUMENT);
|
||||||
|
private readonly modal = inject(SettingsModalService);
|
||||||
|
private readonly registry = inject(ThemeRegistryService);
|
||||||
|
|
||||||
|
private removeListeners: Array<() => void> = [];
|
||||||
|
private resumePage: SettingsPage | null = null;
|
||||||
|
private shouldRestoreModalOnCancel = true;
|
||||||
|
|
||||||
|
readonly isPicking = signal(false);
|
||||||
|
readonly hoveredKey = signal<string | null>(null);
|
||||||
|
readonly selectedKey = signal<string | null>(null);
|
||||||
|
|
||||||
|
start(page: SettingsPage = 'theme', restoreModalOnCancel = true): void {
|
||||||
|
if (this.isPicking()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resumePage = page;
|
||||||
|
this.shouldRestoreModalOnCancel = restoreModalOnCancel;
|
||||||
|
this.modal.close();
|
||||||
|
this.attachListeners();
|
||||||
|
this.hoveredKey.set(null);
|
||||||
|
this.isPicking.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
if (!this.isPicking()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.detachListeners();
|
||||||
|
this.hoveredKey.set(null);
|
||||||
|
this.isPicking.set(false);
|
||||||
|
|
||||||
|
if (this.shouldRestoreModalOnCancel && this.resumePage) {
|
||||||
|
this.modal.open(this.resumePage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection(): void {
|
||||||
|
this.selectedKey.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private completePick(key: string): void {
|
||||||
|
this.selectedKey.set(key);
|
||||||
|
this.detachListeners();
|
||||||
|
this.hoveredKey.set(null);
|
||||||
|
this.isPicking.set(false);
|
||||||
|
|
||||||
|
if (this.resumePage) {
|
||||||
|
this.modal.open(this.resumePage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachListeners(): void {
|
||||||
|
const onPointerMove = (event: Event) => {
|
||||||
|
const key = this.resolveThemeKeyFromTarget(event.target);
|
||||||
|
|
||||||
|
this.hoveredKey.set(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = (event: Event) => {
|
||||||
|
const key = this.resolveThemeKeyFromTarget(event.target);
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if ('stopImmediatePropagation' in event) {
|
||||||
|
(event as Event & { stopImmediatePropagation(): void }).stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.completePick(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (event: Event) => {
|
||||||
|
const keyboardEvent = event as KeyboardEvent;
|
||||||
|
|
||||||
|
if (keyboardEvent.key === 'Escape') {
|
||||||
|
keyboardEvent.preventDefault();
|
||||||
|
keyboardEvent.stopPropagation();
|
||||||
|
this.cancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.documentRef.addEventListener('pointermove', onPointerMove, true);
|
||||||
|
this.documentRef.addEventListener('click', onClick, true);
|
||||||
|
this.documentRef.addEventListener('keydown', onKeyDown, true);
|
||||||
|
|
||||||
|
this.removeListeners = [
|
||||||
|
() => this.documentRef.removeEventListener('pointermove', onPointerMove, true),
|
||||||
|
() => this.documentRef.removeEventListener('click', onClick, true),
|
||||||
|
() => this.documentRef.removeEventListener('keydown', onKeyDown, true)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachListeners(): void {
|
||||||
|
for (const removeListener of this.removeListeners) {
|
||||||
|
removeListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeListeners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveThemeKeyFromTarget(target: EventTarget | null): string | null {
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themedElement = target.closest<HTMLElement>('[data-theme-key]');
|
||||||
|
const key = themedElement?.dataset['themeKey'] ?? null;
|
||||||
|
const definition = this.registry.getDefinition(key);
|
||||||
|
|
||||||
|
return definition?.pickerVisible
|
||||||
|
? key
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Injectable, computed, inject } from '@angular/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ThemeContainerKey,
|
||||||
|
ThemeGridEditorItem,
|
||||||
|
ThemeGridRect
|
||||||
|
} from '../domain/theme.models';
|
||||||
|
import { createDefaultThemeDocument } from '../domain/theme.defaults';
|
||||||
|
import { ThemeRegistryService } from './theme-registry.service';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class LayoutSyncService {
|
||||||
|
private readonly registry = inject(ThemeRegistryService);
|
||||||
|
private readonly theme = inject(ThemeService);
|
||||||
|
|
||||||
|
readonly draftLayout = computed(() => this.theme.draftTheme().layout);
|
||||||
|
|
||||||
|
containers() {
|
||||||
|
return this.registry.layoutContainers();
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsForContainer(containerKey: ThemeContainerKey): ThemeGridEditorItem[] {
|
||||||
|
const draftTheme = this.theme.draftTheme();
|
||||||
|
const defaults = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
return this.registry.entries()
|
||||||
|
.filter((entry) => entry.layoutEditable && entry.container === containerKey)
|
||||||
|
.map((entry) => ({
|
||||||
|
key: entry.key,
|
||||||
|
label: entry.label,
|
||||||
|
description: entry.description,
|
||||||
|
grid: draftTheme.layout[entry.key]?.grid ?? defaults.layout[entry.key].grid
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGrid(key: string, grid: ThemeGridRect): void {
|
||||||
|
this.theme.ensureLayoutEntry(key);
|
||||||
|
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
||||||
|
draft.layout[key] = {
|
||||||
|
...draft.layout[key],
|
||||||
|
grid
|
||||||
|
};
|
||||||
|
}, true, `${key} layout updated.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetContainer(containerKey: ThemeContainerKey): void {
|
||||||
|
const defaults = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
this.theme.updateStructuredDraft((draft: ReturnType<typeof createDefaultThemeDocument>) => {
|
||||||
|
for (const entry of this.registry.entries()) {
|
||||||
|
if (entry.container === containerKey && entry.layoutEditable) {
|
||||||
|
draft.layout[entry.key] = defaults.layout[entry.key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true, `${containerKey} restored to its default layout.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
import type { SavedThemeSummary } from '../domain/theme.models';
|
||||||
|
import { ThemeLibraryStorageService } from '../infrastructure/theme-library.storage';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeLibraryService {
|
||||||
|
private readonly storage = inject(ThemeLibraryStorageService);
|
||||||
|
private readonly theme = inject(ThemeService);
|
||||||
|
|
||||||
|
readonly isAvailable = signal(this.storage.isAvailable);
|
||||||
|
readonly entries = signal<SavedThemeSummary[]>([]);
|
||||||
|
readonly selectedFileName = signal<string | null>(null);
|
||||||
|
readonly isBusy = signal(false);
|
||||||
|
readonly savedThemesPath = signal<string | null>(null);
|
||||||
|
readonly selectedEntry = computed(() => {
|
||||||
|
const selectedFileName = this.selectedFileName();
|
||||||
|
|
||||||
|
return selectedFileName
|
||||||
|
? this.entries().find((entry) => entry.fileName === selectedFileName) ?? null
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async refresh(preferredSelection?: string | null): Promise<void> {
|
||||||
|
if (!this.isAvailable() || this.isBusy()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isBusy.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.refreshEntries(preferredSelection);
|
||||||
|
} catch {
|
||||||
|
this.theme.announceStatus('Unable to refresh the saved themes library.');
|
||||||
|
} finally {
|
||||||
|
this.isBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select(fileName: string | null): void {
|
||||||
|
this.selectedFileName.set(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async useSelectedTheme(): Promise<boolean> {
|
||||||
|
const selectedEntry = this.selectedEntry();
|
||||||
|
|
||||||
|
if (!selectedEntry || this.isBusy()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeText = await this.storage.readThemeText(selectedEntry.fileName);
|
||||||
|
|
||||||
|
if (!themeText) {
|
||||||
|
this.theme.announceStatus('Unable to read the selected saved theme.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.theme.loadThemeText(themeText, 'apply', `${selectedEntry.themeName} applied from saved themes.`, 'saved theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
async openSelectedThemeInDraft(): Promise<boolean> {
|
||||||
|
const selectedEntry = this.selectedEntry();
|
||||||
|
|
||||||
|
if (!selectedEntry || this.isBusy()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeText = await this.storage.readThemeText(selectedEntry.fileName);
|
||||||
|
|
||||||
|
if (!themeText) {
|
||||||
|
this.theme.announceStatus('Unable to read the selected saved theme.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.theme.loadThemeText(themeText, 'draft', `${selectedEntry.themeName} loaded into the draft editor.`, 'saved theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDraftAsNewTheme(): Promise<string | null> {
|
||||||
|
if (this.isBusy()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.theme.draftIsValid()) {
|
||||||
|
this.theme.announceStatus('Fix JSON errors before saving a theme.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isBusy.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileName = await this.storage.saveNewTheme(this.theme.draftTheme().meta.name, this.theme.draftText());
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
this.theme.announceStatus('Unable to save the current draft as a new theme.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshEntries(fileName);
|
||||||
|
this.theme.announceStatus(`${this.theme.draftTheme().meta.name} saved to the Electron themes folder.`);
|
||||||
|
return fileName;
|
||||||
|
} finally {
|
||||||
|
this.isBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDraftToSelectedTheme(): Promise<boolean> {
|
||||||
|
const selectedEntry = this.selectedEntry();
|
||||||
|
|
||||||
|
if (!selectedEntry || this.isBusy()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.theme.draftIsValid()) {
|
||||||
|
this.theme.announceStatus('Fix JSON errors before updating a saved theme.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isBusy.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = await this.storage.overwriteTheme(selectedEntry.fileName, this.theme.draftText());
|
||||||
|
|
||||||
|
if (!saved) {
|
||||||
|
this.theme.announceStatus('Unable to update the selected saved theme.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshEntries(selectedEntry.fileName);
|
||||||
|
this.theme.announceStatus(`${selectedEntry.themeName} updated in the Electron themes folder.`);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
this.isBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSelectedTheme(): Promise<boolean> {
|
||||||
|
const selectedEntry = this.selectedEntry();
|
||||||
|
|
||||||
|
if (!selectedEntry || this.isBusy()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isBusy.set(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deleted = await this.storage.deleteTheme(selectedEntry.fileName);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
this.theme.announceStatus('Unable to remove the selected saved theme.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshEntries(null);
|
||||||
|
this.theme.announceStatus(`${selectedEntry.themeName} removed from saved themes.`);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
this.isBusy.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshEntries(preferredSelection?: string | null): Promise<void> {
|
||||||
|
const entries = await this.storage.listThemes();
|
||||||
|
const savedThemesPath = await this.storage.getSavedThemesPath();
|
||||||
|
const nextSelection = preferredSelection ?? this.selectedFileName();
|
||||||
|
|
||||||
|
this.entries.set(entries);
|
||||||
|
this.savedThemesPath.set(savedThemesPath);
|
||||||
|
|
||||||
|
if (nextSelection && entries.some((entry) => entry.fileName === nextSelection)) {
|
||||||
|
this.selectedFileName.set(nextSelection);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedFileName.set(entries[0]?.fileName ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ThemeLayoutContainerDefinition,
|
||||||
|
ThemeRegistryEntry
|
||||||
|
} from '../domain/theme.models';
|
||||||
|
import {
|
||||||
|
THEME_LAYOUT_CONTAINERS,
|
||||||
|
THEME_REGISTRY,
|
||||||
|
findThemeLayoutContainer,
|
||||||
|
findThemeRegistryEntry
|
||||||
|
} from '../domain/theme.registry';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeRegistryService {
|
||||||
|
private readonly mountedCounts = signal<Record<string, number>>({});
|
||||||
|
private readonly mountedHosts = new Map<string, Set<HTMLElement>>();
|
||||||
|
|
||||||
|
entries(): readonly ThemeRegistryEntry[] {
|
||||||
|
return THEME_REGISTRY;
|
||||||
|
}
|
||||||
|
|
||||||
|
layoutContainers(): readonly ThemeLayoutContainerDefinition[] {
|
||||||
|
return THEME_LAYOUT_CONTAINERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
mountedKeyCounts() {
|
||||||
|
return this.mountedCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefinition(key: string | null | undefined): ThemeRegistryEntry | null {
|
||||||
|
return key
|
||||||
|
? findThemeRegistryEntry(key)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getContainer(key: string | null | undefined): ThemeLayoutContainerDefinition | null {
|
||||||
|
return key
|
||||||
|
? findThemeLayoutContainer(key)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHost(key: string, host: HTMLElement): void {
|
||||||
|
const existingHosts = this.mountedHosts.get(key) ?? new Set<HTMLElement>();
|
||||||
|
|
||||||
|
existingHosts.add(host);
|
||||||
|
this.mountedHosts.set(key, existingHosts);
|
||||||
|
this.syncMountedCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterHost(key: string, host: HTMLElement): void {
|
||||||
|
const existingHosts = this.mountedHosts.get(key);
|
||||||
|
|
||||||
|
if (!existingHosts) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingHosts.delete(host);
|
||||||
|
|
||||||
|
if (existingHosts.size === 0) {
|
||||||
|
this.mountedHosts.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncMountedCounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
firstMountedHost(key: string): HTMLElement | null {
|
||||||
|
const hosts = this.mountedHosts.get(key);
|
||||||
|
|
||||||
|
return hosts
|
||||||
|
? Array.from(hosts)[0] ?? null
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMounted(key: string): boolean {
|
||||||
|
return (this.mountedCounts()[key] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncMountedCounts(): void {
|
||||||
|
const nextCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const [key, hosts] of this.mountedHosts.entries()) {
|
||||||
|
nextCounts[key] = hosts.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mountedCounts.set(nextCounts);
|
||||||
|
}
|
||||||
|
}
|
||||||
515
toju-app/src/app/domains/theme/application/theme.service.ts
Normal file
515
toju-app/src/app/domains/theme/application/theme.service.ts
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ThemeAnimationDefinition,
|
||||||
|
ThemeContainerKey,
|
||||||
|
ThemeDocument,
|
||||||
|
ThemeElementStyleProperty,
|
||||||
|
ThemeElementStyles
|
||||||
|
} from '../domain/theme.models';
|
||||||
|
import {
|
||||||
|
DEFAULT_THEME_JSON,
|
||||||
|
createDefaultThemeDocument,
|
||||||
|
isLegacyDefaultThemeDocument
|
||||||
|
} from '../domain/theme.defaults';
|
||||||
|
import {
|
||||||
|
createAnimationStarterDefinition
|
||||||
|
} from '../domain/theme.schema';
|
||||||
|
import {
|
||||||
|
findThemeLayoutContainer
|
||||||
|
} from '../domain/theme.registry';
|
||||||
|
import { validateThemeDocument } from '../domain/theme.validation';
|
||||||
|
import {
|
||||||
|
loadThemeStorageSnapshot,
|
||||||
|
saveActiveThemeText,
|
||||||
|
saveDraftThemeText
|
||||||
|
} from '../infrastructure/theme.storage';
|
||||||
|
|
||||||
|
function toKebabCase(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||||
|
.replace(/[_\s]+/g, '-')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyTheme(document: ThemeDocument): string {
|
||||||
|
return JSON.stringify(document, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBuiltInDefaultMigration(document: ThemeDocument): ThemeDocument {
|
||||||
|
return isLegacyDefaultThemeDocument(document)
|
||||||
|
? createDefaultThemeDocument()
|
||||||
|
: document;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeService {
|
||||||
|
private readonly documentRef = inject(DOCUMENT);
|
||||||
|
|
||||||
|
private readonly activeThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
|
||||||
|
private readonly activeThemeTextInternal = signal(DEFAULT_THEME_JSON);
|
||||||
|
private readonly draftThemeInternal = signal<ThemeDocument>(createDefaultThemeDocument());
|
||||||
|
private readonly draftTextInternal = signal(DEFAULT_THEME_JSON);
|
||||||
|
private readonly draftIsValidInternal = signal(true);
|
||||||
|
private readonly draftErrorsInternal = signal<string[]>([]);
|
||||||
|
private readonly statusMessageInternal = signal<string | null>(null);
|
||||||
|
|
||||||
|
private initialized = false;
|
||||||
|
private statusTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private animationStyleElement: HTMLStyleElement | null = null;
|
||||||
|
|
||||||
|
readonly activeTheme = this.activeThemeInternal.asReadonly();
|
||||||
|
readonly activeThemeText = this.activeThemeTextInternal.asReadonly();
|
||||||
|
readonly draftTheme = this.draftThemeInternal.asReadonly();
|
||||||
|
readonly draftText = this.draftTextInternal.asReadonly();
|
||||||
|
readonly draftIsValid = this.draftIsValidInternal.asReadonly();
|
||||||
|
readonly draftErrors = this.draftErrorsInternal.asReadonly();
|
||||||
|
readonly statusMessage = this.statusMessageInternal.asReadonly();
|
||||||
|
readonly activeThemeName = computed(() => this.activeThemeInternal().meta.name);
|
||||||
|
readonly knownAnimationClasses = computed(() => Object.keys(this.draftThemeInternal().animations));
|
||||||
|
readonly isDraftDirty = computed(() => {
|
||||||
|
return this.draftTextInternal().trim() !== this.activeThemeTextInternal().trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
initialize(): void {
|
||||||
|
if (this.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
const storageSnapshot = loadThemeStorageSnapshot();
|
||||||
|
const activeText = storageSnapshot.activeText ?? DEFAULT_THEME_JSON;
|
||||||
|
const activeResult = this.parseAndValidateTheme(activeText, 'saved active theme');
|
||||||
|
|
||||||
|
if (activeResult.valid && activeResult.value) {
|
||||||
|
const resolvedTheme = resolveBuiltInDefaultMigration(activeResult.value);
|
||||||
|
const formatted = stringifyTheme(resolvedTheme);
|
||||||
|
|
||||||
|
this.activeThemeInternal.set(resolvedTheme);
|
||||||
|
this.activeThemeTextInternal.set(formatted);
|
||||||
|
saveActiveThemeText(formatted);
|
||||||
|
} else {
|
||||||
|
const defaultTheme = createDefaultThemeDocument();
|
||||||
|
const defaultText = stringifyTheme(defaultTheme);
|
||||||
|
|
||||||
|
this.activeThemeInternal.set(defaultTheme);
|
||||||
|
this.activeThemeTextInternal.set(defaultText);
|
||||||
|
saveActiveThemeText(defaultText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftText = storageSnapshot.draftText ?? this.activeThemeTextInternal();
|
||||||
|
const draftResult = this.parseAndValidateTheme(draftText, 'saved draft theme');
|
||||||
|
|
||||||
|
if (draftResult.valid && draftResult.value) {
|
||||||
|
const resolvedDraftTheme = resolveBuiltInDefaultMigration(draftResult.value);
|
||||||
|
const formattedDraft = stringifyTheme(resolvedDraftTheme);
|
||||||
|
|
||||||
|
this.draftThemeInternal.set(resolvedDraftTheme);
|
||||||
|
this.draftTextInternal.set(formattedDraft);
|
||||||
|
this.draftIsValidInternal.set(true);
|
||||||
|
this.draftErrorsInternal.set([]);
|
||||||
|
saveDraftThemeText(formattedDraft);
|
||||||
|
} else {
|
||||||
|
this.draftThemeInternal.set(this.activeThemeInternal());
|
||||||
|
this.draftTextInternal.set(this.activeThemeTextInternal());
|
||||||
|
this.draftIsValidInternal.set(false);
|
||||||
|
this.draftErrorsInternal.set(draftResult.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncAnimationStylesheet();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDraftText(text: string): void {
|
||||||
|
this.draftTextInternal.set(text);
|
||||||
|
saveDraftThemeText(text);
|
||||||
|
|
||||||
|
const result = this.parseAndValidateTheme(text, 'theme draft');
|
||||||
|
|
||||||
|
if (result.valid && result.value) {
|
||||||
|
this.draftThemeInternal.set(result.value);
|
||||||
|
this.draftIsValidInternal.set(true);
|
||||||
|
this.draftErrorsInternal.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.draftIsValidInternal.set(false);
|
||||||
|
this.draftErrorsInternal.set(result.errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDraft(): void {
|
||||||
|
if (!this.draftIsValidInternal()) {
|
||||||
|
this.setStatusMessage('Fix JSON errors before formatting the theme draft.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = stringifyTheme(this.draftThemeInternal());
|
||||||
|
|
||||||
|
this.draftTextInternal.set(formatted);
|
||||||
|
saveDraftThemeText(formatted);
|
||||||
|
this.setStatusMessage('Theme draft formatted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDraft(): boolean {
|
||||||
|
if (!this.draftIsValidInternal()) {
|
||||||
|
this.setStatusMessage('The current draft has validation errors. The previous working theme is still active.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = stringifyTheme(this.draftThemeInternal());
|
||||||
|
|
||||||
|
this.commitTheme(this.draftThemeInternal(), formatted, 'Theme applied.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThemeText(
|
||||||
|
text: string,
|
||||||
|
mode: 'draft' | 'apply',
|
||||||
|
successMessage: string,
|
||||||
|
sourceLabel = 'theme'
|
||||||
|
): boolean {
|
||||||
|
const result = this.parseAndValidateTheme(text, sourceLabel);
|
||||||
|
|
||||||
|
if (!result.valid || !result.value) {
|
||||||
|
this.setStatusMessage(result.errors[0] ?? `The ${sourceLabel} could not be loaded.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTheme = resolveBuiltInDefaultMigration(result.value);
|
||||||
|
const formatted = stringifyTheme(resolvedTheme);
|
||||||
|
|
||||||
|
if (mode === 'apply') {
|
||||||
|
this.commitTheme(resolvedTheme, formatted, successMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.draftThemeInternal.set(resolvedTheme);
|
||||||
|
this.draftTextInternal.set(formatted);
|
||||||
|
this.draftIsValidInternal.set(true);
|
||||||
|
this.draftErrorsInternal.set([]);
|
||||||
|
saveDraftThemeText(formatted);
|
||||||
|
this.setStatusMessage(successMessage);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
announceStatus(message: string): void {
|
||||||
|
this.setStatusMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetToDefault(reason: 'button' | 'shortcut' = 'button'): void {
|
||||||
|
const defaultTheme = createDefaultThemeDocument();
|
||||||
|
const defaultText = stringifyTheme(defaultTheme);
|
||||||
|
|
||||||
|
this.activeThemeInternal.set(defaultTheme);
|
||||||
|
this.activeThemeTextInternal.set(defaultText);
|
||||||
|
this.draftThemeInternal.set(defaultTheme);
|
||||||
|
this.draftTextInternal.set(defaultText);
|
||||||
|
this.draftIsValidInternal.set(true);
|
||||||
|
this.draftErrorsInternal.set([]);
|
||||||
|
saveActiveThemeText(defaultText);
|
||||||
|
saveDraftThemeText(defaultText);
|
||||||
|
this.syncAnimationStylesheet();
|
||||||
|
this.setStatusMessage(reason === 'shortcut'
|
||||||
|
? 'Theme reset to the default preset by shortcut.'
|
||||||
|
: 'Theme reset to the default preset.');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGlobalShortcut(event: KeyboardEvent): boolean {
|
||||||
|
const usesModifier = event.ctrlKey || event.metaKey;
|
||||||
|
|
||||||
|
if (!usesModifier || !event.shiftKey || event.code !== 'Digit0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this.resetToDefault('shortcut');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureElementEntry(key: string): void {
|
||||||
|
this.updateStructuredDraft((draft) => {
|
||||||
|
draft.elements[key] = draft.elements[key] ?? {};
|
||||||
|
}, false, `Prepared ${key} in the theme draft.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLayoutEntry(key: string): void {
|
||||||
|
this.updateStructuredDraft((draft) => {
|
||||||
|
const defaults = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
draft.layout[key] = draft.layout[key] ?? defaults.layout[key];
|
||||||
|
}, false, `Prepared ${key} layout in the theme draft.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setElementStyle(
|
||||||
|
key: string,
|
||||||
|
property: ThemeElementStyleProperty,
|
||||||
|
value: string | number,
|
||||||
|
applyImmediately = true
|
||||||
|
): void {
|
||||||
|
this.updateStructuredDraft((draft) => {
|
||||||
|
draft.elements[key] = {
|
||||||
|
...draft.elements[key],
|
||||||
|
[property]: value
|
||||||
|
};
|
||||||
|
}, applyImmediately, `${key} updated.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnimation(
|
||||||
|
key: string,
|
||||||
|
definition: ThemeAnimationDefinition = createAnimationStarterDefinition(),
|
||||||
|
applyImmediately = true
|
||||||
|
): void {
|
||||||
|
this.updateStructuredDraft((draft) => {
|
||||||
|
draft.animations[key] = definition;
|
||||||
|
}, applyImmediately, `Animation ${key} updated.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getHostStyles(key: string): Record<string, string> {
|
||||||
|
const elementTheme = this.activeThemeInternal().elements[key] ?? {};
|
||||||
|
const styles: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (key === 'appRoot') {
|
||||||
|
Object.assign(styles, this.buildTokenStyles(this.activeThemeInternal()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const backgroundLayers = [elementTheme.gradient, elementTheme.backgroundImage]
|
||||||
|
.filter((layer): layer is string => typeof layer === 'string' && layer.trim().length > 0);
|
||||||
|
|
||||||
|
if (backgroundLayers.length > 0) {
|
||||||
|
styles['backgroundImage'] = backgroundLayers.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementTheme.width) styles['width'] = elementTheme.width;
|
||||||
|
if (elementTheme.height) styles['height'] = elementTheme.height;
|
||||||
|
if (elementTheme.minWidth) styles['minWidth'] = elementTheme.minWidth;
|
||||||
|
if (elementTheme.minHeight) styles['minHeight'] = elementTheme.minHeight;
|
||||||
|
if (elementTheme.maxWidth) styles['maxWidth'] = elementTheme.maxWidth;
|
||||||
|
if (elementTheme.maxHeight) styles['maxHeight'] = elementTheme.maxHeight;
|
||||||
|
if (elementTheme.position) styles['position'] = elementTheme.position;
|
||||||
|
if (elementTheme.top) styles['top'] = elementTheme.top;
|
||||||
|
if (elementTheme.right) styles['right'] = elementTheme.right;
|
||||||
|
if (elementTheme.bottom) styles['bottom'] = elementTheme.bottom;
|
||||||
|
if (elementTheme.left) styles['left'] = elementTheme.left;
|
||||||
|
if (elementTheme.padding) styles['padding'] = elementTheme.padding;
|
||||||
|
if (elementTheme.margin) styles['margin'] = elementTheme.margin;
|
||||||
|
if (elementTheme.border) styles['border'] = elementTheme.border;
|
||||||
|
if (elementTheme.borderRadius) styles['borderRadius'] = elementTheme.borderRadius;
|
||||||
|
if (elementTheme.backgroundColor) styles['backgroundColor'] = elementTheme.backgroundColor;
|
||||||
|
if (elementTheme.color) styles['color'] = elementTheme.color;
|
||||||
|
if (elementTheme.backgroundSize) styles['backgroundSize'] = elementTheme.backgroundSize;
|
||||||
|
if (elementTheme.backgroundPosition) styles['backgroundPosition'] = elementTheme.backgroundPosition;
|
||||||
|
if (elementTheme.backgroundRepeat) styles['backgroundRepeat'] = elementTheme.backgroundRepeat;
|
||||||
|
if (elementTheme.boxShadow) styles['boxShadow'] = elementTheme.boxShadow;
|
||||||
|
if (elementTheme.backdropFilter) styles['backdropFilter'] = elementTheme.backdropFilter;
|
||||||
|
|
||||||
|
if (typeof elementTheme.opacity === 'number') {
|
||||||
|
styles['opacity'] = `${elementTheme.opacity}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAnimationClass(key: string): string | null {
|
||||||
|
const animationClass = this.activeThemeInternal().elements[key]?.animationClass?.trim();
|
||||||
|
|
||||||
|
return animationClass && animationClass.length > 0
|
||||||
|
? animationClass
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLink(key: string): string | null {
|
||||||
|
return this.activeThemeInternal().elements[key]?.link ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextOverride(key: string): string | null {
|
||||||
|
return this.activeThemeInternal().elements[key]?.textOverride ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIcon(key: string): string | null {
|
||||||
|
return this.activeThemeInternal().elements[key]?.icon ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayoutContainerStyles(containerKey: ThemeContainerKey): Record<string, string> {
|
||||||
|
const container = findThemeLayoutContainer(containerKey);
|
||||||
|
|
||||||
|
if (!container) {
|
||||||
|
return {
|
||||||
|
display: 'grid'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: container.templateColumns ?? `repeat(${container.columns}, minmax(0, 1fr))`,
|
||||||
|
gridTemplateRows: container.templateRows ?? (container.rows === 1
|
||||||
|
? 'minmax(0, 1fr)'
|
||||||
|
: `repeat(${container.rows}, minmax(0, 1fr))`),
|
||||||
|
minHeight: '0',
|
||||||
|
minWidth: '0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getLayoutItemStyles(key: string): Record<string, string> {
|
||||||
|
const defaults = createDefaultThemeDocument();
|
||||||
|
const layoutEntry = this.activeThemeInternal().layout[key] ?? defaults.layout[key];
|
||||||
|
|
||||||
|
if (!layoutEntry) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gridColumn: `${layoutEntry.grid.x + 1} / span ${layoutEntry.grid.w}`,
|
||||||
|
gridRow: `${layoutEntry.grid.y + 1} / span ${layoutEntry.grid.h}`,
|
||||||
|
minWidth: '0',
|
||||||
|
minHeight: '0'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStructuredDraft(
|
||||||
|
mutator: (draft: ThemeDocument) => void,
|
||||||
|
applyImmediately: boolean,
|
||||||
|
successMessage: string
|
||||||
|
): void {
|
||||||
|
if (!this.draftIsValidInternal()) {
|
||||||
|
this.setStatusMessage('Fix JSON errors before using the structured theme tools.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDraft = structuredClone(this.draftThemeInternal());
|
||||||
|
|
||||||
|
mutator(nextDraft);
|
||||||
|
|
||||||
|
const result = validateThemeDocument(nextDraft);
|
||||||
|
|
||||||
|
if (!result.valid || !result.value) {
|
||||||
|
this.setStatusMessage('The structured change could not be validated.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = stringifyTheme(result.value);
|
||||||
|
|
||||||
|
this.draftThemeInternal.set(result.value);
|
||||||
|
this.draftTextInternal.set(formatted);
|
||||||
|
this.draftIsValidInternal.set(true);
|
||||||
|
this.draftErrorsInternal.set([]);
|
||||||
|
saveDraftThemeText(formatted);
|
||||||
|
|
||||||
|
if (applyImmediately) {
|
||||||
|
this.commitTheme(result.value, formatted, successMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setStatusMessage(successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private commitTheme(theme: ThemeDocument, text: string, successMessage: string): void {
|
||||||
|
this.activeThemeInternal.set(theme);
|
||||||
|
this.activeThemeTextInternal.set(text);
|
||||||
|
this.draftThemeInternal.set(theme);
|
||||||
|
this.draftTextInternal.set(text);
|
||||||
|
this.draftIsValidInternal.set(true);
|
||||||
|
this.draftErrorsInternal.set([]);
|
||||||
|
saveActiveThemeText(text);
|
||||||
|
saveDraftThemeText(text);
|
||||||
|
this.syncAnimationStylesheet();
|
||||||
|
this.setStatusMessage(successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseAndValidateTheme(text: string, label: string) {
|
||||||
|
try {
|
||||||
|
return validateThemeDocument(JSON.parse(text) as unknown);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [`${label} could not be parsed: ${error instanceof Error ? error.message : 'unknown JSON error'}`],
|
||||||
|
value: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTokenStyles(theme: ThemeDocument): Record<string, string> {
|
||||||
|
const styles: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.colors)) {
|
||||||
|
styles[`--${toKebabCase(tokenName)}`] = tokenValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.spacing)) {
|
||||||
|
styles[`--theme-spacing-${toKebabCase(tokenName)}`] = tokenValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.radii)) {
|
||||||
|
const cssVariableName = tokenName === 'radius'
|
||||||
|
? '--radius'
|
||||||
|
: `--theme-radius-${toKebabCase(tokenName)}`;
|
||||||
|
|
||||||
|
styles[cssVariableName] = tokenValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [tokenName, tokenValue] of Object.entries(theme.tokens.effects)) {
|
||||||
|
styles[`--theme-effect-${toKebabCase(tokenName)}`] = tokenValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncAnimationStylesheet(): void {
|
||||||
|
const theme = this.activeThemeInternal();
|
||||||
|
const css = Object.entries(theme.animations)
|
||||||
|
.map(([className, definition]) => this.buildAnimationRule(className, definition))
|
||||||
|
.filter((rule) => rule.length > 0)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
if (!this.animationStyleElement) {
|
||||||
|
this.animationStyleElement = this.documentRef.createElement('style');
|
||||||
|
this.animationStyleElement.setAttribute('data-toju-theme-animations', 'true');
|
||||||
|
this.documentRef.head.appendChild(this.animationStyleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationStyleElement.textContent = css;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildAnimationRule(className: string, definition: ThemeAnimationDefinition): string {
|
||||||
|
const animationClass = `.${className}`;
|
||||||
|
const declarationLines = [
|
||||||
|
`animation-name: ${className};`,
|
||||||
|
`animation-duration: ${definition.duration ?? '240ms'};`,
|
||||||
|
`animation-timing-function: ${definition.easing ?? 'ease'};`,
|
||||||
|
`animation-delay: ${definition.delay ?? '0ms'};`,
|
||||||
|
`animation-iteration-count: ${definition.iterationCount ?? '1'};`,
|
||||||
|
`animation-fill-mode: ${definition.fillMode ?? 'both'};`,
|
||||||
|
`animation-direction: ${definition.direction ?? 'normal'};`
|
||||||
|
];
|
||||||
|
const classRule = `${animationClass} {\n ${declarationLines.join('\n ')}\n}`;
|
||||||
|
|
||||||
|
if (!definition.keyframes || Object.keys(definition.keyframes).length === 0) {
|
||||||
|
return classRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframeRule = `@keyframes ${className} {\n${Object.entries(definition.keyframes)
|
||||||
|
.map(([step, declarations]) => {
|
||||||
|
const lines = Object.entries(declarations)
|
||||||
|
.map(([property, value]) => ` ${toKebabCase(property)}: ${value};`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return ` ${step} {\n${lines}\n }`;
|
||||||
|
})
|
||||||
|
.join('\n')}\n}`;
|
||||||
|
|
||||||
|
return `${keyframeRule}\n\n${classRule}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setStatusMessage(message: string): void {
|
||||||
|
this.statusMessageInternal.set(message);
|
||||||
|
|
||||||
|
if (this.statusTimeoutId) {
|
||||||
|
clearTimeout(this.statusTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.statusTimeoutId = setTimeout(() => {
|
||||||
|
this.statusMessageInternal.set(null);
|
||||||
|
this.statusTimeoutId = null;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
171
toju-app/src/app/domains/theme/domain/theme-llm-guide.ts
Normal file
171
toju-app/src/app/domains/theme/domain/theme-llm-guide.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { DEFAULT_THEME_DOCUMENT } from './theme.defaults';
|
||||||
|
import {
|
||||||
|
THEME_LAYOUT_CONTAINERS,
|
||||||
|
THEME_REGISTRY
|
||||||
|
} from './theme.registry';
|
||||||
|
import {
|
||||||
|
THEME_ANIMATION_FIELDS,
|
||||||
|
THEME_ELEMENT_STYLE_FIELDS,
|
||||||
|
createAnimationStarterDefinition
|
||||||
|
} from './theme.schema';
|
||||||
|
|
||||||
|
function formatExample(example: string | number): string {
|
||||||
|
return typeof example === 'number'
|
||||||
|
? `${example}`
|
||||||
|
: JSON.stringify(example);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLayoutKeysForContainer(containerKey: string): string[] {
|
||||||
|
return THEME_REGISTRY
|
||||||
|
.filter((entry) => entry.container === containerKey && entry.layoutEditable)
|
||||||
|
.map((entry) => entry.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeCapabilities(entry: (typeof THEME_REGISTRY)[number]): string {
|
||||||
|
const capabilities = [
|
||||||
|
entry.layoutEditable ? 'layout' : null,
|
||||||
|
entry.supportsTextOverride ? 'textOverride' : null,
|
||||||
|
entry.supportsLink ? 'link' : null,
|
||||||
|
entry.supportsIcon ? 'icon' : null
|
||||||
|
].filter((value): value is string => value !== null);
|
||||||
|
|
||||||
|
return capabilities.length > 0
|
||||||
|
? capabilities.join(', ')
|
||||||
|
: 'visual style overrides only';
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.colors);
|
||||||
|
const radiusTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.radii);
|
||||||
|
const effectTokenKeys = Object.keys(DEFAULT_THEME_DOCUMENT.tokens.effects);
|
||||||
|
const layoutEditableKeys = THEME_REGISTRY
|
||||||
|
.filter((entry) => entry.layoutEditable)
|
||||||
|
.map((entry) => entry.key);
|
||||||
|
const guideTemplateDocument = {
|
||||||
|
meta: {
|
||||||
|
name: 'Theme Name',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Short mood and material direction.'
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
colors: {
|
||||||
|
background: '224 28% 7%',
|
||||||
|
foreground: '210 40% 96%',
|
||||||
|
primary: '193 95% 68%',
|
||||||
|
panelBackground: '224 24% 11%',
|
||||||
|
titleBarBackground: '226 34% 7%'
|
||||||
|
},
|
||||||
|
spacing: {},
|
||||||
|
radii: {
|
||||||
|
radius: '0.875rem',
|
||||||
|
surface: '1.35rem'
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
glassBlur: 'blur(18px) saturate(135%)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layout: {
|
||||||
|
chatRoomMainPanel: {
|
||||||
|
container: 'roomLayout',
|
||||||
|
grid: {
|
||||||
|
x: 4,
|
||||||
|
y: 0,
|
||||||
|
w: 12,
|
||||||
|
h: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
titleBar: {
|
||||||
|
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.72)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
},
|
||||||
|
chatRoomMainPanel: {
|
||||||
|
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||||
|
borderRadius: 'var(--theme-radius-surface)',
|
||||||
|
boxShadow: 'var(--theme-effect-panel-shadow)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
animations: {
|
||||||
|
'theme-fade-in': createAnimationStarterDefinition()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THEME_LLM_GUIDE = [
|
||||||
|
'TOJU THEME CREATION SHEET FOR LLMS',
|
||||||
|
'',
|
||||||
|
'Goal',
|
||||||
|
'- Produce one valid JSON theme document for Toju Theme Studio.',
|
||||||
|
'- Return JSON only when asked to generate a theme. Do not wrap the result in Markdown fences or add commentary.',
|
||||||
|
'',
|
||||||
|
'Core rules',
|
||||||
|
'- Keep the top-level keys exactly: meta, tokens, layout, elements, animations.',
|
||||||
|
'- Use strict JSON with double-quoted keys, no comments, and no trailing commas.',
|
||||||
|
'- Omitted optional keys inherit from the built-in default theme, so leave out anything you are not intentionally changing.',
|
||||||
|
'- Do not invent new top-level sections, layout containers, or element style properties.',
|
||||||
|
'- links must be absolute http or https URLs.',
|
||||||
|
'- animationClass must be a safe CSS class token and should match an entry in animations or an existing class already shipped by the app.',
|
||||||
|
'- layout.grid values must be integers. x and y are zero-based. w and h must be greater than 0.',
|
||||||
|
'- opacity must be a number between 0 and 1.',
|
||||||
|
'',
|
||||||
|
'Theme creation workflow',
|
||||||
|
'- 1. Set meta.name, meta.version, and an optional meta.description.',
|
||||||
|
'- 2. Define the palette and tokens first. Prefer token-driven colors instead of hard-coded values in every element.',
|
||||||
|
'- 3. Move only layout-editable surfaces in layout.',
|
||||||
|
'- 4. Add visual overrides in elements using the supported style fields below.',
|
||||||
|
'- 5. Add animations only when an element actually references them with animationClass.',
|
||||||
|
'',
|
||||||
|
'Token rules',
|
||||||
|
'- tokens.colors entries become CSS variables like --background or --surface-highlight-alt.',
|
||||||
|
'- Color token values should usually be raw HSL channels such as "224 28% 7%". The shell wraps many built-in color tokens with hsl(var(--token)).',
|
||||||
|
'- You may add extra color tokens if you also reference them with CSS variables in element overrides.',
|
||||||
|
'- tokens.spacing entries become --theme-spacing-<kebab-case-key>.',
|
||||||
|
'- tokens.radii.radius maps to --radius. Other radius keys become --theme-radius-<kebab-case-key>.',
|
||||||
|
'- tokens.effects entries become --theme-effect-<kebab-case-key>.',
|
||||||
|
`- Built-in shell color tokens: ${colorTokenKeys.join(', ')}.`,
|
||||||
|
`- Built-in shell radius tokens: ${radiusTokenKeys.join(', ')}.`,
|
||||||
|
`- Built-in shell effect tokens: ${effectTokenKeys.join(', ')}.`,
|
||||||
|
'',
|
||||||
|
'Top-level schema reference',
|
||||||
|
'- meta: { name: string, version: string, description?: string }',
|
||||||
|
'- tokens: { colors: Record<string, string>, spacing: Record<string, string>, radii: Record<string, string>, effects: Record<string, string> }',
|
||||||
|
'- layout: Record<string, { container: "appShell" | "roomLayout", grid: { x: number, y: number, w: number, h: number } }>',
|
||||||
|
'- elements: Record<string, ThemeElementStyles>',
|
||||||
|
'- animations: Record<string, { duration?, easing?, delay?, iterationCount?, fillMode?, direction?, keyframes? }>',
|
||||||
|
'',
|
||||||
|
'Layout containers',
|
||||||
|
...THEME_LAYOUT_CONTAINERS.map((container) => {
|
||||||
|
const layoutKeys = getLayoutKeysForContainer(container.key);
|
||||||
|
|
||||||
|
return `- ${container.key}: ${container.columns} columns x ${container.rows} rows. ${container.description} Layout keys: ${layoutKeys.join(', ')}.`;
|
||||||
|
}),
|
||||||
|
`- Only these keys should normally appear in layout: ${layoutEditableKeys.join(', ')}.`,
|
||||||
|
'',
|
||||||
|
'Registered theme element keys',
|
||||||
|
...THEME_REGISTRY.map((entry) => {
|
||||||
|
const container = entry.container ?? 'none';
|
||||||
|
|
||||||
|
return `- ${entry.key}: ${entry.label}. Category=${entry.category}. Container=${container}. ${entry.description} Supported extras: ${describeCapabilities(entry)}.`;
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
'Supported element style fields',
|
||||||
|
...THEME_ELEMENT_STYLE_FIELDS.map((field) => {
|
||||||
|
return `- ${field.key}: ${field.description} Example: ${formatExample(field.example)}.`;
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
'Supported animation fields',
|
||||||
|
...THEME_ANIMATION_FIELDS.map((field) => {
|
||||||
|
return `- ${field.key}: ${field.description} Example: ${formatExample(field.example)}.`;
|
||||||
|
}),
|
||||||
|
'',
|
||||||
|
'Minimal valid starting point',
|
||||||
|
JSON.stringify(guideTemplateDocument, null, 2),
|
||||||
|
'',
|
||||||
|
'Output checklist',
|
||||||
|
'- Return one JSON object only.',
|
||||||
|
'- Prefer token references like hsl(var(--foreground)) and var(--theme-effect-glass-blur) inside element overrides.',
|
||||||
|
'- Keep layout edits plausible for the declared container grid size.',
|
||||||
|
'- If a field is unsupported, omit it instead of guessing.',
|
||||||
|
'- If a section does not need changes, leave it empty rather than filling it with noise.'
|
||||||
|
].join('\n');
|
||||||
241
toju-app/src/app/domains/theme/domain/theme.defaults.ts
Normal file
241
toju-app/src/app/domains/theme/domain/theme.defaults.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import {
|
||||||
|
ThemeDocument,
|
||||||
|
ThemeElementStyles,
|
||||||
|
ThemeLayoutEntry
|
||||||
|
} from './theme.models';
|
||||||
|
import {
|
||||||
|
THEME_LAYOUT_CONTAINERS,
|
||||||
|
THEME_REGISTRY,
|
||||||
|
getLayoutEditableThemeKeys
|
||||||
|
} from './theme.registry';
|
||||||
|
|
||||||
|
function createDefaultElements(): Record<string, ThemeElementStyles> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
THEME_REGISTRY.map((entry) => [entry.key, {}])
|
||||||
|
) as Record<string, ThemeElementStyles>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||||
|
const layoutEntries: Record<string, ThemeLayoutEntry> = {};
|
||||||
|
|
||||||
|
for (const key of getLayoutEditableThemeKeys()) {
|
||||||
|
if (key === 'serversRail') {
|
||||||
|
layoutEntries[key] = {
|
||||||
|
container: 'appShell',
|
||||||
|
grid: { x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 1,
|
||||||
|
h: 1 }
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'appWorkspace') {
|
||||||
|
const appShell = THEME_LAYOUT_CONTAINERS.find((container) => container.key === 'appShell');
|
||||||
|
|
||||||
|
layoutEntries[key] = {
|
||||||
|
container: 'appShell',
|
||||||
|
grid: { x: 1,
|
||||||
|
y: 0,
|
||||||
|
w: (appShell?.columns ?? 20) - 1,
|
||||||
|
h: 1 }
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'chatRoomChannelsPanel') {
|
||||||
|
layoutEntries[key] = {
|
||||||
|
container: 'roomLayout',
|
||||||
|
grid: { x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: 4,
|
||||||
|
h: 12 }
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'chatRoomMainPanel') {
|
||||||
|
layoutEntries[key] = {
|
||||||
|
container: 'roomLayout',
|
||||||
|
grid: { x: 4,
|
||||||
|
y: 0,
|
||||||
|
w: 12,
|
||||||
|
h: 12 }
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'chatRoomMembersPanel') {
|
||||||
|
layoutEntries[key] = {
|
||||||
|
container: 'roomLayout',
|
||||||
|
grid: { x: 16,
|
||||||
|
y: 0,
|
||||||
|
w: 4,
|
||||||
|
h: 12 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layoutEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||||
|
const elements = createDefaultElements();
|
||||||
|
|
||||||
|
elements['appRoot'] = {
|
||||||
|
backgroundColor: 'hsl(var(--background))',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.18), transparent 34%), linear-gradient(180deg, rgba(5, 8, 15, 0.98), rgba(9, 12, 20, 1))'
|
||||||
|
};
|
||||||
|
elements['serversRail'] = {
|
||||||
|
backgroundColor: 'hsl(var(--rail-background) / 0.96)',
|
||||||
|
gradient: 'linear-gradient(180deg, rgba(10, 14, 25, 0.92), rgba(6, 9, 16, 0.98))',
|
||||||
|
boxShadow: 'inset -1px 0 0 hsl(var(--border) / 0.82), 18px 0 38px rgba(0, 0, 0, 0.22)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
};
|
||||||
|
elements['appWorkspace'] = {
|
||||||
|
backgroundColor: 'hsl(var(--workspace-background))',
|
||||||
|
gradient: 'radial-gradient(circle at top right, hsl(var(--surface-highlight-alt) / 0.14), transparent 30%), linear-gradient(180deg, rgba(9, 12, 21, 0.96), rgba(7, 10, 18, 1))'
|
||||||
|
};
|
||||||
|
elements['titleBar'] = {
|
||||||
|
backgroundColor: 'hsl(var(--title-bar-background) / 0.82)',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
gradient: 'linear-gradient(180deg, rgba(20, 26, 41, 0.52), rgba(7, 10, 18, 0.18))',
|
||||||
|
boxShadow: 'inset 0 -1px 0 hsl(var(--border) / 0.78), 0 12px 28px rgba(0, 0, 0, 0.18)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
};
|
||||||
|
elements['chatRoomChannelsPanel'] = {
|
||||||
|
backgroundColor: 'hsl(var(--panel-background) / 0.9)',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.7)',
|
||||||
|
borderRadius: 'var(--theme-radius-surface)',
|
||||||
|
gradient: 'linear-gradient(180deg, rgba(18, 24, 38, 0.82), rgba(10, 13, 23, 0.88))',
|
||||||
|
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
};
|
||||||
|
elements['chatRoomMainPanel'] = {
|
||||||
|
backgroundColor: 'hsl(var(--panel-background) / 0.82)',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.62)',
|
||||||
|
borderRadius: 'var(--theme-radius-surface)',
|
||||||
|
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.12), transparent 28%), linear-gradient(180deg, rgba(16, 20, 34, 0.82), rgba(8, 11, 19, 0.92))',
|
||||||
|
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
};
|
||||||
|
elements['chatRoomMembersPanel'] = {
|
||||||
|
backgroundColor: 'hsl(var(--panel-background-alt) / 0.92)',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.7)',
|
||||||
|
borderRadius: 'var(--theme-radius-surface)',
|
||||||
|
gradient: 'linear-gradient(180deg, rgba(22, 27, 41, 0.82), rgba(11, 14, 24, 0.9))',
|
||||||
|
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
};
|
||||||
|
elements['chatRoomEmptyState'] = {
|
||||||
|
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
|
||||||
|
color: 'hsl(var(--muted-foreground))',
|
||||||
|
border: '1px dashed hsl(var(--border) / 0.7)',
|
||||||
|
borderRadius: 'var(--theme-radius-surface)',
|
||||||
|
gradient: 'radial-gradient(circle at top, hsl(var(--surface-highlight) / 0.08), transparent 45%)',
|
||||||
|
boxShadow: 'var(--theme-effect-soft-shadow)'
|
||||||
|
};
|
||||||
|
elements['voiceWorkspace'] = {
|
||||||
|
backgroundColor: 'hsl(var(--panel-background) / 0.74)',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.62)',
|
||||||
|
borderRadius: 'calc(var(--theme-radius-surface) + 0.2rem)',
|
||||||
|
gradient: 'radial-gradient(circle at top right, hsl(var(--surface-highlight) / 0.14), transparent 32%), linear-gradient(180deg, rgba(18, 23, 37, 0.78), rgba(9, 12, 21, 0.88))',
|
||||||
|
boxShadow: 'var(--theme-effect-panel-shadow)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
};
|
||||||
|
elements['floatingVoiceControls'] = {
|
||||||
|
backgroundColor: 'hsl(var(--panel-background-alt) / 0.94)',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
border: '1px solid hsl(var(--border) / 0.78)',
|
||||||
|
borderRadius: 'var(--theme-radius-pill)',
|
||||||
|
gradient: 'linear-gradient(180deg, rgba(24, 31, 47, 0.92), rgba(13, 17, 29, 0.96))',
|
||||||
|
boxShadow: 'var(--theme-effect-soft-shadow)',
|
||||||
|
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||||
|
};
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOnlyLegacyRadius(radii: Record<string, string>): boolean {
|
||||||
|
const keys = Object.keys(radii);
|
||||||
|
|
||||||
|
return keys.length === 1 && radii['radius'] === '0.375rem';
|
||||||
|
}
|
||||||
|
|
||||||
|
function allElementsEmpty(elements: Record<string, ThemeElementStyles>): boolean {
|
||||||
|
return Object.values(elements).every((elementStyles) => Object.keys(elementStyles).length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultThemeDocument(): ThemeDocument {
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
name: 'Toju Default Dark',
|
||||||
|
version: '2.0.0',
|
||||||
|
description: 'Built-in dark glass theme for the full Toju app shell.'
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
colors: {
|
||||||
|
background: '224 28% 7%',
|
||||||
|
foreground: '210 40% 96%',
|
||||||
|
card: '224 25% 10%',
|
||||||
|
cardForeground: '210 40% 96%',
|
||||||
|
popover: '224 26% 9%',
|
||||||
|
popoverForeground: '210 40% 96%',
|
||||||
|
primary: '193 95% 68%',
|
||||||
|
primaryForeground: '222 47% 11%',
|
||||||
|
secondary: '223 19% 16%',
|
||||||
|
secondaryForeground: '210 40% 96%',
|
||||||
|
muted: '223 18% 14%',
|
||||||
|
mutedForeground: '215 20% 70%',
|
||||||
|
accent: '218 22% 18%',
|
||||||
|
accentForeground: '210 40% 98%',
|
||||||
|
destructive: '0 72% 55%',
|
||||||
|
destructiveForeground: '0 0% 100%',
|
||||||
|
border: '222 18% 22%',
|
||||||
|
input: '222 18% 22%',
|
||||||
|
ring: '193 95% 68%',
|
||||||
|
railBackground: '226 33% 8%',
|
||||||
|
workspaceBackground: '224 30% 9%',
|
||||||
|
panelBackground: '224 24% 11%',
|
||||||
|
panelBackgroundAlt: '222 22% 13%',
|
||||||
|
titleBarBackground: '226 34% 7%',
|
||||||
|
surfaceHighlight: '193 95% 68%',
|
||||||
|
surfaceHighlightAlt: '261 82% 72%'
|
||||||
|
},
|
||||||
|
spacing: {},
|
||||||
|
radii: {
|
||||||
|
radius: '0.875rem',
|
||||||
|
surface: '1.35rem',
|
||||||
|
pill: '999px'
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
panelShadow: '0 24px 60px rgba(0, 0, 0, 0.42)',
|
||||||
|
softShadow: '0 14px 36px rgba(0, 0, 0, 0.28)',
|
||||||
|
glassBlur: 'blur(18px) saturate(135%)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
layout: createDefaultLayout(),
|
||||||
|
elements: createDarkDefaultElements(),
|
||||||
|
animations: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLegacyDefaultThemeDocument(document: ThemeDocument): boolean {
|
||||||
|
return document.meta.name === 'Toju Default Theme'
|
||||||
|
&& document.meta.version === '1.0.0'
|
||||||
|
&& document.meta.description === 'Safe baseline theme that matches the built-in Toju shell layout.'
|
||||||
|
&& Object.keys(document.tokens.colors).length === 0
|
||||||
|
&& Object.keys(document.tokens.spacing).length === 0
|
||||||
|
&& hasOnlyLegacyRadius(document.tokens.radii)
|
||||||
|
&& Object.keys(document.tokens.effects).length === 0
|
||||||
|
&& allElementsEmpty(document.elements)
|
||||||
|
&& Object.keys(document.animations).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_THEME_DOCUMENT: ThemeDocument = createDefaultThemeDocument();
|
||||||
|
export const DEFAULT_THEME_JSON = JSON.stringify(DEFAULT_THEME_DOCUMENT, null, 2);
|
||||||
133
toju-app/src/app/domains/theme/domain/theme.models.ts
Normal file
133
toju-app/src/app/domains/theme/domain/theme.models.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
export type ThemeContainerKey = 'appShell' | 'roomLayout';
|
||||||
|
|
||||||
|
export interface ThemeMeta {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeTokenGroups {
|
||||||
|
colors: Record<string, string>;
|
||||||
|
spacing: Record<string, string>;
|
||||||
|
radii: Record<string, string>;
|
||||||
|
effects: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeGridRect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeLayoutEntry {
|
||||||
|
container: ThemeContainerKey;
|
||||||
|
grid: ThemeGridRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeElementStyles {
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
minWidth?: string;
|
||||||
|
minHeight?: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
maxHeight?: string;
|
||||||
|
position?: 'static' | 'relative' | 'absolute' | 'sticky';
|
||||||
|
top?: string;
|
||||||
|
right?: string;
|
||||||
|
bottom?: string;
|
||||||
|
left?: string;
|
||||||
|
opacity?: number;
|
||||||
|
padding?: string;
|
||||||
|
margin?: string;
|
||||||
|
border?: string;
|
||||||
|
borderRadius?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
color?: string;
|
||||||
|
backgroundImage?: string;
|
||||||
|
backgroundSize?: string;
|
||||||
|
backgroundPosition?: string;
|
||||||
|
backgroundRepeat?: string;
|
||||||
|
gradient?: string;
|
||||||
|
boxShadow?: string;
|
||||||
|
backdropFilter?: string;
|
||||||
|
icon?: string;
|
||||||
|
textOverride?: string;
|
||||||
|
link?: string;
|
||||||
|
animationClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThemeElementStyleProperty = keyof ThemeElementStyles;
|
||||||
|
|
||||||
|
export interface ThemeAnimationDefinition {
|
||||||
|
duration?: string;
|
||||||
|
easing?: string;
|
||||||
|
delay?: string;
|
||||||
|
iterationCount?: string;
|
||||||
|
fillMode?: 'none' | 'forwards' | 'backwards' | 'both';
|
||||||
|
direction?: 'normal' | 'reverse' | 'alternate' | 'alternate-reverse';
|
||||||
|
keyframes?: Record<string, Record<string, string | number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeDocument {
|
||||||
|
meta: ThemeMeta;
|
||||||
|
tokens: ThemeTokenGroups;
|
||||||
|
layout: Record<string, ThemeLayoutEntry>;
|
||||||
|
elements: Record<string, ThemeElementStyles>;
|
||||||
|
animations: Record<string, ThemeAnimationDefinition>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeLayoutContainerDefinition {
|
||||||
|
key: ThemeContainerKey;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
columns: number;
|
||||||
|
rows: number;
|
||||||
|
templateColumns?: string;
|
||||||
|
templateRows?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeRegistryEntry {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
category: 'shell' | 'room' | 'overlay' | 'state';
|
||||||
|
container?: ThemeContainerKey;
|
||||||
|
layoutEditable: boolean;
|
||||||
|
pickerVisible: boolean;
|
||||||
|
supportsTextOverride: boolean;
|
||||||
|
supportsLink: boolean;
|
||||||
|
supportsIcon: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SavedThemeSummary {
|
||||||
|
description: string | null;
|
||||||
|
error: string | null;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
isValid: boolean;
|
||||||
|
modifiedAt: number;
|
||||||
|
themeName: string;
|
||||||
|
version: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
value: ThemeDocument | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeGridEditorItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
grid: ThemeGridRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeSchemaField<T extends string = string> {
|
||||||
|
key: T;
|
||||||
|
description: string;
|
||||||
|
type: 'string' | 'number' | 'object';
|
||||||
|
example: string | number;
|
||||||
|
examples: readonly (string | number)[];
|
||||||
|
}
|
||||||
161
toju-app/src/app/domains/theme/domain/theme.registry.ts
Normal file
161
toju-app/src/app/domains/theme/domain/theme.registry.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
ThemeLayoutContainerDefinition,
|
||||||
|
ThemeRegistryEntry
|
||||||
|
} from './theme.models';
|
||||||
|
|
||||||
|
export const THEME_LAYOUT_CONTAINERS: readonly ThemeLayoutContainerDefinition[] = [
|
||||||
|
{
|
||||||
|
key: 'appShell',
|
||||||
|
label: 'App Shell',
|
||||||
|
description: 'Controls how the global rail and workspace split the application frame.',
|
||||||
|
columns: 20,
|
||||||
|
rows: 1,
|
||||||
|
templateColumns: '4rem repeat(19, minmax(0, 1fr))'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'roomLayout',
|
||||||
|
label: 'Room Workspace',
|
||||||
|
description: 'Controls the channel list, main chat panel, and member list inside a room.',
|
||||||
|
columns: 20,
|
||||||
|
rows: 12,
|
||||||
|
templateColumns: 'repeat(4, 4.25rem) repeat(12, minmax(0, 1fr)) repeat(4, 4.25rem)'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||||
|
{
|
||||||
|
key: 'appRoot',
|
||||||
|
label: 'App Root',
|
||||||
|
description: 'Global workspace wrapper that owns theme tokens and high-level app background styling.',
|
||||||
|
category: 'shell',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: false,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'serversRail',
|
||||||
|
label: 'Servers Rail',
|
||||||
|
description: 'The persistent left navigation rail that holds saved servers and the create button.',
|
||||||
|
category: 'shell',
|
||||||
|
container: 'appShell',
|
||||||
|
layoutEditable: true,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'appWorkspace',
|
||||||
|
label: 'App Workspace',
|
||||||
|
description: 'The main workspace area to the right of the server rail.',
|
||||||
|
category: 'shell',
|
||||||
|
container: 'appShell',
|
||||||
|
layoutEditable: true,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'titleBar',
|
||||||
|
label: 'Title Bar',
|
||||||
|
description: 'The top application bar that shows the active room context and desktop controls.',
|
||||||
|
category: 'shell',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: true,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatRoomChannelsPanel',
|
||||||
|
label: 'Channels Panel',
|
||||||
|
description: 'The room-side panel showing text and voice channels.',
|
||||||
|
category: 'room',
|
||||||
|
container: 'roomLayout',
|
||||||
|
layoutEditable: true,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatRoomMainPanel',
|
||||||
|
label: 'Chat Panel',
|
||||||
|
description: 'The main room panel that hosts chat messages and the voice workspace overlay.',
|
||||||
|
category: 'room',
|
||||||
|
container: 'roomLayout',
|
||||||
|
layoutEditable: true,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatRoomMembersPanel',
|
||||||
|
label: 'Members Panel',
|
||||||
|
description: 'The right-hand room panel showing online and offline members.',
|
||||||
|
category: 'room',
|
||||||
|
container: 'roomLayout',
|
||||||
|
layoutEditable: true,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'chatRoomEmptyState',
|
||||||
|
label: 'Room Empty State',
|
||||||
|
description: 'The empty-state panel displayed when no room or no text channels are available.',
|
||||||
|
category: 'state',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: true,
|
||||||
|
supportsLink: true,
|
||||||
|
supportsIcon: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'voiceWorkspace',
|
||||||
|
label: 'Voice Workspace',
|
||||||
|
description: 'The stream-focused overlay and mini-window used when voice workspace is open.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'floatingVoiceControls',
|
||||||
|
label: 'Floating Voice Controls',
|
||||||
|
description: 'The compact floating voice controls shown when the active voice server is off-screen.',
|
||||||
|
category: 'overlay',
|
||||||
|
layoutEditable: false,
|
||||||
|
pickerVisible: true,
|
||||||
|
supportsTextOverride: false,
|
||||||
|
supportsLink: false,
|
||||||
|
supportsIcon: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findThemeRegistryEntry(key: string): ThemeRegistryEntry | null {
|
||||||
|
return THEME_REGISTRY.find((entry) => entry.key === key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findThemeLayoutContainer(key: string): ThemeLayoutContainerDefinition | null {
|
||||||
|
return THEME_LAYOUT_CONTAINERS.find((container) => container.key === key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayoutEditableThemeKeys(): string[] {
|
||||||
|
return THEME_REGISTRY
|
||||||
|
.filter((entry) => entry.layoutEditable)
|
||||||
|
.map((entry) => entry.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPickerVisibleThemeKeys(): string[] {
|
||||||
|
return THEME_REGISTRY
|
||||||
|
.filter((entry) => entry.pickerVisible)
|
||||||
|
.map((entry) => entry.key);
|
||||||
|
}
|
||||||
432
toju-app/src/app/domains/theme/domain/theme.schema.ts
Normal file
432
toju-app/src/app/domains/theme/domain/theme.schema.ts
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
import {
|
||||||
|
ThemeDocument,
|
||||||
|
ThemeElementStyleProperty,
|
||||||
|
ThemeSchemaField
|
||||||
|
} from './theme.models';
|
||||||
|
|
||||||
|
export const THEME_TOP_LEVEL_FIELDS: readonly ThemeSchemaField[] = [
|
||||||
|
{
|
||||||
|
key: 'meta',
|
||||||
|
description: 'Theme metadata used for naming, versioning, and describing the preset.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "name": "Aurora" }',
|
||||||
|
examples: ['{ "name": "Aurora" }']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tokens',
|
||||||
|
description: 'Global CSS-variable token overrides for colors, spacing, radii, and effects.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "colors": { "background": "220 18% 10%" } }',
|
||||||
|
examples: ['{ "colors": { "background": "220 18% 10%" } }']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'layout',
|
||||||
|
description: 'Grid layout entries for registered moveable surfaces.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }',
|
||||||
|
examples: ['{ "chatRoomMainPanel": { "container": "roomLayout", "grid": { "x": 4, "y": 0, "w": 12, "h": 12 } } }']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'elements',
|
||||||
|
description: 'Per-element visual overrides such as color, spacing, links, icons, and text.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "titleBar": { "backgroundColor": "rgba(7,11,20,0.84)" } }',
|
||||||
|
examples: ['{ "titleBar": { "backgroundColor": "rgba(7,11,20,0.84)" } }']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'animations',
|
||||||
|
description: 'Animation class definitions keyed by class name. Entries can include timing and keyframes.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "theme-fade-in": { "duration": "240ms" } }',
|
||||||
|
examples: ['{ "theme-fade-in": { "duration": "240ms" } }']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_TOKEN_GROUP_FIELDS: readonly ThemeSchemaField[] = [
|
||||||
|
{
|
||||||
|
key: 'colors',
|
||||||
|
description: 'Maps token names such as background or primary to CSS variable values.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "background": "220 18% 10%" }',
|
||||||
|
examples: ['{ "background": "220 18% 10%" }']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'spacing',
|
||||||
|
description: 'Custom theme spacing variables exposed as --theme-spacing-* CSS variables.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "panelGap": "12px" }',
|
||||||
|
examples: ['{ "panelGap": "12px" }']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'radii',
|
||||||
|
description: 'Theme radius variables exposed as --radius or --theme-radius-* CSS variables.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "radius": "18px" }',
|
||||||
|
examples: ['{ "radius": "18px" }']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'effects',
|
||||||
|
description: 'Custom effect variables exposed as --theme-effect-* CSS variables.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "glassBlur": "18px" }',
|
||||||
|
examples: ['{ "glassBlur": "18px" }']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_LAYOUT_FIELDS: readonly ThemeSchemaField[] = [
|
||||||
|
{
|
||||||
|
key: 'container',
|
||||||
|
description: 'The registered layout container that owns this grid item.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'roomLayout',
|
||||||
|
examples: ['appShell', 'roomLayout']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'grid',
|
||||||
|
description: 'Grid coordinates for the item within its container.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "x": 4, "y": 0, "w": 12, "h": 12 }',
|
||||||
|
examples: ['{ "x": 4, "y": 0, "w": 12, "h": 12 }']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_GRID_FIELDS: readonly ThemeSchemaField[] = [
|
||||||
|
{
|
||||||
|
key: 'x',
|
||||||
|
description: 'Horizontal grid start column, zero-based.',
|
||||||
|
type: 'number',
|
||||||
|
example: 4,
|
||||||
|
examples: [0, 1, 4]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'y',
|
||||||
|
description: 'Vertical grid start row, zero-based.',
|
||||||
|
type: 'number',
|
||||||
|
example: 0,
|
||||||
|
examples: [0, 1, 6]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'w',
|
||||||
|
description: 'Grid width in columns.',
|
||||||
|
type: 'number',
|
||||||
|
example: 12,
|
||||||
|
examples: [1, 4, 12]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'h',
|
||||||
|
description: 'Grid height in rows.',
|
||||||
|
type: 'number',
|
||||||
|
example: 12,
|
||||||
|
examples: [1, 4, 12]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_ANIMATION_FIELDS: readonly ThemeSchemaField[] = [
|
||||||
|
{
|
||||||
|
key: 'duration',
|
||||||
|
description: 'Animation duration.',
|
||||||
|
type: 'string',
|
||||||
|
example: '240ms',
|
||||||
|
examples: ['200ms', '240ms', '600ms']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'easing',
|
||||||
|
description: 'Animation easing function.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'ease-out',
|
||||||
|
examples: ['ease', 'ease-out', 'cubic-bezier(0.16, 1, 0.3, 1)']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'delay',
|
||||||
|
description: 'Optional animation delay.',
|
||||||
|
type: 'string',
|
||||||
|
example: '0ms',
|
||||||
|
examples: ['0ms', '120ms']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'iterationCount',
|
||||||
|
description: 'How many times the animation should run.',
|
||||||
|
type: 'string',
|
||||||
|
example: '1',
|
||||||
|
examples: ['1', 'infinite']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fillMode',
|
||||||
|
description: 'Animation fill behavior after running.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'both',
|
||||||
|
examples: ['none', 'forwards', 'backwards', 'both']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'direction',
|
||||||
|
description: 'Animation direction.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'normal',
|
||||||
|
examples: ['normal', 'reverse', 'alternate']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keyframes',
|
||||||
|
description: 'Optional keyframe map keyed by from, to, or percentages.',
|
||||||
|
type: 'object',
|
||||||
|
example: '{ "0%": { "opacity": "0" }, "100%": { "opacity": "1" } }',
|
||||||
|
examples: ['{ "0%": { "opacity": "0" }, "100%": { "opacity": "1" } }']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_ELEMENT_STYLE_FIELDS: readonly ThemeSchemaField<ThemeElementStyleProperty>[] = [
|
||||||
|
{
|
||||||
|
key: 'width',
|
||||||
|
description: 'CSS width applied to the selected element host.',
|
||||||
|
type: 'string',
|
||||||
|
example: '280px',
|
||||||
|
examples: ['280px', '20rem', 'min(24rem, 30vw)']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'height',
|
||||||
|
description: 'CSS height applied to the selected element host.',
|
||||||
|
type: 'string',
|
||||||
|
example: '100%',
|
||||||
|
examples: ['100%', '22rem', 'calc(100vh - 4rem)']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minWidth',
|
||||||
|
description: 'CSS minimum width for the element host.',
|
||||||
|
type: 'string',
|
||||||
|
example: '16rem',
|
||||||
|
examples: ['16rem', '240px']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'minHeight',
|
||||||
|
description: 'CSS minimum height for the element host.',
|
||||||
|
type: 'string',
|
||||||
|
example: '14rem',
|
||||||
|
examples: ['14rem', '200px']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxWidth',
|
||||||
|
description: 'CSS maximum width for the element host.',
|
||||||
|
type: 'string',
|
||||||
|
example: '34rem',
|
||||||
|
examples: ['34rem', '80vw']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'maxHeight',
|
||||||
|
description: 'CSS maximum height for the element host.',
|
||||||
|
type: 'string',
|
||||||
|
example: '90vh',
|
||||||
|
examples: ['90vh', '48rem']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'position',
|
||||||
|
description: 'CSS positioning mode for the host element.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'relative',
|
||||||
|
examples: ['static', 'relative', 'absolute', 'sticky']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'top',
|
||||||
|
description: 'CSS top inset used with positioned elements.',
|
||||||
|
type: 'string',
|
||||||
|
example: '12px',
|
||||||
|
examples: ['0', '12px', '2rem']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'right',
|
||||||
|
description: 'CSS right inset used with positioned elements.',
|
||||||
|
type: 'string',
|
||||||
|
example: '12px',
|
||||||
|
examples: ['0', '12px', '2rem']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'bottom',
|
||||||
|
description: 'CSS bottom inset used with positioned elements.',
|
||||||
|
type: 'string',
|
||||||
|
example: '12px',
|
||||||
|
examples: ['0', '12px', '2rem']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'left',
|
||||||
|
description: 'CSS left inset used with positioned elements.',
|
||||||
|
type: 'string',
|
||||||
|
example: '12px',
|
||||||
|
examples: ['0', '12px', '2rem']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'opacity',
|
||||||
|
description: 'Element opacity between 0 and 1.',
|
||||||
|
type: 'number',
|
||||||
|
example: 0.96,
|
||||||
|
examples: [0.72, 0.88, 1]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'padding',
|
||||||
|
description: 'CSS padding shorthand for internal spacing.',
|
||||||
|
type: 'string',
|
||||||
|
example: '12px',
|
||||||
|
examples: ['12px', '12px 16px', '1rem 1.25rem']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'margin',
|
||||||
|
description: 'CSS margin shorthand for external spacing.',
|
||||||
|
type: 'string',
|
||||||
|
example: '0',
|
||||||
|
examples: ['0', '12px', '0 0 12px']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'border',
|
||||||
|
description: 'CSS border shorthand.',
|
||||||
|
type: 'string',
|
||||||
|
example: '1px solid rgba(255,255,255,0.08)',
|
||||||
|
examples: ['1px solid rgba(255,255,255,0.08)', '0']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'borderRadius',
|
||||||
|
description: 'CSS border radius shorthand.',
|
||||||
|
type: 'string',
|
||||||
|
example: '16px',
|
||||||
|
examples: ['12px', '16px', '999px']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backgroundColor',
|
||||||
|
description: 'CSS background-color value.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'rgba(12, 18, 28, 0.88)',
|
||||||
|
examples: ['rgba(12, 18, 28, 0.88)', 'hsl(var(--card))']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'color',
|
||||||
|
description: 'CSS text color value.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'hsl(var(--foreground))',
|
||||||
|
examples: ['hsl(var(--foreground))', '#e8edf4']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backgroundImage',
|
||||||
|
description: 'CSS background image or image URL.',
|
||||||
|
type: 'string',
|
||||||
|
example: "url('/assets/themes/paper-noise.png')",
|
||||||
|
examples: ["url('/assets/themes/paper-noise.png')", "url('https://example.com/bg.jpg')"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backgroundSize',
|
||||||
|
description: 'CSS background-size value.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'cover',
|
||||||
|
examples: ['cover', 'contain', 'auto 100%']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backgroundPosition',
|
||||||
|
description: 'CSS background-position value.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'center',
|
||||||
|
examples: ['center', 'top left', '50% 20%']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backgroundRepeat',
|
||||||
|
description: 'CSS background-repeat value.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'no-repeat',
|
||||||
|
examples: ['no-repeat', 'repeat', 'repeat-x']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gradient',
|
||||||
|
description: 'CSS gradient layered above any background image.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.72))',
|
||||||
|
examples: ['linear-gradient(180deg, rgba(0,0,0,0.12), rgba(0,0,0,0.72))', 'radial-gradient(circle at top, rgba(255,255,255,0.12), transparent 60%)']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'boxShadow',
|
||||||
|
description: 'CSS box-shadow value.',
|
||||||
|
type: 'string',
|
||||||
|
example: '0 18px 45px rgba(0,0,0,0.24)',
|
||||||
|
examples: ['0 18px 45px rgba(0,0,0,0.24)', 'none']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backdropFilter',
|
||||||
|
description: 'CSS backdrop-filter value.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'blur(18px)',
|
||||||
|
examples: ['blur(18px)', 'saturate(140%) blur(12px)']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'icon',
|
||||||
|
description: 'Optional theme icon string or image URL used by supported elements.',
|
||||||
|
type: 'string',
|
||||||
|
example: "url('/assets/themes/orb.svg')",
|
||||||
|
examples: ["url('/assets/themes/orb.svg')", 'TX']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'textOverride',
|
||||||
|
description: 'Replacement text for supported elements.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'Studio Mode',
|
||||||
|
examples: ['Studio Mode', 'Open to your crew']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'link',
|
||||||
|
description: 'Safe http or https URL opened externally when supported elements are clicked.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'https://example.com',
|
||||||
|
examples: ['https://example.com', 'https://toju.app/themes']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'animationClass',
|
||||||
|
description: 'Animation class name defined in the animations section or in app CSS.',
|
||||||
|
type: 'string',
|
||||||
|
example: 'theme-fade-in',
|
||||||
|
examples: ['theme-fade-in', 'theme-glide-in']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const THEME_SCHEMA = {
|
||||||
|
topLevel: THEME_TOP_LEVEL_FIELDS,
|
||||||
|
tokens: THEME_TOKEN_GROUP_FIELDS,
|
||||||
|
layout: THEME_LAYOUT_FIELDS,
|
||||||
|
grid: THEME_GRID_FIELDS,
|
||||||
|
animation: THEME_ANIMATION_FIELDS,
|
||||||
|
elementStyle: THEME_ELEMENT_STYLE_FIELDS
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function findThemeElementStyleField(field: string): ThemeSchemaField<ThemeElementStyleProperty> | null {
|
||||||
|
return THEME_ELEMENT_STYLE_FIELDS.find((entry) => entry.key === field) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuggestedValueOptions(
|
||||||
|
field: ThemeElementStyleProperty | string,
|
||||||
|
animationKeys: readonly string[] = []
|
||||||
|
): readonly (string | number)[] {
|
||||||
|
if (field === 'animationClass' && animationKeys.length > 0) {
|
||||||
|
return animationKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
return findThemeElementStyleField(field)?.examples ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSuggestedFieldDefault(
|
||||||
|
field: ThemeElementStyleProperty,
|
||||||
|
animationKeys: readonly string[] = []
|
||||||
|
): string | number {
|
||||||
|
return getSuggestedValueOptions(field, animationKeys)[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAnimationStarterDefinition(): ThemeDocument['animations'][string] {
|
||||||
|
return {
|
||||||
|
duration: '240ms',
|
||||||
|
easing: 'ease-out',
|
||||||
|
delay: '0ms',
|
||||||
|
iterationCount: '1',
|
||||||
|
fillMode: 'both',
|
||||||
|
direction: 'normal',
|
||||||
|
keyframes: {
|
||||||
|
'0%': {
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateY(10px)'
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '1',
|
||||||
|
transform: 'translateY(0)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
452
toju-app/src/app/domains/theme/domain/theme.validation.ts
Normal file
452
toju-app/src/app/domains/theme/domain/theme.validation.ts
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
import {
|
||||||
|
ThemeAnimationDefinition,
|
||||||
|
ThemeDocument,
|
||||||
|
ThemeElementStyleProperty,
|
||||||
|
ThemeElementStyles,
|
||||||
|
ThemeValidationResult
|
||||||
|
} from './theme.models';
|
||||||
|
import { createDefaultThemeDocument } from './theme.defaults';
|
||||||
|
import {
|
||||||
|
THEME_LAYOUT_CONTAINERS,
|
||||||
|
THEME_REGISTRY,
|
||||||
|
getLayoutEditableThemeKeys
|
||||||
|
} from './theme.registry';
|
||||||
|
|
||||||
|
const TOP_LEVEL_KEYS = ['meta', 'tokens', 'layout', 'elements', 'animations'] as const;
|
||||||
|
const META_KEYS = ['name', 'version', 'description'] as const;
|
||||||
|
const TOKEN_GROUP_KEYS = ['colors', 'spacing', 'radii', 'effects'] as const;
|
||||||
|
const LAYOUT_ENTRY_KEYS = ['container', 'grid'] as const;
|
||||||
|
const GRID_KEYS = ['x', 'y', 'w', 'h'] as const;
|
||||||
|
const ANIMATION_KEYS = ['duration', 'easing', 'delay', 'iterationCount', 'fillMode', 'direction', 'keyframes'] as const;
|
||||||
|
const POSITION_VALUES = ['static', 'relative', 'absolute', 'sticky'] as const;
|
||||||
|
const FILL_MODE_VALUES = ['none', 'forwards', 'backwards', 'both'] as const;
|
||||||
|
const DIRECTION_VALUES = ['normal', 'reverse', 'alternate', 'alternate-reverse'] as const;
|
||||||
|
const SAFE_LINK_PROTOCOLS = ['http:', 'https:'] as const;
|
||||||
|
const SAFE_CLASS_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
||||||
|
const KEYFRAME_STEP_PATTERN = /^(from|to|(?:\d|[1-9]\d|100)%)$/;
|
||||||
|
const ELEMENT_STYLE_KEYS: readonly ThemeElementStyleProperty[] = [
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'minWidth',
|
||||||
|
'minHeight',
|
||||||
|
'maxWidth',
|
||||||
|
'maxHeight',
|
||||||
|
'position',
|
||||||
|
'top',
|
||||||
|
'right',
|
||||||
|
'bottom',
|
||||||
|
'left',
|
||||||
|
'opacity',
|
||||||
|
'padding',
|
||||||
|
'margin',
|
||||||
|
'border',
|
||||||
|
'borderRadius',
|
||||||
|
'backgroundColor',
|
||||||
|
'color',
|
||||||
|
'backgroundImage',
|
||||||
|
'backgroundSize',
|
||||||
|
'backgroundPosition',
|
||||||
|
'backgroundRepeat',
|
||||||
|
'gradient',
|
||||||
|
'boxShadow',
|
||||||
|
'backdropFilter',
|
||||||
|
'icon',
|
||||||
|
'textOverride',
|
||||||
|
'link',
|
||||||
|
'animationClass'
|
||||||
|
];
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateUnknownKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
allowedKeys: readonly string[],
|
||||||
|
path: string,
|
||||||
|
errors: string[]
|
||||||
|
): void {
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (!allowedKeys.includes(key)) {
|
||||||
|
errors.push(`${path}.${key} is not part of the supported theme schema.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateString(value: unknown, path: string, errors: string[], allowEmpty = false): value is string {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
errors.push(`${path} must be a string.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowEmpty && value.trim().length === 0) {
|
||||||
|
errors.push(`${path} cannot be empty.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateEnum<T extends readonly string[]>(
|
||||||
|
value: unknown,
|
||||||
|
allowedValues: T,
|
||||||
|
path: string,
|
||||||
|
errors: string[]
|
||||||
|
): value is T[number] {
|
||||||
|
if (typeof value !== 'string' || !allowedValues.includes(value)) {
|
||||||
|
errors.push(`${path} must be one of: ${allowedValues.join(', ')}.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateInteger(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
errors: string[],
|
||||||
|
minimum: number,
|
||||||
|
allowZero: boolean
|
||||||
|
): value is number {
|
||||||
|
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||||
|
errors.push(`${path} must be an integer.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((allowZero && value < minimum) || (!allowZero && value <= minimum)) {
|
||||||
|
errors.push(`${path} must be ${allowZero ? 'greater than or equal to' : 'greater than'} ${minimum}.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateNumberRange(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
errors: string[],
|
||||||
|
minimum: number,
|
||||||
|
maximum: number
|
||||||
|
): value is number {
|
||||||
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||||
|
errors.push(`${path} must be a number.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < minimum || value > maximum) {
|
||||||
|
errors.push(`${path} must be between ${minimum} and ${maximum}.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateStringRecord(value: unknown, path: string, errors: string[]): value is Record<string, string> {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
errors.push(`${path} must be an object containing string values.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, recordValue] of Object.entries(value)) {
|
||||||
|
validateString(recordValue, `${path}.${key}`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateElementStyles(value: unknown, path: string, errors: string[]): value is ThemeElementStyles {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
errors.push(`${path} must be an object.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUnknownKeys(value, ELEMENT_STYLE_KEYS, path, errors);
|
||||||
|
|
||||||
|
for (const [key, fieldValue] of Object.entries(value)) {
|
||||||
|
if (key === 'opacity') {
|
||||||
|
validateNumberRange(fieldValue, `${path}.${key}`, errors, 0, 1);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'position') {
|
||||||
|
validateEnum(fieldValue, POSITION_VALUES, `${path}.${key}`, errors);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'link') {
|
||||||
|
if (validateString(fieldValue, `${path}.${key}`, errors)) {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(fieldValue);
|
||||||
|
|
||||||
|
if (!SAFE_LINK_PROTOCOLS.includes(parsedUrl.protocol as (typeof SAFE_LINK_PROTOCOLS)[number])) {
|
||||||
|
errors.push(`${path}.${key} must use http or https.`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors.push(`${path}.${key} must be a valid absolute URL.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'animationClass') {
|
||||||
|
if (validateString(fieldValue, `${path}.${key}`, errors) && !SAFE_CLASS_PATTERN.test(fieldValue)) {
|
||||||
|
errors.push(`${path}.${key} must be a safe CSS class token.`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateString(fieldValue, `${path}.${key}`, errors, key === 'backgroundImage' || key === 'gradient');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAnimationDefinition(value: unknown, path: string, errors: string[]): value is ThemeAnimationDefinition {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
errors.push(`${path} must be an object.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUnknownKeys(value, ANIMATION_KEYS, path, errors);
|
||||||
|
|
||||||
|
if (value['duration'] !== undefined) {
|
||||||
|
validateString(value['duration'], `${path}.duration`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['easing'] !== undefined) {
|
||||||
|
validateString(value['easing'], `${path}.easing`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['delay'] !== undefined) {
|
||||||
|
validateString(value['delay'], `${path}.delay`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['iterationCount'] !== undefined) {
|
||||||
|
validateString(value['iterationCount'], `${path}.iterationCount`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['fillMode'] !== undefined) {
|
||||||
|
validateEnum(value['fillMode'], FILL_MODE_VALUES, `${path}.fillMode`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['direction'] !== undefined) {
|
||||||
|
validateEnum(value['direction'], DIRECTION_VALUES, `${path}.direction`, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value['keyframes'] !== undefined) {
|
||||||
|
if (!isPlainObject(value['keyframes'])) {
|
||||||
|
errors.push(`${path}.keyframes must be an object.`);
|
||||||
|
} else {
|
||||||
|
for (const [step, declarations] of Object.entries(value['keyframes'])) {
|
||||||
|
if (!KEYFRAME_STEP_PATTERN.test(step)) {
|
||||||
|
errors.push(`${path}.keyframes.${step} is not a supported keyframe step.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPlainObject(declarations)) {
|
||||||
|
errors.push(`${path}.keyframes.${step} must be an object of CSS declarations.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [cssProperty, cssValue] of Object.entries(declarations)) {
|
||||||
|
if (typeof cssValue !== 'string' && typeof cssValue !== 'number') {
|
||||||
|
errors.push(`${path}.keyframes.${step}.${cssProperty} must be a string or number.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseThemeDocument(input: Partial<ThemeDocument>): ThemeDocument {
|
||||||
|
const document = createDefaultThemeDocument();
|
||||||
|
|
||||||
|
document.meta = {
|
||||||
|
...document.meta,
|
||||||
|
...input.meta
|
||||||
|
};
|
||||||
|
|
||||||
|
document.tokens = {
|
||||||
|
colors: {
|
||||||
|
...document.tokens.colors,
|
||||||
|
...(input.tokens?.colors ?? {})
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
...document.tokens.spacing,
|
||||||
|
...(input.tokens?.spacing ?? {})
|
||||||
|
},
|
||||||
|
radii: {
|
||||||
|
...document.tokens.radii,
|
||||||
|
...(input.tokens?.radii ?? {})
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
...document.tokens.effects,
|
||||||
|
...(input.tokens?.effects ?? {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.layout = {
|
||||||
|
...document.layout,
|
||||||
|
...(input.layout ?? {})
|
||||||
|
};
|
||||||
|
|
||||||
|
document.elements = {
|
||||||
|
...document.elements,
|
||||||
|
...(input.elements ?? {})
|
||||||
|
};
|
||||||
|
|
||||||
|
document.animations = {
|
||||||
|
...document.animations,
|
||||||
|
...(input.animations ?? {})
|
||||||
|
};
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateThemeDocument(input: unknown): ThemeValidationResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: ['Theme document must be a JSON object.'],
|
||||||
|
value: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUnknownKeys(input, TOP_LEVEL_KEYS, 'theme', errors);
|
||||||
|
|
||||||
|
const meta = input['meta'];
|
||||||
|
|
||||||
|
if (!isPlainObject(meta)) {
|
||||||
|
errors.push('theme.meta must be an object.');
|
||||||
|
} else {
|
||||||
|
validateUnknownKeys(meta, META_KEYS, 'theme.meta', errors);
|
||||||
|
validateString(meta['name'], 'theme.meta.name', errors);
|
||||||
|
validateString(meta['version'], 'theme.meta.version', errors);
|
||||||
|
|
||||||
|
if (meta['description'] !== undefined) {
|
||||||
|
validateString(meta['description'], 'theme.meta.description', errors, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = input['tokens'];
|
||||||
|
|
||||||
|
if (tokens !== undefined) {
|
||||||
|
if (!isPlainObject(tokens)) {
|
||||||
|
errors.push('theme.tokens must be an object.');
|
||||||
|
} else {
|
||||||
|
validateUnknownKeys(tokens, TOKEN_GROUP_KEYS, 'theme.tokens', errors);
|
||||||
|
|
||||||
|
if (tokens['colors'] !== undefined) {
|
||||||
|
validateStringRecord(tokens['colors'], 'theme.tokens.colors', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens['spacing'] !== undefined) {
|
||||||
|
validateStringRecord(tokens['spacing'], 'theme.tokens.spacing', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens['radii'] !== undefined) {
|
||||||
|
validateStringRecord(tokens['radii'], 'theme.tokens.radii', errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokens['effects'] !== undefined) {
|
||||||
|
validateStringRecord(tokens['effects'], 'theme.tokens.effects', errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = input['layout'];
|
||||||
|
|
||||||
|
if (layout !== undefined) {
|
||||||
|
if (!isPlainObject(layout)) {
|
||||||
|
errors.push('theme.layout must be an object.');
|
||||||
|
} else {
|
||||||
|
const allowedLayoutKeys = getLayoutEditableThemeKeys();
|
||||||
|
|
||||||
|
validateUnknownKeys(layout, allowedLayoutKeys, 'theme.layout', errors);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(layout)) {
|
||||||
|
const basePath = `theme.layout.${key}`;
|
||||||
|
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
errors.push(`${basePath} must be an object.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUnknownKeys(value, LAYOUT_ENTRY_KEYS, basePath, errors);
|
||||||
|
|
||||||
|
if (value['container'] !== undefined) {
|
||||||
|
validateEnum(
|
||||||
|
value['container'],
|
||||||
|
THEME_LAYOUT_CONTAINERS.map((container) => container.key) as unknown as readonly string[],
|
||||||
|
`${basePath}.container`,
|
||||||
|
errors
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = value['grid'];
|
||||||
|
|
||||||
|
if (!isPlainObject(grid)) {
|
||||||
|
errors.push(`${basePath}.grid must be an object.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateUnknownKeys(grid, GRID_KEYS, `${basePath}.grid`, errors);
|
||||||
|
validateInteger(grid['x'], `${basePath}.grid.x`, errors, 0, true);
|
||||||
|
validateInteger(grid['y'], `${basePath}.grid.y`, errors, 0, true);
|
||||||
|
validateInteger(grid['w'], `${basePath}.grid.w`, errors, 0, false);
|
||||||
|
validateInteger(grid['h'], `${basePath}.grid.h`, errors, 0, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = input['elements'];
|
||||||
|
|
||||||
|
if (elements !== undefined) {
|
||||||
|
if (!isPlainObject(elements)) {
|
||||||
|
errors.push('theme.elements must be an object.');
|
||||||
|
} else {
|
||||||
|
const allowedElementKeys = THEME_REGISTRY.map((entry) => entry.key);
|
||||||
|
|
||||||
|
validateUnknownKeys(elements, allowedElementKeys, 'theme.elements', errors);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(elements)) {
|
||||||
|
validateElementStyles(value, `theme.elements.${key}`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const animations = input['animations'];
|
||||||
|
|
||||||
|
if (animations !== undefined) {
|
||||||
|
if (!isPlainObject(animations)) {
|
||||||
|
errors.push('theme.animations must be an object.');
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(animations)) {
|
||||||
|
if (!SAFE_CLASS_PATTERN.test(key)) {
|
||||||
|
errors.push(`theme.animations.${key} must use a safe CSS class token.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateAnimationDefinition(value, `theme.animations.${key}`, errors);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors,
|
||||||
|
value: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
value: normaliseThemeDocument(input as Partial<ThemeDocument>)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<div class="theme-grid-editor rounded-2xl border border-border bg-background/50 p-3">
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-3 px-1">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-foreground">{{ container().label }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ container().description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{{ container().columns }} cols x {{ container().rows }} rows
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
#canvasRef
|
||||||
|
class="theme-grid-editor__frame relative overflow-hidden rounded-xl border border-border/80"
|
||||||
|
[ngStyle]="frameStyle()"
|
||||||
|
>
|
||||||
|
<div class="theme-grid-editor__grid"></div>
|
||||||
|
|
||||||
|
@for (item of items(); track item.key) {
|
||||||
|
<div
|
||||||
|
class="theme-grid-editor__item absolute"
|
||||||
|
[class.theme-grid-editor__item--selected]="selectedKey() === item.key"
|
||||||
|
[class.theme-grid-editor__item--disabled]="disabled()"
|
||||||
|
[ngStyle]="itemStyle(item)"
|
||||||
|
(click)="selectItem(item.key)"
|
||||||
|
(keydown.enter)="selectItem(item.key)"
|
||||||
|
(keydown.space)="selectItem(item.key)"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
[attr.aria-label]="'Select ' + item.label"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="theme-grid-editor__item-body"
|
||||||
|
(pointerdown)="startMove($event, item)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-semibold text-foreground">{{ item.label }}</p>
|
||||||
|
<p class="mt-1 line-clamp-2 text-[11px] leading-4 text-muted-foreground">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-full bg-background/80 px-2 py-0.5 text-[10px] font-medium text-muted-foreground shadow-sm">
|
||||||
|
{{ item.grid.x }},{{ item.grid.y }} · {{ item.grid.w }}x{{ item.grid.h }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="theme-grid-editor__handle"
|
||||||
|
(pointerdown)="startResize($event, item)"
|
||||||
|
aria-label="Resize {{ item.label }}"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (disabled()) {
|
||||||
|
<div
|
||||||
|
class="theme-grid-editor__disabled absolute inset-0 flex items-center justify-center rounded-xl bg-background/75 px-6 text-center text-sm text-muted-foreground backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
Fix JSON validation errors to re-enable the grid editor.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__frame {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, hsl(var(--primary) / 0.08), transparent 45%),
|
||||||
|
linear-gradient(180deg, hsl(var(--background) / 0.96), hsl(var(--card) / 0.98));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(to right, hsl(var(--border) / 0.65) 1px, transparent 1px),
|
||||||
|
linear-gradient(to bottom, hsl(var(--border) / 0.65) 1px, transparent 1px);
|
||||||
|
background-size:
|
||||||
|
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows)),
|
||||||
|
calc(100% / var(--theme-grid-columns)) calc(100% / var(--theme-grid-rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__item {
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__item-body {
|
||||||
|
height: 100%;
|
||||||
|
border: 1px solid hsl(var(--border) / 0.8);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, hsl(var(--card) / 0.96), hsl(var(--background) / 0.96)),
|
||||||
|
radial-gradient(circle at top right, hsl(var(--primary) / 0.1), transparent 45%);
|
||||||
|
box-shadow: 0 12px 30px rgb(0 0 0 / 10%);
|
||||||
|
cursor: grab;
|
||||||
|
padding: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__item:active .theme-grid-editor__item-body {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__item--selected .theme-grid-editor__item-body {
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px hsl(var(--primary)),
|
||||||
|
0 14px 34px hsl(var(--primary) / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__item--disabled .theme-grid-editor__item-body {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__item:focus-visible .theme-grid-editor__item-body {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px hsl(var(--primary)),
|
||||||
|
0 14px 34px hsl(var(--primary) / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
width: 0.95rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--primary));
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--background));
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-grid-editor__disabled {
|
||||||
|
border: 1px dashed hsl(var(--border));
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
viewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ThemeGridEditorItem,
|
||||||
|
ThemeGridRect,
|
||||||
|
ThemeLayoutContainerDefinition
|
||||||
|
} from '../../domain/theme.models';
|
||||||
|
|
||||||
|
type DragMode = 'move' | 'resize';
|
||||||
|
|
||||||
|
interface DragState {
|
||||||
|
key: string;
|
||||||
|
mode: DragMode;
|
||||||
|
startClientX: number;
|
||||||
|
startClientY: number;
|
||||||
|
startGrid: ThemeGridRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-theme-grid-editor',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './theme-grid-editor.component.html',
|
||||||
|
styleUrl: './theme-grid-editor.component.scss'
|
||||||
|
})
|
||||||
|
export class ThemeGridEditorComponent {
|
||||||
|
readonly container = input.required<ThemeLayoutContainerDefinition>();
|
||||||
|
readonly items = input.required<ThemeGridEditorItem[]>();
|
||||||
|
readonly selectedKey = input<string | null>(null);
|
||||||
|
readonly disabled = input(false);
|
||||||
|
|
||||||
|
readonly itemChanged = output<{ key: string; grid: ThemeGridRect }>();
|
||||||
|
readonly itemSelected = output<string>();
|
||||||
|
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private dragState: DragState | null = null;
|
||||||
|
|
||||||
|
readonly canvasRef = viewChild.required<ElementRef<HTMLElement>>('canvasRef');
|
||||||
|
readonly frameStyle = computed(() => ({
|
||||||
|
'--theme-grid-columns': `${this.container().columns}`,
|
||||||
|
'--theme-grid-rows': `${this.container().rows}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
itemStyle(item: ThemeGridEditorItem): Record<string, string> {
|
||||||
|
const { columns, rows } = this.container();
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: `${(item.grid.x / columns) * 100}%`,
|
||||||
|
top: `${(item.grid.y / rows) * 100}%`,
|
||||||
|
width: `${(item.grid.w / columns) * 100}%`,
|
||||||
|
height: `${(item.grid.h / rows) * 100}%`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
selectItem(key: string): void {
|
||||||
|
this.itemSelected.emit(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
startMove(event: PointerEvent, item: ThemeGridEditorItem): void {
|
||||||
|
this.startDrag(event, item, 'move');
|
||||||
|
}
|
||||||
|
|
||||||
|
startResize(event: PointerEvent, item: ThemeGridEditorItem): void {
|
||||||
|
this.startDrag(event, item, 'resize');
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:pointermove', ['$event'])
|
||||||
|
onPointerMove(event: PointerEvent): void {
|
||||||
|
if (!this.dragState || this.disabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasRect = this.canvasRef().nativeElement.getBoundingClientRect();
|
||||||
|
const columnWidth = canvasRect.width / this.container().columns;
|
||||||
|
const rowHeight = canvasRect.height / this.container().rows;
|
||||||
|
const deltaColumns = Math.round((event.clientX - this.dragState.startClientX) / columnWidth);
|
||||||
|
const deltaRows = Math.round((event.clientY - this.dragState.startClientY) / rowHeight);
|
||||||
|
const nextGrid = { ...this.dragState.startGrid };
|
||||||
|
|
||||||
|
if (this.dragState.mode === 'move') {
|
||||||
|
nextGrid.x = this.clamp(deltaColumns + this.dragState.startGrid.x, 0, this.container().columns - nextGrid.w);
|
||||||
|
nextGrid.y = this.clamp(deltaRows + this.dragState.startGrid.y, 0, this.container().rows - nextGrid.h);
|
||||||
|
} else {
|
||||||
|
nextGrid.w = this.clamp(deltaColumns + this.dragState.startGrid.w, 1, this.container().columns - nextGrid.x);
|
||||||
|
nextGrid.h = this.clamp(deltaRows + this.dragState.startGrid.h, 1, this.container().rows - nextGrid.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemChanged.emit({
|
||||||
|
key: this.dragState.key,
|
||||||
|
grid: nextGrid
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:pointerup')
|
||||||
|
@HostListener('document:pointercancel')
|
||||||
|
onPointerUp(): void {
|
||||||
|
this.dragState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEscape(): void {
|
||||||
|
this.dragState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private startDrag(event: PointerEvent, item: ThemeGridEditorItem, mode: DragMode): void {
|
||||||
|
if (this.disabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.itemSelected.emit(item.key);
|
||||||
|
this.dragState = {
|
||||||
|
key: item.key,
|
||||||
|
mode,
|
||||||
|
startClientX: event.clientX,
|
||||||
|
startClientY: event.clientY,
|
||||||
|
startGrid: { ...item.grid }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private clamp(value: number, minimum: number, maximum: number): number {
|
||||||
|
return Math.min(Math.max(value, minimum), maximum);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
NgZone,
|
||||||
|
OnDestroy,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
viewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import { indentWithTab } from '@codemirror/commands';
|
||||||
|
import { json } from '@codemirror/lang-json';
|
||||||
|
import { indentUnit } from '@codemirror/language';
|
||||||
|
import { EditorSelection, EditorState } from '@codemirror/state';
|
||||||
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
|
import { EditorView, keymap } from '@codemirror/view';
|
||||||
|
import { basicSetup } from 'codemirror';
|
||||||
|
|
||||||
|
const THEME_JSON_EDITOR_THEME = EditorView.theme({
|
||||||
|
'&': {
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
color: '#e7eef9'
|
||||||
|
},
|
||||||
|
'&.cm-focused': {
|
||||||
|
outline: 'none'
|
||||||
|
},
|
||||||
|
'.cm-scroller': {
|
||||||
|
overflow: 'auto',
|
||||||
|
fontFamily: "'IBM Plex Mono', 'JetBrains Mono', 'Fira Code', monospace",
|
||||||
|
lineHeight: '1.55'
|
||||||
|
},
|
||||||
|
'.cm-content': {
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: '1rem 0',
|
||||||
|
caretColor: '#f8fafc'
|
||||||
|
},
|
||||||
|
'.cm-line': {
|
||||||
|
padding: '0 1rem 0 0.5rem'
|
||||||
|
},
|
||||||
|
'.cm-cursor, .cm-dropCursor': {
|
||||||
|
borderLeftColor: '#f8fafc'
|
||||||
|
},
|
||||||
|
'.cm-gutters': {
|
||||||
|
minHeight: '100%',
|
||||||
|
borderRight: '1px solid #2f405c',
|
||||||
|
backgroundColor: '#172033',
|
||||||
|
color: '#7b8aa5'
|
||||||
|
},
|
||||||
|
'.cm-activeLine': {
|
||||||
|
backgroundColor: 'rgb(148 163 184 / 0.08)'
|
||||||
|
},
|
||||||
|
'.cm-activeLineGutter': {
|
||||||
|
backgroundColor: 'rgb(148 163 184 / 0.12)',
|
||||||
|
color: '#d7e2f2'
|
||||||
|
},
|
||||||
|
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': {
|
||||||
|
backgroundColor: 'rgb(96 165 250 / 0.22)'
|
||||||
|
},
|
||||||
|
'.cm-panels': {
|
||||||
|
backgroundColor: '#111827',
|
||||||
|
color: '#e5eefc',
|
||||||
|
borderBottom: '1px solid #2f405c'
|
||||||
|
},
|
||||||
|
'.cm-searchMatch': {
|
||||||
|
backgroundColor: 'rgb(250 204 21 / 0.18)',
|
||||||
|
outline: '1px solid rgb(250 204 21 / 0.32)'
|
||||||
|
},
|
||||||
|
'.cm-searchMatch.cm-searchMatch-selected': {
|
||||||
|
backgroundColor: 'rgb(250 204 21 / 0.28)'
|
||||||
|
},
|
||||||
|
'.cm-tooltip': {
|
||||||
|
border: '1px solid #314158',
|
||||||
|
backgroundColor: '#111827'
|
||||||
|
},
|
||||||
|
'.cm-tooltip-autocomplete ul li[aria-selected]': {
|
||||||
|
backgroundColor: 'rgb(96 165 250 / 0.18)',
|
||||||
|
color: '#f8fafc'
|
||||||
|
}
|
||||||
|
}, { dark: true });
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-theme-json-code-editor',
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<div
|
||||||
|
class="theme-json-code-editor"
|
||||||
|
[style.minHeight]="editorMinHeight()"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
#editorHostRef
|
||||||
|
class="theme-json-code-editor__host"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-json-code-editor {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #2f405c;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, rgb(96 165 250 / 0.16), transparent 34%),
|
||||||
|
linear-gradient(180deg, #172033, #0e1625);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(125 211 252 / 0.08),
|
||||||
|
0 0 0 1px rgb(15 23 42 / 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-json-code-editor:focus-within {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgb(125 211 252 / 0.5),
|
||||||
|
0 0 0 3px rgb(14 165 233 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-json-code-editor__host {
|
||||||
|
min-height: inherit;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ThemeJsonCodeEditorComponent implements OnDestroy {
|
||||||
|
private readonly zone = inject(NgZone);
|
||||||
|
|
||||||
|
readonly editorHostRef = viewChild<ElementRef<HTMLDivElement>>('editorHostRef');
|
||||||
|
readonly value = input.required<string>();
|
||||||
|
readonly fullscreen = input(false);
|
||||||
|
readonly valueChange = output<string>();
|
||||||
|
|
||||||
|
readonly editorMinHeight = computed(() => this.fullscreen() ? 'max(34rem, calc(100vh - 15rem))' : '28rem');
|
||||||
|
|
||||||
|
private editorView: EditorView | null = null;
|
||||||
|
private isApplyingExternalValue = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const host = this.editorHostRef();
|
||||||
|
|
||||||
|
if (!host || this.editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.createEditor(host.nativeElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const nextValue = this.value();
|
||||||
|
|
||||||
|
if (!this.editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentValue = this.editorView.state.doc.toString();
|
||||||
|
|
||||||
|
if (currentValue === nextValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isApplyingExternalValue = true;
|
||||||
|
this.editorView.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: currentValue.length,
|
||||||
|
insert: nextValue
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.isApplyingExternalValue = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
this.fullscreen();
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.editorView?.requestMeasure();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.editorView?.destroy();
|
||||||
|
this.editorView = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.editorView?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
focusRange(from: number, to = from): void {
|
||||||
|
if (!this.editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const documentLength = this.editorView.state.doc.length;
|
||||||
|
const selectionStart = Math.max(0, Math.min(from, documentLength));
|
||||||
|
const selectionEnd = Math.max(selectionStart, Math.min(to, documentLength));
|
||||||
|
|
||||||
|
this.editorView.dispatch({
|
||||||
|
selection: EditorSelection.range(selectionStart, selectionEnd),
|
||||||
|
effects: EditorView.scrollIntoView(selectionStart, { y: 'center' })
|
||||||
|
});
|
||||||
|
this.editorView.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private createEditor(host: HTMLDivElement): void {
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
this.editorView = new EditorView({
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: this.value(),
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
keymap.of([indentWithTab]),
|
||||||
|
indentUnit.of(' '),
|
||||||
|
json(),
|
||||||
|
oneDark,
|
||||||
|
THEME_JSON_EDITOR_THEME,
|
||||||
|
EditorState.tabSize.of(2),
|
||||||
|
EditorView.contentAttributes.of({
|
||||||
|
spellcheck: 'false',
|
||||||
|
autocapitalize: 'off',
|
||||||
|
autocorrect: 'off',
|
||||||
|
'aria-label': 'Theme JSON editor'
|
||||||
|
}),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (!update.docChanged || this.isApplyingExternalValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = update.state.doc.toString();
|
||||||
|
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.valueChange.emit(nextValue);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
parent: host
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
<div
|
||||||
|
class="theme-settings flex min-h-0 w-full flex-col space-y-4"
|
||||||
|
[class.min-h-full]="isFullscreen()"
|
||||||
|
[class.p-4]="isFullscreen()"
|
||||||
|
[class.theme-settings--fullscreen]="isFullscreen()"
|
||||||
|
>
|
||||||
|
<section class="theme-studio-card p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Studio</p>
|
||||||
|
<h4 class="mt-1 text-xl font-semibold text-foreground">{{ draftTheme().meta.name }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="startPicker()"
|
||||||
|
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Pick UI Element
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="formatDraft()"
|
||||||
|
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Format JSON
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="copyLlmThemeGuide()"
|
||||||
|
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Copy LLM Guide
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="applyDraft()"
|
||||||
|
[disabled]="!draftIsValid()"
|
||||||
|
class="inline-flex items-center rounded-full bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Apply Draft
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="restoreDefaultTheme()"
|
||||||
|
class="inline-flex items-center rounded-full border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
|
||||||
|
>
|
||||||
|
Restore Default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (llmGuideCopyMessage()) {
|
||||||
|
<div class="mt-3 inline-flex rounded-full border border-emerald-500/20 bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-700">
|
||||||
|
{{ llmGuideCopyMessage() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="theme-settings__hero-grid mt-4">
|
||||||
|
<div class="theme-settings__hero-stat">
|
||||||
|
<div class="theme-settings__workspace-selector theme-settings__workspace-selector--compact">
|
||||||
|
<label class="theme-settings__workspace-selector-label">Workspace</label>
|
||||||
|
<select
|
||||||
|
class="theme-settings__workspace-select"
|
||||||
|
[value]="activeWorkspace()"
|
||||||
|
(change)="onWorkspaceSelect($event)"
|
||||||
|
>
|
||||||
|
@for (workspace of workspaceTabs; track workspace.key) {
|
||||||
|
<option [value]="workspace.key">{{ workspace.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="theme-settings__hero-stat">
|
||||||
|
<span class="theme-settings__hero-label">Regions</span>
|
||||||
|
<strong class="theme-settings__hero-value">{{ mountedEntryCount() }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="theme-settings__hero-stat">
|
||||||
|
<span class="theme-settings__hero-label">Draft</span>
|
||||||
|
<strong
|
||||||
|
class="theme-settings__hero-value"
|
||||||
|
[class.text-amber-700]="isDraftDirty()"
|
||||||
|
[class.text-emerald-700]="!isDraftDirty()"
|
||||||
|
>
|
||||||
|
{{ isDraftDirty() ? 'Unsaved changes' : 'In sync' }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (statusMessage()) {
|
||||||
|
<div class="mt-3 rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||||
|
{{ statusMessage() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!draftIsValid()) {
|
||||||
|
<div class="mt-3 rounded-2xl border border-destructive/30 bg-destructive/8 p-4">
|
||||||
|
<p class="text-sm font-semibold text-destructive">The draft is invalid. The last working theme is still active.</p>
|
||||||
|
<ul class="mt-2 space-y-1 text-sm text-destructive/90">
|
||||||
|
@for (error of draftErrors(); track error) {
|
||||||
|
<li>{{ error }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="theme-settings__workspace min-h-0 flex-1">
|
||||||
|
<aside class="theme-settings__sidebar">
|
||||||
|
@if (savedThemesAvailable()) {
|
||||||
|
<section class="theme-studio-card p-3.5">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Saved Themes</p>
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{{ savedThemesBusy() ? 'Syncing' : savedThemes().length + ' saved' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="saveDraftAsNewTheme()"
|
||||||
|
[disabled]="!draftIsValid() || savedThemesBusy()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Save New
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="saveDraftToSelectedTheme()"
|
||||||
|
[disabled]="!draftIsValid() || !selectedSavedTheme() || savedThemesBusy()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Save Selected
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="useSelectedSavedTheme()"
|
||||||
|
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Use
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="editSelectedSavedTheme()"
|
||||||
|
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="removeSelectedSavedTheme()"
|
||||||
|
[disabled]="!selectedSavedTheme() || (savedThemesBusy() && savedThemes().length === 0)"
|
||||||
|
class="rounded-full border border-destructive/25 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/15 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="refreshSavedThemes()"
|
||||||
|
[disabled]="savedThemesBusy() && savedThemes().length === 0"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (savedThemes().length > 0) {
|
||||||
|
<div class="theme-settings__saved-theme-list mt-4">
|
||||||
|
@for (savedTheme of savedThemes(); track savedTheme.fileName) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="selectSavedTheme(savedTheme.fileName)"
|
||||||
|
class="theme-settings__saved-theme-button"
|
||||||
|
[class.theme-settings__saved-theme-button--active]="selectedSavedTheme()?.fileName === savedTheme.fileName"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-semibold text-foreground">{{ savedTheme.themeName }}</p>
|
||||||
|
<p class="mt-1 font-mono text-[11px] text-muted-foreground">{{ savedTheme.fileName }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (savedTheme.isValid) {
|
||||||
|
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Ready</span>
|
||||||
|
} @else {
|
||||||
|
<span class="rounded-full bg-destructive/10 px-2 py-0.5 text-[10px] font-medium text-destructive">Invalid</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (savedTheme.version) {
|
||||||
|
<p class="mt-2 text-[11px] font-medium text-muted-foreground">v{{ savedTheme.version }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (savedTheme.description) {
|
||||||
|
<p class="mt-2 text-xs leading-5 text-muted-foreground">{{ savedTheme.description }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (savedTheme.error) {
|
||||||
|
<p class="mt-2 text-xs leading-5 text-destructive">{{ savedTheme.error }}</p>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="mt-4 rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||||
|
Save the current draft to create your first reusable Electron theme.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (savedThemesPath()) {
|
||||||
|
<p class="mt-3 font-mono text-[11px] leading-5 text-muted-foreground">{{ savedThemesPath() }}</p>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<section class="theme-studio-card p-3.5">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Explorer</p>
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">
|
||||||
|
{{ filteredEntries().length }} shown
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="explorerQuery()"
|
||||||
|
(input)="onExplorerQueryInput($event)"
|
||||||
|
placeholder="Search theme keys"
|
||||||
|
class="theme-settings__search-input mt-2 w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-settings__entry-list mt-4">
|
||||||
|
@for (entry of filteredEntries(); track entry.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="selectThemeEntry(entry.key)"
|
||||||
|
class="theme-settings__entry-button"
|
||||||
|
[class.theme-settings__entry-button--active]="selectedElementKey() === entry.key"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-semibold text-foreground">{{ entry.label }}</span>
|
||||||
|
@if (isMounted(entry)) {
|
||||||
|
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[10px] font-medium text-emerald-700">Mounted</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="mt-1 block font-mono text-[11px] text-muted-foreground">{{ entry.key }}</span>
|
||||||
|
<span class="mt-2 block text-xs leading-5 text-muted-foreground">{{ entry.description }}</span>
|
||||||
|
</button>
|
||||||
|
} @empty {
|
||||||
|
<div class="rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||||
|
No registered theme keys match this filter.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="theme-settings__main">
|
||||||
|
<section class="theme-studio-card p-3.5">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
@if (selectedElement()) {
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</span>
|
||||||
|
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
|
||||||
|
@if (selectedElement()!.container) {
|
||||||
|
<span class="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground">{{
|
||||||
|
selectedElement()!.container
|
||||||
|
}}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedElement()) {
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="jumpToStyles()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Open styles in JSON
|
||||||
|
</button>
|
||||||
|
@if (selectedElement()!.layoutEditable) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="jumpToLayout()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Open layout in JSON
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedElementCapabilities().length > 0) {
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
@for (capability of selectedElementCapabilities(); track capability) {
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1 text-[11px] font-medium text-muted-foreground">{{ capability }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (activeWorkspace() === 'editor') {
|
||||||
|
<section class="theme-studio-card theme-settings__editor-card p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-3">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Theme JSON</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 text-[11px] font-medium text-muted-foreground">
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftLineCount() }} lines</span>
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftCharacterCount() }} chars</span>
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1">{{ draftErrorCount() }} errors</span>
|
||||||
|
<span class="rounded-full bg-slate-900 px-2.5 py-1 text-slate-200">IDE editor</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="theme-settings__editor-panel pt-3">
|
||||||
|
<app-theme-json-code-editor
|
||||||
|
#jsonEditorRef
|
||||||
|
[value]="draftText()"
|
||||||
|
[fullscreen]="isFullscreen()"
|
||||||
|
(valueChange)="onDraftEditorValueChange($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeWorkspace() === 'inspector') {
|
||||||
|
<div class="space-y-6">
|
||||||
|
<section class="theme-studio-card p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Selection</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="startPicker()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Pick live element
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedElement()) {
|
||||||
|
<div class="mt-4 rounded-2xl border border-border/80 bg-background/65 p-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-base font-semibold text-foreground">{{ selectedElement()!.label }}</p>
|
||||||
|
<span class="rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-muted-foreground">{{ selectedElement()!.key }}</span>
|
||||||
|
@if (isMounted(selectedElement()!)) {
|
||||||
|
<span class="rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-700">Mounted now</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ selectedElement()!.description }}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="theme-studio-card p-4">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Schema Hints</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="addStarterAnimation()"
|
||||||
|
[disabled]="!draftIsValid()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Add fade animation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 xl:grid-cols-2">
|
||||||
|
@for (field of visiblePropertyHints(); track field.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="applySuggestedProperty(field.key)"
|
||||||
|
[disabled]="!draftIsValid()"
|
||||||
|
class="rounded-2xl border border-border/80 bg-background/65 p-3 text-left transition-colors hover:bg-secondary/45 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-sm font-semibold text-foreground">{{ field.key }}</p>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ field.description }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full bg-secondary px-2 py-0.5 text-[10px] font-medium text-muted-foreground">{{ field.type }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 inline-flex rounded-full bg-primary/10 px-2.5 py-1 font-mono text-[11px] text-primary">
|
||||||
|
{{ field.example }}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="theme-studio-card p-5">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Animation Keys</p>
|
||||||
|
|
||||||
|
@if (animationKeys().length > 0) {
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2">
|
||||||
|
@for (animationKey of animationKeys(); track animationKey) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="jumpToAnimation(animationKey)"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 font-mono text-[11px] text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
{{ animationKey }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="mt-4 rounded-2xl border border-dashed border-border bg-background/60 px-4 py-5 text-sm text-muted-foreground">
|
||||||
|
No custom animation keys yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-2xl border border-border/80 bg-background/65 p-4">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@for (field of THEME_ANIMATION_FIELDS; track field.key) {
|
||||||
|
<span class="rounded-full bg-secondary px-2.5 py-1 font-mono text-[11px] text-foreground/80">{{ field.key }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (activeWorkspace() === 'layout') {
|
||||||
|
<section class="theme-studio-card p-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<p class="text-sm font-semibold text-foreground">Layout Grid</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@for (container of layoutContainers; track container.key) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="selectContainer(container.key)"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||||
|
[class.bg-primary/10]="selectedContainer() === container.key"
|
||||||
|
[class.border-primary/40]="selectedContainer() === container.key"
|
||||||
|
>
|
||||||
|
{{ container.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="resetSelectedContainer()"
|
||||||
|
[disabled]="!draftIsValid()"
|
||||||
|
class="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Reset Container
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<app-theme-grid-editor
|
||||||
|
[container]="selectedContainer() === 'appShell' ? layoutContainers[0] : layoutContainers[1]"
|
||||||
|
[items]="selectedContainerItems()"
|
||||||
|
[selectedKey]="selectedElementKey()"
|
||||||
|
[disabled]="!draftIsValid()"
|
||||||
|
(itemChanged)="handleGridChange($event)"
|
||||||
|
(itemSelected)="handleGridSelection($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (selectedElementGrid()) {
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-4">
|
||||||
|
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">x</p>
|
||||||
|
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.x }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">y</p>
|
||||||
|
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.y }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">w</p>
|
||||||
|
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.w }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-border/80 bg-background/65 p-3">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">h</p>
|
||||||
|
<p class="mt-2 font-mono text-lg font-semibold text-foreground">{{ selectedElementGrid()!.grid.h }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-selector {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-selector--compact {
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-selector-label {
|
||||||
|
font-size: 0.69rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-select {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
background: hsl(var(--background) / 0.82);
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-select:focus {
|
||||||
|
border-color: hsl(var(--primary) / 0.4);
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-selector--compact .theme-settings__workspace-select {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__editor-card {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__editor-panel {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__saved-theme-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__saved-theme-button {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid hsl(var(--border) / 0.8);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: hsl(var(--background) / 0.65);
|
||||||
|
padding: 0.85rem;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background-color 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__saved-theme-button:hover {
|
||||||
|
background: hsl(var(--secondary) / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__saved-theme-button--active {
|
||||||
|
border-color: hsl(var(--primary) / 0.38);
|
||||||
|
background: hsl(var(--primary) / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-json-editor-panel__header {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-json-editor-panel__title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-json-editor-panel__caption {
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__editor-panel app-theme-json-code-editor {
|
||||||
|
display: block;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
viewChild
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
|
import {
|
||||||
|
ThemeContainerKey,
|
||||||
|
ThemeElementStyleProperty,
|
||||||
|
ThemeRegistryEntry
|
||||||
|
} from '../../domain/theme.models';
|
||||||
|
import {
|
||||||
|
THEME_ANIMATION_FIELDS as THEME_ANIMATION_FIELD_HINTS,
|
||||||
|
THEME_ELEMENT_STYLE_FIELDS,
|
||||||
|
createAnimationStarterDefinition,
|
||||||
|
getSuggestedFieldDefault
|
||||||
|
} from '../../domain/theme.schema';
|
||||||
|
import { ElementPickerService } from '../../application/element-picker.service';
|
||||||
|
import { LayoutSyncService } from '../../application/layout-sync.service';
|
||||||
|
import { ThemeLibraryService } from '../../application/theme-library.service';
|
||||||
|
import { ThemeRegistryService } from '../../application/theme-registry.service';
|
||||||
|
import { ThemeService } from '../../application/theme.service';
|
||||||
|
import { THEME_LLM_GUIDE } from '../../domain/theme-llm-guide';
|
||||||
|
import { ThemeGridEditorComponent } from './theme-grid-editor.component';
|
||||||
|
import { ThemeJsonCodeEditorComponent } from './theme-json-code-editor.component';
|
||||||
|
|
||||||
|
type JumpSection = 'elements' | 'layout' | 'animations';
|
||||||
|
type ThemeStudioWorkspace = 'editor' | 'inspector' | 'layout';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-theme-settings',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ThemeGridEditorComponent,
|
||||||
|
ThemeJsonCodeEditorComponent
|
||||||
|
],
|
||||||
|
templateUrl: './theme-settings.component.html',
|
||||||
|
styleUrl: './theme-settings.component.scss'
|
||||||
|
})
|
||||||
|
export class ThemeSettingsComponent {
|
||||||
|
private readonly modal = inject(SettingsModalService);
|
||||||
|
private readonly theme = inject(ThemeService);
|
||||||
|
private readonly themeLibrary = inject(ThemeLibraryService);
|
||||||
|
private readonly registry = inject(ThemeRegistryService);
|
||||||
|
private readonly picker = inject(ElementPickerService);
|
||||||
|
private readonly layoutSync = inject(LayoutSyncService);
|
||||||
|
|
||||||
|
readonly editorRef = viewChild<ThemeJsonCodeEditorComponent>('jsonEditorRef');
|
||||||
|
|
||||||
|
readonly draftText = this.theme.draftText;
|
||||||
|
readonly draftErrors = this.theme.draftErrors;
|
||||||
|
readonly draftIsValid = this.theme.draftIsValid;
|
||||||
|
readonly statusMessage = this.theme.statusMessage;
|
||||||
|
readonly isDraftDirty = this.theme.isDraftDirty;
|
||||||
|
readonly isFullscreen = this.modal.themeStudioFullscreen;
|
||||||
|
readonly draftTheme = this.theme.draftTheme;
|
||||||
|
readonly THEME_ANIMATION_FIELDS = THEME_ANIMATION_FIELD_HINTS;
|
||||||
|
readonly animationKeys = this.theme.knownAnimationClasses;
|
||||||
|
readonly layoutContainers = this.layoutSync.containers();
|
||||||
|
readonly themeEntries = this.registry.entries();
|
||||||
|
readonly workspaceTabs: ReadonlyArray<{ key: ThemeStudioWorkspace; label: string; description: string }> = [
|
||||||
|
{
|
||||||
|
key: 'editor',
|
||||||
|
label: 'JSON Editor',
|
||||||
|
description: 'Edit the raw theme document in a fixed-contrast code view.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inspector',
|
||||||
|
label: 'Element Inspector',
|
||||||
|
description: 'Browse themeable regions, supported overrides, and starter values.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'layout',
|
||||||
|
label: 'Layout Studio',
|
||||||
|
description: 'Move shells around the grid without hunting through JSON.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
readonly mountedKeyCounts = computed(() => this.registry.mountedKeyCounts());
|
||||||
|
readonly activeWorkspace = signal<ThemeStudioWorkspace>('editor');
|
||||||
|
readonly explorerQuery = signal('');
|
||||||
|
|
||||||
|
readonly selectedContainer = signal<ThemeContainerKey>('roomLayout');
|
||||||
|
readonly selectedElementKey = signal<string>('chatRoomMainPanel');
|
||||||
|
readonly selectedElement = computed(() => this.registry.getDefinition(this.selectedElementKey()));
|
||||||
|
readonly selectedElementCapabilities = computed(() => {
|
||||||
|
const selected = this.selectedElement();
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
selected.layoutEditable ? 'Layout editable' : null,
|
||||||
|
selected.supportsTextOverride ? 'Text override' : null,
|
||||||
|
selected.supportsLink ? 'Safe external link' : null,
|
||||||
|
selected.supportsIcon ? 'Icon slot' : null
|
||||||
|
].filter((value): value is string => value !== null);
|
||||||
|
});
|
||||||
|
readonly selectedContainerItems = computed(() => this.layoutSync.itemsForContainer(this.selectedContainer()));
|
||||||
|
readonly selectedElementGrid = computed(() => {
|
||||||
|
return this.selectedContainerItems().find((item) => item.key === this.selectedElementKey()) ?? null;
|
||||||
|
});
|
||||||
|
readonly visiblePropertyHints = computed(() => {
|
||||||
|
const selected = this.selectedElement();
|
||||||
|
|
||||||
|
return THEME_ELEMENT_STYLE_FIELDS.filter((field) => {
|
||||||
|
if (field.key === 'textOverride' && !selected?.supportsTextOverride) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.key === 'link' && !selected?.supportsLink) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.key === 'icon' && !selected?.supportsIcon) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
readonly mountedEntries = computed(() => {
|
||||||
|
return this.themeEntries.filter((entry) => entry.pickerVisible || entry.layoutEditable);
|
||||||
|
});
|
||||||
|
readonly filteredEntries = computed(() => {
|
||||||
|
const query = this.explorerQuery().trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return this.mountedEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.mountedEntries().filter((entry) => {
|
||||||
|
const haystack = `${entry.label} ${entry.key} ${entry.description} ${entry.category}`.toLowerCase();
|
||||||
|
|
||||||
|
return haystack.includes(query);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
readonly draftLineCount = computed(() => this.draftText().split('\n').length);
|
||||||
|
readonly draftCharacterCount = computed(() => this.draftText().length);
|
||||||
|
readonly draftErrorCount = computed(() => this.draftErrors().length);
|
||||||
|
readonly mountedEntryCount = computed(() => this.mountedEntries().length);
|
||||||
|
readonly llmGuideCopyMessage = signal<string | null>(null);
|
||||||
|
readonly savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||||
|
readonly savedThemes = this.themeLibrary.entries;
|
||||||
|
readonly savedThemesBusy = this.themeLibrary.isBusy;
|
||||||
|
readonly savedThemesPath = this.themeLibrary.savedThemesPath;
|
||||||
|
readonly selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||||
|
|
||||||
|
private llmGuideCopyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (this.savedThemesAvailable()) {
|
||||||
|
void this.themeLibrary.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const pickedKey = this.picker.selectedKey();
|
||||||
|
|
||||||
|
if (!pickedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeWorkspace.set('inspector');
|
||||||
|
this.selectThemeEntry(pickedKey, 'elements');
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (!this.isFullscreen()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.focusEditor();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDraftEditorValueChange(value: string): void {
|
||||||
|
this.theme.updateDraftText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDraft(): void {
|
||||||
|
this.theme.applyDraft();
|
||||||
|
}
|
||||||
|
|
||||||
|
setWorkspace(workspace: ThemeStudioWorkspace): void {
|
||||||
|
this.activeWorkspace.set(workspace);
|
||||||
|
|
||||||
|
if (workspace === 'layout') {
|
||||||
|
const selected = this.selectedElement();
|
||||||
|
|
||||||
|
if (selected?.container) {
|
||||||
|
this.selectedContainer.set(selected.container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspace === 'editor') {
|
||||||
|
this.focusEditor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onWorkspaceSelect(event: Event): void {
|
||||||
|
const select = event.target as HTMLSelectElement;
|
||||||
|
|
||||||
|
this.setWorkspace(select.value as ThemeStudioWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
onExplorerQueryInput(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
|
||||||
|
this.explorerQuery.set(input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDraft(): void {
|
||||||
|
this.theme.formatDraft();
|
||||||
|
this.focusEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyLlmThemeGuide(): Promise<void> {
|
||||||
|
const copied = await this.copyTextToClipboard(THEME_LLM_GUIDE);
|
||||||
|
|
||||||
|
this.setLlmGuideCopyMessage(copied
|
||||||
|
? 'LLM guide copied.'
|
||||||
|
: 'Manual copy opened.');
|
||||||
|
}
|
||||||
|
|
||||||
|
startPicker(): void {
|
||||||
|
this.picker.start('theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
selectSavedTheme(fileName: string): void {
|
||||||
|
this.themeLibrary.select(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSavedThemes(): Promise<void> {
|
||||||
|
await this.themeLibrary.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDraftAsNewTheme(): Promise<void> {
|
||||||
|
await this.themeLibrary.saveDraftAsNewTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDraftToSelectedTheme(): Promise<void> {
|
||||||
|
await this.themeLibrary.saveDraftToSelectedTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
async useSelectedSavedTheme(): Promise<void> {
|
||||||
|
await this.themeLibrary.useSelectedTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
async editSelectedSavedTheme(): Promise<void> {
|
||||||
|
const opened = await this.themeLibrary.openSelectedThemeInDraft();
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setWorkspace('editor');
|
||||||
|
this.focusEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeSelectedSavedTheme(): Promise<void> {
|
||||||
|
const selectedSavedTheme = this.selectedSavedTheme();
|
||||||
|
|
||||||
|
if (!selectedSavedTheme) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = window.confirm(`Delete saved theme "${selectedSavedTheme.themeName}"?`);
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.themeLibrary.removeSelectedTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDefaultTheme(): void {
|
||||||
|
this.theme.resetToDefault('button');
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.selectedContainer.set('roomLayout');
|
||||||
|
this.selectedElementKey.set('chatRoomMainPanel');
|
||||||
|
this.focusJsonAnchor('elements', 'chatRoomMainPanel');
|
||||||
|
}
|
||||||
|
|
||||||
|
selectThemeEntry(key: string, section: JumpSection = 'elements'): void {
|
||||||
|
const definition = this.registry.getDefinition(key);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === 'layout') {
|
||||||
|
this.activeWorkspace.set('layout');
|
||||||
|
} else if (section === 'animations') {
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
} else {
|
||||||
|
this.activeWorkspace.set('inspector');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedElementKey.set(key);
|
||||||
|
|
||||||
|
if (definition.container) {
|
||||||
|
this.selectedContainer.set(definition.container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectContainer(containerKey: ThemeContainerKey): void {
|
||||||
|
this.activeWorkspace.set('layout');
|
||||||
|
this.selectedContainer.set(containerKey);
|
||||||
|
|
||||||
|
const matchingItem = this.layoutSync.itemsForContainer(containerKey)[0];
|
||||||
|
|
||||||
|
if (matchingItem) {
|
||||||
|
this.selectedElementKey.set(matchingItem.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applySuggestedProperty(property: ThemeElementStyleProperty): void {
|
||||||
|
if (!this.draftIsValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = getSuggestedFieldDefault(property, this.animationKeys());
|
||||||
|
|
||||||
|
this.theme.setElementStyle(this.selectedElementKey(), property, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
addStarterAnimation(): void {
|
||||||
|
if (!this.draftIsValid()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.theme.setAnimation('theme-fade-in', createAnimationStarterDefinition());
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGridChange(event: { key: string; grid: { x: number; y: number; w: number; h: number } }): void {
|
||||||
|
this.layoutSync.updateGrid(event.key, event.grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGridSelection(key: string): void {
|
||||||
|
this.selectThemeEntry(key, 'layout');
|
||||||
|
}
|
||||||
|
|
||||||
|
resetSelectedContainer(): void {
|
||||||
|
this.layoutSync.resetContainer(this.selectedContainer());
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToLayout(): void {
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.focusJsonAnchor('layout', this.selectedElementKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToStyles(): void {
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.focusJsonAnchor('elements', this.selectedElementKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToAnimation(animationKey: string): void {
|
||||||
|
this.activeWorkspace.set('editor');
|
||||||
|
this.focusJsonAnchor('animations', animationKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
isMounted(entry: ThemeRegistryEntry): boolean {
|
||||||
|
return (this.mountedKeyCounts()[entry.key] ?? 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusEditor(): void {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.editorRef()?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusJsonAnchor(section: JumpSection, key: string): void {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const editor = this.editorRef();
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = this.draftText();
|
||||||
|
let anchorIndex = this.findAnchorIndex(text, section, key);
|
||||||
|
|
||||||
|
if (anchorIndex === -1 && this.draftIsValid()) {
|
||||||
|
if (section === 'elements') {
|
||||||
|
this.theme.ensureElementEntry(key);
|
||||||
|
} else if (section === 'layout') {
|
||||||
|
this.theme.ensureLayoutEntry(key);
|
||||||
|
} else if (section === 'animations') {
|
||||||
|
this.theme.setAnimation(key, createAnimationStarterDefinition(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
text = this.draftText();
|
||||||
|
anchorIndex = this.findAnchorIndex(text, section, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorIndex === -1) {
|
||||||
|
editor.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectionEnd = Math.min(anchorIndex + key.length + 2, text.length);
|
||||||
|
|
||||||
|
editor.focusRange(anchorIndex, selectionEnd);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyTextToClipboard(value: string): Promise<boolean> {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return true;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.setAttribute('readonly', 'true');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copied = document.execCommand('copy');
|
||||||
|
|
||||||
|
if (copied) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
window.prompt('Copy this LLM theme guide', value);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.prompt('Copy this LLM theme guide', value);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLlmGuideCopyMessage(message: string): void {
|
||||||
|
this.llmGuideCopyMessage.set(message);
|
||||||
|
|
||||||
|
if (this.llmGuideCopyTimeoutId) {
|
||||||
|
clearTimeout(this.llmGuideCopyTimeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.llmGuideCopyTimeoutId = setTimeout(() => {
|
||||||
|
this.llmGuideCopyMessage.set(null);
|
||||||
|
this.llmGuideCopyTimeoutId = null;
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findAnchorIndex(text: string, section: JumpSection, key: string): number {
|
||||||
|
const sectionAnchor = `"${section}": {`;
|
||||||
|
const sectionIndex = text.indexOf(sectionAnchor);
|
||||||
|
|
||||||
|
if (sectionIndex === -1) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.indexOf(`"${key}"`, sectionIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
248
toju-app/src/app/domains/theme/feature/theme-node.directive.ts
Normal file
248
toju-app/src/app/domains/theme/feature/theme-node.directive.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
HostListener,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
input
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
import { ExternalLinkService } from '../../../core/platform';
|
||||||
|
import { ElementPickerService } from '../application/element-picker.service';
|
||||||
|
import { ThemeRegistryService } from '../application/theme-registry.service';
|
||||||
|
import { ThemeService } from '../application/theme.service';
|
||||||
|
|
||||||
|
function looksLikeImageReference(value: string): boolean {
|
||||||
|
return value.startsWith('url(')
|
||||||
|
|| value.startsWith('http://')
|
||||||
|
|| value.startsWith('https://')
|
||||||
|
|| value.startsWith('/')
|
||||||
|
|| value.startsWith('./');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appThemeNode]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class ThemeNodeDirective {
|
||||||
|
readonly themeKey = input.required<string>({ alias: 'appThemeNode' });
|
||||||
|
|
||||||
|
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly theme = inject(ThemeService);
|
||||||
|
private readonly registry = inject(ThemeRegistryService);
|
||||||
|
private readonly picker = inject(ElementPickerService);
|
||||||
|
private readonly externalLinks = inject(ExternalLinkService);
|
||||||
|
|
||||||
|
private appliedStyleKeys = new Set<string>();
|
||||||
|
private appliedClasses = new Set<string>();
|
||||||
|
private originalTextContent = new WeakMap<HTMLElement, string>();
|
||||||
|
private registeredKey: string | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const key = this.themeKey();
|
||||||
|
const definition = this.registry.getDefinition(key);
|
||||||
|
const host = this.host.nativeElement;
|
||||||
|
|
||||||
|
host.dataset['themeKey'] = key;
|
||||||
|
host.dataset['themeLabel'] = definition?.label ?? key;
|
||||||
|
|
||||||
|
if (this.registeredKey && this.registeredKey !== key) {
|
||||||
|
this.registry.unregisterHost(this.registeredKey, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.registeredKey !== key) {
|
||||||
|
this.registry.registerHost(key, host);
|
||||||
|
this.registeredKey = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
this.applyThemeStyles();
|
||||||
|
this.applyAnimationClasses();
|
||||||
|
this.applyTextOverride();
|
||||||
|
this.applyIconOverride();
|
||||||
|
this.applyLinkState();
|
||||||
|
this.applyPickerState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('keydown.enter', ['$event'])
|
||||||
|
onEnterKey(event: Event): void {
|
||||||
|
this.openConfiguredLink(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('click', ['$event'])
|
||||||
|
onClick(event: MouseEvent): void {
|
||||||
|
this.openConfiguredLink(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (this.registeredKey) {
|
||||||
|
this.registry.unregisterHost(this.registeredKey, this.host.nativeElement);
|
||||||
|
this.registeredKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearAppliedStyles();
|
||||||
|
this.clearAppliedClasses();
|
||||||
|
this.restoreTextTarget();
|
||||||
|
this.resetIconTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyThemeStyles(): void {
|
||||||
|
const styles = this.theme.getHostStyles(this.themeKey());
|
||||||
|
|
||||||
|
this.clearAppliedStyles();
|
||||||
|
|
||||||
|
for (const [styleKey, styleValue] of Object.entries(styles)) {
|
||||||
|
this.host.nativeElement.style.setProperty(styleKey, styleValue);
|
||||||
|
this.appliedStyleKeys.add(styleKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyAnimationClasses(): void {
|
||||||
|
this.clearAppliedClasses();
|
||||||
|
|
||||||
|
const animationClass = this.theme.getAnimationClass(this.themeKey());
|
||||||
|
|
||||||
|
if (!animationClass) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host.nativeElement.classList.add(animationClass);
|
||||||
|
this.appliedClasses.add(animationClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTextOverride(): void {
|
||||||
|
const definition = this.registry.getDefinition(this.themeKey());
|
||||||
|
const textTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="text"]');
|
||||||
|
|
||||||
|
if (!definition?.supportsTextOverride || !textTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.originalTextContent.has(textTarget)) {
|
||||||
|
this.originalTextContent.set(textTarget, textTarget.textContent ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
textTarget.textContent = this.theme.getTextOverride(this.themeKey()) ?? this.originalTextContent.get(textTarget) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyIconOverride(): void {
|
||||||
|
const definition = this.registry.getDefinition(this.themeKey());
|
||||||
|
const iconTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="icon"]');
|
||||||
|
const iconValue = this.theme.getIcon(this.themeKey());
|
||||||
|
|
||||||
|
if (!definition?.supportsIcon || !iconTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!iconValue) {
|
||||||
|
this.resetIconTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
iconTarget.dataset['themeVisible'] = 'true';
|
||||||
|
|
||||||
|
if (looksLikeImageReference(iconValue)) {
|
||||||
|
const imageReference = iconValue.startsWith('url(')
|
||||||
|
? iconValue
|
||||||
|
: `url('${iconValue}')`;
|
||||||
|
|
||||||
|
iconTarget.style.backgroundImage = imageReference;
|
||||||
|
iconTarget.textContent = '';
|
||||||
|
} else {
|
||||||
|
iconTarget.style.backgroundImage = 'none';
|
||||||
|
iconTarget.textContent = iconValue.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyLinkState(): void {
|
||||||
|
const definition = this.registry.getDefinition(this.themeKey());
|
||||||
|
const link = this.theme.getLink(this.themeKey());
|
||||||
|
const supportsLink = definition?.supportsLink && !!link;
|
||||||
|
|
||||||
|
this.host.nativeElement.dataset['themeLinked'] = supportsLink ? 'true' : 'false';
|
||||||
|
|
||||||
|
if (!supportsLink) {
|
||||||
|
this.host.nativeElement.removeAttribute('tabindex');
|
||||||
|
this.host.nativeElement.removeAttribute('role');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host.nativeElement.setAttribute('tabindex', '0');
|
||||||
|
this.host.nativeElement.setAttribute('role', 'link');
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyPickerState(): void {
|
||||||
|
const definition = this.registry.getDefinition(this.themeKey());
|
||||||
|
const isHovered = this.picker.hoveredKey() === this.themeKey();
|
||||||
|
const isSelected = this.picker.selectedKey() === this.themeKey();
|
||||||
|
const isActive = this.picker.isPicking() && !!definition?.pickerVisible;
|
||||||
|
|
||||||
|
this.host.nativeElement.dataset['themePickerActive'] = isActive ? 'true' : 'false';
|
||||||
|
this.host.nativeElement.dataset['themePickerHovered'] = isHovered ? 'true' : 'false';
|
||||||
|
this.host.nativeElement.dataset['themePickerSelected'] = isSelected ? 'true' : 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
private openConfiguredLink(event: Event): void {
|
||||||
|
if (this.picker.isPicking()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = this.registry.getDefinition(this.themeKey());
|
||||||
|
const link = this.theme.getLink(this.themeKey());
|
||||||
|
|
||||||
|
if (!definition?.supportsLink || !link) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target;
|
||||||
|
|
||||||
|
if (target instanceof Element && target.closest('button, input, textarea, select, a, [role="button"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
this.externalLinks.open(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAppliedStyles(): void {
|
||||||
|
for (const styleKey of this.appliedStyleKeys) {
|
||||||
|
this.host.nativeElement.style.removeProperty(styleKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appliedStyleKeys.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearAppliedClasses(): void {
|
||||||
|
for (const className of this.appliedClasses) {
|
||||||
|
this.host.nativeElement.classList.remove(className);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.appliedClasses.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreTextTarget(): void {
|
||||||
|
const textTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="text"]');
|
||||||
|
|
||||||
|
if (!textTarget || !this.originalTextContent.has(textTarget)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textTarget.textContent = this.originalTextContent.get(textTarget) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetIconTarget(): void {
|
||||||
|
const iconTarget = this.host.nativeElement.querySelector<HTMLElement>('[data-theme-slot="icon"]');
|
||||||
|
|
||||||
|
if (!iconTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
iconTarget.dataset['themeVisible'] = 'false';
|
||||||
|
iconTarget.style.backgroundImage = 'none';
|
||||||
|
iconTarget.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
import { ElementPickerService } from '../application/element-picker.service';
|
||||||
|
import { ThemeRegistryService } from '../application/theme-registry.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-theme-picker-overlay',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
@if (picker.isPicking()) {
|
||||||
|
<div class="pointer-events-none fixed inset-x-0 bottom-4 z-[95] flex justify-center px-4">
|
||||||
|
<div class="pointer-events-auto max-w-xl rounded-2xl border border-border bg-card/95 px-4 py-3 shadow-2xl backdrop-blur-xl">
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Theme Picker Active</p>
|
||||||
|
<p class="mt-1 text-sm text-foreground">
|
||||||
|
Click a highlighted area to inspect its theme key.
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
Hovering:
|
||||||
|
<span class="font-medium text-foreground">{{ hoveredEntry()?.label || 'Move over a themeable region' }}</span>
|
||||||
|
@if (hoveredEntry()) {
|
||||||
|
<span class="ml-1 rounded-full bg-secondary px-2 py-0.5 font-mono text-[11px] text-foreground/80">{{ hoveredEntry()!.key }}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="cancel()"
|
||||||
|
class="inline-flex items-center rounded-full border border-border bg-secondary px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ThemePickerOverlayComponent {
|
||||||
|
readonly picker = inject(ElementPickerService);
|
||||||
|
private readonly registry = inject(ThemeRegistryService);
|
||||||
|
|
||||||
|
readonly hoveredEntry = computed(() => {
|
||||||
|
return this.registry.getDefinition(this.picker.hoveredKey());
|
||||||
|
});
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.picker.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
13
toju-app/src/app/domains/theme/index.ts
Normal file
13
toju-app/src/app/domains/theme/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export * from './application/theme.service';
|
||||||
|
export * from './application/theme-library.service';
|
||||||
|
export * from './application/theme-registry.service';
|
||||||
|
export * from './application/element-picker.service';
|
||||||
|
export * from './application/layout-sync.service';
|
||||||
|
export * from './domain/theme.models';
|
||||||
|
export * from './domain/theme.defaults';
|
||||||
|
export * from './domain/theme.registry';
|
||||||
|
export * from './domain/theme.schema';
|
||||||
|
export * from './domain/theme.validation';
|
||||||
|
|
||||||
|
export { ThemeNodeDirective } from './feature/theme-node.directive';
|
||||||
|
export { ThemePickerOverlayComponent } from './feature/theme-picker-overlay.component';
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||||
|
import type { SavedThemeFileDescriptor } from '../../../core/platform/electron/electron-api.models';
|
||||||
|
import type { SavedThemeSummary } from '../domain/theme.models';
|
||||||
|
import { validateThemeDocument } from '../domain/theme.validation';
|
||||||
|
|
||||||
|
const THEME_LIBRARY_REQUEST_TIMEOUT_MS = 4000;
|
||||||
|
|
||||||
|
function sanitizeSavedThemeStem(value: string): string {
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
return normalized.length > 0
|
||||||
|
? normalized
|
||||||
|
: 'theme';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSavedThemeFileName(stem: string, suffix?: number): string {
|
||||||
|
return suffix && suffix > 1
|
||||||
|
? `${stem}-${suffix}.json`
|
||||||
|
: `${stem}.json`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackThemeName(fileName: string): string {
|
||||||
|
return fileName.replace(/\.json$/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(operation: Promise<T>, label: string): Promise<T> {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<T>((_resolve, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error(`${label} timed out.`));
|
||||||
|
}, THEME_LIBRARY_REQUEST_TIMEOUT_MS);
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeLibraryStorageService {
|
||||||
|
private readonly electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
|
get isAvailable(): boolean {
|
||||||
|
return this.electronBridge.isAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSavedThemesPath(): Promise<string | null> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withTimeout(electronApi.getSavedThemesPath(), 'Loading the saved themes path');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async listThemes(): Promise<SavedThemeSummary[]> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await withTimeout(electronApi.listSavedThemes(), 'Loading saved themes');
|
||||||
|
const summaries = await Promise.all(files.map(async (file) => await this.readSavedThemeSummary(file)));
|
||||||
|
|
||||||
|
return summaries.sort((left, right) => {
|
||||||
|
const nameOrder = left.themeName.localeCompare(right.themeName, undefined, { sensitivity: 'base' });
|
||||||
|
|
||||||
|
return nameOrder !== 0
|
||||||
|
? nameOrder
|
||||||
|
: right.modifiedAt - left.modifiedAt;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async readThemeText(fileName: string): Promise<string | null> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withTimeout(electronApi.readSavedTheme(fileName), `Reading saved theme ${fileName}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveNewTheme(preferredName: string, text: string): Promise<string | null> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existingFiles = await withTimeout(electronApi.listSavedThemes(), 'Loading saved themes');
|
||||||
|
const existingNames = new Set(existingFiles.map((file) => file.fileName.toLowerCase()));
|
||||||
|
const stem = sanitizeSavedThemeStem(preferredName);
|
||||||
|
|
||||||
|
let suffix = 1;
|
||||||
|
let fileName = buildSavedThemeFileName(stem);
|
||||||
|
|
||||||
|
while (existingNames.has(fileName.toLowerCase())) {
|
||||||
|
suffix += 1;
|
||||||
|
fileName = buildSavedThemeFileName(stem, suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTimeout(electronApi.writeSavedTheme(fileName, text), `Saving theme ${fileName}`);
|
||||||
|
|
||||||
|
return fileName;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async overwriteTheme(fileName: string, text: string): Promise<boolean> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withTimeout(electronApi.writeSavedTheme(fileName, text), `Saving theme ${fileName}`);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTheme(fileName: string): Promise<boolean> {
|
||||||
|
const electronApi = this.electronBridge.getApi();
|
||||||
|
|
||||||
|
if (!electronApi) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withTimeout(electronApi.deleteSavedTheme(fileName), `Deleting theme ${fileName}`);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readSavedThemeSummary(file: SavedThemeFileDescriptor): Promise<SavedThemeSummary> {
|
||||||
|
const text = await this.readThemeText(file.fileName);
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
error: 'File could not be read.',
|
||||||
|
fileName: file.fileName,
|
||||||
|
filePath: file.path,
|
||||||
|
isValid: false,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
themeName: fallbackThemeName(file.fileName),
|
||||||
|
version: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
const result = validateThemeDocument(parsed);
|
||||||
|
|
||||||
|
if (!result.valid || !result.value) {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
error: result.errors[0] ?? 'Theme JSON is invalid.',
|
||||||
|
fileName: file.fileName,
|
||||||
|
filePath: file.path,
|
||||||
|
isValid: false,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
themeName: fallbackThemeName(file.fileName),
|
||||||
|
version: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: result.value.meta.description ?? null,
|
||||||
|
error: null,
|
||||||
|
fileName: file.fileName,
|
||||||
|
filePath: file.path,
|
||||||
|
isValid: true,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
themeName: result.value.meta.name,
|
||||||
|
version: result.value.meta.version,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
description: null,
|
||||||
|
error: error instanceof Error ? error.message : 'Theme JSON could not be parsed.',
|
||||||
|
fileName: file.fileName,
|
||||||
|
filePath: file.path,
|
||||||
|
isValid: false,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
themeName: fallbackThemeName(file.fileName),
|
||||||
|
version: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
STORAGE_KEY_THEME_ACTIVE,
|
||||||
|
STORAGE_KEY_THEME_DRAFT
|
||||||
|
} from '../../../core/constants';
|
||||||
|
|
||||||
|
export interface ThemeStorageSnapshot {
|
||||||
|
activeText: string | null;
|
||||||
|
draftText: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredThemeText(key: string): string | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
|
||||||
|
return typeof raw === 'string' && raw.length > 0
|
||||||
|
? raw
|
||||||
|
: null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStoredThemeText(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
/* storage can be unavailable in private contexts */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadThemeStorageSnapshot(): ThemeStorageSnapshot {
|
||||||
|
return {
|
||||||
|
activeText: readStoredThemeText(STORAGE_KEY_THEME_ACTIVE),
|
||||||
|
draftText: readStoredThemeText(STORAGE_KEY_THEME_DRAFT)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveActiveThemeText(text: string): void {
|
||||||
|
writeStoredThemeText(STORAGE_KEY_THEME_ACTIVE, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDraftThemeText(text: string): void {
|
||||||
|
writeStoredThemeText(STORAGE_KEY_THEME_DRAFT, text);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
@if (showFloatingControls()) {
|
@if (showFloatingControls()) {
|
||||||
<div class="fixed bottom-4 right-4 z-50 border border-border bg-card shadow-lg">
|
<div
|
||||||
|
appThemeNode="floatingVoiceControls"
|
||||||
|
class="fixed bottom-4 right-4 z-50 border border-border bg-card shadow-lg"
|
||||||
|
>
|
||||||
<div class="flex items-center gap-2 p-2">
|
<div class="flex items-center gap-2 p-2">
|
||||||
<!-- Back to server button -->
|
<!-- Back to server button -->
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { ScreenShareFacade, ScreenShareQuality } from '../../../../domains/scree
|
|||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../../shared';
|
||||||
|
import { ThemeNodeDirective } from '../../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-floating-voice-controls',
|
selector: 'app-floating-voice-controls',
|
||||||
@@ -35,7 +36,8 @@ import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
DebugConsoleComponent,
|
DebugConsoleComponent,
|
||||||
ScreenShareQualityDialogComponent
|
ScreenShareQualityDialogComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
<div
|
<div class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5">
|
||||||
class="flex flex-col rounded-md border border-border bg-background px-3 py-2.5"
|
|
||||||
[style.height]="showConnectionError() ? null : 'calc(100px - 0.75rem)'"
|
|
||||||
>
|
|
||||||
<!-- Connection Error Banner -->
|
<!-- Connection Error Banner -->
|
||||||
@if (showConnectionError()) {
|
@if (showConnectionError()) {
|
||||||
<div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2">
|
<div class="mb-3 flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-2">
|
||||||
@@ -18,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
<div class="mb-2 flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="currentUser()?.displayName || '?'"
|
[name]="currentUser()?.displayName || '?'"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -56,89 +53,100 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Voice Controls -->
|
<!-- Voice Controls -->
|
||||||
<div class="mt-auto flex items-center justify-center gap-2">
|
<div
|
||||||
@if (isConnected()) {
|
class="grid overflow-hidden duration-200 ease-out motion-reduce:transition-none"
|
||||||
<!-- Mute Toggle -->
|
style="transition-property: grid-template-rows, opacity, margin-top"
|
||||||
<button
|
[style.gridTemplateRows]="isConnected() ? '1fr' : '0fr'"
|
||||||
type="button"
|
[style.opacity]="isConnected() ? '1' : '0'"
|
||||||
(click)="toggleMute()"
|
[style.marginTop.rem]="isConnected() ? 0.5 : 0"
|
||||||
[class]="getMuteButtonClass()"
|
[style.visibility]="isConnected() ? 'visible' : 'hidden'"
|
||||||
>
|
[class.pointer-events-none]="!isConnected()"
|
||||||
@if (isMuted()) {
|
[attr.aria-hidden]="isConnected() ? null : 'true'"
|
||||||
<ng-icon
|
>
|
||||||
name="lucideMicOff"
|
<div class="overflow-hidden">
|
||||||
class="w-5 h-5"
|
<div class="flex items-center justify-center gap-2">
|
||||||
/>
|
<!-- Mute Toggle -->
|
||||||
} @else {
|
<button
|
||||||
<ng-icon
|
type="button"
|
||||||
name="lucideMic"
|
(click)="toggleMute()"
|
||||||
class="w-5 h-5"
|
[class]="getMuteButtonClass()"
|
||||||
/>
|
>
|
||||||
}
|
@if (isMuted()) {
|
||||||
</button>
|
<ng-icon
|
||||||
|
name="lucideMicOff"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideMic"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Deafen Toggle -->
|
<!-- Deafen Toggle -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleDeafen()"
|
(click)="toggleDeafen()"
|
||||||
[class]="getDeafenButtonClass()"
|
[class]="getDeafenButtonClass()"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideHeadphones"
|
name="lucideHeadphones"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Camera Toggle -->
|
<!-- Camera Toggle -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleCamera()"
|
(click)="toggleCamera()"
|
||||||
[class]="getCameraButtonClass()"
|
[class]="getCameraButtonClass()"
|
||||||
>
|
>
|
||||||
@if (isCameraEnabled()) {
|
@if (isCameraEnabled()) {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideVideoOff"
|
name="lucideVideoOff"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideVideo"
|
name="lucideVideo"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Screen Share Toggle -->
|
<!-- Screen Share Toggle -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="toggleScreenShare()"
|
(click)="toggleScreenShare()"
|
||||||
[class]="getScreenShareButtonClass()"
|
[class]="getScreenShareButtonClass()"
|
||||||
>
|
>
|
||||||
@if (isScreenSharing()) {
|
@if (isScreenSharing()) {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideMonitorOff"
|
name="lucideMonitorOff"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
/>
|
/>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideMonitor"
|
name="lucideMonitor"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Disconnect -->
|
<!-- Disconnect -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="disconnect()"
|
(click)="disconnect()"
|
||||||
class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-destructive/20 bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15"
|
class="inline-flex h-10 w-10 items-center justify-center rounded-md border border-destructive/20 bg-destructive/10 text-destructive transition-colors hover:bg-destructive/15"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucidePhoneOff"
|
name="lucidePhoneOff"
|
||||||
class="w-5 h-5"
|
class="w-5 h-5"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,50 @@
|
|||||||
<div class="flex h-full flex-col bg-background">
|
<div class="flex h-full flex-col bg-background">
|
||||||
@if (currentRoom()) {
|
@if (currentRoom()) {
|
||||||
<!-- Main Content -->
|
<div
|
||||||
<div class="flex min-h-0 flex-1 overflow-hidden">
|
class="grid min-h-0 flex-1 overflow-hidden"
|
||||||
<aside class="flex min-h-0 w-[17rem] shrink-0 overflow-hidden border-r border-border bg-card">
|
[ngStyle]="roomLayoutStyles()"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
appThemeNode="chatRoomChannelsPanel"
|
||||||
|
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||||
|
[ngStyle]="channelsPanelLayoutStyles()"
|
||||||
|
>
|
||||||
<app-rooms-side-panel
|
<app-rooms-side-panel
|
||||||
panelMode="channels"
|
panelMode="channels"
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Chat Area -->
|
<main
|
||||||
<main class="relative min-h-0 min-w-0 flex-1 overflow-hidden bg-background">
|
appThemeNode="chatRoomMainPanel"
|
||||||
|
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||||
|
[ngStyle]="mainPanelLayoutStyles()"
|
||||||
|
>
|
||||||
@if (!isVoiceWorkspaceExpanded()) {
|
@if (!isVoiceWorkspaceExpanded()) {
|
||||||
@if (hasTextChannels()) {
|
@if (hasTextChannels()) {
|
||||||
<div class="h-full overflow-hidden">
|
<div class="h-full overflow-hidden">
|
||||||
<app-chat-messages />
|
<app-chat-messages />
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex h-full items-center justify-center px-6">
|
<div
|
||||||
|
appThemeNode="chatRoomEmptyState"
|
||||||
|
class="flex h-full items-center justify-center px-6"
|
||||||
|
>
|
||||||
<div class="max-w-md text-center text-muted-foreground">
|
<div class="max-w-md text-center text-muted-foreground">
|
||||||
|
<div
|
||||||
|
data-theme-slot="icon"
|
||||||
|
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground"
|
||||||
|
></div>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideHash"
|
name="lucideHash"
|
||||||
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
||||||
/>
|
/>
|
||||||
<h2 class="mb-2 text-xl font-medium text-foreground">No text channels</h2>
|
<h2
|
||||||
|
data-theme-slot="text"
|
||||||
|
class="mb-2 text-xl font-medium text-foreground"
|
||||||
|
>
|
||||||
|
No text channels
|
||||||
|
</h2>
|
||||||
<p class="text-sm">There are no existing text channels currently.</p>
|
<p class="text-sm">There are no existing text channels currently.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -33,7 +54,11 @@
|
|||||||
<app-voice-workspace />
|
<app-voice-workspace />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="flex min-h-0 w-[17rem] shrink-0 overflow-hidden border-l border-border bg-card">
|
<aside
|
||||||
|
appThemeNode="chatRoomMembersPanel"
|
||||||
|
class="flex min-h-0 overflow-hidden border-l border-border bg-card"
|
||||||
|
[ngStyle]="membersPanelLayoutStyles()"
|
||||||
|
>
|
||||||
<app-rooms-side-panel
|
<app-rooms-side-panel
|
||||||
panelMode="users"
|
panelMode="users"
|
||||||
[showVoiceControls]="false"
|
[showVoiceControls]="false"
|
||||||
@@ -42,14 +67,25 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<!-- No Room Selected -->
|
<div
|
||||||
<div class="flex flex-1 items-center justify-center bg-background px-6">
|
appThemeNode="chatRoomEmptyState"
|
||||||
|
class="flex flex-1 items-center justify-center bg-background px-6"
|
||||||
|
>
|
||||||
<div class="text-center text-muted-foreground">
|
<div class="text-center text-muted-foreground">
|
||||||
|
<div
|
||||||
|
data-theme-slot="icon"
|
||||||
|
class="theme-icon-slot mx-auto mb-4 h-14 w-14 items-center justify-center rounded-3xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-sm font-semibold uppercase tracking-[0.18em] text-foreground"
|
||||||
|
></div>
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideHash"
|
name="lucideHash"
|
||||||
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
class="mx-auto mb-4 h-16 w-16 opacity-30"
|
||||||
/>
|
/>
|
||||||
<h2 class="mb-2 text-xl font-medium">No room selected</h2>
|
<h2
|
||||||
|
data-theme-slot="text"
|
||||||
|
class="mb-2 text-xl font-medium"
|
||||||
|
>
|
||||||
|
No room selected
|
||||||
|
</h2>
|
||||||
<p class="text-sm">Select or create a room to start chatting</p>
|
<p class="text-sm">Select or create a room to start chatting</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import {
|
|||||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||||
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
import { selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||||
|
import {
|
||||||
|
ThemeNodeDirective,
|
||||||
|
ThemeService
|
||||||
|
} from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-room',
|
selector: 'app-chat-room',
|
||||||
@@ -38,7 +42,8 @@ import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
|||||||
NgIcon,
|
NgIcon,
|
||||||
ChatMessagesComponent,
|
ChatMessagesComponent,
|
||||||
VoiceWorkspaceComponent,
|
VoiceWorkspaceComponent,
|
||||||
RoomsSidePanelComponent
|
RoomsSidePanelComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -59,6 +64,7 @@ import { VoiceWorkspaceService } from '../../../domains/voice-session';
|
|||||||
export class ChatRoomComponent {
|
export class ChatRoomComponent {
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly settingsModal = inject(SettingsModalService);
|
private readonly settingsModal = inject(SettingsModalService);
|
||||||
|
private readonly theme = inject(ThemeService);
|
||||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||||
showMenu = signal(false);
|
showMenu = signal(false);
|
||||||
showAdminPanel = signal(false);
|
showAdminPanel = signal(false);
|
||||||
@@ -68,6 +74,10 @@ export class ChatRoomComponent {
|
|||||||
textChannels = this.store.selectSignal(selectTextChannels);
|
textChannels = this.store.selectSignal(selectTextChannels);
|
||||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||||
hasTextChannels = computed(() => this.textChannels().length > 0);
|
hasTextChannels = computed(() => this.textChannels().length > 0);
|
||||||
|
roomLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
||||||
|
channelsPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomChannelsPanel'));
|
||||||
|
mainPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMainPanel'));
|
||||||
|
membersPanelLayoutStyles = computed(() => this.theme.getLayoutItemStyles('chatRoomMembersPanel'));
|
||||||
|
|
||||||
/** Open the settings modal to the Server admin page for the current room. */
|
/** Open the settings modal to the Server admin page for the current room. */
|
||||||
toggleAdminPanel() {
|
toggleAdminPanel() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="absolute inset-0">
|
<div class="absolute inset-0">
|
||||||
@if (showExpanded()) {
|
@if (showExpanded()) {
|
||||||
<section
|
<section
|
||||||
|
appThemeNode="voiceWorkspace"
|
||||||
class="pointer-events-auto absolute inset-0 bg-background/95 backdrop-blur-xl"
|
class="pointer-events-auto absolute inset-0 bg-background/95 backdrop-blur-xl"
|
||||||
(mouseenter)="onWorkspacePointerMove()"
|
(mouseenter)="onWorkspacePointerMove()"
|
||||||
(mousemove)="onWorkspacePointerMove()"
|
(mousemove)="onWorkspacePointerMove()"
|
||||||
@@ -276,6 +277,7 @@
|
|||||||
|
|
||||||
@if (showMiniWindow()) {
|
@if (showMiniWindow()) {
|
||||||
<div
|
<div
|
||||||
|
appThemeNode="voiceWorkspace"
|
||||||
class="pointer-events-auto absolute z-20 w-[20rem] select-none overflow-hidden rounded-[1.75rem] border border-white/10 bg-card/95 shadow-2xl backdrop-blur-xl"
|
class="pointer-events-auto absolute z-20 w-[20rem] select-none overflow-hidden rounded-[1.75rem] border border-white/10 bg-card/95 shadow-2xl backdrop-blur-xl"
|
||||||
[style.left.px]="miniPosition().left"
|
[style.left.px]="miniPosition().left"
|
||||||
[style.top.px]="miniPosition().top"
|
[style.top.px]="miniPosition().top"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../..
|
|||||||
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
import { VoiceWorkspacePlaybackService } from './voice-workspace-playback.service';
|
||||||
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile.component';
|
import { VoiceWorkspaceStreamTileComponent } from './voice-workspace-stream-tile.component';
|
||||||
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
||||||
|
import { ThemeNodeDirective } from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-voice-workspace',
|
selector: 'app-voice-workspace',
|
||||||
@@ -59,7 +60,8 @@ import { VoiceWorkspaceStreamItem } from './voice-workspace.models';
|
|||||||
NgIcon,
|
NgIcon,
|
||||||
ScreenShareQualityDialogComponent,
|
ScreenShareQualityDialogComponent,
|
||||||
VoiceWorkspaceStreamTileComponent,
|
VoiceWorkspaceStreamTileComponent,
|
||||||
UserAvatarComponent
|
UserAvatarComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||||
@if (isOpen()) {
|
@if (isOpen() && !isThemeStudioFullscreen()) {
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200"
|
class="fixed inset-0 z-[90] bg-black/80 backdrop-blur-sm transition-opacity duration-200"
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
<!-- Modal -->
|
<!-- Modal -->
|
||||||
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
<div class="fixed inset-0 z-[91] flex items-center justify-center p-4 pointer-events-none">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto relative bg-card border border-border rounded-xl shadow-2xl w-full max-w-4xl h-[min(680px,85vh)] flex overflow-hidden transition-all duration-200"
|
class="pointer-events-auto relative flex w-full max-w-4xl overflow-hidden rounded-xl border border-border bg-card shadow-2xl transition-all duration-200"
|
||||||
|
style="height: min(680px, 85vh)"
|
||||||
[class.scale-100]="animating()"
|
[class.scale-100]="animating()"
|
||||||
[class.opacity-100]="animating()"
|
[class.opacity-100]="animating()"
|
||||||
[class.scale-95]="!animating()"
|
[class.scale-95]="!animating()"
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<!-- Side Navigation -->
|
<!-- Side Navigation -->
|
||||||
<nav class="w-52 flex-shrink-0 bg-secondary/40 border-r border-border flex flex-col">
|
<nav class="flex w-52 flex-shrink-0 flex-col border-r border-border bg-secondary/40">
|
||||||
<div class="p-4 border-b border-border">
|
<div class="p-4 border-b border-border">
|
||||||
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
|
<h2 class="text-lg font-semibold text-foreground">Settings</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +100,15 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-auto border-t border-border px-4 py-3">
|
<div class="mt-auto space-y-3 border-t border-border px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="restoreDefaultTheme()"
|
||||||
|
class="w-full rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-left text-xs font-medium text-destructive transition-colors hover:bg-destructive/15"
|
||||||
|
>
|
||||||
|
Restore default theme
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openThirdPartyLicenses()"
|
(click)="openThirdPartyLicenses()"
|
||||||
@@ -122,6 +131,9 @@
|
|||||||
@case ('network') {
|
@case ('network') {
|
||||||
Network
|
Network
|
||||||
}
|
}
|
||||||
|
@case ('theme') {
|
||||||
|
Theme Studio
|
||||||
|
}
|
||||||
@case ('notifications') {
|
@case ('notifications') {
|
||||||
Notifications
|
Notifications
|
||||||
}
|
}
|
||||||
@@ -148,16 +160,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
(click)="close()"
|
<button
|
||||||
type="button"
|
(click)="close()"
|
||||||
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
type="button"
|
||||||
>
|
class="p-2 hover:bg-secondary rounded-lg transition-colors text-muted-foreground hover:text-foreground"
|
||||||
<ng-icon
|
>
|
||||||
name="lucideX"
|
<ng-icon
|
||||||
class="w-5 h-5"
|
name="lucideX"
|
||||||
/>
|
class="w-5 h-5"
|
||||||
</button>
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable Content Area -->
|
<!-- Scrollable Content Area -->
|
||||||
@@ -169,6 +183,74 @@
|
|||||||
@case ('network') {
|
@case ('network') {
|
||||||
<app-network-settings />
|
<app-network-settings />
|
||||||
}
|
}
|
||||||
|
@case ('theme') {
|
||||||
|
<div class="mx-auto flex h-full max-w-3xl items-center justify-center">
|
||||||
|
<div class="w-full rounded-[1.5rem] border border-border bg-card/90 p-6 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-primary">Active Theme</p>
|
||||||
|
<h4 class="mt-2 text-xl font-semibold text-foreground">{{ activeThemeName() }}</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (themeStudioMinimized()) {
|
||||||
|
<span class="rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">Minimized</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (savedThemesAvailable()) {
|
||||||
|
<div class="mt-5">
|
||||||
|
<label class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Saved Theme</label>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<select
|
||||||
|
class="min-w-[16rem] flex-1 rounded-full border border-border bg-background px-3 py-2 text-sm font-medium text-foreground outline-none transition-colors focus:border-primary/40 focus:ring-2 focus:ring-primary/15"
|
||||||
|
[value]="selectedSavedTheme()?.fileName || ''"
|
||||||
|
[disabled]="savedThemesBusy() && savedThemes().length === 0"
|
||||||
|
(change)="onSavedThemeSelect($event)"
|
||||||
|
>
|
||||||
|
<option value="">{{ savedThemes().length > 0 ? 'Choose saved theme' : 'No saved themes' }}</option>
|
||||||
|
@for (savedTheme of savedThemes(); track savedTheme.fileName) {
|
||||||
|
<option
|
||||||
|
[value]="savedTheme.fileName"
|
||||||
|
[disabled]="!savedTheme.isValid"
|
||||||
|
>
|
||||||
|
{{ savedTheme.themeName }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="editSelectedSavedTheme()"
|
||||||
|
[disabled]="!selectedSavedTheme()?.isValid || (savedThemesBusy() && savedThemes().length === 0)"
|
||||||
|
class="inline-flex items-center rounded-full border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Edit In Studio
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="mt-5 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="openThemeStudio()"
|
||||||
|
class="inline-flex items-center rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{{ themeStudioMinimized() ? 'Re-open Theme Studio' : 'Open Theme Studio' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="restoreDefaultTheme()"
|
||||||
|
class="inline-flex items-center rounded-full border border-destructive/25 bg-destructive/10 px-3 py-2 text-sm font-medium text-destructive transition-colors hover:bg-destructive/15"
|
||||||
|
>
|
||||||
|
Restore Default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@case ('notifications') {
|
@case ('notifications') {
|
||||||
<app-notifications-settings />
|
<app-notifications-settings />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
computed,
|
computed,
|
||||||
effect,
|
effect,
|
||||||
|
untracked,
|
||||||
HostListener,
|
HostListener,
|
||||||
viewChild
|
viewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
lucideDownload,
|
lucideDownload,
|
||||||
lucideGlobe,
|
lucideGlobe,
|
||||||
lucideAudioLines,
|
lucideAudioLines,
|
||||||
|
lucidePalette,
|
||||||
lucideSettings,
|
lucideSettings,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
@@ -43,6 +45,7 @@ import { PermissionsSettingsComponent } from './permissions-settings/permissions
|
|||||||
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
import { DebuggingSettingsComponent } from './debugging-settings/debugging-settings.component';
|
||||||
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
import { UpdatesSettingsComponent } from './updates-settings/updates-settings.component';
|
||||||
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-licenses';
|
||||||
|
import { ThemeLibraryService, ThemeService } from '../../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-settings-modal',
|
selector: 'app-settings-modal',
|
||||||
@@ -70,6 +73,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
|||||||
lucideDownload,
|
lucideDownload,
|
||||||
lucideGlobe,
|
lucideGlobe,
|
||||||
lucideAudioLines,
|
lucideAudioLines,
|
||||||
|
lucidePalette,
|
||||||
lucideSettings,
|
lucideSettings,
|
||||||
lucideUsers,
|
lucideUsers,
|
||||||
lucideBan,
|
lucideBan,
|
||||||
@@ -82,6 +86,8 @@ export class SettingsModalComponent {
|
|||||||
readonly modal = inject(SettingsModalService);
|
readonly modal = inject(SettingsModalService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(RealtimeSessionFacade);
|
private webrtc = inject(RealtimeSessionFacade);
|
||||||
|
private theme = inject(ThemeService);
|
||||||
|
private themeLibrary = inject(ThemeLibraryService);
|
||||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||||
private lastRequestedServerId: string | null = null;
|
private lastRequestedServerId: string | null = null;
|
||||||
|
|
||||||
@@ -93,11 +99,22 @@ export class SettingsModalComponent {
|
|||||||
|
|
||||||
isOpen = this.modal.isOpen;
|
isOpen = this.modal.isOpen;
|
||||||
activePage = this.modal.activePage;
|
activePage = this.modal.activePage;
|
||||||
|
themeStudioFullscreen = this.modal.themeStudioFullscreen;
|
||||||
|
themeStudioMinimized = this.modal.themeStudioMinimized;
|
||||||
|
isThemeStudioFullscreen = computed(() => this.activePage() === 'theme' && this.themeStudioFullscreen());
|
||||||
|
activeThemeName = this.theme.activeThemeName;
|
||||||
|
savedThemesAvailable = this.themeLibrary.isAvailable;
|
||||||
|
savedThemes = this.themeLibrary.entries;
|
||||||
|
savedThemesBusy = this.themeLibrary.isBusy;
|
||||||
|
selectedSavedTheme = this.themeLibrary.selectedEntry;
|
||||||
|
|
||||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'general',
|
{ id: 'general',
|
||||||
label: 'General',
|
label: 'General',
|
||||||
icon: 'lucideSettings' },
|
icon: 'lucideSettings' },
|
||||||
|
{ id: 'theme',
|
||||||
|
label: 'Theme Studio',
|
||||||
|
icon: 'lucidePalette' },
|
||||||
{ id: 'network',
|
{ id: 'network',
|
||||||
label: 'Network',
|
label: 'Network',
|
||||||
icon: 'lucideGlobe' },
|
icon: 'lucideGlobe' },
|
||||||
@@ -220,6 +237,16 @@ export class SettingsModalComponent {
|
|||||||
this.animating.set(true);
|
this.animating.set(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (!this.isOpen() || this.activePage() !== 'theme' || !this.savedThemesAvailable()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
untracked(() => {
|
||||||
|
void this.refreshSavedThemes();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
|
|
||||||
@@ -280,6 +307,11 @@ export class SettingsModalComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isThemeStudioFullscreen()) {
|
||||||
|
this.modal.minimizeThemeStudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
@@ -303,6 +335,40 @@ export class SettingsModalComponent {
|
|||||||
this.modal.navigate(page);
|
this.modal.navigate(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openThemeStudio(): void {
|
||||||
|
this.modal.openThemeStudio();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSavedThemes(): Promise<void> {
|
||||||
|
await this.themeLibrary.refresh();
|
||||||
|
this.syncSavedThemeSelectionToActiveTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSavedThemeSelect(event: Event): Promise<void> {
|
||||||
|
const select = event.target as HTMLSelectElement;
|
||||||
|
const fileName = select.value || null;
|
||||||
|
|
||||||
|
this.themeLibrary.select(fileName);
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applied = await this.themeLibrary.useSelectedTheme();
|
||||||
|
|
||||||
|
if (!applied) {
|
||||||
|
this.syncSavedThemeSelectionToActiveTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async editSelectedSavedTheme(): Promise<void> {
|
||||||
|
const opened = await this.themeLibrary.openSelectedThemeInDraft();
|
||||||
|
|
||||||
|
if (opened) {
|
||||||
|
this.modal.openThemeStudio();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBackdropClick(): void {
|
onBackdropClick(): void {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
@@ -312,4 +378,16 @@ export class SettingsModalComponent {
|
|||||||
|
|
||||||
this.selectedServerId.set(select.value || null);
|
this.selectedServerId.set(select.value || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreDefaultTheme(): void {
|
||||||
|
this.theme.resetToDefault('button');
|
||||||
|
this.syncSavedThemeSelectionToActiveTheme();
|
||||||
|
this.navigate('theme');
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSavedThemeSelectionToActiveTheme(): void {
|
||||||
|
const matchingTheme = this.savedThemes().find((entry) => entry.isValid && entry.themeName === this.activeThemeName()) ?? null;
|
||||||
|
|
||||||
|
this.themeLibrary.select(matchingTheme?.fileName ?? null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
<div
|
<div
|
||||||
class="fixed left-16 right-0 top-0 z-50 flex h-10 items-center justify-between border-b border-border bg-card px-4 select-none"
|
appThemeNode="titleBar"
|
||||||
|
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
|
||||||
style="-webkit-app-region: drag"
|
style="-webkit-app-region: drag"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 min-w-0 relative"
|
class="flex items-center gap-2 min-w-0 relative"
|
||||||
style="-webkit-app-region: no-drag"
|
style="-webkit-app-region: no-drag"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
data-theme-slot="icon"
|
||||||
|
class="theme-icon-slot h-6 w-6 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-secondary/70 bg-center bg-cover bg-no-repeat text-[10px] font-semibold uppercase tracking-[0.16em] text-foreground"
|
||||||
|
></span>
|
||||||
|
|
||||||
@if (inRoom()) {
|
@if (inRoom()) {
|
||||||
<ng-icon
|
<span
|
||||||
name="lucideHash"
|
data-theme-slot="text"
|
||||||
class="w-5 h-5 text-muted-foreground"
|
class="flex min-w-0 items-center gap-2 text-sm font-semibold text-foreground"
|
||||||
/>
|
>
|
||||||
<span class="truncate text-sm font-semibold text-foreground">{{ roomContextTitle() }}</span>
|
<span class="truncate">{{ roomName() }}</span>
|
||||||
|
|
||||||
|
@if (isVoiceWorkspaceExpanded()) {
|
||||||
|
<span class="shrink-0 text-muted-foreground">/</span>
|
||||||
|
<span class="truncate">{{ connectedVoiceChannelName() }}</span>
|
||||||
|
} @else if (textChannels().length > 0) {
|
||||||
|
<span class="shrink-0 text-muted-foreground">/</span>
|
||||||
|
<span class="flex min-w-0 items-center gap-1 text-foreground">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideHash"
|
||||||
|
class="h-4 w-4 shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ activeTextChannelName() }}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
@if (showRoomCompatibilityNotice()) {
|
@if (showRoomCompatibilityNotice()) {
|
||||||
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
<span class="inline-flex items-center gap-1 rounded bg-destructive/15 px-2 py-0.5 text-xs text-destructive">
|
||||||
@@ -36,7 +57,11 @@
|
|||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="text-sm text-muted-foreground truncate">{{ username() }} | {{ serverName() }}</span>
|
<span
|
||||||
|
data-theme-slot="text"
|
||||||
|
class="text-sm text-muted-foreground truncate"
|
||||||
|
>{{ username() }} | {{ serverName() }}</span
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
class="text-xs px-2 py-0.5 rounded bg-destructive/15 text-destructive"
|
||||||
[class.hidden]="!isReconnecting()"
|
[class.hidden]="!isReconnecting()"
|
||||||
@@ -59,57 +84,62 @@
|
|||||||
Login
|
Login
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<div class="relative">
|
||||||
type="button"
|
<button
|
||||||
(click)="toggleMenu()"
|
type="button"
|
||||||
class="ml-2 rounded-md p-2 transition-colors hover:bg-secondary"
|
(click)="toggleMenu()"
|
||||||
title="Menu"
|
class="grid h-8 w-8 place-items-center rounded-md transition-colors hover:bg-secondary"
|
||||||
>
|
title="Menu"
|
||||||
<ng-icon
|
>
|
||||||
name="lucideMenu"
|
<ng-icon
|
||||||
class="w-5 h-5 text-muted-foreground"
|
name="lucideMenu"
|
||||||
/>
|
class="h-4 w-4 text-muted-foreground"
|
||||||
</button>
|
/>
|
||||||
<!-- Anchored dropdown under the menu button -->
|
</button>
|
||||||
@if (showMenu()) {
|
|
||||||
<div class="absolute right-0 top-full z-50 mt-2 w-64 rounded-md border border-border bg-popover p-1 shadow-lg">
|
@if (showMenu()) {
|
||||||
@if (inRoom()) {
|
<div
|
||||||
<button
|
class="absolute right-0 top-full z-50 mt-2 w-64 rounded-md border border-border bg-popover p-1 shadow-lg"
|
||||||
type="button"
|
style="-webkit-app-region: no-drag"
|
||||||
(click)="createInviteLink()"
|
>
|
||||||
[disabled]="creatingInvite()"
|
@if (inRoom()) {
|
||||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="createInviteLink()"
|
||||||
|
[disabled]="creatingInvite()"
|
||||||
|
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
@if (creatingInvite()) {
|
||||||
|
Creating Invite Link…
|
||||||
|
} @else {
|
||||||
|
Create Invite Link
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="leaveServer()"
|
||||||
|
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
|
||||||
|
>
|
||||||
|
Leave Server
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<div
|
||||||
|
class="px-3 py-2 text-xs leading-5 text-muted-foreground"
|
||||||
|
[class.hidden]="!inviteStatus()"
|
||||||
>
|
>
|
||||||
@if (creatingInvite()) {
|
{{ inviteStatus() }}
|
||||||
Creating Invite Link…
|
</div>
|
||||||
} @else {
|
<div class="mx-2 my-1 h-px bg-border"></div>
|
||||||
Create Invite Link
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="leaveServer()"
|
(click)="logout()"
|
||||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
|
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
|
||||||
>
|
>
|
||||||
Leave Server
|
Logout
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
<div
|
|
||||||
class="px-3 py-2 text-xs leading-5 text-muted-foreground"
|
|
||||||
[class.hidden]="!inviteStatus()"
|
|
||||||
>
|
|
||||||
{{ inviteStatus() }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mx-2 my-1 h-px bg-border"></div>
|
}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
|
||||||
(click)="logout()"
|
|
||||||
class="w-full rounded-md px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-secondary"
|
|
||||||
>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (isElectron()) {
|
@if (isElectron()) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
|
|||||||
import { LeaveServerDialogComponent } from '../../shared';
|
import { LeaveServerDialogComponent } from '../../shared';
|
||||||
import { Room } from '../../shared-kernel';
|
import { Room } from '../../shared-kernel';
|
||||||
import { VoiceWorkspaceService } from '../../domains/voice-session';
|
import { VoiceWorkspaceService } from '../../domains/voice-session';
|
||||||
|
import { ThemeNodeDirective } from '../../domains/theme';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-title-bar',
|
selector: 'app-title-bar',
|
||||||
@@ -44,7 +45,8 @@ import { VoiceWorkspaceService } from '../../domains/voice-session';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
LeaveServerDialogComponent
|
LeaveServerDialogComponent,
|
||||||
|
ThemeNodeDirective
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({ lucideMinus,
|
provideIcons({ lucideMinus,
|
||||||
@@ -109,23 +111,6 @@ export class TitleBarComponent {
|
|||||||
|
|
||||||
return voiceChannel?.name || 'Voice Lounge';
|
return voiceChannel?.name || 'Voice Lounge';
|
||||||
});
|
});
|
||||||
roomContextTitle = computed(() => {
|
|
||||||
const room = this.currentRoom();
|
|
||||||
|
|
||||||
if (!room) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isVoiceWorkspaceExpanded()) {
|
|
||||||
return `${room.name} / ${this.connectedVoiceChannelName()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.textChannels().length === 0) {
|
|
||||||
return room.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${room.name} / #${this.activeTextChannelName()}`;
|
|
||||||
});
|
|
||||||
roomContextMeta = computed(() => {
|
roomContextMeta = computed(() => {
|
||||||
if (!this.currentRoom()) {
|
if (!this.currentRoom()) {
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -87,3 +87,306 @@
|
|||||||
--ring: 214 72% 50%;
|
--ring: 214 72% 50%;
|
||||||
--radius: 0.375rem;
|
--radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-icon-slot {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-icon-slot[data-theme-visible='true'] {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__workspace {
|
||||||
|
gap: 0.875rem;
|
||||||
|
grid-template-columns: minmax(15.5rem, 17rem) minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-studio-fullscreen-shell {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-gutter: stable both-edges;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted-foreground) / 0.6) hsl(var(--card) / 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-studio-fullscreen-shell::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-studio-fullscreen-shell::-webkit-scrollbar-track {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--card) / 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-studio-fullscreen-shell::-webkit-scrollbar-thumb {
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--muted-foreground) / 0.6);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-studio-fullscreen-shell::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--primary) / 0.45);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__sidebar {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__sidebar .theme-studio-card:last-child {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__main {
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-studio-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: 0 10px 24px rgb(15 23 42 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__hero-grid {
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__hero-stat {
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__hero-label {
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__hero-value {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__workspace-tab,
|
||||||
|
.theme-settings--fullscreen .theme-settings__entry-button {
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.72rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__search-input {
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
padding: 0.72rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings--fullscreen .theme-settings__entry-list {
|
||||||
|
max-height: none;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-studio-card {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, hsl(var(--card) / 0.96), hsl(var(--background) / 0.96)),
|
||||||
|
radial-gradient(circle at top right, hsl(var(--primary) / 0.08), transparent 45%);
|
||||||
|
box-shadow: 0 14px 34px rgb(15 23 42 / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__hero-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__hero-stat {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: hsl(var(--background) / 0.72);
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__hero-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__hero-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: hsl(var(--background) / 0.72);
|
||||||
|
padding: 0.85rem 0.95rem;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab:hover {
|
||||||
|
background: hsl(var(--secondary) / 0.78);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab--active {
|
||||||
|
border-color: hsl(var(--primary) / 0.4);
|
||||||
|
background: hsl(var(--primary) / 0.08);
|
||||||
|
box-shadow: 0 8px 22px hsl(var(--primary) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__workspace-tab-description {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__search-input {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.95rem;
|
||||||
|
background: hsl(var(--background) / 0.82);
|
||||||
|
padding: 0.8rem 0.95rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
box-shadow 160ms ease,
|
||||||
|
background-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__search-input:focus {
|
||||||
|
border-color: hsl(var(--primary) / 0.4);
|
||||||
|
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__entry-list {
|
||||||
|
display: flex;
|
||||||
|
max-height: min(60vh, 34rem);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__entry-button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: hsl(var(--background) / 0.72);
|
||||||
|
padding: 0.9rem 0.95rem;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
border-color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__entry-button:hover {
|
||||||
|
background: hsl(var(--secondary) / 0.72);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__entry-button--active {
|
||||||
|
border-color: hsl(var(--primary) / 0.42);
|
||||||
|
background: hsl(var(--primary) / 0.08);
|
||||||
|
box-shadow: 0 8px 24px hsl(var(--primary) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1279px) {
|
||||||
|
.theme-settings__hero-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.theme-settings__workspace {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__sidebar {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-settings__entry-list {
|
||||||
|
max-height: 24rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.theme-settings__hero-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme-key][data-theme-linked='true'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme-key][data-theme-picker-active='true'] {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme-key][data-theme-picker-hovered='true'] {
|
||||||
|
outline: 2px solid hsl(var(--primary));
|
||||||
|
outline-offset: -2px;
|
||||||
|
box-shadow: inset 0 0 0 9999px hsl(var(--primary) / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme-key][data-theme-picker-selected='true'] {
|
||||||
|
outline: 2px solid hsl(var(--primary));
|
||||||
|
outline-offset: -2px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 3px hsl(var(--primary) / 0.18),
|
||||||
|
inset 0 0 0 9999px hsl(var(--primary) / 0.06);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user