Add database-backed runtime configuration for feature flags and TTS provider credentials, editable via a new admin UI panel. Replace static NEXT_PUBLIC_* and TTS provider env vars with admin-managed settings stored in the database and injected at SSR for client access. Implement admin-only panels for managing shared TTS provider credentials (with encrypted API keys) and live site feature flags. Add schema migrations, API routes, React contexts, and hooks for SSR-injected runtime config and live updates. Update client and server logic to resolve configuration from the database at runtime, enforcing admin restrictions and supporting migration from legacy env-based config. BREAKING CHANGE: Feature flags and TTS provider credentials are now managed at runtime via the admin UI. Environment variables are only used for initial seeding and are ignored after first boot. Existing deployments must migrate configuration to the admin panel.
176 lines
6.6 KiB
TypeScript
176 lines
6.6 KiB
TypeScript
import type { DocumentListState } from '@/types/documents';
|
|
|
|
// Runtime config (admin-controlled) is layered on top of the static defaults
|
|
// below. We resolve it lazily so this module stays importable from non-React
|
|
// contexts (Dexie, server routes). The actual values come from
|
|
// `window.__OPENREADER_RUNTIME_CONFIG__` (SSR-injected) on the client, and
|
|
// from the built-in defaults during SSR.
|
|
|
|
function readRuntimeFlag(key: string, defaultValue: boolean): boolean {
|
|
if (typeof window === 'undefined') return defaultValue;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__;
|
|
if (!injected || typeof injected !== 'object') return defaultValue;
|
|
const value = injected[key];
|
|
return typeof value === 'boolean' ? value : defaultValue;
|
|
}
|
|
|
|
function readRuntimeString(key: string, defaultValue: string): string {
|
|
if (typeof window === 'undefined') return defaultValue;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__;
|
|
if (!injected || typeof injected !== 'object') return defaultValue;
|
|
const value = injected[key];
|
|
return typeof value === 'string' && value ? value : defaultValue;
|
|
}
|
|
|
|
export type ViewType = 'single' | 'dual' | 'scroll';
|
|
|
|
export type SavedVoices = Record<string, string>;
|
|
|
|
export const SEGMENT_PRELOAD_DEPTH_MIN = 1;
|
|
export const SEGMENT_PRELOAD_DEPTH_MAX = 5;
|
|
export const SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN = 1;
|
|
export const SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX = 10;
|
|
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN = 150;
|
|
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX = 1200;
|
|
export const TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP = 25;
|
|
|
|
export function clampSegmentPreloadDepth(value: number | undefined | null): number {
|
|
const candidate = Math.floor(Number(value) || SEGMENT_PRELOAD_DEPTH_MIN);
|
|
return Math.max(SEGMENT_PRELOAD_DEPTH_MIN, Math.min(SEGMENT_PRELOAD_DEPTH_MAX, candidate));
|
|
}
|
|
|
|
export function clampSegmentPreloadSentenceLookahead(value: number | undefined | null): number {
|
|
const candidate = Math.floor(Number(value) || SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN);
|
|
return Math.max(SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN, Math.min(SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX, candidate));
|
|
}
|
|
|
|
export function clampTtsSegmentMaxBlockLength(value: number | undefined | null): number {
|
|
const candidate = Math.floor(Number(value) || TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN);
|
|
return Math.max(TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN, Math.min(TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX, candidate));
|
|
}
|
|
|
|
export interface AppConfigValues {
|
|
apiKey: string;
|
|
baseUrl: string;
|
|
viewType: ViewType;
|
|
voiceSpeed: number;
|
|
audioPlayerSpeed: number;
|
|
voice: string;
|
|
skipBlank: boolean;
|
|
epubTheme: boolean;
|
|
headerMargin: number;
|
|
footerMargin: number;
|
|
leftMargin: number;
|
|
rightMargin: number;
|
|
ttsProvider: string;
|
|
ttsModel: string;
|
|
ttsInstructions: string;
|
|
savedVoices: SavedVoices;
|
|
smartSentenceSplitting: boolean;
|
|
segmentPreloadDepthPages: number;
|
|
segmentPreloadSentenceLookahead: number;
|
|
ttsSegmentMaxBlockLength: number;
|
|
pdfHighlightEnabled: boolean;
|
|
pdfWordHighlightEnabled: boolean;
|
|
epubHighlightEnabled: boolean;
|
|
epubWordHighlightEnabled: boolean;
|
|
firstVisit: boolean;
|
|
documentListState: DocumentListState;
|
|
privacyAccepted: boolean;
|
|
documentsMigrationPrompted: boolean;
|
|
}
|
|
|
|
/**
|
|
* Build defaults lazily so we can read SSR-injected admin overrides
|
|
* (`window.__OPENREADER_RUNTIME_CONFIG__`). Modules that need the defaults
|
|
* statically should call `getAppConfigDefaults()` at use time. The exported
|
|
* `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so
|
|
* mutations to the runtime config (admin edits) are picked up by anything
|
|
* that reads through it.
|
|
*/
|
|
export function getAppConfigDefaults(): AppConfigValues {
|
|
const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true);
|
|
return {
|
|
apiKey: '',
|
|
baseUrl: '',
|
|
viewType: 'single',
|
|
voiceSpeed: 1,
|
|
audioPlayerSpeed: 1,
|
|
voice: '',
|
|
skipBlank: true,
|
|
epubTheme: false,
|
|
headerMargin: 0,
|
|
footerMargin: 0,
|
|
leftMargin: 0,
|
|
rightMargin: 0,
|
|
ttsProvider: readRuntimeString('defaultTtsProvider', 'custom-openai'),
|
|
ttsModel: readRuntimeString('defaultTtsModel', 'kokoro'),
|
|
ttsInstructions: '',
|
|
savedVoices: {},
|
|
smartSentenceSplitting: true,
|
|
segmentPreloadDepthPages: 1,
|
|
segmentPreloadSentenceLookahead: 3,
|
|
ttsSegmentMaxBlockLength: 450,
|
|
pdfHighlightEnabled: true,
|
|
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
|
|
epubHighlightEnabled: true,
|
|
epubWordHighlightEnabled: wordHighlightEnabledByDefault,
|
|
firstVisit: false,
|
|
documentListState: {
|
|
sortBy: 'name',
|
|
sortDirection: 'asc',
|
|
folders: [],
|
|
collapsedFolders: [],
|
|
showHint: true,
|
|
viewMode: 'grid',
|
|
},
|
|
privacyAccepted: false,
|
|
documentsMigrationPrompted: false,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Static defaults snapshot resolved at first access. For callers that need
|
|
* fresh values after admin edits, prefer `getAppConfigDefaults()` directly.
|
|
* Most consumers just need a stable defaults object for spreads, so this is
|
|
* resolved once per process — admin overrides take effect on next page load
|
|
* (which is the SSR-injected behavior we want anyway).
|
|
*/
|
|
let cachedDefaults: AppConfigValues | null = null;
|
|
export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => {
|
|
// Return a getter-backed object that resolves on first access. On the
|
|
// server, this resolves to built-in defaults; on the client, to the
|
|
// SSR-injected admin values.
|
|
const handler: ProxyHandler<AppConfigValues> = {
|
|
get(_target, prop) {
|
|
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
|
|
return (cachedDefaults as unknown as Record<string | symbol, unknown>)[prop as string];
|
|
},
|
|
has(_target, prop) {
|
|
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
|
|
return prop in (cachedDefaults as object);
|
|
},
|
|
ownKeys() {
|
|
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
|
|
return Reflect.ownKeys(cachedDefaults as object);
|
|
},
|
|
getOwnPropertyDescriptor(_target, prop) {
|
|
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
|
|
const value = (cachedDefaults as unknown as Record<string | symbol, unknown>)[prop as string];
|
|
if (value === undefined && !(prop in (cachedDefaults as object))) return undefined;
|
|
return {
|
|
value,
|
|
writable: true,
|
|
enumerable: true,
|
|
configurable: true,
|
|
};
|
|
},
|
|
};
|
|
return new Proxy({} as AppConfigValues, handler);
|
|
})();
|
|
|
|
export interface AppConfigRow extends AppConfigValues {
|
|
id: string;
|
|
}
|