diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 6ec0818..a0c7883 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,5 +1,6 @@ import { auth } from '@/auth'; import { query } from '@/lib/db'; +import { DEFAULT_FONT_ID, isFontId, type FontId } from '@/lib/fonts'; import { logRouteOutcome, type LogFields } from '@/lib/log'; import { getRequestId, jsonResponse, PRIVATE_NO_STORE, readContentLength } from '@/lib/request'; import { LIMITS, validateSettingsRequest } from '@/lib/validation'; @@ -9,12 +10,14 @@ type SettingsRow = { save_history: boolean; key_jwk: string | null; girl_mode?: boolean; + font_family?: string | null; }; type SettingsPatch = { systemPrompt: string | null; saveHistory: boolean | null; girlMode: boolean | null; + font: FontId | null; keyJwk: string | null; }; @@ -26,45 +29,89 @@ function isPlainObject(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } -function isMissingGirlModeColumn(error: unknown): boolean { +function isMissingColumn(error: unknown, column: string): boolean { if (!error || typeof error !== 'object') return false; const candidate = error as { code?: string; message?: string }; const message = candidate.message ?? String(error); - return candidate.code === '42703' && message.includes('girl_mode'); + return candidate.code === '42703' && message.includes(column); } -async function getSettingsRow(email: string): Promise<{ row?: SettingsRow; legacySchema: boolean }> { +async function getSettingsRow(email: string): Promise<{ row?: SettingsRow; legacySchema: boolean; hasFontColumn: boolean }> { try { const result = await query( - 'SELECT system_prompt, save_history, key_jwk, girl_mode FROM user_settings WHERE email = $1', + 'SELECT system_prompt, save_history, key_jwk, girl_mode, font_family FROM user_settings WHERE email = $1', [email], ); - return { row: result.rows[0] as SettingsRow | undefined, legacySchema: false }; + return { row: result.rows[0] as SettingsRow | undefined, legacySchema: false, hasFontColumn: true }; } catch (error) { - if (!isMissingGirlModeColumn(error)) throw error; + if (isMissingColumn(error, 'font_family')) { + try { + const result = await query( + 'SELECT system_prompt, save_history, key_jwk, girl_mode FROM user_settings WHERE email = $1', + [email], + ); + return { row: result.rows[0] as SettingsRow | undefined, legacySchema: false, hasFontColumn: false }; + } catch (fallbackError) { + if (!isMissingColumn(fallbackError, 'girl_mode')) throw fallbackError; + const result = await query( + 'SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1', + [email], + ); + return { row: result.rows[0] as SettingsRow | undefined, legacySchema: true, hasFontColumn: false }; + } + } + if (!isMissingColumn(error, 'girl_mode')) throw error; const result = await query( 'SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1', [email], ); - return { row: result.rows[0] as SettingsRow | undefined, legacySchema: true }; + return { row: result.rows[0] as SettingsRow | undefined, legacySchema: true, hasFontColumn: false }; } } -async function upsertSettingsRow(email: string, patch: SettingsPatch): Promise<{ legacySchema: boolean }> { +async function upsertSettingsRow(email: string, patch: SettingsPatch): Promise<{ legacySchema: boolean; hasFontColumn: boolean }> { try { await query( - `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk, girl_mode) - VALUES ($1, COALESCE($2, ''), COALESCE($3, FALSE), $4, COALESCE($5, FALSE)) + `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk, girl_mode, font_family) + VALUES ($1, COALESCE($2, ''), COALESCE($3, FALSE), $4, COALESCE($5, FALSE), COALESCE($6, '${DEFAULT_FONT_ID}')) ON CONFLICT (email) DO UPDATE SET system_prompt = COALESCE($2, user_settings.system_prompt), save_history = COALESCE($3, user_settings.save_history), key_jwk = COALESCE($4, user_settings.key_jwk), - girl_mode = COALESCE($5, user_settings.girl_mode)`, - [email, patch.systemPrompt, patch.saveHistory, patch.keyJwk, patch.girlMode], + girl_mode = COALESCE($5, user_settings.girl_mode), + font_family = COALESCE($6, user_settings.font_family)`, + [email, patch.systemPrompt, patch.saveHistory, patch.keyJwk, patch.girlMode, patch.font], ); - return { legacySchema: false }; + return { legacySchema: false, hasFontColumn: true }; } catch (error) { - if (!isMissingGirlModeColumn(error)) throw error; + if (isMissingColumn(error, 'font_family')) { + try { + await query( + `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk, girl_mode) + VALUES ($1, COALESCE($2, ''), COALESCE($3, FALSE), $4, COALESCE($5, FALSE)) + ON CONFLICT (email) DO UPDATE SET + system_prompt = COALESCE($2, user_settings.system_prompt), + save_history = COALESCE($3, user_settings.save_history), + key_jwk = COALESCE($4, user_settings.key_jwk), + girl_mode = COALESCE($5, user_settings.girl_mode)`, + [email, patch.systemPrompt, patch.saveHistory, patch.keyJwk, patch.girlMode], + ); + return { legacySchema: false, hasFontColumn: false }; + } catch (fallbackError) { + if (!isMissingColumn(fallbackError, 'girl_mode')) throw fallbackError; + await query( + `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk) + VALUES ($1, COALESCE($2, ''), COALESCE($3, FALSE), $4) + ON CONFLICT (email) DO UPDATE SET + system_prompt = COALESCE($2, user_settings.system_prompt), + save_history = COALESCE($3, user_settings.save_history), + key_jwk = COALESCE($4, user_settings.key_jwk)`, + [email, patch.systemPrompt, patch.saveHistory, patch.keyJwk], + ); + return { legacySchema: true, hasFontColumn: false }; + } + } + if (!isMissingColumn(error, 'girl_mode')) throw error; await query( `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk) VALUES ($1, COALESCE($2, ''), COALESCE($3, FALSE), $4) @@ -74,7 +121,7 @@ async function upsertSettingsRow(email: string, patch: SettingsPatch): Promise<{ key_jwk = COALESCE($4, user_settings.key_jwk)`, [email, patch.systemPrompt, patch.saveHistory, patch.keyJwk], ); - return { legacySchema: true }; + return { legacySchema: true, hasFontColumn: false }; } } @@ -88,6 +135,8 @@ export async function GET(req: Request) { hasKey: null, saveHistory: null, girlMode: null, + font: null, + hasFontColumn: null, legacySchema: null, newUser: null, error: null, @@ -102,16 +151,21 @@ export async function GET(req: Request) { } ctx.user = session.user.email; - const { row, legacySchema } = await getSettingsRow(session.user.email); + const { row, legacySchema, hasFontColumn } = await getSettingsRow(session.user.email); + const savedFont = row?.font_family ?? null; + const font = savedFont && isFontId(savedFont) ? savedFont : DEFAULT_FONT_ID; ctx.status = 200; ctx.hasKey = !!row?.key_jwk; ctx.saveHistory = row?.save_history ?? false; ctx.girlMode = legacySchema ? null : (row?.girl_mode ?? false); + ctx.font = font; + ctx.hasFontColumn = hasFontColumn; ctx.legacySchema = legacySchema; ctx.newUser = !row; return jsonResponse({ systemPrompt: row?.system_prompt ?? '', saveHistory: row?.save_history ?? false, + font, ...(legacySchema ? {} : { girlMode: row?.girl_mode ?? false }), keyJwk: row?.key_jwk ?? null, }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); @@ -137,12 +191,15 @@ export async function PUT(req: Request) { hasSystemPromptField: null, hasSaveHistoryField: null, hasGirlModeField: null, + hasFontField: null, hasKeyJwkField: null, systemPromptChars: null, keyJwkChars: null, saveHistory: null, girlMode: null, + font: null, hasKey: null, + hasFontColumn: null, legacySchema: null, error: null, }; @@ -168,6 +225,7 @@ export async function PUT(req: Request) { ctx.hasSystemPromptField = hasOwn(body, 'systemPrompt'); ctx.hasSaveHistoryField = hasOwn(body, 'saveHistory'); ctx.hasGirlModeField = hasOwn(body, 'girlMode'); + ctx.hasFontField = hasOwn(body, 'font'); ctx.hasKeyJwkField = hasOwn(body, 'keyJwk'); ctx.systemPromptChars = typeof body.systemPrompt === 'string' ? body.systemPrompt.length : null; ctx.keyJwkChars = typeof body.keyJwk === 'string' ? body.keyJwk.length : null; @@ -184,6 +242,7 @@ export async function PUT(req: Request) { systemPrompt: hasOwn(input, 'systemPrompt') ? parsed.value.systemPrompt : null, saveHistory: hasOwn(input, 'saveHistory') ? parsed.value.saveHistory : null, girlMode: hasOwn(input, 'girlMode') ? parsed.value.girlMode : null, + font: hasOwn(input, 'font') ? parsed.value.font : null, keyJwk: hasOwn(input, 'keyJwk') ? parsed.value.keyJwk : null, }; @@ -191,11 +250,14 @@ export async function PUT(req: Request) { ctx.keyJwkChars = patch.keyJwk === null ? ctx.keyJwkChars : patch.keyJwk.length; ctx.saveHistory = patch.saveHistory; ctx.girlMode = patch.girlMode; + ctx.font = patch.font; ctx.hasKey = patch.keyJwk === null ? null : patch.keyJwk.length > 0; - const { legacySchema } = await upsertSettingsRow(session.user.email, patch); + const { legacySchema, hasFontColumn } = await upsertSettingsRow(session.user.email, patch); ctx.status = 204; ctx.girlMode = legacySchema ? null : patch.girlMode; + ctx.font = hasFontColumn ? patch.font : null; + ctx.hasFontColumn = hasFontColumn; ctx.legacySchema = legacySchema; return new Response(null, { status: 204, diff --git a/app/globals.css b/app/globals.css index 26fddc6..a2400d4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,6 +4,7 @@ --bg: #0c0c0c; --body-bg: var(--bg); --fg: #c8c8c8; + --app-font-family: 'Courier New', Courier, monospace; --assistant-fg: #e0e0e0; --green: #33ff33; --blue: #5599ff; @@ -83,7 +84,7 @@ body { isolation: isolate; background: var(--body-bg); color: var(--fg); - font-family: 'Courier New', Courier, monospace; + font-family: var(--app-font-family); font-size: 14px; line-height: 1.6; } @@ -157,6 +158,8 @@ header { border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; + position: relative; + z-index: 3; } .logo { @@ -269,6 +272,8 @@ header select option { gap: 8px; flex-shrink: 0; box-shadow: var(--panel-shadow); + position: relative; + z-index: 2; } .settings-row { @@ -412,6 +417,7 @@ header select option { flex: 1; position: relative; min-height: 0; + z-index: 1; } .messages { @@ -691,6 +697,8 @@ header select option { flex-shrink: 0; gap: 8px; box-shadow: inset 0 1px 0 var(--accent-glow-soft); + position: relative; + z-index: 2; } .pending-attachments { @@ -921,7 +929,7 @@ header select option { background: var(--code-bg); color: var(--assistant-fg); padding: 1px 5px; - font-family: 'Courier New', monospace; + font-family: var(--app-font-family); font-size: 0.9em; border: 1px solid var(--code-border); } diff --git a/app/page.tsx b/app/page.tsx index 44d9fc9..2bc1c93 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -5,6 +5,7 @@ import { signOut } from 'next-auth/react'; import RenderedMarkdown from './rendered-markdown'; import { renderMarkdown } from '@/lib/markdown'; import { getOrCreateKey, encrypt, decrypt } from '@/lib/crypto'; +import { DEFAULT_FONT_ID, FONTS, getFontFamily, isFontId, type FontId } from '@/lib/fonts'; import { MODELS } from '@/lib/models'; import { splitMessageFollowups, type Role, type Image, type Pdf, type Message } from '@/lib/chat'; import { LIMITS } from '@/lib/validation'; @@ -14,6 +15,7 @@ type PendingPdf = Pdf; const MODEL_KEY = 'gippidy-model'; const KEY_WARNED = 'gippidy-key-warned'; +const FONT_KEY = 'gippidy-font'; const GIRL_MODE_KEY = 'gippidy-girl-mode'; const ACTIVE_HISTORY_CHAT_KEY = 'gippidy-active-history-chat'; const HISTORY_PREVIEW_CACHE_KEY = 'gippidy-history-preview-cache'; @@ -50,7 +52,7 @@ type HistoryRestoreResult = | { kind: 'ok'; item: HistoryItem } | { kind: 'missing' } | { kind: 'error' }; -type SettingsPatch = { systemPrompt?: string; saveHistory?: boolean; girlMode?: boolean; keyJwk?: string | null }; +type SettingsPatch = { systemPrompt?: string; saveHistory?: boolean; girlMode?: boolean; font?: FontId; keyJwk?: string | null }; type PendingSettings = Omit; function withRenderedHtml(message: Message): Message { @@ -126,6 +128,10 @@ function setGirlModeDom(enabled: boolean) { else document.documentElement.removeAttribute(GIRL_MODE_ATTR); } +function setFontDom(font: FontId) { + document.documentElement.style.setProperty('--app-font-family', getFontFamily(font)); +} + function isBuiltInDefaultSystemPrompt(prompt: string): boolean { return ( prompt === '' || @@ -243,6 +249,7 @@ export default function Home() { const webSearchPhaseRef = useRef<'off' | 'searching' | 'generating'>('off'); const [saveHistory, setSaveHistory] = useState(false); const [girlMode, setGirlMode] = useState(false); + const [font, setFont] = useState(DEFAULT_FONT_ID); const [showHistory, setShowHistory] = useState(false); const [historyItems, setHistoryItems] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); @@ -281,6 +288,7 @@ export default function Home() { const systemPromptRef = useRef(systemPrompt); const saveHistoryRef = useRef(saveHistory); const girlModeRef = useRef(girlMode); + const fontRef = useRef(font); const chatStateVersionRef = useRef(0); const initialSettingsLoadedRef = useRef(false); const pendingSettingsRef = useRef({}); @@ -298,6 +306,10 @@ export default function Home() { girlModeRef.current = girlMode; }, [girlMode]); + useEffect(() => { + fontRef.current = font; + }, [font]); + useEffect(() => () => { if (saveSettingsTimer.current) clearTimeout(saveSettingsTimer.current); if (streamingHtmlTimerRef.current) clearTimeout(streamingHtmlTimerRef.current); @@ -367,6 +379,13 @@ export default function Home() { setGirlModeDom(enabled); }; + const applyFont = (nextFont: FontId) => { + fontRef.current = nextFont; + setFont(nextFont); + localStorage.setItem(FONT_KEY, nextFont); + setFontDom(nextFont); + }; + const rememberActiveHistoryChat = (id: string | null) => { if (id) localStorage.setItem(ACTIVE_HISTORY_CHAT_KEY, id); else localStorage.removeItem(ACTIVE_HISTORY_CHAT_KEY); @@ -591,6 +610,8 @@ export default function Home() { useEffect(() => { const saved = localStorage.getItem(MODEL_KEY); if (saved) setModel(saved); + const savedFont = localStorage.getItem(FONT_KEY); + if (savedFont && isFontId(savedFont)) applyFont(savedFont); const savedGirlMode = localStorage.getItem(GIRL_MODE_KEY); const activeHistoryChatId = localStorage.getItem(ACTIVE_HISTORY_CHAT_KEY); if (savedGirlMode === '1' || savedGirlMode === '0') { @@ -630,10 +651,11 @@ export default function Home() { if (!r.ok) throw new Error(`settings_get_${r.status}`); return r.json(); }) - .then(async ({ systemPrompt, saveHistory: sh, girlMode: gm, keyJwk }) => { + .then(async ({ systemPrompt, saveHistory: sh, girlMode: gm, font: fo, keyJwk }) => { const pending = pendingSettingsRef.current; const nextSaveHistory = pending.saveHistory ?? Boolean(sh); const nextGirlMode = pending.girlMode ?? (typeof gm === 'boolean' ? gm : girlModeRef.current); + const nextFont = pending.font ?? (isFontId(fo) ? fo : fontRef.current); const rawSystemPrompt = pending.systemPrompt ?? (systemPrompt ?? ''); const nextSystemPrompt = resolveDefaultSystemPrompt(rawSystemPrompt, nextGirlMode); @@ -645,6 +667,7 @@ export default function Home() { saveHistoryRef.current = nextSaveHistory; setSaveHistory(nextSaveHistory); applyGirlMode(nextGirlMode); + applyFont(nextFont); // Load or create the encryption key (shared across all deployments via DB) const { key, jwk } = await getOrCreateKey(keyJwk ?? null); cryptoKeyRef.current = key; @@ -757,6 +780,16 @@ export default function Home() { persistSettings({ systemPrompt: s }); }; + const handleFontChange = (nextFont: string) => { + if (!isFontId(nextFont)) { + logClientEvent('settings.invalid_font', 'warn', { font: nextFont }); + return; + } + applyFont(nextFont); + rememberPendingSettings({ font: nextFont }); + persistSettings({ font: nextFont }, true); + }; + const handleToggleSaveHistory = (val: boolean) => { saveHistoryRef.current = val; setSaveHistory(val); @@ -1258,6 +1291,14 @@ export default function Home() { ))} +
+ + +