313 lines
10 KiB
TypeScript
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 = "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>
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
}
|