Browser pairing, notifications, element hiding; drop the top bar
Pairing decrypts a Chromium browser's cookie store (PBKDF2-HMAC-SHA1 against its Keychain key, then AES-128-CBC) and injects the result into WKHTTPCookieStore. Only the configured apps' hosts and their sign-in hosts survive the filter. Browsers are offered most-recently-used first, since the first entry becomes the default and someone with four Chromium browsers installed wants the one they actually browse in. WKWebView defines window.Notification but it does nothing: constructing one throws no error and shows no banner, so a page believes it notified you. Measured on the machine as `api=function shim=no` before the shim was made unconditional; `from page: Odoo - Test notification -> raised` after. Anything on a page can be right-clicked away. The rule is re-asserted on every navigation, because the injected script only carries a snapshot from when the view was built and a selector added since would otherwise come back on reload. The top bar is gone. Navigation lives beside the cog, the nav carries the traffic lights, and two-finger swipe goes back and forward. The seed is now the real app list, scoped to exact hosts so a Drive link inside Gmail switches rather than being swallowed.
This commit is contained in:
+73
-55
@@ -1,4 +1,5 @@
|
||||
import type { Config, Group, WorkApp } from "../types";
|
||||
import { Back, Cog, Collapse, EyeOff, Forward, Reload } from "./icons";
|
||||
import { Favicon, HEADING, ICON_CHROME } from "./ui";
|
||||
|
||||
interface Props {
|
||||
@@ -9,51 +10,65 @@ interface Props {
|
||||
onToggleCollapse: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onToggleGroup: (g: Group) => void;
|
||||
onBack: () => void;
|
||||
onForward: () => void;
|
||||
onReload: () => void;
|
||||
onPick: () => void;
|
||||
}
|
||||
|
||||
const RAIL = 52;
|
||||
/**
|
||||
* Wide enough that the macOS traffic lights fit inside the rail rather than
|
||||
* spilling over the page. Everything else about the rail follows from that.
|
||||
*/
|
||||
const RAIL = 72;
|
||||
const PANEL = 240;
|
||||
/** The strip the traffic lights sit in. Draggable, since there is no title bar. */
|
||||
const TITLEBAR = 36;
|
||||
|
||||
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
||||
|
||||
function Chevron({ dir }: { dir: "left" | "right" }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d={dir === "left" ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Cog() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Nav({
|
||||
config,
|
||||
activeId,
|
||||
collapsed,
|
||||
onSelect,
|
||||
onToggleCollapse,
|
||||
onOpenSettings,
|
||||
onToggleGroup,
|
||||
config, activeId, collapsed,
|
||||
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
||||
onBack, onForward, onReload, onPick,
|
||||
}: Props) {
|
||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||
const inGroup = (id: string | null) =>
|
||||
config.apps.filter((a) => a.groupId === id).sort((a, b) => a.order - b.order);
|
||||
const ungrouped = inGroup(null);
|
||||
const disabled = !activeId;
|
||||
|
||||
const shell =
|
||||
"flex shrink-0 flex-col overflow-hidden border-r border-slate-300 bg-white " +
|
||||
"dark:border-slate-800 dark:bg-slate-900";
|
||||
|
||||
/* Back, forward, reload, hide-an-element and settings, in that order —
|
||||
navigation first because it is what gets reached for most. */
|
||||
const controls = (
|
||||
<>
|
||||
<button onClick={onBack} disabled={disabled} title="Back (or swipe left)" className={ICON_CHROME}>
|
||||
<Back />
|
||||
</button>
|
||||
<button onClick={onForward} disabled={disabled} title="Forward (or swipe right)" className={ICON_CHROME}>
|
||||
<Forward />
|
||||
</button>
|
||||
<button onClick={onReload} disabled={disabled} title="Reload" className={ICON_CHROME}>
|
||||
<Reload />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPick}
|
||||
disabled={disabled}
|
||||
title="Hide an element on this page — or right-click it"
|
||||
className={ICON_CHROME}
|
||||
>
|
||||
<EyeOff />
|
||||
</button>
|
||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||
<Cog />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------ icon rail
|
||||
if (collapsed) {
|
||||
const railBtn = (app: WorkApp) => {
|
||||
@@ -65,41 +80,38 @@ export default function Nav({
|
||||
title={app.name}
|
||||
aria-label={app.name}
|
||||
className={
|
||||
"relative grid size-9 cursor-pointer place-items-center rounded-lg transition-colors " +
|
||||
"relative grid size-10 cursor-pointer place-items-center rounded-lg transition-colors " +
|
||||
(active
|
||||
? "bg-slate-100 dark:bg-slate-800"
|
||||
: "opacity-70 hover:bg-slate-100 hover:opacity-100 dark:hover:bg-slate-800")
|
||||
}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute -left-2 h-5 w-[3px] rounded-full bg-sky-500" />
|
||||
)}
|
||||
<Favicon url={app.url} name={app.name} size={18} />
|
||||
{active && <span className="absolute left-0 h-5 w-[3px] rounded-full bg-sky-500" />}
|
||||
<Favicon url={app.url} name={app.name} size={20} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`${shell} w-[52px] items-center`} style={{ width: RAIL }}>
|
||||
<div className="flex w-full flex-col items-center gap-1 border-b border-slate-200 py-2 dark:border-slate-800">
|
||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||
<Cog />
|
||||
</button>
|
||||
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="w-full shrink-0" />
|
||||
<div className="flex w-full flex-col items-center border-b border-slate-200 pb-2 dark:border-slate-800">
|
||||
<div className="flex flex-wrap justify-center gap-0.5 px-1">{controls}</div>
|
||||
<button onClick={onToggleCollapse} title="Expand" className={ICON_CHROME}>
|
||||
<Chevron dir="right" />
|
||||
<Collapse open={false} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex min-h-0 flex-1 flex-col items-center gap-1 overflow-y-auto py-3">
|
||||
{ungrouped.map(railBtn)}
|
||||
{ungrouped.length > 0 && groups.length > 0 && (
|
||||
<span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />
|
||||
<span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />
|
||||
)}
|
||||
{groups.map((g, i) => {
|
||||
const apps = inGroup(g.id);
|
||||
if (apps.length === 0) return null;
|
||||
return (
|
||||
<div key={g.id} className="flex flex-col items-center gap-1">
|
||||
{i > 0 && <span className="my-1 h-px w-6 bg-slate-200 dark:bg-slate-800" />}
|
||||
{i > 0 && <span className="my-1 h-px w-7 bg-slate-200 dark:bg-slate-800" />}
|
||||
{apps.map(railBtn)}
|
||||
</div>
|
||||
);
|
||||
@@ -125,22 +137,30 @@ export default function Nav({
|
||||
>
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<span className="truncate">{app.name}</span>
|
||||
{app.hidden.length > 0 && (
|
||||
<span
|
||||
title={`${app.hidden.length} element${app.hidden.length === 1 ? "" : "s"} hidden here`}
|
||||
className="ml-auto font-mono text-[10px] opacity-50"
|
||||
>
|
||||
{app.hidden.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className={shell} style={{ width: PANEL }}>
|
||||
<header className="flex items-center justify-between gap-2 border-b border-slate-200 px-3 py-2 dark:border-slate-800">
|
||||
<span className="truncate text-[13px] font-semibold tracking-tight">Apps</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button onClick={onOpenSettings} title="Settings" className={ICON_CHROME}>
|
||||
<Cog />
|
||||
</button>
|
||||
<button onClick={onToggleCollapse} title="Collapse" className={ICON_CHROME}>
|
||||
<Chevron dir="left" />
|
||||
</button>
|
||||
</div>
|
||||
<div data-tauri-drag-region style={{ height: TITLEBAR }} className="shrink-0" />
|
||||
<header className="flex items-center gap-0.5 border-b border-slate-200 px-1.5 pb-2 dark:border-slate-800">
|
||||
{controls}
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title="Collapse"
|
||||
className={`${ICON_CHROME} ml-auto`}
|
||||
>
|
||||
<Collapse open />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto px-2 py-3">
|
||||
@@ -158,9 +178,7 @@ export default function Nav({
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={`size-3 transition-transform ${g.collapsed ? "" : "rotate-90"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
fill="none" stroke="currentColor" strokeWidth="2.5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 6l6 6-6 6" />
|
||||
</svg>
|
||||
|
||||
+234
-99
@@ -1,49 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import * as api from "../api";
|
||||
import type { Config, Group, WorkApp } from "../types";
|
||||
import type { Theme } from "../hooks/useAppearance";
|
||||
import type { Browser, Config, Group, PairResult, WorkApp } from "../types";
|
||||
import { Trash } from "./icons";
|
||||
import {
|
||||
BTN,
|
||||
BTN_PRIMARY,
|
||||
Badge,
|
||||
Dialog,
|
||||
Favicon,
|
||||
HELP,
|
||||
ICON_CHROME,
|
||||
INPUT,
|
||||
LABEL,
|
||||
SUBPANEL,
|
||||
SectionHeading,
|
||||
Segmented,
|
||||
SUBPANEL,
|
||||
Spinner,
|
||||
} 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;
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
|
||||
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings({ config, theme, onConfig, onTheme, onClose }: Props) {
|
||||
export default function Settings({
|
||||
config, theme, activeId, focusHiddenFor, onConfig, onTheme, onClose,
|
||||
}: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [groupId, setGroupId] = useState<string>("");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<WorkApp | null>(null);
|
||||
|
||||
const [browsers, setBrowsers] = useState<Browser[]>([]);
|
||||
const [browser, setBrowser] = useState("");
|
||||
const [pairing, setPairing] = useState(false);
|
||||
const [notifyStatus, setNotifyStatus] = useState<string | null>(null);
|
||||
const [paired, setPaired] = useState<PairResult | null>(null);
|
||||
|
||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||
|
||||
// Same order the nav shows, so the two lists never disagree about position.
|
||||
@@ -53,6 +57,18 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
(a, b) => groupRank(a.groupId) - groupRank(b.groupId) || a.order - b.order,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
api.listBrowsers().then((b) => {
|
||||
setBrowsers(b);
|
||||
setBrowser(config.settings.pairedBrowser ?? b[0]?.id ?? "");
|
||||
});
|
||||
}, [config.settings.pairedBrowser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusHiddenFor) return;
|
||||
document.getElementById("hidden-elements")?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [focusHiddenFor]);
|
||||
|
||||
const run = async (fn: () => Promise<Config>) => {
|
||||
try {
|
||||
onConfig(await fn());
|
||||
@@ -78,6 +94,23 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
const patch = (app: WorkApp, fields: Partial<WorkApp>) =>
|
||||
run(() => api.updateApp({ ...app, ...fields }));
|
||||
|
||||
const pair = async () => {
|
||||
if (!browser) return;
|
||||
setPairing(true);
|
||||
setPaired(null);
|
||||
try {
|
||||
setPaired(await api.pairBrowser(browser));
|
||||
onConfig(await api.getConfig());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setPairing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const withHidden = orderedApps.filter((a) => a.hidden.length > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog title="Settings" onCancel={onClose} wide footer={<div />}>
|
||||
@@ -92,87 +125,68 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
<section className="space-y-2">
|
||||
<SectionHeading>Apps</SectionHeading>
|
||||
<div className="space-y-1.5">
|
||||
{config.apps.length === 0 && (
|
||||
<p className={HELP}>Nothing yet. Add the first one below.</p>
|
||||
)}
|
||||
{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"
|
||||
<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} />
|
||||
<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`}
|
||||
>
|
||||
<Favicon url={app.url} name={app.name} />
|
||||
<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!`}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
<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`}
|
||||
/>
|
||||
<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)}
|
||||
<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]`}
|
||||
/>
|
||||
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`}
|
||||
>
|
||||
<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>
|
||||
<option key={g.id} value={g.id}>{g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={addApp} className={BTN_PRIMARY}>
|
||||
Add
|
||||
</button>
|
||||
<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;
|
||||
@@ -180,21 +194,150 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* --------------------------------------------------- groups */}
|
||||
{/* ------------------------------------------- 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} />
|
||||
<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>
|
||||
|
||||
{/* -------------------------------------------------- pairing */}
|
||||
<section className="space-y-2">
|
||||
<SectionHeading>Browser pairing</SectionHeading>
|
||||
{browsers.length === 0 ? (
|
||||
<p className={HELP}>No browser with a readable cookie store was found.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className={`${SUBPANEL} flex items-end gap-2`}>
|
||||
<label className="flex-1 space-y-1">
|
||||
<span className={LABEL}>Import cookies from</span>
|
||||
<select value={browser} onChange={(e) => setBrowser(e.target.value)}
|
||||
className={`${INPUT} w-full cursor-pointer`}>
|
||||
{browsers.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button onClick={pair} disabled={pairing || !browser} className={BTN_PRIMARY}>
|
||||
{pairing ? <Spinner /> : config.settings.lastPairedAt ? "Pair again" : "Pair"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{config.settings.lastPairedAt && !paired && (
|
||||
<p className={HELP}>Last paired {config.settings.lastPairedAt}.</p>
|
||||
)}
|
||||
|
||||
{paired && (
|
||||
<div className="space-y-1 rounded-lg bg-sky-500/10 px-3 py-2">
|
||||
<p className="text-[12px] text-sky-700 dark:text-sky-300">
|
||||
Imported {paired.imported} cookie{paired.imported === 1 ? "" : "s"} across{" "}
|
||||
{paired.domains} domain{paired.domains === 1 ? "" : "s"}.
|
||||
</p>
|
||||
{paired.domainNames.length > 0 && (
|
||||
<p className="font-mono text-[11px] leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{paired.domainNames.join(" ")}
|
||||
</p>
|
||||
)}
|
||||
{paired.warnings.map((w, i) => (
|
||||
<p key={i} className="text-[11px] text-slate-600 dark:text-slate-300">{w}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={HELP}>
|
||||
Only cookies for the apps above and their sign-in hosts are read — nothing
|
||||
else leaves the browser. macOS asks for Keychain permission the first time.
|
||||
Google and Microsoft tie sessions to the browser that made them, so those two
|
||||
may still ask you to sign in once; after that it sticks.
|
||||
</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>
|
||||
</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"
|
||||
>
|
||||
<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 }))
|
||||
}
|
||||
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">
|
||||
@@ -205,7 +348,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
title={`Delete ${g.name} — its apps stay, ungrouped`}
|
||||
className={`${ICON_CHROME} hover:text-red-500!`}
|
||||
>
|
||||
<TrashIcon />
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -213,17 +356,11 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
<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)}
|
||||
<input value={groupName} onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addGroup()}
|
||||
placeholder="Finance"
|
||||
className={`${INPUT} w-full`}
|
||||
/>
|
||||
placeholder="Finance" className={`${INPUT} w-full`} />
|
||||
</label>
|
||||
<button onClick={addGroup} className={BTN}>
|
||||
Add group
|
||||
</button>
|
||||
<button onClick={addGroup} className={BTN}>Add group</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -242,9 +379,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end border-t border-slate-200 pt-4 dark:border-slate-800">
|
||||
<button onClick={onClose} className={BTN}>
|
||||
Done
|
||||
</button>
|
||||
<button onClick={onClose} className={BTN}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -262,7 +397,7 @@ export default function Settings({ config, theme, onConfig, onTheme, onClose }:
|
||||
>
|
||||
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 logging in again.
|
||||
adding it back does not mean signing in again.
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { ICON_CHROME } from "./ui";
|
||||
|
||||
interface Props {
|
||||
url: string | null;
|
||||
appName: string | null;
|
||||
onBack: () => void;
|
||||
onForward: () => void;
|
||||
onReload: () => void;
|
||||
onOpenExternal: () => void;
|
||||
}
|
||||
|
||||
/** Reserves the strip the macOS traffic lights sit in. */
|
||||
const TRAFFIC_LIGHTS = 78;
|
||||
|
||||
function Icon({ d }: { d: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser chrome, kept to the four things a tabless app still needs. It spans
|
||||
* the whole window rather than sitting inside the stage, so the traffic lights
|
||||
* have somewhere to live no matter how narrow the nav gets.
|
||||
*/
|
||||
export default function TopBar({
|
||||
url,
|
||||
appName,
|
||||
onBack,
|
||||
onForward,
|
||||
onReload,
|
||||
onOpenExternal,
|
||||
}: Props) {
|
||||
return (
|
||||
<header
|
||||
data-tauri-drag-region
|
||||
className="flex h-[38px] shrink-0 items-center gap-1 border-b border-slate-300
|
||||
bg-white px-2 dark:border-slate-800 dark:bg-slate-900"
|
||||
style={{ paddingLeft: TRAFFIC_LIGHTS }}
|
||||
>
|
||||
<button onClick={onBack} title="Back" className={ICON_CHROME}>
|
||||
<Icon d="M15 6l-6 6 6 6" />
|
||||
</button>
|
||||
<button onClick={onForward} title="Forward" className={ICON_CHROME}>
|
||||
<Icon d="M9 6l6 6-6 6" />
|
||||
</button>
|
||||
<button onClick={onReload} title="Reload" className={ICON_CHROME}>
|
||||
<Icon d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="mx-2 flex min-w-0 flex-1 items-center gap-2 text-[11px]"
|
||||
>
|
||||
{appName && (
|
||||
<span className="shrink-0 font-medium text-slate-600 dark:text-slate-300">
|
||||
{appName}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-slate-400 dark:text-slate-500">{url ?? ""}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenExternal}
|
||||
title="Open this page in your browser"
|
||||
className={ICON_CHROME}
|
||||
disabled={!url}
|
||||
>
|
||||
<Icon d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** One stroke weight, one size, one place to find them. */
|
||||
type Props = { className?: string };
|
||||
|
||||
const S = ({ d, className = "size-4" }: { d: string } & Props) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={d} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Back = (p: Props) => <S d="M15 6l-6 6 6 6" {...p} />;
|
||||
export const Forward = (p: Props) => <S d="M9 6l6 6-6 6" {...p} />;
|
||||
export const Reload = (p: Props) => <S d="M20 11a8 8 0 10-2.3 5.7M20 5v6h-6" {...p} />;
|
||||
export const External = (p: Props) => (
|
||||
<S d="M14 5h5v5M19 5l-8 8M18 14v4a2 2 0 01-2 2H6a2 2 0 01-2-2V8a2 2 0 012-2h4" {...p} />
|
||||
);
|
||||
export const EyeOff = (p: Props) => (
|
||||
<S d="M3 3l18 18M10.6 10.6a2 2 0 002.8 2.8M9.4 5.4A9.5 9.5 0 0112 5c5 0 9 4.5 9 7a11 11 0 01-2.4 3.5M6.2 6.9C4 8.3 3 10.4 3 12c0 2.5 4 7 9 7a9.6 9.6 0 004-.85" {...p} />
|
||||
);
|
||||
export const Trash = ({ className = "size-3.5" }: Props) => (
|
||||
<svg viewBox="0 0 24 24" className={className} fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 7h16M10 11v6M14 11v6" />
|
||||
<path d="M6 7l1 12.5A1.5 1.5 0 008.5 21h7a1.5 1.5 0 001.5-1.5L18 7" />
|
||||
<path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" />
|
||||
</svg>
|
||||
);
|
||||
export const Cog = (p: Props) => (
|
||||
<svg viewBox="0 0 24 24" className={p.className ?? "size-4"} fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.14.34.4.62.73.79.24.13.51.2.78.21H21a2 2 0 110 4h-.09c-.7.01-1.33.43-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
export const Collapse = ({ open, className = "size-4" }: Props & { open: boolean }) => (
|
||||
<S d={open ? "M15 6l-6 6 6 6" : "M9 6l6 6-6 6"} className={className} />
|
||||
);
|
||||
@@ -56,6 +56,16 @@ export const ICON_CHROME =
|
||||
`${ICON_BTN} text-slate-500 hover:bg-slate-100 hover:text-slate-900 ` +
|
||||
"dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white";
|
||||
|
||||
/** The only spinning thing in the app; used where a wait has no known length. */
|
||||
export function Spinner({ className = "size-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg className={`${className} animate-spin`} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" className="opacity-25" />
|
||||
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeading({ children }: { children: ReactNode }) {
|
||||
return <h2 className={`flex items-center gap-2 ${HEADING}`}>{children}</h2>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user