mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
- ThemeService: read/write/validate theme.json, auto-backup on save - ThemeController: GET/PUT/POST /api/config/theme (public GET, versioned PUT) - ThemeConfig type with version + updatedAt fields - Default theme: Global Hospital blue scale - ConfigThemeModule registered in AppModule Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
99 lines
3.7 KiB
TypeScript
99 lines
3.7 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { DEFAULT_THEME, type ThemeConfig } from './theme.defaults';
|
|
|
|
const THEME_PATH = join(process.cwd(), 'data', 'theme.json');
|
|
const BACKUP_DIR = join(process.cwd(), 'data', 'theme-backups');
|
|
|
|
@Injectable()
|
|
export class ThemeService implements OnModuleInit {
|
|
private readonly logger = new Logger(ThemeService.name);
|
|
private cached: ThemeConfig | null = null;
|
|
|
|
onModuleInit() {
|
|
this.load();
|
|
}
|
|
|
|
getTheme(): ThemeConfig {
|
|
if (this.cached) return this.cached;
|
|
return this.load();
|
|
}
|
|
|
|
updateTheme(updates: Partial<ThemeConfig>): ThemeConfig {
|
|
const current = this.getTheme();
|
|
|
|
const merged: ThemeConfig = {
|
|
brand: { ...current.brand, ...updates.brand },
|
|
colors: {
|
|
brand: { ...current.colors.brand, ...updates.colors?.brand },
|
|
},
|
|
typography: { ...current.typography, ...updates.typography },
|
|
login: { ...current.login, ...updates.login, poweredBy: { ...current.login.poweredBy, ...updates.login?.poweredBy } },
|
|
sidebar: { ...current.sidebar, ...updates.sidebar },
|
|
ai: {
|
|
quickActions: updates.ai?.quickActions ?? current.ai.quickActions,
|
|
},
|
|
};
|
|
|
|
merged.version = (current.version ?? 0) + 1;
|
|
merged.updatedAt = new Date().toISOString();
|
|
|
|
this.backup();
|
|
|
|
const dir = dirname(THEME_PATH);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(THEME_PATH, JSON.stringify(merged, null, 2), 'utf8');
|
|
this.cached = merged;
|
|
|
|
this.logger.log(`Theme updated to v${merged.version}`);
|
|
return merged;
|
|
}
|
|
|
|
resetTheme(): ThemeConfig {
|
|
this.backup();
|
|
const dir = dirname(THEME_PATH);
|
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
writeFileSync(THEME_PATH, JSON.stringify(DEFAULT_THEME, null, 2), 'utf8');
|
|
this.cached = DEFAULT_THEME;
|
|
this.logger.log('Theme reset to defaults');
|
|
return DEFAULT_THEME;
|
|
}
|
|
|
|
private load(): ThemeConfig {
|
|
try {
|
|
if (existsSync(THEME_PATH)) {
|
|
const raw = readFileSync(THEME_PATH, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
this.cached = {
|
|
brand: { ...DEFAULT_THEME.brand, ...parsed.brand },
|
|
colors: { brand: { ...DEFAULT_THEME.colors.brand, ...parsed.colors?.brand } },
|
|
typography: { ...DEFAULT_THEME.typography, ...parsed.typography },
|
|
login: { ...DEFAULT_THEME.login, ...parsed.login, poweredBy: { ...DEFAULT_THEME.login.poweredBy, ...parsed.login?.poweredBy } },
|
|
sidebar: { ...DEFAULT_THEME.sidebar, ...parsed.sidebar },
|
|
ai: { quickActions: parsed.ai?.quickActions ?? DEFAULT_THEME.ai.quickActions },
|
|
};
|
|
this.logger.log('Theme loaded from file');
|
|
return this.cached;
|
|
}
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to load theme: ${err}`);
|
|
}
|
|
|
|
this.cached = DEFAULT_THEME;
|
|
this.logger.log('Using default theme');
|
|
return DEFAULT_THEME;
|
|
}
|
|
|
|
private backup() {
|
|
try {
|
|
if (!existsSync(THEME_PATH)) return;
|
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
copyFileSync(THEME_PATH, join(BACKUP_DIR, `theme-${ts}.json`));
|
|
} catch (err) {
|
|
this.logger.warn(`Backup failed: ${err}`);
|
|
}
|
|
}
|
|
}
|