import { useEffect, useState } from "react"; import * as api from "../api"; import type { Theme } from "../hooks/useAppearance"; import type { Config, Group, WorkApp } from "../types"; import { Trash } from "./icons"; import { BTN, BTN_PRIMARY, Badge, Dialog, Favicon, HELP, ICON_CHROME, INPUT, LABEL, SUBPANEL, SectionHeading, Segmented, } from "./ui"; interface Props { config: Config; /** The app a test notification is fired from. */ activeId: string | null; theme: Theme; /** Opens straight onto the hidden-elements section when set. */ focusHiddenFor?: string | null; onConfig: (c: Config) => void; onTheme: (t: Theme) => void; onClose: () => void; } export default function Settings({ config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose, }: Props) { const [name, setName] = useState(""); const [url, setUrl] = useState(""); const [groupId, setGroupId] = useState(""); const [groupName, setGroupName] = useState(""); const [error, setError] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); const [notifyStatus, setNotifyStatus] = useState(null); const [reports, setReports] = useState<[string, string][]>([]); const groups = [...config.groups].sort((a, b) => a.order - b.order); // Same order the nav shows, so the two lists never disagree about position. const groupRank = (id: string | null) => id === null ? -1 : (groups.find((g) => g.id === id)?.order ?? Number.MAX_SAFE_INTEGER); const orderedApps = [...config.apps].sort( (a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order, ); useEffect(() => { if (!focusHiddenFor) return; document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" }); }, [focusHiddenFor]); const run = async (fn: () => Promise) => { try { onConfig(await fn()); setError(null); } catch (e) { setError(String(e)); } }; const addApp = async () => { if (!name.trim() || !url.trim()) return; await run(() => api.addApp(name, url, groupId || null)); setName(""); setUrl(""); }; const addGroup = async () => { if (!groupName.trim()) return; await run(() => api.addGroup(groupName)); setGroupName(""); }; const patch = (app: WorkApp, fields: Partial) => run(() => api.updateApp({ ...app, ...fields })); const withHidden = orderedApps.filter((a) => a.hidden.length > 0); return ( <> }>
{error && (

{error}

)} {/* ----------------------------------------------------- apps */}
Apps
{config.apps.length === 0 &&

Nothing yet. Add the first one below.

} {orderedApps.map((app) => (
patch(app, { name: e.target.value })} className={`${INPUT} h-[26px] w-full flex-1`} /> patch(app, { url: e.target.value })} title="Changing this rebuilds the app's view" className={`${INPUT} h-[26px] w-full flex-[1.4] font-mono text-[11px]`} />
))}

An app owns its URL's host. Links to any other app on this list switch to it; everything else opens in your browser.

{/* ------------------------------------------- hidden elements */}
Hidden elements {withHidden.length === 0 ? (

Nothing hidden. Right-click anything in an app and choose Hide this element — or use the eye button in the nav to pick one.

) : (
{withHidden.map((app) => (
{app.name} {app.hidden.length}
{app.hidden.map((sel, i) => (
{ const next = [...app.hidden]; next[i] = e.target.value; run(() => api.setHidden(app.id, next)); }} title="Edit the selector to widen or narrow what it hides" className={`${INPUT} h-[26px] w-full flex-1 font-mono text-[11px]`} />
))}
))}
)}

Changes apply immediately, without a reload. A selector that stops matching after the site changes simply hides nothing.

{/* ------------------------------------------------------ zoom */}
Zoom
{orderedApps.map((app) => (
{app.name} run(() => api.setZoom(app.id, Number(e.target.value) / 100)) } className="min-w-0 flex-1 cursor-pointer accent-sky-500" /> {Math.round((app.zoom ?? 1) * 100)}%
))}

⌘+ and ⌘− zoom the app you are in; ⌘0 puts it back to 100%. Each app keeps its own size.

{/* --------------------------------------------- notifications */}
Notifications
{reports.length > 0 && (
{reports.map(([name, report]) => (

{name} · {report}

))}
)}
{notifyStatus && (

{notifyStatus}

)}

WKWebView defines a notification API that silently does nothing, so it is replaced with one that forwards to macOS. Notifications raised by a service worker in the background are not covered — only those a page raises while open.

{/* ---------------------------------------------------- groups */}
Groups
{groups.length === 0 &&

No groups yet.

} {groups.map((g: Group) => (
run(() => api.updateGroup({ ...g, name: e.target.value }))} className={`${INPUT} h-[26px] w-full flex-1`} /> {config.apps.filter((a) => a.groupId === g.id).length} apps
))}
{/* ----------------------------------------------- appearance */}
Appearance value={theme} onChange={onTheme} options={[ { value: "system", label: "System" }, { value: "light", label: "Light" }, { value: "dark", label: "Dark" }, ]} />
{confirmDelete && ( setConfirmDelete(null)} onConfirm={async () => { await run(() => api.deleteApp(confirmDelete.id)); setConfirmDelete(null); }} confirmLabel="Remove" destructive > It disappears from the nav and its view is closed. The session it holds for{" "} {confirmDelete.url} is kept, so adding it back does not mean signing in again. )} ); }