feat(web): sources: connect from the manifest, device sign-in, file import, schedule, runs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 22:18:17 +01:00
co-authored by Claude Opus 5.5
parent 67d94f5107
commit b38a2674f4
5 changed files with 747 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { api, type DeviceStart } from "../api.ts";
import { friendlyError } from "../format.ts";
type Phase = { k: "starting" } | { k: "waiting"; d: DeviceStart } | { k: "connected" } | { k: "expired" } | { k: "error"; msg: string };
/**
* Device-code sign-in: shows the code and the link, then polls at the plugin's interval until the user
* approves, the code expires, or the dialog closes. A modal, because the user leaves for another site and
* comes back to exactly this.
*/
export function DeviceSignIn({ connectionId, sourceName, onClose }: { connectionId: string; sourceName: string; onClose: () => void }) {
const qc = useQueryClient();
const ref = useRef<HTMLDialogElement>(null);
const [phase, setPhase] = useState<Phase>({ k: "starting" });
const [attempt, setAttempt] = useState(0);
const [copied, setCopied] = useState(false);
useEffect(() => {
const dlg = ref.current;
if (dlg && !dlg.open) dlg.showModal();
}, []);
useEffect(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
setPhase({ k: "starting" });
api
.deviceStart(connectionId)
.then((d) => {
if (stopped) return;
setPhase({ k: "waiting", d });
const deadline = Date.now() + d.expiresInSec * 1000;
const every = Math.max(1, d.intervalSec) * 1000;
const tick = async () => {
if (stopped) return;
if (Date.now() > deadline) return setPhase({ k: "expired" });
try {
const r = await api.devicePoll(connectionId, d.deviceCode);
if (stopped) return;
if (r.status === "connected") {
setPhase({ k: "connected" });
void qc.invalidateQueries({ queryKey: ["sources"] });
return;
}
timer = setTimeout(() => void tick(), every);
} catch (e) {
if (!stopped) setPhase({ k: "error", msg: friendlyError(e) });
}
};
timer = setTimeout(() => void tick(), every);
})
.catch((e) => !stopped && setPhase({ k: "error", msg: friendlyError(e) }));
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
}, [connectionId, attempt, qc]);
async function copy(code: string) {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
} catch {
setCopied(false);
}
}
return (
<dialog ref={ref} className="modal" aria-labelledby="device-title" onClose={onClose} onCancel={onClose}>
<h2 id="device-title">Sign in to {sourceName}</h2>
{phase.k === "starting" && <p className="lede" aria-busy="true">Asking {sourceName} for a code…</p>}
{phase.k === "waiting" && (
<>
<p className="lede">
Open the link, sign in to {sourceName} if asked, and enter this code. This window updates by itself once you approve.
</p>
<div className="device-code">
<span className="code tabular" aria-label={`Code: ${phase.d.userCode.split("").join(" ")}`}>{phase.d.userCode}</span>
<button type="button" className="btn small" onClick={() => void copy(phase.d.userCode)}>{copied ? "Copied" : "Copy"}</button>
</div>
<a className="btn primary device-link" href={phase.d.verificationUrl} target="_blank" rel="noopener noreferrer">
Open {phase.d.verificationUrl.replace(/^https?:\/\//, "")}
</a>
<p className="hint waiting" role="status">Waiting for you to approve…</p>
</>
)}
{phase.k === "connected" && (
<p className="lede" role="status">Connected. {sourceName} will sync on its schedule, or use “Sync now”.</p>
)}
{phase.k === "expired" && (
<p className="lede" role="alert">The code expired before it was approved. Get a new one and try again.</p>
)}
{phase.k === "error" && <p className="form-error" role="alert">{phase.msg}</p>}
<div className="modal-actions">
{(phase.k === "expired" || phase.k === "error") && (
<button type="button" className="btn primary" onClick={() => setAttempt((n) => n + 1)}>Get a new code</button>
)}
<button type="button" className="btn" onClick={() => ref.current?.close()}>{phase.k === "connected" ? "Done" : "Cancel"}</button>
</div>
</dialog>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useId } from "react";
import type { ConfigField } from "../api.ts";
/**
* Inputs rendered from a plugin manifest's `config` fields. Secrets are password inputs and are never
* filled in: on edit, an empty secret keeps the saved one.
*/
export function ConfigFields({
fields,
values,
savedSecrets,
}: {
fields: ConfigField[];
values: Record<string, unknown>;
savedSecrets: boolean;
}) {
const base = useId();
return (
<>
{fields.map((f) => {
const id = `${base}-${f.key}`;
const help = f.type === "secret" && savedSecrets ? "Saved. Leave empty to keep it." : f.help;
const describedBy = help ? `${id}-help` : undefined;
const v = values[f.key];
let input;
if (f.type === "boolean") {
return (
<div key={f.key} className="field">
<label className="inline-check" htmlFor={id}>
<input id={id} name={f.key} type="checkbox" defaultChecked={v === true} aria-describedby={describedBy} />
{f.label}
</label>
{help && <span className="hint" id={describedBy}>{help}</span>}
</div>
);
}
if (f.type === "select") {
input = (
<select id={id} name={f.key} className="select" defaultValue={typeof v === "string" ? v : ""} required={f.required} aria-describedby={describedBy}>
{!f.required && <option value="">Not set</option>}
{(f.options ?? []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
);
} else if (f.type === "secret") {
input = (
<input
id={id}
name={f.key}
className="input"
type="password"
autoComplete="new-password"
spellCheck={false}
required={f.required && !savedSecrets}
placeholder={savedSecrets ? "Saved" : undefined}
aria-describedby={describedBy}
/>
);
} else {
input = (
<input
id={id}
name={f.key}
className="input"
type={f.type === "number" ? "number" : "text"}
inputMode={f.type === "number" ? "decimal" : undefined}
defaultValue={v === undefined || v === null ? "" : String(v)}
required={f.required}
spellCheck={false}
aria-describedby={describedBy}
/>
);
}
return (
<div key={f.key} className="field">
<label htmlFor={id}>
{f.label}
{f.required && <span className="req"> (required)</span>}
</label>
{input}
{help && <span className="hint" id={describedBy}>{help}</span>}
</div>
);
})}
</>
);
}
/**
* Read a connect/edit form into the API's `config`. Plain fields are sent whole (the server replaces
* them); secrets only when typed, so an empty one keeps what is saved.
*/
export function readConfig(fields: ConfigField[], form: FormData): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of fields) {
const raw = form.get(f.key);
if (f.type === "boolean") out[f.key] = raw === "on";
else if (f.type === "secret") {
if (typeof raw === "string" && raw !== "") out[f.key] = raw;
} else if (typeof raw === "string" && raw.trim() !== "") out[f.key] = f.type === "number" ? Number(raw) : raw.trim();
}
return out;
}
+3
View File
@@ -11,6 +11,7 @@ import { Library } from "./routes/library.tsx";
import { Login } from "./routes/login.tsx";
import { NotYet } from "./routes/not-yet.tsx";
import { Register } from "./routes/register.tsx";
import { SourceDetail, Sources } from "./routes/sources.tsx";
const queryClient: QueryClient = new QueryClient({
queryCache: new QueryCache({
@@ -58,6 +59,8 @@ function App() {
<Route path="/">{() => <Library selectedId={null} />}</Route>
<Route path="/entries/:id">{(p) => <Library selectedId={p.id} />}</Route>
<Route path="/changes" component={Changes} />
<Route path="/sources" component={Sources} />
<Route path="/sources/:id">{(p) => <SourceDetail key={p.id} id={p.id} />}</Route>
<Route path="/:page/*?">{(p) => <NotYet page={p.page} />}</Route>
</Switch>
</Shell>
+456
View File
@@ -0,0 +1,456 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useLocation, useSearch } from "wouter";
import { api, type Connection, type PluginInfo, type Run } from "../api.ts";
import { DeviceSignIn } from "../components/DeviceSignIn.tsx";
import { LoadError } from "../components/LoadError.tsx";
import { ConfigFields, readConfig } from "../components/SourceForm.tsx";
import { friendlyError, mediaTypeLabel, timeAgo } from "../format.ts";
import { useSources } from "../hooks.ts";
import { scheduleLabel, SCHEDULES } from "../labels.ts";
export function usePlugins() {
return useQuery({ queryKey: ["plugins"], queryFn: api.plugins, staleTime: 5 * 60_000 });
}
const syncs = (p: PluginInfo | undefined) => !!p && (p.capabilities.includes("pull") || p.capabilities.includes("pullChanges"));
const imports = (p: PluginInfo | undefined) => !!p && p.capabilities.includes("importFile");
function StatusBadge({ c }: { c: Connection }) {
if (c.status === "active") return <span className="badge ok">Active</span>;
if (c.status === "error") return <span className="badge bad">Needs attention</span>;
return <span className="badge off">Paused</span>;
}
/** What a plugin does, in plain words. */
function pluginAbilities(p: PluginInfo): string {
const out: string[] = [];
if (syncs(p)) out.push("Syncs on a schedule");
if (imports(p)) out.push("Imports an export file");
if (p.deviceAuth) out.push("Signs in with a code");
return out.join(" · ");
}
function ScheduleSelect({ id, value, onChange, disabled }: { id: string; value: number | null; onChange: (v: number | null) => void; disabled?: boolean }) {
const options = SCHEDULES.includes(value) ? SCHEDULES : [...SCHEDULES, value];
return (
<select id={id} className="select" value={value === null ? "" : String(value)} disabled={disabled} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}>
{options.map((m) => (
<option key={m ?? "off"} value={m === null ? "" : String(m)}>{scheduleLabel(m)}</option>
))}
</select>
);
}
// ---------------------------------------------------------------------------------------------
// /sources
// ---------------------------------------------------------------------------------------------
export function Sources() {
const sources = useSources();
const plugins = usePlugins();
const [connecting, setConnecting] = useState<string | null>(null);
const byId = new Map((plugins.data ?? []).map((p) => [p.id, p]));
return (
<div className="screen">
<div className="screen-inner">
<header className="screen-head">
<h1>Sources</h1>
<p className="lede">
The places STTracker watches for what you read, watch, play and listen to. Sources only read: nothing you do here is sent back to them.
</p>
</header>
{sources.error && sources.data && <LoadError error={sources.error} retry={() => void sources.refetch()} stale />}
{sources.error && !sources.data ? (
<LoadError error={sources.error} retry={() => void sources.refetch()} />
) : (sources.data ?? []).length > 0 ? (
<section className="section" aria-labelledby="your-sources">
<h2 id="your-sources">Your sources</h2>
<ul className="conn-list">
{sources.data!.map((c) => (
<li key={c.id}>
<Link href={`/sources/${c.id}`} className="conn-row">
<span className="conn-name">{c.name}</span>
<span className="conn-meta">{byId.get(c.pluginId)?.name ?? c.pluginId}</span>
<span className="conn-meta">{c.lastRunAt ? `Last sync ${timeAgo(c.lastRunAt)}` : "Never synced"}</span>
<span className="conn-meta">{syncs(byId.get(c.pluginId)) ? scheduleLabel(c.scheduleMinutes) : "Import only"}</span>
<StatusBadge c={c} />
</Link>
</li>
))}
</ul>
</section>
) : null}
<section className="section" aria-labelledby="connect">
<h2 id="connect">Connect a source</h2>
<span className="hint">Each source can be connected more than once, for example for two accounts.</span>
{plugins.error && !plugins.data ? (
<LoadError error={plugins.error} retry={() => void plugins.refetch()} />
) : (
<ul className="plugin-grid">
{(plugins.data ?? []).map((p) => (
<li key={p.id} className={`plugin${connecting === p.id ? " open" : ""}`}>
<div className="plugin-head">
<div>
<h3>{p.name}</h3>
<p className="plugin-types">{p.mediaTypes.slice(0, 6).map(mediaTypeLabel).join(", ")}{p.mediaTypes.length > 6 ? "…" : ""}</p>
</div>
{connecting !== p.id && (
<button type="button" className="btn small" onClick={() => setConnecting(p.id)} aria-label={`Connect ${p.name}`}>
Connect
</button>
)}
</div>
<p className="plugin-does">{pluginAbilities(p)}</p>
{connecting === p.id && <ConnectForm plugin={p} onCancel={() => setConnecting(null)} />}
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
}
function ConnectForm({ plugin, onCancel }: { plugin: PluginInfo; onCancel: () => void }) {
const qc = useQueryClient();
const [, navigate] = useLocation();
const [error, setError] = useState<string | null>(null);
const [schedule, setSchedule] = useState<number | null>(syncs(plugin) ? 1440 : null);
const first = useRef<HTMLInputElement>(null);
useEffect(() => first.current?.focus(), []);
const create = useMutation({
mutationFn: api.createSource,
onSuccess: (c) => {
void qc.invalidateQueries({ queryKey: ["sources"] });
navigate(`/sources/${c.id}${plugin.deviceAuth ? "?signin=1" : ""}`);
},
onError: (e) => setError(friendlyError(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
setError(null);
create.mutate({
pluginId: plugin.id,
name: String(f.get("name") ?? "").trim() || plugin.name,
config: readConfig(plugin.config, f),
scheduleMinutes: syncs(plugin) ? schedule : null,
});
}
return (
<form className="connect-form" onSubmit={submit} aria-label={`Connect ${plugin.name}`}>
<div className="field">
<label htmlFor={`name-${plugin.id}`}>Name</label>
<input ref={first} id={`name-${plugin.id}`} name="name" className="input" defaultValue={plugin.name} maxLength={200} />
</div>
<ConfigFields fields={plugin.config} values={{}} savedSecrets={false} />
{syncs(plugin) && (
<div className="field">
<label htmlFor={`sched-${plugin.id}`}>Sync</label>
<ScheduleSelect id={`sched-${plugin.id}`} value={schedule} onChange={setSchedule} />
</div>
)}
{plugin.deviceAuth && <p className="hint">Next, you'll sign in to {plugin.name} with a short code.</p>}
{error && <p className="form-error" role="alert">{error}</p>}
<div className="form-actions">
<button type="submit" className="btn primary" disabled={create.isPending}>{create.isPending ? "Connecting…" : `Connect ${plugin.name}`}</button>
<button type="button" className="btn ghost" onClick={onCancel}>Cancel</button>
</div>
</form>
);
}
// ---------------------------------------------------------------------------------------------
// /sources/:id
// ---------------------------------------------------------------------------------------------
export function SourceDetail({ id }: { id: string }) {
const qc = useQueryClient();
const [, navigate] = useLocation();
const search = new URLSearchParams(useSearch());
const sources = useSources();
const plugins = usePlugins();
const conn = sources.data?.find((c) => c.id === id);
const plugin = plugins.data?.find((p) => p.id === conn?.pluginId);
const [signingIn, setSigningIn] = useState(search.get("signin") === "1");
const [actionError, setActionError] = useState<string | null>(null);
const [said, setSaid] = useState("");
const [pollUntil, setPollUntil] = useState(0);
const runs = useQuery({
queryKey: ["runs", id],
queryFn: () => api.runs(id),
enabled: !!conn,
refetchInterval: (q) => (q.state.data?.some((r) => r.status === "running") || Date.now() < pollUntil ? 2000 : false),
});
// When a run finishes, its results show up elsewhere: refresh them.
const lastFinished = runs.data?.find((r) => r.status !== "running")?.id;
const seen = useRef<string | undefined>(undefined);
useEffect(() => {
if (seen.current !== undefined && lastFinished !== seen.current) {
for (const k of ["changes", "entries", "sources", "log"]) void qc.invalidateQueries({ queryKey: [k] });
setPollUntil(0);
}
seen.current = lastFinished;
}, [lastFinished, qc]);
const done = (msg: string) => () => {
setActionError(null);
setSaid(msg);
void qc.invalidateQueries({ queryKey: ["sources"] });
};
const fail = (e: unknown) => setActionError(friendlyError(e));
const run = useMutation({
mutationFn: () => api.runSource(id),
onSuccess: () => {
done("Sync queued.")();
setPollUntil(Date.now() + 60_000);
void qc.invalidateQueries({ queryKey: ["runs", id] });
},
onError: fail,
});
const patch = useMutation({
mutationFn: (b: Parameters<typeof api.patchSource>[1]) => api.patchSource(id, b),
onSuccess: (_c, b) => done(b.status ? (b.status === "paused" ? "Paused." : "Resumed.") : "Saved.")(),
onError: fail,
});
const remove = useMutation({
mutationFn: () => api.deleteSource(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["sources"] });
navigate("/sources", { replace: true });
},
onError: fail,
});
const [confirming, setConfirming] = useState(false);
if (sources.isPending || plugins.isPending) return <div className="screen"><div className="screen-inner" aria-busy="true" /></div>;
if (!conn) {
return (
<div className="screen">
<div className="screen-inner">
{sources.error ? (
<LoadError error={sources.error} retry={() => void sources.refetch()} />
) : (
<div className="empty-state">
<strong>This source isn't connected</strong>
<p>It may have been disconnected. Anything it imported is still in your library.</p>
<Link href="/sources" className="btn">All sources</Link>
</div>
)}
</div>
</div>
);
}
const canSync = syncs(plugin);
const running = runs.data?.some((r) => r.status === "running") ?? false;
return (
<div className="screen">
<div className="screen-inner">
<Link href="/sources" className="btn ghost back-link">‹ All sources</Link>
{sources.error && <LoadError error={sources.error} retry={() => void sources.refetch()} stale />}
<header className="screen-head with-action">
<div>
<h1>{conn.name}</h1>
<p className="lede src-sub">
{plugin?.name ?? conn.pluginId} <StatusBadge c={conn} />
</p>
</div>
<div className="head-actions">
{canSync && (
<button type="button" className="btn primary" onClick={() => run.mutate()} disabled={run.isPending || running || conn.status !== "active"}>
{running ? "Syncing…" : "Sync now"}
</button>
)}
{conn.status === "active" ? (
<button type="button" className="btn" onClick={() => patch.mutate({ status: "paused" })} disabled={patch.isPending}>Pause</button>
) : (
<button type="button" className="btn" onClick={() => patch.mutate({ status: "active" })} disabled={patch.isPending}>Resume</button>
)}
</div>
</header>
<p className="sr-only" role="status" aria-live="polite">{said}</p>
{actionError && <p className="form-error" role="alert">{actionError}</p>}
<div className="src-grid">
<section className="box" aria-labelledby="src-status">
<h2 id="src-status">Status</h2>
<div className="r"><span>Last sync</span><span>{conn.lastRunAt ? timeAgo(conn.lastRunAt) : "Never"}</span></div>
{canSync && (
<div className="r">
<label htmlFor="schedule">Sync</label>
<span>
<ScheduleSelect id="schedule" value={conn.scheduleMinutes} onChange={(v) => patch.mutate({ scheduleMinutes: v })} disabled={patch.isPending} />
</span>
</div>
)}
<div className="r"><span>Sign-in</span><span>{conn.hasCredentials ? "Saved" : plugin?.deviceAuth ? "Not signed in" : "Not needed"}</span></div>
<div className="r"><span>Write-back</span><span className="muted-val">Not available in this version</span></div>
<p className="hint">STTracker only reads from {conn.name}. Your edits here stay here.</p>
{conn.status === "paused" && canSync && <p className="hint">Paused: scheduled syncs and “Sync now” are off until you resume.</p>}
</section>
{plugin?.deviceAuth && (
<section className="box" aria-labelledby="src-signin">
<h2 id="src-signin">Sign in</h2>
<p className="box-text">
{conn.hasCredentials ? `Signed in. Sign in again if ${plugin.name} stops syncing.` : `Connect your ${plugin.name} account with a short code.`}
</p>
<button type="button" className="btn" onClick={() => setSigningIn(true)}>
{conn.hasCredentials ? "Sign in again" : `Sign in to ${plugin.name}`}
</button>
</section>
)}
{imports(plugin) && <ImportBox conn={conn} onQueued={() => (setPollUntil(Date.now() + 120_000), void qc.invalidateQueries({ queryKey: ["runs", id] }))} />}
<SettingsBox conn={conn} plugin={plugin} />
<section className="box wide" aria-labelledby="src-runs">
<h2 id="src-runs">Recent syncs</h2>
{runs.error && runs.data && <LoadError error={runs.error} retry={() => void runs.refetch()} stale />}
{runs.error && !runs.data ? (
<LoadError error={runs.error} retry={() => void runs.refetch()} />
) : runs.data && runs.data.length === 0 ? (
<p className="empty-line">No syncs yet.{canSync ? " Use “Sync now” to run the first one." : ""}</p>
) : (
<ol className="runs">{(runs.data ?? []).map((r) => <RunRow key={r.id} r={r} />)}</ol>
)}
</section>
<section className="box wide danger-zone" aria-labelledby="src-remove">
<h2 id="src-remove">Disconnect</h2>
<p className="box-text">Stops syncing and forgets the sign-in. Everything it imported stays in your library.</p>
{confirming ? (
<div className="form-actions">
<button type="button" className="btn danger" onClick={() => remove.mutate()} disabled={remove.isPending}>Disconnect {conn.name}</button>
<button type="button" className="btn ghost" onClick={() => setConfirming(false)}>Keep it</button>
</div>
) : (
<button type="button" className="btn danger" onClick={() => setConfirming(true)}>Disconnect…</button>
)}
</section>
</div>
</div>
{signingIn && plugin && <DeviceSignIn connectionId={conn.id} sourceName={plugin.name} onClose={() => (setSigningIn(false), navigate(`/sources/${id}`, { replace: true }))} />}
</div>
);
}
function ImportBox({ conn, onQueued }: { conn: Connection; onQueued: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const input = useRef<HTMLInputElement>(null);
const upload = useMutation({
mutationFn: (f: File) => api.importToSource(conn.id, f),
onSuccess: () => {
setMsg({ ok: true, text: "Import queued. New entries appear in your library as it runs; anything that clashes with your edits waits in Changes." });
setFile(null);
if (input.current) input.current.value = "";
onQueued();
},
onError: (e) => setMsg({ ok: false, text: friendlyError(e) }),
});
return (
<section className="box" aria-labelledby="src-import">
<h2 id="src-import">Import a file</h2>
<form
className="import-form"
onSubmit={(e) => {
e.preventDefault();
if (file) upload.mutate(file);
}}
>
<label className="file-pick">
<span className="sr-only">File to import</span>
<input ref={input} type="file" accept=".csv,.json,.zip,text/csv,application/json" onChange={(e) => (setMsg(null), setFile(e.target.files?.[0] ?? null))} />
</label>
<button type="submit" className="btn" disabled={!file || upload.isPending}>{upload.isPending ? "Uploading…" : "Import"}</button>
</form>
<p className="hint">Up to 25 MB. Importing the same file twice doesn't create duplicates.</p>
{msg && <p className={msg.ok ? "ok-text" : "error-text"} role={msg.ok ? "status" : "alert"}>{msg.text}</p>}
</section>
);
}
function SettingsBox({ conn, plugin }: { conn: Connection; plugin: PluginInfo | undefined }) {
const qc = useQueryClient();
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [formKey, setFormKey] = useState(0);
const save = useMutation({
mutationFn: (b: Parameters<typeof api.patchSource>[1]) => api.patchSource(conn.id, b),
onSuccess: () => {
setMsg({ ok: true, text: "Saved." });
setFormKey((k) => k + 1);
void qc.invalidateQueries({ queryKey: ["sources"] });
},
onError: (e) => setMsg({ ok: false, text: friendlyError(e) }),
});
const fields = plugin?.config ?? [];
return (
<section className="box" aria-labelledby="src-settings">
<h2 id="src-settings">Settings</h2>
<form
key={formKey}
className="stack-form"
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
setMsg(null);
save.mutate({ name: String(f.get("name") ?? "").trim() || conn.name, config: readConfig(fields, f) });
}}
>
<div className="field">
<label htmlFor="src-name">Name</label>
<input id="src-name" name="name" className="input" defaultValue={conn.name} maxLength={200} />
</div>
<ConfigFields fields={fields} values={conn.config} savedSecrets={conn.hasCredentials} />
<div className="form-actions">
<button type="submit" className="btn" disabled={save.isPending}>{save.isPending ? "Saving…" : "Save"}</button>
</div>
{msg && <p className={msg.ok ? "ok-text" : "error-text"} role={msg.ok ? "status" : "alert"}>{msg.text}</p>}
</form>
</section>
);
}
const COUNT_WORDS: [string, string][] = [
["seen", "seen"],
["added", "added"],
["changed", "updated"],
["pending", "waiting in Changes"],
["conflicts", "conflicts"],
["removed", "gone at source"],
["listens", "listens"],
];
function RunRow({ r }: { r: Run }) {
const counts = COUNT_WORDS.filter(([k]) => (r.counts[k] ?? 0) > 0).map(([k, w]) => `${r.counts[k]} ${w}`);
const mode = r.mode === "file" ? "File import" : r.mode === "full" ? "Full sync" : "Sync";
return (
<li className={`run run-${r.status}`}>
<div className="run-line">
<span className={`badge ${r.status === "ok" ? "ok" : r.status === "failed" ? "bad" : "off"}`}>
{r.status === "ok" ? "Done" : r.status === "failed" ? "Failed" : "Running"}
</span>
<span className="run-mode">{mode}</span>
<time dateTime={r.startedAt} className="run-when">{timeAgo(r.startedAt)}</time>
</div>
{counts.length > 0 && <p className="run-counts tabular">{counts.join(" · ")}</p>}
{r.error && <p className="error-text">{r.error}</p>}
{r.log && (
<details>
<summary>Log</summary>
<pre>{r.log}</pre>
</details>
)}
</li>
);
}
+80
View File
@@ -473,3 +473,83 @@ input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px;
.change-top { flex-wrap: wrap; }
.change-title { white-space: normal; }
}
/* ---------- Forms (shared by the new screens) ---------- */
.field > label { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; }
.field .req { color: var(--text-faint); font-weight: 400; }
.inline-check { display: inline-flex; gap: 8px; align-items: center; font-size: 0.84rem; color: var(--text); }
.form-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.stack-form { display: grid; gap: 12px; }
.ok-text { font-size: 0.8rem; color: var(--ok); }
.box-text { font-size: 0.84rem; color: var(--text-muted); margin-bottom: 10px; max-width: 60ch; }
.muted-val { color: var(--text-muted); }
.back-link { margin: 0 0 10px -8px; }
/* ---------- Sources ---------- */
.conn-list { list-style: none; display: grid; gap: 2px; margin-top: 10px; }
.conn-row {
display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) minmax(0, 0.8fr) auto;
gap: 14px; align-items: center; padding: 10px 12px; margin: 0 -12px; border-radius: var(--r-control);
transition: background 0.12s;
}
.conn-row:hover { background: var(--surface-2); }
.conn-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conn-meta { color: var(--text-muted); font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.plugin-grid { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.plugin { background: var(--surface-2); border-radius: var(--r-box); padding: 13px 14px; display: grid; gap: 6px; align-content: start; }
.plugin.open { grid-column: 1 / -1; }
.plugin-head { display: flex; justify-content: space-between; gap: 10px; align-items: flex-start; }
.plugin h3 { font-size: 0.92rem; font-weight: 600; }
.plugin-types { font-size: 0.76rem; color: var(--text-muted); margin-top: 1px; }
.plugin-does { font-size: 0.76rem; color: var(--text-faint); }
.connect-form { display: grid; gap: 12px; margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--surface-3); max-width: 520px; }
.src-sub { display: flex; gap: 10px; align-items: center; }
.head-actions { display: flex; gap: 6px; flex: none; }
.src-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.src-grid .box .r label { color: var(--text-muted); }
.src-grid .box .r .select { width: auto; padding: 4px 28px 4px 9px; font-size: 0.82rem; }
.src-grid .box .hint { display: block; }
.import-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.file-pick input { font-size: 0.8rem; color: var(--text-muted); max-width: 100%; }
.file-pick input::file-selector-button {
font: inherit; font-size: 0.8rem; color: var(--text); background: var(--surface-3); border: 0;
border-radius: var(--r-control); padding: 6px 11px; margin-right: 10px; cursor: pointer;
}
.file-pick input::file-selector-button:hover { background: #3a346a; }
.runs { list-style: none; display: grid; }
.run { padding: 9px 0; border-top: 1px solid var(--surface-3); display: grid; gap: 4px; }
.run:first-child { border-top: 0; padding-top: 2px; }
.run-line { display: flex; gap: 10px; align-items: center; font-size: 0.84rem; }
.run-mode { flex: 1; }
.run-when { color: var(--text-faint); font-size: 0.76rem; }
.run-counts { font-size: 0.78rem; color: var(--text-muted); }
.run details summary { font-size: 0.76rem; color: var(--text-faint); cursor: pointer; width: max-content; }
.run details summary:hover { color: var(--text); }
.run pre { margin-top: 6px; max-height: 220px; overflow: auto; font-size: 0.74rem; line-height: 1.5; color: var(--text-muted); background: var(--surface-0); padding: 8px 10px; border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; }
.danger-zone { background: transparent; border: 1px solid var(--surface-3); }
/* ---------- Modal (device sign-in) ---------- */
.modal {
margin: auto; width: min(440px, calc(100vw - 32px)); border: 1px solid var(--surface-3); border-radius: 14px;
background: var(--surface-1); color: var(--text); padding: 22px 22px 18px; box-shadow: 0 24px 60px #000a;
}
.modal::backdrop { background: #0b0916b8; }
@media (prefers-reduced-motion: no-preference) {
.modal[open] { animation: modal-in 0.28s var(--ease-out); }
@keyframes modal-in { from { opacity: 0; translate: 0 8px; } }
}
.modal h2 { font-size: 1.05rem; font-weight: 650; }
.modal .lede { color: var(--text-muted); margin: 6px 0 16px; font-size: 0.86rem; }
.device-code { display: flex; align-items: center; gap: 12px; justify-content: space-between; background: var(--surface-0); border-radius: var(--r-box); padding: 14px 16px; }
.device-code .code { font-size: 1.7rem; font-weight: 650; letter-spacing: 0.14em; color: var(--rating); }
.device-link { margin-top: 12px; width: 100%; justify-content: center; padding: 9px 14px; }
.waiting { margin-top: 10px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 18px; }
@media (max-width: 860px) {
.src-grid { grid-template-columns: 1fr; }
.screen-head.with-action { flex-direction: column; }
.conn-row { grid-template-columns: minmax(0, 1fr) auto; gap: 2px 10px; }
.conn-row .conn-meta { grid-column: 1; }
.conn-row .badge { grid-row: 1; grid-column: 2; }
}