Repository Public
1'use client';2import { useEffect, useState } from 'react';3import type { UserSettings } from '@cr/shared';4import { ArrowLeft, ArrowRight, X } from 'lucide-react';5import { DEFAULT_SETTINGS, useProjects, useSaveSettings, useSettingsQuery } from '@/lib/queries';6import { errorMessage } from '@/lib/api';7import { PRESETS } from '@/stores/ui';8import { toast } from '@/stores/toasts';9import { Button } from '@/components/ui/button';10import { Input } from '@/components/ui/input';11import { NONE, Select } from '@/components/ui/select';12import { Switch } from '@/components/ui/switch';13import { ErrorState, PageHeader, Section, Skeleton } from '@/components/ui/misc';14import { TOOLBAR_KEY_IDS, toolbarKey } from '@/components/terminal/keys';15import { SecuritySettings } from '@/components/settings/security';16import { ThemeSegmented } from '@/components/shell/theme-toggle';1718function Row({ label, hint, children }: { label: string; hint?: React.ReactNode; children: React.ReactNode }) {19 return (20 <div className="flex flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">21 <div className="min-w-0 flex-1 basis-56">22 <p className="text-[13px] text-text">{label}</p>23 {hint && <p className="mt-0.5 text-xs text-muted">{hint}</p>}24 </div>25 <div className="w-full shrink-0 sm:w-64">{children}</div>26 </div>27 );28}29/** Number input committed on blur/Enter, clamped to the schema bounds. */30function NumberSetting({ value, min, max, onCommit, label, unit }: { value: number; min: number; max: number; onCommit: (n: number) => void; label: string; unit?: string }) {31 const [text, setText] = useState(String(value));32 useEffect(() => setText(String(value)), [value]);33 const commit = () => {34 const n = Math.round(Number(text));35 if (!Number.isFinite(n)) return setText(String(value));36 const c = Math.max(min, Math.min(max, n));37 setText(String(c));38 if (c !== value) onCommit(c);39 };40 return (41 <div className="flex items-center gap-2">42 <Input aria-label={label} inputMode="numeric" value={text} onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && commit()} className="tnum" />43 <span className="w-20 shrink-0 text-[11px] text-muted">{unit} ({min}–{max})</span>44 </div>45 );46}4748export default function SettingsPage() {49 const q = useSettingsQuery();50 const save = useSaveSettings();51 const projects = useProjects().data ?? [];52 const s = q.data ?? DEFAULT_SETTINGS;53 const set = (patch: Partial<UserSettings>) => save(patch).catch((e) => toast.error('Could not save settings', errorMessage(e)));54 const available = TOOLBAR_KEY_IDS.filter((k) => !s.mobileToolbar.includes(k));55 const move = (i: number, d: -1 | 1) => {56 const a = [...s.mobileToolbar];57 const j = i + d;58 if (j < 0 || j >= a.length) return;59 [a[i], a[j]] = [a[j]!, a[i]!];60 void set({ mobileToolbar: a });61 };62 return (63 <div className="flex h-full flex-col overflow-hidden">64 <PageHeader title="Settings" description="Saved on the server for this administrator. Changes apply immediately." />65 <div className="min-h-0 flex-1 overflow-y-auto">66 {q.isPending ? <div className="flex flex-col gap-4 p-4 md:p-6">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-32" />)}</div>67 : q.isError ? <ErrorState error={q.error} onRetry={() => q.refetch()} />68 : (69 <div className="mx-auto flex max-w-3xl flex-col gap-4 p-4 md:p-6" data-testid="settings">70 <Section title="Appearance">71 <div className="divide-y divide-line">72 <Row label="Theme" hint="Light by default. “System” follows this device."><ThemeSegmented /></Row>73 <Row label="Default layout" hint="Applied on a device that has no saved layout yet.">74 <Select label="Default layout" value={s.defaultLayout} onValueChange={(v) => set({ defaultLayout: v as UserSettings['defaultLayout'] })} options={PRESETS.map((p) => ({ value: p.id, label: p.label }))} />75 </Row>76 <Row label="Default project" hint="Used for new sessions and the file browser when no session is active.">77 <Select label="Default project" value={s.defaultProjectId ?? NONE} onValueChange={(v) => set({ defaultProjectId: v === NONE ? null : v })} options={[{ value: NONE, label: 'None' }, ...projects.map((p) => ({ value: p.id, label: p.name }))]} />78 </Row>79 </div>80 </Section>81 <Section title="Terminal">82 <div className="divide-y divide-line">83 <Row label="Font" hint="JetBrains Mono is bundled. Any other name must be installed on this device.">84 <Input aria-label="Terminal font" defaultValue={s.terminalFont} key={s.terminalFont} onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== s.terminalFont) void set({ terminalFont: v }); }} />85 </Row>86 <Row label="Terminal colours" hint="“Auto” follows the app theme. A dark terminal inside the light app is shown as a screen card.">87 <Select label="Terminal colours" value={s.terminalTheme} onValueChange={(v) => set({ terminalTheme: v as UserSettings['terminalTheme'] })} options={[{ value: 'auto', label: 'Auto (follow app theme)' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]} />88 </Row>89 <Row label="Font size (desktop)"><NumberSetting label="Desktop font size" value={s.terminalFontSize} min={8} max={28} unit="px" onCommit={(n) => set({ terminalFontSize: n })} /></Row>90 <Row label="Font size (phone)" hint="Also adjustable by pinching the terminal."><NumberSetting label="Phone font size" value={s.mobileFontSize} min={8} max={28} unit="px" onCommit={(n) => set({ mobileFontSize: n })} /></Row>91 <Row label="Scrollback" hint="Lines kept in the browser. The server keeps its own bounded history."><NumberSetting label="Scrollback lines" value={s.scrollback} min={500} max={50000} unit="lines" onCommit={(n) => set({ scrollback: n })} /></Row>92 </div>93 </Section>94 <Section title="Phone key toolbar" aside="Paste and hide-keyboard are always present">95 <div className="flex flex-col gap-3 p-4">96 <ul className="flex flex-wrap gap-1.5" aria-label="Toolbar keys in order" data-testid="toolbar-config">97 {s.mobileToolbar.map((k, i) => (98 <li key={k} className="flex items-center overflow-hidden rounded-sm border border-line-2 bg-surface-2">99 <button aria-label={`Move ${toolbarKey(k).aria} left`} disabled={i === 0} onClick={() => move(i, -1)} className="flex h-8 w-6 items-center justify-center text-muted hover:text-text disabled:opacity-30 pointer-coarse:h-11 pointer-coarse:w-9"><ArrowLeft className="size-3" /></button>100 <span className="min-w-6 px-1 text-center font-mono text-xs">{toolbarKey(k).label}</span>101 <button aria-label={`Move ${toolbarKey(k).aria} right`} disabled={i === s.mobileToolbar.length - 1} onClick={() => move(i, 1)} className="flex h-8 w-6 items-center justify-center text-muted hover:text-text disabled:opacity-30 pointer-coarse:h-11 pointer-coarse:w-9"><ArrowRight className="size-3" /></button>102 <button aria-label={`Remove ${toolbarKey(k).aria}`} onClick={() => set({ mobileToolbar: s.mobileToolbar.filter((x) => x !== k) })} className="flex h-8 w-6 items-center justify-center border-l border-line-2 text-muted hover:text-error pointer-coarse:h-11 pointer-coarse:w-9"><X className="size-3" /></button>103 </li>104 ))}105 </ul>106 <div className="flex flex-wrap items-center gap-2">107 <div className="w-44"><Select label="Add a key" value="" placeholder="Add a key…" onValueChange={(v) => v && set({ mobileToolbar: [...s.mobileToolbar, v] })} options={available.map((k) => ({ value: k, label: `${toolbarKey(k).label} ${toolbarKey(k).aria}` }))} disabled={available.length === 0} /></div>108 <Button size="sm" variant="ghost" onClick={() => set({ mobileToolbar: DEFAULT_SETTINGS.mobileToolbar })}>Reset to default</Button>109 </div>110 </div>111 </Section>112 <Section title="Editor">113 <Row label="Autosave" hint="Saves 1.5 s after the last change. Never overwrites a file that changed on disk."><Switch label="Autosave" checked={s.autosave} onCheckedChange={(v) => set({ autosave: v })} /></Row>114 </Section>115 <Section title="Notifications">116 <div className="divide-y divide-line">117 <Row label="In-app notifications" hint="Toasts for session and task events while the app is open."><Switch label="In-app notifications" checked={s.notifications.inApp} onCheckedChange={(v) => set({ notifications: { ...s.notifications, inApp: v } })} /></Row>118 <Row label="Web push" hint="Stores your preference. Delivery depends on push support on the server; this client does not subscribe to push yet."><Switch label="Web push" checked={s.notifications.webPush} onCheckedChange={(v) => set({ notifications: { ...s.notifications, webPush: v } })} /></Row>119 <Row label="Detail level" hint="Generic text never reveals session or task titles on a lock screen.">120 <Select label="Notification detail" value={s.notifications.detail} onValueChange={(v) => set({ notifications: { ...s.notifications, detail: v as 'generic' | 'titles' } })} options={[{ value: 'generic', label: 'Generic' }, { value: 'titles', label: 'Include titles' }]} />121 </Row>122 </div>123 </Section>124 <Section title="Sessions">125 <Row label="Session retention" hint="Ended sessions older than this are removed from the list by the server."><NumberSetting label="Session retention days" value={s.sessionRetentionDays} min={1} max={3650} unit="days" onCommit={(n) => set({ sessionRetentionDays: n })} /></Row>126 </Section>127 <SecuritySettings />128 </div>129 )}130 </div>131 </div>132 );133}134