SPB Git

Repository Public

page.tsx 10.0 KB 134 lines tsx

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
Files main
Menu

Theme