LazyBoy2/web/src/settings.tsx

313 lines
10 KiB
TypeScript

import { useEffect, useState } from "react";
import { LOCALE_OPTIONS, useI18n, type Locale } from "./i18n";
import { persistTheme, readTheme, THEME_PREFS, type ThemePref } from "./theme";
export const APP_NAME = "LazyBoy";
export const APP_VERSION = (import.meta.env.LAZYBOY_VERSION || "0.1.0").trim() || "0.1.0";
export type SettingsSectionId = "general" | "updates";
const SECTIONS: readonly { id: SettingsSectionId; icon: "gear" | "download" }[] = [
{ id: "general", icon: "gear" },
{ id: "updates", icon: "download" },
];
export type SettingsComputer = {
pending: "start" | "restart" | "update" | null;
working: boolean;
upToDate: boolean;
onUpdate: () => void;
onRestart: () => void;
};
function detectOs(): string {
const ua = navigator.userAgent;
if (/Mac/i.test(ua)) return "darwin";
if (/Win/i.test(ua)) return "win32";
if (/Linux/i.test(ua)) return "linux";
return navigator.platform || "unknown";
}
function GearIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
}
function DownloadIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M8 17h8" />
<path d="M12 7v9" />
<path d="M16 12l-4 4-4-4" />
<path d="M20 16.58A5 5 0 0 0 18 7h-1.26A8 8 0 1 0 4 15.25" />
</svg>
);
}
function CloseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
);
}
function SettingsGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="lb-settings-group">
<h3>{title}</h3>
{children}
</section>
);
}
function OverlayShell({
label,
className,
onClose,
children,
}: {
label: string;
className: string;
onClose: () => void;
children: React.ReactNode;
}) {
return (
<div className="modal-backdrop lb-overlay" onClick={onClose}>
<div
className={className}
role="dialog"
aria-modal="true"
aria-label={label}
onClick={(event) => event.stopPropagation()}
>
{children}
</div>
</div>
);
}
function GeneralPanel() {
const { locale, setLocale, t } = useI18n();
const [theme, setTheme] = useState<ThemePref>(readTheme);
return (
<div className="lb-settings-stack">
<SettingsGroup title={t.localWorkspace}>
<div className="lb-account-card">
<span className="lb-account-card__avatar" aria-hidden="true">GB</span>
<span className="lb-account-card__body">
<strong>{t.localWorkspace}</strong>
<span>{t.workspaceDetail}</span>
</span>
</div>
</SettingsGroup>
<SettingsGroup title={t.appearance}>
<label className="lb-settings-row">
<span className="lb-settings-copy">{t.theme}</span>
<select
className="lb-settings-select"
aria-label={t.theme}
value={theme}
onChange={(event) => {
const next = event.target.value as ThemePref;
if (!THEME_PREFS.includes(next)) return;
persistTheme(next);
setTheme(next);
}}
>
<option value="system">{t.themeSystem}</option>
<option value="light">{t.themeLight}</option>
<option value="dark">{t.themeDark}</option>
</select>
</label>
<label className="lb-settings-row">
<span className="lb-settings-copy">{t.language}</span>
<select
className="lb-settings-select"
aria-label={t.language}
value={locale}
onChange={(event) => setLocale(event.target.value as Locale)}
>
{LOCALE_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>{option.native}</option>
))}
</select>
</label>
</SettingsGroup>
</div>
);
}
function ComputerRow({
label,
description,
extra,
control,
}: {
label: string;
description: string;
extra: string | null;
control: React.ReactNode;
}) {
return (
<div className="lb-settings-row">
<span className="lb-settings-copy">
<strong>{label}</strong>
<small>{description}</small>
{extra ? <small>{extra}</small> : null}
</span>
<span className="lb-settings-control">{control}</span>
</div>
);
}
function UpdatesPanel({ computer }: { computer?: SettingsComputer }) {
const { t } = useI18n();
const pending = computer?.pending ?? null;
const disabled = Boolean(pending);
const busyCopy = computer?.working ? t.computerBusyCopy : null;
return (
<div className="lb-settings-stack">
<SettingsGroup title={t.updates}>
<div className="lb-settings-row">
<span className="lb-settings-copy">
<strong>{APP_NAME} {APP_VERSION}</strong>
<small>{t.localBuild}</small>
</span>
</div>
</SettingsGroup>
{computer ? (
<SettingsGroup title={t.appComputer}>
{computer.upToDate ? (
<div className="lb-settings-banner" role="status">
<strong>{t.computerUpToDate}</strong>
<span>{t.computerUpdateCopy}</span>
{busyCopy ? <span>{busyCopy}</span> : null}
<button type="button" className="outline" disabled={disabled} onClick={computer.onUpdate}>
{pending === "update" ? t.computerUpdating : t.update}
</button>
</div>
) : (
<ComputerRow
label={t.updateComputer}
description={t.computerUpdateCopy}
extra={busyCopy}
control={
<button type="button" className="outline" disabled={disabled} onClick={computer.onUpdate}>
{pending === "update" ? t.computerUpdating : t.update}
</button>
}
/>
)}
<ComputerRow
label={t.restartComputer}
description={t.restartComputerCopy}
extra={busyCopy}
control={
<button type="button" className="danger" disabled={disabled} onClick={computer.onRestart}>
{pending === "restart" ? t.computerRestarting : t.restart}
</button>
}
/>
</SettingsGroup>
) : null}
</div>
);
}
export function SettingsDialog({ onClose, computer }: { onClose: () => void; computer?: SettingsComputer }) {
const { t } = useI18n();
const [section, setSection] = useState<SettingsSectionId>("general");
const headingId = `lb-settings-heading-${section}`;
const panelId = `lb-settings-panel-${section}`;
const sectionLabel = section === "general" ? t.general : t.updates;
return (
<OverlayShell label={t.settingsLabel} className="lb-settings-dialog" onClose={onClose}>
<div className="lb-settings-layout">
<nav aria-label={t.settings} className="lb-settings-nav">
{SECTIONS.map((item) => {
const selected = item.id === section;
return (
<button
key={item.id}
type="button"
className="lb-settings-nav__item"
data-active={selected || undefined}
aria-current={selected ? "page" : undefined}
aria-controls={selected ? panelId : undefined}
onClick={() => setSection(item.id)}
>
{item.icon === "gear" ? <GearIcon /> : <DownloadIcon />}
<span>{item.id === "general" ? t.general : t.updates}</span>
</button>
);
})}
</nav>
<section className="lb-settings-panel" id={panelId} aria-labelledby={headingId}>
<button type="button" className="lb-settings-close" aria-label={t.close} onClick={onClose}>
<CloseIcon />
</button>
<h2 id={headingId}>{sectionLabel}</h2>
<div className="lb-settings-body">
{section === "general" ? <GeneralPanel /> : <UpdatesPanel computer={computer} />}
</div>
</section>
</div>
</OverlayShell>
);
}
export function AboutDialog({ onClose }: { onClose: () => void }) {
const { t, format } = useI18n();
const [copied, setCopied] = useState(false);
const [copyGeneration, setCopyGeneration] = useState(0);
useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timeout);
}, [copied, copyGeneration]);
const copyVersion = async () => {
try {
await navigator.clipboard.writeText([
`Version: ${APP_VERSION}`,
`OS: ${detectOs()}`,
].join("\n"));
setCopied(true);
setCopyGeneration((n) => n + 1);
} catch {
/* leave the action idle, matching grok-bot */
}
};
return (
<OverlayShell label={t.about} className="lb-about-dialog" onClose={onClose}>
<button type="button" className="lb-about-close" aria-label={t.close} onClick={onClose}>
<CloseIcon />
</button>
<div className="lb-about-brand">
<span className="lb-about-icon">
<img src="/lazyboy-icon.png" width={64} height={64} alt="" draggable={false} decoding="async" />
</span>
<div className="lb-about-heading">
<h2>{APP_NAME}</h2>
<p>{format("versionLine", { version: APP_VERSION })}</p>
</div>
<small>{t.copyright}</small>
</div>
<footer>
<button type="button" onClick={() => void copyVersion()}>
{copied ? t.copied : t.copyVersionInfo}
</button>
</footer>
</OverlayShell>
);
}