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:
co-authored by
Claude Opus 5.5
parent
67d94f5107
commit
b38a2674f4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user