Each rise in an app's unread count while you are elsewhere adds to a tally beside its name, and a dot on its icon in the rail. Looking at the app is the only thing that clears it. The window has no frame of its own, so the shell now keeps a 6px margin in a normal window - the only part of it that is not a web page, and so the only place left to grab. Full screen gives the room back. Dialogs get the app blurred behind them. An app's webview paints above the shell, so it has to be moved aside before a dialog can be seen at all, and once moved there is nothing left to blur - a still taken on the way out is the only way to keep the background there. It runs on a blocking worker: its completion handler is on the main thread, and waiting there deadlocks until the timeout and returns nothing. Settings is much larger, the cog moved to the foot of the nav, group names read as names rather than shouted headings, and "Refresh icons" bumps a version every favicon URL carries, for the ones that cache wrong.
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
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<string | null>(null);
|
|
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
|
|
|
|
const [notifyStatus, setNotifyStatus] = useState<string | null>(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<Config>) => {
|
|
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<WorkApp>) =>
|
|
run(() => api.updateApp({ ...app, ...fields }));
|
|
|
|
|
|
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
|
|
|
|
return (
|
|
<>
|
|
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
|
|
<div className="space-y-6">
|
|
{error && (
|
|
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-[12px] text-red-600 dark:text-red-400">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
{/* ----------------------------------------------------- apps */}
|
|
<section className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<SectionHeading>Apps</SectionHeading>
|
|
<button
|
|
onClick={() => run(() => api.refreshFavicons())}
|
|
title="Fetch every icon again — one that cached wrong will not fix itself"
|
|
className={BTN}
|
|
>
|
|
Refresh icons
|
|
</button>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
{config.apps.length === 0 && <p className={HELP}>Nothing yet. Add the first one below.</p>}
|
|
{orderedApps.map((app) => (
|
|
<div
|
|
key={app.id}
|
|
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
|
>
|
|
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
|
<input
|
|
value={app.name}
|
|
onChange={(e) => patch(app, { name: e.target.value })}
|
|
className={`${INPUT} h-[26px] w-full flex-1`}
|
|
/>
|
|
<input
|
|
value={app.url}
|
|
onChange={(e) => 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]`}
|
|
/>
|
|
<select
|
|
value={app.groupId ?? ""}
|
|
onChange={(e) => patch(app, { groupId: e.target.value || null })}
|
|
className={`${INPUT} h-[26px] w-[110px] shrink-0 cursor-pointer`}
|
|
>
|
|
<option value="">No group</option>
|
|
{groups.map((g) => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => setConfirmDelete(app)}
|
|
title={`Remove ${app.name}`}
|
|
className={`${ICON_CHROME} hover:text-red-500!`}
|
|
>
|
|
<Trash />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className={`${SUBPANEL} flex items-end gap-2`}>
|
|
<label className="flex-1 space-y-1">
|
|
<span className={LABEL}>Name</span>
|
|
<input value={name} onChange={(e) => setName(e.target.value)}
|
|
placeholder="Odoo" className={`${INPUT} w-full`} />
|
|
</label>
|
|
<label className="flex-[1.4] space-y-1">
|
|
<span className={LABEL}>URL</span>
|
|
<input value={url} onChange={(e) => setUrl(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && addApp()}
|
|
placeholder="example.com" className={`${INPUT} w-full font-mono text-[11px]`} />
|
|
</label>
|
|
<label className="w-[110px] space-y-1">
|
|
<span className={LABEL}>Group</span>
|
|
<select value={groupId} onChange={(e) => setGroupId(e.target.value)}
|
|
className={`${INPUT} w-full cursor-pointer`}>
|
|
<option value="">No group</option>
|
|
{groups.map((g) => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button onClick={addApp} className={BTN_PRIMARY}>Add</button>
|
|
</div>
|
|
<p className={HELP}>
|
|
An app owns its URL's host. Links to any other app on this list switch to it;
|
|
everything else opens in your browser.
|
|
</p>
|
|
</section>
|
|
|
|
{/* ------------------------------------------- hidden elements */}
|
|
<section id="hidden-elements" className="space-y-2">
|
|
<SectionHeading>Hidden elements</SectionHeading>
|
|
{withHidden.length === 0 ? (
|
|
<p className={HELP}>
|
|
Nothing hidden. Right-click anything in an app and choose <em>Hide this
|
|
element</em> — or use the eye button in the nav to pick one.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{withHidden.map((app) => (
|
|
<div key={app.id} className={SUBPANEL}>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
|
<span className="text-[12px] font-medium">{app.name}</span>
|
|
<Badge tone="accent">{app.hidden.length}</Badge>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{app.hidden.map((sel, i) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<input
|
|
value={sel}
|
|
onChange={(e) => {
|
|
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]`}
|
|
/>
|
|
<button
|
|
onClick={() =>
|
|
run(() => api.setHidden(app.id, app.hidden.filter((_, j) => j !== i)))
|
|
}
|
|
title="Show this again"
|
|
className={`${ICON_CHROME} hover:text-red-500!`}
|
|
>
|
|
<Trash />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className={HELP}>
|
|
Changes apply immediately, without a reload. A selector that stops matching
|
|
after the site changes simply hides nothing.
|
|
</p>
|
|
</section>
|
|
|
|
{/* ------------------------------------------------------ zoom */}
|
|
<section className="space-y-2">
|
|
<SectionHeading>Zoom</SectionHeading>
|
|
<div className="space-y-1.5">
|
|
{orderedApps.map((app) => (
|
|
<div
|
|
key={app.id}
|
|
className="flex items-center gap-3 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800"
|
|
>
|
|
<Favicon url={app.url} name={app.name} version={config.settings.faviconVersion ?? 0} />
|
|
<span className="w-28 shrink-0 truncate text-[12px]">{app.name}</span>
|
|
<input
|
|
type="range"
|
|
min={50}
|
|
max={200}
|
|
step={5}
|
|
value={Math.round((app.zoom ?? 1) * 100)}
|
|
onChange={(e) =>
|
|
run(() => api.setZoom(app.id, Number(e.target.value) / 100))
|
|
}
|
|
className="min-w-0 flex-1 cursor-pointer accent-sky-500"
|
|
/>
|
|
<span className="w-12 shrink-0 text-right font-mono text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
|
|
{Math.round((app.zoom ?? 1) * 100)}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className={HELP}>
|
|
⌘+ and ⌘− zoom the app you are in; ⌘0 puts it back to 100%. Each app keeps
|
|
its own size.
|
|
</p>
|
|
</section>
|
|
|
|
{/* --------------------------------------------- notifications */}
|
|
<section className="space-y-2">
|
|
<SectionHeading>Notifications</SectionHeading>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => activeId && api.testNotification(activeId)}
|
|
disabled={!activeId}
|
|
className={BTN}
|
|
>
|
|
Send a test notification
|
|
</button>
|
|
<button
|
|
onClick={async () => setNotifyStatus(await api.notificationStatus())}
|
|
className={BTN}
|
|
>
|
|
Check permission
|
|
</button>
|
|
<button
|
|
onClick={async () => {
|
|
await api.probeApps();
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
setReports(await api.appReports());
|
|
}}
|
|
className={BTN}
|
|
>
|
|
Check background apps
|
|
</button>
|
|
</div>
|
|
{reports.length > 0 && (
|
|
<div className="space-y-0.5">
|
|
{reports.map(([name, report]) => (
|
|
<p key={name} className={`${HELP} font-mono`}>
|
|
<span className="font-semibold">{name}</span> · {report}
|
|
</p>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="hidden">
|
|
</div>
|
|
{notifyStatus && (
|
|
<p className={`${HELP} font-mono`}>{notifyStatus}</p>
|
|
)}
|
|
<p className={HELP}>
|
|
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.
|
|
</p>
|
|
</section>
|
|
|
|
{/* ---------------------------------------------------- groups */}
|
|
<section className="space-y-2">
|
|
<SectionHeading>Groups</SectionHeading>
|
|
<div className="space-y-1.5">
|
|
{groups.length === 0 && <p className={HELP}>No groups yet.</p>}
|
|
{groups.map((g: Group) => (
|
|
<div key={g.id}
|
|
className="flex items-center gap-2 rounded-lg border border-slate-200 px-2 py-1.5 dark:border-slate-800">
|
|
<input
|
|
value={g.name}
|
|
onChange={(e) => run(() => api.updateGroup({ ...g, name: e.target.value }))}
|
|
className={`${INPUT} h-[26px] w-full flex-1`}
|
|
/>
|
|
<span className="shrink-0 font-mono text-[10px] text-slate-400">
|
|
{config.apps.filter((a) => a.groupId === g.id).length} apps
|
|
</span>
|
|
<button
|
|
onClick={() => run(() => api.deleteGroup(g.id))}
|
|
title={`Delete ${g.name} — its apps stay, ungrouped`}
|
|
className={`${ICON_CHROME} hover:text-red-500!`}
|
|
>
|
|
<Trash />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className={`${SUBPANEL} flex items-end gap-2`}>
|
|
<label className="flex-1 space-y-1">
|
|
<span className={LABEL}>New group</span>
|
|
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && addGroup()}
|
|
placeholder="Finance" className={`${INPUT} w-full`} />
|
|
</label>
|
|
<button onClick={addGroup} className={BTN}>Add group</button>
|
|
</div>
|
|
</section>
|
|
|
|
{/* ----------------------------------------------- appearance */}
|
|
<section className="space-y-2">
|
|
<SectionHeading>Appearance</SectionHeading>
|
|
<Segmented<Theme>
|
|
value={theme}
|
|
onChange={onTheme}
|
|
options={[
|
|
{ value: "system", label: "System" },
|
|
{ value: "light", label: "Light" },
|
|
{ value: "dark", label: "Dark" },
|
|
]}
|
|
/>
|
|
</section>
|
|
|
|
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
|
|
<button onClick={onClose} className={BTN}>Done</button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{confirmDelete && (
|
|
<Dialog
|
|
title={`Remove ${confirmDelete.name}?`}
|
|
onCancel={() => 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{" "}
|
|
<span className="font-mono text-[12px]">{confirmDelete.url}</span> is kept, so
|
|
adding it back does not mean signing in again.
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
}
|