Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9546fa13f2 |
@@ -8,7 +8,6 @@ import { engagementRoutes } from "./routes/engagements.ts";
|
||||
import { entryRoutes } from "./routes/entries.ts";
|
||||
import { exportRoutes } from "./routes/export.ts";
|
||||
import { listRoutes } from "./routes/lists.ts";
|
||||
import { logRoutes } from "./routes/log.ts";
|
||||
import { metaRoutes } from "./routes/meta.ts";
|
||||
import { sourceRoutes } from "./routes/sources.ts";
|
||||
import { workRoutes } from "./routes/works.ts";
|
||||
@@ -27,7 +26,6 @@ export function createApp(deps: AppDeps): App {
|
||||
...engagementRoutes,
|
||||
...workRoutes,
|
||||
...listRoutes,
|
||||
...logRoutes,
|
||||
...copyRoutes,
|
||||
...customFieldRoutes,
|
||||
...attachmentRoutes,
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { listLog } from "@sttracker/core/log";
|
||||
import { z } from "zod";
|
||||
import { route } from "../http.ts";
|
||||
import { qInt } from "./schemas.ts";
|
||||
|
||||
export const logRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "log",
|
||||
summary: "Your Log: engagements and listens, newest first (keyset cursor).",
|
||||
auth: "any:read",
|
||||
query: z.object({ limit: qInt.optional(), cursor: z.string().max(2000).optional() }),
|
||||
handler: async ({ deps, user, query }) => listLog(deps.db, user.id, query),
|
||||
}),
|
||||
];
|
||||
@@ -38,9 +38,9 @@ export const sourceRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "plugins",
|
||||
summary: "Installed source plugins (manifests, plus deviceAuth: whether sign-in uses a device code).",
|
||||
summary: "Installed source plugins (manifests).",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps }) => deps.registry.list().map((m) => ({ ...m, deviceAuth: !!deps.registry.get(m.id)?.deviceAuth })),
|
||||
handler: async ({ deps }) => deps.registry.list(),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
|
||||
@@ -329,31 +329,6 @@ forEachDialect("api", (getDb) => {
|
||||
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403);
|
||||
});
|
||||
|
||||
test("log: engagements newest first with keyset paging; copies carry workTitle", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string; workId: string };
|
||||
for (const value of ["2026-01-02", "2026-03-04"]) {
|
||||
const r = await call(app, "POST", `entries/${e.id}/engagements`, { cookie: ana, body: { finishedAt: { value, precision: "day" } } });
|
||||
expect(r.status).toBe(201);
|
||||
}
|
||||
const page1 = (await (await call(app, "GET", "log?limit=1", { cookie: ana })).json()) as { items: { at: string; title: string; kind: string }[]; nextCursor: string };
|
||||
expect(page1.items).toEqual([expect.objectContaining({ kind: "engagement", at: "2026-03-04", title: "Dune" })]);
|
||||
const page2 = (await (await call(app, "GET", `log?limit=1&cursor=${page1.nextCursor}`, { cookie: ana })).json()) as { items: { at: string }[]; nextCursor: string | null };
|
||||
expect(page2.items.map((i) => i.at)).toEqual(["2026-01-02"]);
|
||||
expect(page2.nextCursor).toBeNull();
|
||||
expect((await call(app, "GET", "log?cursor=bad", { cookie: ana })).status).toBe(400);
|
||||
expect((await call(app, "GET", "log")).status).toBe(401);
|
||||
|
||||
await call(app, "POST", "copies", { cookie: ana, body: { workId: e.workId, format: "Paperback" } });
|
||||
expect(await (await call(app, "GET", "copies", { cookie: ana })).json()).toEqual([expect.objectContaining({ workTitle: "Dune", format: "Paperback" })]);
|
||||
});
|
||||
|
||||
test("plugins: manifests say whether the source signs in with a device code", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const list = (await (await call(app, "GET", "plugins", { cookie: ana })).json()) as { id: string; deviceAuth: boolean; config: unknown[] }[];
|
||||
expect(list.find((p) => p.id === "echo")).toEqual(expect.objectContaining({ deviceAuth: false, config: expect.any(Array) }));
|
||||
});
|
||||
|
||||
test("JSON bodies need Content-Type application/json (415)", async () => {
|
||||
const res = await app.fetch(
|
||||
new Request(`${BASE}/auth/register`, {
|
||||
|
||||
+2
-172
@@ -147,121 +147,9 @@ export interface Connection {
|
||||
id: string;
|
||||
pluginId: string;
|
||||
name: string;
|
||||
status: "active" | "paused" | "error";
|
||||
status: string;
|
||||
lastRunAt: string | null;
|
||||
hasCredentials: boolean;
|
||||
config: Record<string, unknown>;
|
||||
scheduleMinutes: number | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: "string" | "secret" | "number" | "boolean" | "select";
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
help?: string;
|
||||
}
|
||||
|
||||
export interface PluginInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
mediaTypes: string[];
|
||||
capabilities: string[];
|
||||
config: ConfigField[];
|
||||
attribution?: string;
|
||||
deviceAuth: boolean;
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
id: string;
|
||||
mode: "full" | "changes" | "file";
|
||||
status: "running" | "ok" | "failed";
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
counts: Record<string, number>;
|
||||
log: string;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceStart {
|
||||
userCode: string;
|
||||
verificationUrl: string;
|
||||
deviceCode: string;
|
||||
intervalSec: number;
|
||||
expiresInSec: number;
|
||||
}
|
||||
|
||||
export type LogItem =
|
||||
| {
|
||||
kind: "engagement";
|
||||
id: string;
|
||||
at: string;
|
||||
entryId: string;
|
||||
workId: string;
|
||||
title: string;
|
||||
mediaType: string;
|
||||
coverUrl: string;
|
||||
startedAt: PartialDate | null;
|
||||
finishedAt: PartialDate | null;
|
||||
platform: string | null;
|
||||
rating: number | null;
|
||||
}
|
||||
| {
|
||||
kind: "listen";
|
||||
id: string;
|
||||
at: string;
|
||||
workId: string | null;
|
||||
title: string;
|
||||
mediaType: "track";
|
||||
artist: string;
|
||||
track: string;
|
||||
album: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type ListKind = "list" | "grouping" | "thread";
|
||||
export interface List {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
kind: ListKind;
|
||||
visibility: "private" | "friends" | "public";
|
||||
rating: number | null;
|
||||
review: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
workId: string;
|
||||
position: number;
|
||||
note: string | null;
|
||||
work: { id: string; title: string; mediaType: string; year: number | null; coverUrl: string };
|
||||
}
|
||||
export interface ListWithItems extends List {
|
||||
items: ListItem[];
|
||||
}
|
||||
|
||||
export interface OwnedCopy extends Copy {
|
||||
workTitle: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppToken {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: ("read" | "write" | "ingest")[];
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface About {
|
||||
name: string;
|
||||
attributions: { pluginId: string; name: string; attribution: string }[];
|
||||
}
|
||||
|
||||
export interface ChangeEvent {
|
||||
@@ -269,15 +157,10 @@ export interface ChangeEvent {
|
||||
type: string;
|
||||
status: string;
|
||||
connectionId: string | null;
|
||||
runId: string | null;
|
||||
targetEntity: string;
|
||||
targetId: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
/** The target entry's title (an engagement's entry; a list's name), when it still exists. */
|
||||
title?: string | null;
|
||||
/** Merged events: the title of the entry this was merged into, when known. */
|
||||
intoTitle?: string | null;
|
||||
}
|
||||
|
||||
export interface HistoryRecord {
|
||||
@@ -293,14 +176,10 @@ export interface HistoryRecord {
|
||||
export interface CustomFieldDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: CustomFieldType;
|
||||
type: "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
|
||||
options: string[];
|
||||
appliesTo?: string[] | "all";
|
||||
level?: "entry" | "engagement" | "copy";
|
||||
}
|
||||
|
||||
export type CustomFieldType = "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
|
||||
|
||||
export interface Work {
|
||||
id: string;
|
||||
media_type: string;
|
||||
@@ -368,56 +247,7 @@ export const api = {
|
||||
},
|
||||
|
||||
sources: () => request<Connection[]>("GET", "sources"),
|
||||
plugins: () => request<PluginInfo[]>("GET", "plugins"),
|
||||
createSource: (body: { pluginId: string; name: string; config: Record<string, unknown>; scheduleMinutes?: number | null }) =>
|
||||
request<Connection>("POST", "sources", { body }),
|
||||
patchSource: (id: string, body: Partial<{ name: string; config: Record<string, unknown>; scheduleMinutes: number | null; status: "active" | "paused" }>) =>
|
||||
request<Connection>("PATCH", `sources/${id}`, { body }),
|
||||
deleteSource: (id: string) => request<void>("DELETE", `sources/${id}`),
|
||||
runSource: (id: string) => request<{ jobId: string }>("POST", `sources/${id}/run`),
|
||||
importToSource: (id: string, file: File) => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
return request<{ jobId: string }>("POST", `sources/${id}/import`, { form });
|
||||
},
|
||||
deviceStart: (id: string) => request<DeviceStart>("POST", `sources/${id}/device/start`),
|
||||
devicePoll: (id: string, deviceCode: string) => request<{ status: "pending" | "connected" }>("POST", `sources/${id}/device/poll`, { body: { deviceCode } }),
|
||||
runs: (id: string) => request<Run[]>("GET", `sources/${id}/runs`, { query: { limit: 20 } }),
|
||||
|
||||
changes: () => request<Page<ChangeEvent>>("GET", "changes", { query: { limit: 200 } }),
|
||||
resolveChange: (id: string, action: "accept" | "ignore" | "keep_mine") => request<void>("POST", `changes/${id}/resolve`, { body: { action } }),
|
||||
|
||||
log: (cursor?: string) => request<Page<LogItem>>("GET", "log", { query: { cursor, limit: 50 } }),
|
||||
|
||||
lists: () => request<List[]>("GET", "lists"),
|
||||
list: (id: string) => request<ListWithItems>("GET", `lists/${id}`),
|
||||
createList: (body: { name: string; kind: ListKind; description?: string }) => request<List>("POST", "lists", { body }),
|
||||
patchList: (id: string, body: Partial<Pick<List, "name" | "description" | "kind" | "rating" | "review" | "visibility">>) =>
|
||||
request<List>("PATCH", `lists/${id}`, { body }),
|
||||
deleteList: (id: string) => request<void>("DELETE", `lists/${id}`),
|
||||
addListItem: (id: string, workId: string) => request<ListItem>("POST", `lists/${id}/items`, { body: { workId } }),
|
||||
moveListItem: (itemId: string, position: number) => request<void>("PATCH", `list-items/${itemId}`, { body: { position } }),
|
||||
removeListItem: (itemId: string) => request<void>("DELETE", `list-items/${itemId}`),
|
||||
|
||||
ownedCopies: () => request<OwnedCopy[]>("GET", "copies"),
|
||||
updateCopy: (id: string, body: Partial<Omit<Copy, "id">>) => request<Copy>("PATCH", `copies/${id}`, { body }),
|
||||
deleteCopy: (id: string) => request<void>("DELETE", `copies/${id}`),
|
||||
|
||||
allCustomFields: () => request<CustomFieldDef[]>("GET", "custom-fields"),
|
||||
createCustomField: (body: { name: string; type: CustomFieldType; options?: string[]; appliesTo?: string[] | "all"; level?: "entry" | "engagement" | "copy" }) =>
|
||||
request<CustomFieldDef>("POST", "custom-fields", { body }),
|
||||
|
||||
tokens: () => request<AppToken[]>("GET", "me/tokens"),
|
||||
createToken: (name: string, scopes: AppToken["scopes"]) => request<{ id: string; token: string }>("POST", "me/tokens", { body: { name, scopes } }),
|
||||
revokeToken: (id: string) => request<void>("DELETE", `me/tokens/${id}`),
|
||||
createInvite: (ttlDays?: number) => request<{ code: string }>("POST", "admin/invites", { body: ttlDays ? { ttlDays } : {} }),
|
||||
reloadPlugins: () => request<PluginInfo[]>("POST", "admin/plugins/reload"),
|
||||
importBundle: (file: File) => {
|
||||
const form = new FormData();
|
||||
form.set("file", file);
|
||||
return request<{ works: number; entries: number }>("POST", "import/bundle", { form });
|
||||
},
|
||||
about: () => request<About>("GET", "about"),
|
||||
customFields: (mediaType: string) => request<CustomFieldDef[]>("GET", "custom-fields", { query: { mediaType, level: "entry" } }),
|
||||
|
||||
work: (id: string) => request<Work>("GET", `works/${id}`),
|
||||
|
||||
@@ -6,15 +6,15 @@ import { orderEngagements, setCustomValue } from "../edits.ts";
|
||||
import {
|
||||
actorConnectionId,
|
||||
describeRating,
|
||||
draftFailedSave,
|
||||
draftIncoming,
|
||||
draftSaved,
|
||||
draftSettled,
|
||||
draftStartSave,
|
||||
engagementNouns,
|
||||
formatDateRange,
|
||||
formatPartialDate,
|
||||
formatRating,
|
||||
friendlyError,
|
||||
newDraft,
|
||||
ordinal,
|
||||
parsePartialDate,
|
||||
rollupLabel,
|
||||
@@ -498,35 +498,29 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function ReviewBox({ entry }: { entry: EntryView }) {
|
||||
const patch = useEntryPatch(entry.id);
|
||||
const [saved, setSaved] = useState(false);
|
||||
// Local text plus the server value it last agreed with (see `Draft` in format.ts). A Restore shows
|
||||
// up here unless the user has typed since; a failed save keeps what they typed.
|
||||
const [draft, setDraft] = useState<Draft>(() => ({ text: entry.review ?? "", synced: entry.review ?? "", saving: false }));
|
||||
const inFlight = useRef(0);
|
||||
const [draft, setDraft] = useState<Draft>(() => newDraft(entry.review ?? ""));
|
||||
// Saves in flight, oldest first. The entry-patch scope runs them serially, so they settle in order.
|
||||
const saves = useRef<{ was: string; stored: string }[]>([]);
|
||||
const patch = useEntryPatch(entry.id, {
|
||||
onSettled: (_p, error) => {
|
||||
const save = saves.current.shift();
|
||||
if (!save) return;
|
||||
setDraft((d) => draftSettled(d, save, error === null));
|
||||
setSaved(error === null && saves.current.length === 0);
|
||||
},
|
||||
});
|
||||
useEffect(() => setDraft((d) => draftIncoming(d, entry.review ?? "")), [entry.review]);
|
||||
const save = () => {
|
||||
const next = draft.text.trim() === "" ? null : draft.text;
|
||||
const stored = next ?? "";
|
||||
if (stored === draft.synced && stored === (entry.review ?? "")) return;
|
||||
const was = draft.synced;
|
||||
inFlight.current += 1;
|
||||
setDraft((d) => ({ ...draftStartSave(d), synced: stored }));
|
||||
const settle = (d: Draft) => ({ ...d, saving: inFlight.current > 0 });
|
||||
patch.mutate(
|
||||
{ review: next },
|
||||
{
|
||||
onSuccess: () => {
|
||||
inFlight.current -= 1;
|
||||
setDraft((d) => settle(draftSaved(d, stored)));
|
||||
setSaved(true);
|
||||
},
|
||||
onError: () => {
|
||||
inFlight.current -= 1;
|
||||
setDraft((d) => settle(draftFailedSave(d, was)));
|
||||
},
|
||||
},
|
||||
);
|
||||
const s = { was: draft.synced, stored };
|
||||
saves.current.push(s);
|
||||
setDraft((d) => draftStartSave(d, s));
|
||||
patch.mutate({ review: next });
|
||||
};
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { friendlyError } from "../format.ts";
|
||||
|
||||
/**
|
||||
* A failed load. With cached data on screen (`stale`), a small inline banner sits above it so the user
|
||||
* keeps what they had; with nothing cached, it is the whole state.
|
||||
*/
|
||||
export function LoadError({ error, retry, stale, className }: { error: unknown; retry: () => void; stale?: boolean; className?: string }) {
|
||||
if (stale) {
|
||||
return (
|
||||
<div className={`stale-banner${className ? ` ${className}` : ""}`} role="alert">
|
||||
<p>Can't refresh right now. {friendlyError(error)}</p>
|
||||
<button type="button" className="btn ghost small" onClick={retry}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`load-error${className ? ` ${className}` : ""}`} role="alert">
|
||||
<p>{friendlyError(error)}</p>
|
||||
<button type="button" className="btn small" onClick={retry}>Try again</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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;
|
||||
}
|
||||
+30
-19
@@ -270,34 +270,45 @@ export function friendlyError(err: unknown, opts: { ratingScale?: RatingScale }
|
||||
|
||||
/**
|
||||
* A text field edited locally and saved on blur. `synced` is the server value the text last agreed
|
||||
* with; text that differs from it is unsaved and is never replaced. While a save is in flight,
|
||||
* incoming values (the optimistic update, then a rollback on failure) are ignored, so the outcome
|
||||
* does not depend on whether the rollback or the error callback lands first.
|
||||
* with; text that differs from it is unsaved and is never replaced. `pending` counts saves in flight
|
||||
* (one entry's saves run serially, so they settle in order). While any is pending, incoming server
|
||||
* values are only remembered in `incoming`. Values that are our own saves' echoes (optimistic updates
|
||||
* show a save's `stored`, rollbacks show its `was`) are listed in `echoes` and ignored; any other
|
||||
* value (an outside change, a Restore) is applied once the saves settle. So the outcome does not
|
||||
* depend on the order in which rollbacks, refetches and settle callbacks land.
|
||||
*/
|
||||
export interface Draft {
|
||||
text: string;
|
||||
synced: string;
|
||||
saving: boolean;
|
||||
pending: number;
|
||||
incoming: string;
|
||||
echoes: string[];
|
||||
}
|
||||
|
||||
/** A server value arrived (refetch, Restore, rollback). Untouched text follows it; edited text is kept. */
|
||||
export function newDraft(value: string): Draft {
|
||||
return { text: value, synced: value, pending: 0, incoming: value, echoes: [] };
|
||||
}
|
||||
|
||||
/** A server value arrived. Untouched text follows it; edited text is kept. Deferred while saving. */
|
||||
export function draftIncoming(d: Draft, incoming: string): Draft {
|
||||
if (d.saving || incoming === d.synced) return d;
|
||||
const was = d.synced;
|
||||
return { text: d.text === was ? incoming : d.text, synced: incoming, saving: false };
|
||||
if (d.pending > 0 || incoming === d.synced) return { ...d, incoming };
|
||||
return { ...d, text: d.text === d.synced ? incoming : d.text, synced: incoming, incoming };
|
||||
}
|
||||
|
||||
/** Saving the current text: it becomes the synced value unless the save fails. */
|
||||
export function draftStartSave(d: Draft): Draft {
|
||||
return { text: d.text, synced: d.text, saving: true };
|
||||
/** A save of `stored` started (`was` is the synced value before it): it counts as synced unless it fails. */
|
||||
export function draftStartSave(d: Draft, save: { was: string; stored: string }): Draft {
|
||||
return { ...d, synced: save.stored, pending: d.pending + 1, echoes: [...d.echoes, save.was, save.stored] };
|
||||
}
|
||||
|
||||
/** The save succeeded with this stored value. */
|
||||
export function draftSaved(d: Draft, stored: string): Draft {
|
||||
return { ...d, synced: stored, saving: false };
|
||||
}
|
||||
|
||||
/** The save failed: go back to the value before it, so the local text still counts as unsaved. */
|
||||
export function draftFailedSave(d: Draft, was: string): Draft {
|
||||
return { ...d, synced: was, saving: false };
|
||||
/**
|
||||
* The oldest pending save settled. Success: `stored` is on the server. Failure: back to `was`, so the
|
||||
* local text still counts as unsaved. When the last pending save settles, a remembered incoming value
|
||||
* that is not one of our own echoes is applied.
|
||||
*/
|
||||
export function draftSettled(d: Draft, save: { was: string; stored: string }, ok: boolean): Draft {
|
||||
const pending = Math.max(0, d.pending - 1);
|
||||
const next: Draft = { ...d, synced: ok ? save.stored : save.was, pending };
|
||||
if (pending > 0) return next;
|
||||
const settled: Draft = { ...next, echoes: [] };
|
||||
return d.echoes.includes(d.incoming) ? settled : draftIncoming(settled, d.incoming);
|
||||
}
|
||||
|
||||
+10
-3
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api, type ChangeEvent, type EntryPatch, type EntryView } from "./api.ts";
|
||||
import { applyEntryPatch } from "./edits.ts";
|
||||
|
||||
@@ -37,9 +37,15 @@ export function useDebounced<T>(value: T, ms = 220): T {
|
||||
* error) and PATCHes for one entry run one at a time (mutation scope), in the order they were made.
|
||||
* Use `edit(cur => patch)` when the next value depends on the current one (tags, custom fields): it
|
||||
* builds from the latest cache, so quick successive edits compose.
|
||||
*
|
||||
* `onSettled` fires once for EVERY mutation of this hook (it is a hook-level option). Per-call
|
||||
* `mutate(patch, { onSuccess })` callbacks only fire for the observer's latest call, so never use
|
||||
* them for bookkeeping.
|
||||
*/
|
||||
export function useEntryPatch(id: string) {
|
||||
export function useEntryPatch(id: string, opts: { onSettled?: (patch: EntryPatch, error: Error | null) => void } = {}) {
|
||||
const qc = useQueryClient();
|
||||
const onSettledRef = useRef(opts.onSettled);
|
||||
onSettledRef.current = opts.onSettled;
|
||||
const key = ["entry", id];
|
||||
const mutationKey = ["entry-patch", id];
|
||||
const m = useMutation({
|
||||
@@ -60,9 +66,10 @@ export function useEntryPatch(id: string) {
|
||||
// Only the last queued edit writes the server's answer, so earlier answers don't flash old values.
|
||||
if (qc.isMutating({ mutationKey }) <= 1) qc.setQueryData(key, entry);
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_entry, error, patch) => {
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", id] });
|
||||
onSettledRef.current?.(patch, error);
|
||||
},
|
||||
});
|
||||
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
/** Copy for the Changes inbox and Sources screens. Pure functions: no DOM, no network. */
|
||||
import type { ChangeEvent } from "./api.ts";
|
||||
import { formatPartialDate, formatRating, type PartialDate, type RatingScale, type Status, statusLabel } from "./format.ts";
|
||||
|
||||
export type ChangeAction = "accept" | "ignore" | "keep_mine";
|
||||
|
||||
export interface ChangeDescription {
|
||||
message: string;
|
||||
/** The field a changed/conflict event is about. */
|
||||
field?: string;
|
||||
before?: { label: string; value: unknown };
|
||||
after?: { label: string; value: unknown };
|
||||
actions: { action: ChangeAction; label: string }[];
|
||||
}
|
||||
|
||||
interface SourceLike {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FIELD_NAMES: Record<string, string> = {
|
||||
rating: "the rating",
|
||||
status: "the status",
|
||||
review: "the review",
|
||||
tags: "the tags",
|
||||
favorite: "the favourite mark",
|
||||
privateNotes: "the private notes",
|
||||
startedAt: "the start date",
|
||||
finishedAt: "the finish date",
|
||||
progress: "the progress",
|
||||
platform: "the platform",
|
||||
notes: "the notes",
|
||||
name: "the name",
|
||||
description: "the description",
|
||||
items: "the items",
|
||||
visibility: "who can see it",
|
||||
};
|
||||
|
||||
/** "rating" → "the rating"; unknown keys read as "the <words>". */
|
||||
export function fieldPhrase(field: string): string {
|
||||
if (FIELD_NAMES[field]) return FIELD_NAMES[field]!;
|
||||
return `the ${field.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** The connection id in `plugin:<pluginId>:<connectionId>`. */
|
||||
function connectionOf(actor: unknown): string | null {
|
||||
const m = typeof actor === "string" ? /^plugin:[^:]+:(.+)$/.exec(actor) : null;
|
||||
return m ? m[1]! : null;
|
||||
}
|
||||
|
||||
function sourceName(sources: SourceLike[], connectionId: string | null): string {
|
||||
return sources.find((s) => s.id === connectionId)?.name ?? "A source";
|
||||
}
|
||||
|
||||
const FIELD_ACTIONS: ChangeDescription["actions"] = [
|
||||
{ action: "accept", label: "Use the new value" },
|
||||
{ action: "keep_mine", label: "Keep mine" },
|
||||
{ action: "ignore", label: "Ignore" },
|
||||
];
|
||||
|
||||
/** What a pending change says and which buttons it offers, in the product's words. */
|
||||
export function describeChange(ev: ChangeEvent, sources: SourceLike[]): ChangeDescription {
|
||||
const src = sourceName(sources, ev.connectionId);
|
||||
const p = ev.payload;
|
||||
if (ev.type === "removed") {
|
||||
return {
|
||||
message: `${src} no longer has this. Your copy here is safe.`,
|
||||
actions: [
|
||||
{ action: "accept", label: "Delete here too" },
|
||||
{ action: "ignore", label: "Keep it" },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (ev.type === "merged") {
|
||||
return {
|
||||
message: `${src} merged this into ${ev.intoTitle || "another entry"}.`,
|
||||
actions: [
|
||||
{ action: "accept", label: "Follow the merge" },
|
||||
{ action: "ignore", label: "Ignore" },
|
||||
],
|
||||
};
|
||||
}
|
||||
const field = typeof p.field === "string" ? p.field : "";
|
||||
if (ev.type === "conflict") {
|
||||
const incoming = sourceName(sources, connectionOf(p.incomingSource) ?? ev.connectionId);
|
||||
const current = p.currentSource === "user" ? "You" : sourceName(sources, connectionOf(p.currentSource));
|
||||
const other = current === "A source" ? "another source" : current;
|
||||
return {
|
||||
message: `${incoming} and ${other} disagree about ${fieldPhrase(field)}.`,
|
||||
field,
|
||||
before: { label: current === "You" ? "Yours" : current, value: p.current ?? null },
|
||||
after: { label: incoming, value: p.incoming ?? null },
|
||||
actions: FIELD_ACTIONS,
|
||||
};
|
||||
}
|
||||
if (ev.type === "changed") {
|
||||
return {
|
||||
message: `${src} changed ${fieldPhrase(field)}.`,
|
||||
field,
|
||||
before: { label: "Yours", value: p.from ?? null },
|
||||
after: { label: src, value: p.to ?? null },
|
||||
actions: FIELD_ACTIONS,
|
||||
};
|
||||
}
|
||||
return { message: `${src} added this.`, actions: [{ action: "ignore", label: "Dismiss" }] };
|
||||
}
|
||||
|
||||
/** Sync schedule choices, in minutes (null is off). */
|
||||
export const SCHEDULES: (number | null)[] = [null, 60, 360, 1440];
|
||||
|
||||
export function scheduleLabel(minutes: number | null | undefined): string {
|
||||
if (!minutes) return "Off";
|
||||
if (minutes === 60) return "Every hour";
|
||||
if (minutes === 1440) return "Daily";
|
||||
if (minutes % 1440 === 0) return `Every ${minutes / 1440} days`;
|
||||
if (minutes % 60 === 0) return `Every ${minutes / 60} hours`;
|
||||
return `Every ${minutes} minutes`;
|
||||
}
|
||||
|
||||
/** Events grouped by sync run, keeping the newest-first order of first appearance. */
|
||||
export function groupByRun<E extends { runId: string | null }>(events: E[]): { runId: string | null; events: E[] }[] {
|
||||
const groups: { runId: string | null; events: E[] }[] = [];
|
||||
const at = new Map<string | null, number>();
|
||||
for (const e of events) {
|
||||
const i = at.get(e.runId);
|
||||
if (i === undefined) {
|
||||
at.set(e.runId, groups.length);
|
||||
groups.push({ runId: e.runId, events: [e] });
|
||||
} else groups[i]!.events.push(e);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
const isPartialDate = (v: unknown): v is PartialDate =>
|
||||
!!v && typeof v === "object" && typeof (v as PartialDate).value === "string" && typeof (v as PartialDate).precision === "string";
|
||||
|
||||
/** A before/after value in the user's terms: stars in their scale, "Reading", "Mar 2025". Long text is cut to 160. */
|
||||
export function changeValue(field: string, value: unknown, scale: RatingScale, mediaType = "other"): string {
|
||||
if (value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) return "Nothing";
|
||||
if (field === "rating" && typeof value === "number") return formatRating(value, scale);
|
||||
if (field === "status" && typeof value === "string") return statusLabel(value as Status, mediaType);
|
||||
if (isPartialDate(value)) return formatPartialDate(value) || "No date";
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
let text: string;
|
||||
if (Array.isArray(value)) text = value.map((v) => (typeof v === "string" ? v : JSON.stringify(v))).join(", ");
|
||||
else if (typeof value === "object") text = JSON.stringify(value);
|
||||
else text = String(value);
|
||||
return text.length > 160 ? `${text.slice(0, 159)}…` : text;
|
||||
}
|
||||
|
||||
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
|
||||
/** The Log's month heading: "2026-10…" → "October 2026"; a bare year stays a year. Never through `Date`. */
|
||||
export function logMonth(at: string): string {
|
||||
const y = at.slice(0, 4);
|
||||
const m = Number(at.slice(5, 7));
|
||||
return m >= 1 && m <= 12 ? `${MONTH_NAMES[m - 1]} ${y}` : y;
|
||||
}
|
||||
@@ -6,14 +6,10 @@ import { ApiError } from "./api.ts";
|
||||
import { friendlyError } from "./format.ts";
|
||||
import { Shell } from "./components/Shell.tsx";
|
||||
import { useMe } from "./hooks.ts";
|
||||
import { Changes } from "./routes/changes.tsx";
|
||||
import { Library } from "./routes/library.tsx";
|
||||
import { Lists } from "./routes/lists.tsx";
|
||||
import { Log } from "./routes/log.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({
|
||||
@@ -60,12 +56,6 @@ function App() {
|
||||
<Switch>
|
||||
<Route path="/">{() => <Library selectedId={null} />}</Route>
|
||||
<Route path="/entries/:id">{(p) => <Library selectedId={p.id} />}</Route>
|
||||
<Route path="/changes" component={Changes} />
|
||||
<Route path="/log" component={Log} />
|
||||
<Route path="/lists">{() => <Lists selectedId={null} />}</Route>
|
||||
<Route path="/lists/:id">{(p) => <Lists selectedId={p.id} />}</Route>
|
||||
<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>
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Link, useSearch } from "wouter";
|
||||
import { api, type ChangeEvent, type Connection, type Page } from "../api.ts";
|
||||
import { LoadError } from "../components/LoadError.tsx";
|
||||
import { changesFor, usePendingChanges, useSources } from "../hooks.ts";
|
||||
import { friendlyError, timeAgo } from "../format.ts";
|
||||
import { changeValue, describeChange, groupByRun, type ChangeAction } from "../labels.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
changed: "Changed",
|
||||
conflict: "Sources disagree",
|
||||
removed: "Gone from source",
|
||||
merged: "Merged at source",
|
||||
added: "Added",
|
||||
};
|
||||
|
||||
const DONE_LABEL: Record<ChangeAction, string> = {
|
||||
accept: "Done. The new value is in.",
|
||||
keep_mine: "Kept yours. That source won't change it again.",
|
||||
ignore: "Ignored. Nothing changed.",
|
||||
};
|
||||
|
||||
/** The Changes inbox: pending source changes, grouped by sync run. `?entry=<id>` narrows it to one entry. */
|
||||
export function Changes() {
|
||||
const search = new URLSearchParams(useSearch());
|
||||
const entryId = search.get("entry");
|
||||
const changes = usePendingChanges();
|
||||
const sources = useSources();
|
||||
const engagements = useQuery({ queryKey: ["engagements", entryId], queryFn: () => api.engagements(entryId!), enabled: !!entryId });
|
||||
const entry = useQuery({ queryKey: ["entry", entryId], queryFn: () => api.entry(entryId!), enabled: !!entryId });
|
||||
const [said, setSaid] = useState("");
|
||||
|
||||
const all = changes.data?.items ?? [];
|
||||
const items = entryId ? changesFor(all, entryId, new Set((engagements.data ?? []).map((g) => g.id))) : all;
|
||||
const groups = groupByRun(items);
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<div className="screen-inner">
|
||||
<header className="screen-head">
|
||||
<h1>Changes</h1>
|
||||
<p className="lede">When a source changes something you've edited, it waits here. Nothing changes until you choose.</p>
|
||||
</header>
|
||||
{entryId && (
|
||||
<p className="filter-note">
|
||||
Showing changes to <b>{entry.data?.work.title ?? "one entry"}</b>. <Link href="/changes" className="text-link">Show all changes</Link>
|
||||
</p>
|
||||
)}
|
||||
<p className="sr-only" role="status" aria-live="polite">{said}</p>
|
||||
{changes.error && changes.data && <LoadError error={changes.error} retry={() => void changes.refetch()} stale />}
|
||||
|
||||
{changes.isPending ? (
|
||||
<div className="change-skeleton" aria-hidden="true">
|
||||
<div className="change skeleton" />
|
||||
<div className="change skeleton" />
|
||||
</div>
|
||||
) : changes.error && !changes.data ? (
|
||||
<LoadError error={changes.error} retry={() => void changes.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>{entryId ? "Nothing waiting for this entry" : "All caught up"}</strong>
|
||||
<p>
|
||||
{entryId
|
||||
? "Its sources agree with what you have."
|
||||
: "Your sources agree with your library. When one changes something you edited, you'll decide here."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<RunGroup key={g.runId ?? "none"} events={g.events} sources={sources.data ?? []} onResolved={setSaid} />
|
||||
))
|
||||
)}
|
||||
{changes.data?.nextCursor && items.length > 0 && (
|
||||
<p className="hint more-note">Showing the newest 200. Resolve some to see older ones.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunGroup({ events, sources, onResolved }: { events: ChangeEvent[]; sources: Connection[]; onResolved: (s: string) => void }) {
|
||||
const first = events[0]!;
|
||||
const name = sources.find((s) => s.id === first.connectionId)?.name ?? "A source";
|
||||
return (
|
||||
<section className="run-group" aria-label={`${name}, ${timeAgo(first.createdAt)}`}>
|
||||
<h2 className="run-head">
|
||||
<span className="who">{name}</span>
|
||||
<span className="sep" aria-hidden="true">·</span>
|
||||
<span>sync {timeAgo(first.createdAt)}</span>
|
||||
<span className="sep" aria-hidden="true">·</span>
|
||||
<span className="tabular">{events.length} waiting</span>
|
||||
</h2>
|
||||
<ol className="changes">
|
||||
{events.map((ev) => (
|
||||
<ChangeCard key={ev.id} ev={ev} sources={sources} onResolved={onResolved} />
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeCard({ ev, sources, onResolved }: { ev: ChangeEvent; sources: Connection[]; onResolved: (s: string) => void }) {
|
||||
const qc = useQueryClient();
|
||||
const scale = useRatingScale();
|
||||
const d = describeChange(ev, sources);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const key = ["changes", "pending"];
|
||||
const resolve = useMutation({
|
||||
mutationFn: (action: ChangeAction) => api.resolveChange(ev.id, action),
|
||||
onMutate: async () => {
|
||||
setError(null);
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<Page<ChangeEvent>>(key);
|
||||
if (prev) qc.setQueryData<Page<ChangeEvent>>(key, { ...prev, items: prev.items.filter((c) => c.id !== ev.id) });
|
||||
return { prev };
|
||||
},
|
||||
onError: (e, _a, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||
setError(friendlyError(e));
|
||||
},
|
||||
onSuccess: (_v, action) => onResolved(DONE_LABEL[action]),
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: key });
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
if (ev.targetEntity === "entry" && ev.targetId) {
|
||||
void qc.invalidateQueries({ queryKey: ["entry", ev.targetId] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", ev.targetId] });
|
||||
}
|
||||
void qc.invalidateQueries({ queryKey: ["engagements"] });
|
||||
},
|
||||
});
|
||||
const title = ev.title ?? (ev.targetEntity === "list" ? "A list" : "An entry that's gone");
|
||||
const where = ev.targetEntity === "engagement" ? "A read or play of " : ev.targetEntity === "list" ? "List " : "";
|
||||
|
||||
return (
|
||||
<li className={`change type-${ev.type}`}>
|
||||
<div className="change-top">
|
||||
<span className="kind">{TYPE_LABEL[ev.type] ?? ev.type}</span>
|
||||
<span className="change-title">
|
||||
{where}
|
||||
{ev.targetEntity === "entry" && ev.targetId && ev.title ? <Link href={`/entries/${ev.targetId}`}>{title}</Link> : <b>{title}</b>}
|
||||
</span>
|
||||
<time className="when" dateTime={ev.createdAt}>{timeAgo(ev.createdAt)}</time>
|
||||
</div>
|
||||
<p className="change-msg">{d.message}</p>
|
||||
{d.before && d.after && (
|
||||
<dl className="diff">
|
||||
<div className="was">
|
||||
<dt>{d.before.label}</dt>
|
||||
<dd className={d.field === "rating" && d.before.value !== null ? "stars" : undefined}>{changeValue(d.field ?? "", d.before.value, scale)}</dd>
|
||||
</div>
|
||||
<div className="now">
|
||||
<dt>{d.after.label}</dt>
|
||||
<dd className={d.field === "rating" && d.after.value !== null ? "stars" : undefined}>{changeValue(d.field ?? "", d.after.value, scale)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
<div className="change-actions">
|
||||
{d.actions.map((a) => (
|
||||
<button key={a.action} type="button" className={`btn small${a.action === "accept" ? " primary-soft" : ""}`} disabled={resolve.isPending} onClick={() => resolve.mutate(a.action)}>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-q
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { api, type EntryView } from "../api.ts";
|
||||
import { LoadError } from "../components/LoadError.tsx";
|
||||
import { Cover } from "../components/Cover.tsx";
|
||||
import { EntryDetail } from "../components/EntryDetail.tsx";
|
||||
import { Icon } from "../components/Icon.tsx";
|
||||
@@ -166,12 +165,12 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : entries.error && !entries.data ? (
|
||||
) : entries.error ? (
|
||||
<div className="list-note" role="alert">
|
||||
<p>{friendlyError(entries.error)}</p>
|
||||
<button className="btn small" onClick={() => void entries.refetch()}>Try again</button>
|
||||
</div>
|
||||
) : items.length === 0 && !entries.error ? (
|
||||
) : items.length === 0 ? (
|
||||
<div className="list-note">
|
||||
{filtering ? (
|
||||
<>
|
||||
@@ -187,11 +186,6 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{entries.error && (
|
||||
<li>
|
||||
<LoadError error={entries.error} retry={() => void entries.refetch()} stale className="in-list" />
|
||||
</li>
|
||||
)}
|
||||
{items.map((e) => (
|
||||
<li key={e.id}>
|
||||
<Row entry={e} selected={e.id === selectedId} flagged={flagged.has(e.id)} scale={scale} />
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { api, type ListKind, type ListWithItems } from "../api.ts";
|
||||
import { Cover } from "../components/Cover.tsx";
|
||||
import { Icon } from "../components/Icon.tsx";
|
||||
import { LoadError } from "../components/LoadError.tsx";
|
||||
import { StarRating } from "../components/StarRating.tsx";
|
||||
import { friendlyError, mediaTypeLabel } from "../format.ts";
|
||||
import { useDebounced } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
|
||||
const KINDS: { kind: ListKind; label: string; hint: string }[] = [
|
||||
{ kind: "list", label: "List", hint: "Things in an order you choose." },
|
||||
{ kind: "grouping", label: "Grouping", hint: "A set you rate as a whole, like a box set or a trilogy." },
|
||||
{ kind: "thread", label: "Thread", hint: "A line that connects things across media." },
|
||||
];
|
||||
const kindLabel = (k: ListKind) => KINDS.find((x) => x.kind === k)?.label ?? k;
|
||||
|
||||
/** Lists, groupings and threads: the index on the left, the open one on the right. */
|
||||
export function Lists({ selectedId }: { selectedId: string | null }) {
|
||||
const lists = useQuery({ queryKey: ["lists"], queryFn: api.lists });
|
||||
return (
|
||||
<div className={`library${selectedId ? " has-selection" : ""}`}>
|
||||
<section className="list-pane" aria-label="Your lists">
|
||||
<div className="list-tools">
|
||||
<h1 className="pane-title">Lists</h1>
|
||||
<NewList />
|
||||
</div>
|
||||
{lists.error && lists.data && <LoadError error={lists.error} retry={() => void lists.refetch()} stale className="in-list" />}
|
||||
{lists.isPending ? (
|
||||
<p className="list-note" aria-busy="true">Loading…</p>
|
||||
) : lists.error && !lists.data ? (
|
||||
<div className="list-note"><LoadError error={lists.error} retry={() => void lists.refetch()} /></div>
|
||||
) : lists.data!.length === 0 ? (
|
||||
<div className="list-note">
|
||||
<p><strong>No lists yet.</strong> Name one above. A list can hold books, games, shows and albums side by side.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list">
|
||||
{lists.data!.map((l) => (
|
||||
<li key={l.id}>
|
||||
<Link href={`/lists/${l.id}`} className="row list-row" aria-current={l.id === selectedId ? "page" : undefined}>
|
||||
<span className={`kind-mark k-${l.kind}`} aria-hidden="true" />
|
||||
<span>
|
||||
<span className="title">{l.name}</span>
|
||||
<span className="meta">{kindLabel(l.kind)}{l.description ? ` · ${l.description}` : ""}</span>
|
||||
</span>
|
||||
<span />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<section className="detail-pane" aria-label="List">
|
||||
{selectedId ? (
|
||||
<ListDetail key={selectedId} id={selectedId} />
|
||||
) : (
|
||||
<div className="detail-empty"><p><strong>Pick a list</strong>Or make a new one: a reading order, a box set to rate as a whole, or a thread across media.</p></div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewList() {
|
||||
const qc = useQueryClient();
|
||||
const [, navigate] = useLocation();
|
||||
const [kind, setKind] = useState<ListKind>("list");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const create = useMutation({
|
||||
mutationFn: api.createList,
|
||||
onSuccess: (l) => {
|
||||
void qc.invalidateQueries({ queryKey: ["lists"] });
|
||||
navigate(`/lists/${l.id}`);
|
||||
},
|
||||
onError: (e) => setError(friendlyError(e)),
|
||||
});
|
||||
function submit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget;
|
||||
const name = String(new FormData(form).get("name") ?? "").trim();
|
||||
if (!name) return setError("Give the list a name.");
|
||||
setError(null);
|
||||
create.mutate({ name, kind }, { onSuccess: () => form.reset() });
|
||||
}
|
||||
return (
|
||||
<form className="new-list" onSubmit={submit}>
|
||||
<label className="sr-only" htmlFor="new-list-name">New list name</label>
|
||||
<div className="quick">
|
||||
<span className="plus" aria-hidden="true"><Icon name="plus" /></span>
|
||||
<input id="new-list-name" name="name" className="input" placeholder="New list: give it a name" maxLength={500} autoComplete="off" readOnly={create.isPending} />
|
||||
</div>
|
||||
<div className="new-list-row">
|
||||
<div className="kind-chips" role="radiogroup" aria-label="Kind">
|
||||
{KINDS.map((k) => (
|
||||
<button key={k.kind} type="button" role="radio" aria-checked={kind === k.kind} className="chip" title={k.hint} onClick={() => setKind(k.kind)}>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="submit" className="btn small primary" disabled={create.isPending}>Create</button>
|
||||
</div>
|
||||
<p className="hint">{KINDS.find((k) => k.kind === kind)!.hint}</p>
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ListDetail({ id }: { id: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [, navigate] = useLocation();
|
||||
const scale = useRatingScale();
|
||||
const key = ["list", id];
|
||||
const list = useQuery({ queryKey: key, queryFn: () => api.list(id) });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const refresh = () => {
|
||||
void qc.invalidateQueries({ queryKey: key });
|
||||
void qc.invalidateQueries({ queryKey: ["lists"] });
|
||||
};
|
||||
const patch = useMutation({
|
||||
mutationKey: ["list-patch", id],
|
||||
scope: { id: `list:${id}` },
|
||||
mutationFn: (b: Parameters<typeof api.patchList>[1]) => api.patchList(id, b),
|
||||
onMutate: async (b) => {
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<ListWithItems>(key);
|
||||
if (prev) qc.setQueryData<ListWithItems>(key, { ...prev, ...b });
|
||||
return { prev };
|
||||
},
|
||||
onError: (e, _b, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||
setError(friendlyError(e));
|
||||
},
|
||||
onSuccess: () => setError(null),
|
||||
onSettled: refresh,
|
||||
});
|
||||
const move = useMutation({
|
||||
scope: { id: `list:${id}` },
|
||||
mutationFn: ({ itemId, to }: { itemId: string; to: number }) => api.moveListItem(itemId, to),
|
||||
onMutate: async ({ itemId, to }) => {
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<ListWithItems>(key);
|
||||
if (prev) {
|
||||
const items = [...prev.items];
|
||||
const from = items.findIndex((i) => i.id === itemId);
|
||||
const [it] = items.splice(from, 1);
|
||||
items.splice(to, 0, it!);
|
||||
qc.setQueryData<ListWithItems>(key, { ...prev, items: items.map((i, n) => ({ ...i, position: n })) });
|
||||
}
|
||||
return { prev };
|
||||
},
|
||||
onError: (e, _v, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||
setError(friendlyError(e));
|
||||
},
|
||||
onSettled: refresh,
|
||||
});
|
||||
const removeItem = useMutation({ mutationFn: api.removeListItem, onSuccess: refresh, onError: (e) => setError(friendlyError(e)) });
|
||||
const del = useMutation({
|
||||
mutationFn: () => api.deleteList(id),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["lists"] });
|
||||
navigate("/lists", { replace: true });
|
||||
},
|
||||
onError: (e) => setError(friendlyError(e)),
|
||||
});
|
||||
const [said, setSaid] = useState("");
|
||||
|
||||
if (list.isPending) return <div className="detail" aria-busy="true" />;
|
||||
if (!list.data) {
|
||||
return (
|
||||
<div className="detail">
|
||||
<Link href="/lists" className="btn ghost back"><Icon name="back" /> Lists</Link>
|
||||
<LoadError error={list.error} retry={() => void list.refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const l = list.data;
|
||||
const n = l.items.length;
|
||||
|
||||
return (
|
||||
<article className="detail list-detail">
|
||||
<Link href="/lists" className="btn ghost back"><Icon name="back" /> Lists</Link>
|
||||
{list.error && <LoadError error={list.error} retry={() => void list.refetch()} stale />}
|
||||
<header className="list-head">
|
||||
<label className="sr-only" htmlFor="list-name">Name</label>
|
||||
<input
|
||||
id="list-name"
|
||||
className="title-input"
|
||||
defaultValue={l.name}
|
||||
key={l.name}
|
||||
maxLength={500}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (!v) e.target.value = l.name;
|
||||
else if (v !== l.name) patch.mutate({ name: v });
|
||||
}}
|
||||
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
|
||||
/>
|
||||
<div className="det-controls">
|
||||
<label className="sr-only" htmlFor="list-kind">Kind</label>
|
||||
<select id="list-kind" className="select" value={l.kind} onChange={(e) => patch.mutate({ kind: e.target.value as ListKind })}>
|
||||
{KINDS.map((k) => <option key={k.kind} value={k.kind}>{k.label}</option>)}
|
||||
</select>
|
||||
<span className="m tabular">{n === 1 ? "1 thing" : `${n} things`}</span>
|
||||
{l.kind === "grouping" && <StarRating value={l.rating} scale={scale} label={`Rating for ${l.name}`} onChange={(v) => patch.mutate({ rating: v })} />}
|
||||
</div>
|
||||
<label className="sr-only" htmlFor="list-desc">Description</label>
|
||||
<textarea
|
||||
id="list-desc"
|
||||
className="desc-input"
|
||||
rows={2}
|
||||
placeholder="Add a description"
|
||||
defaultValue={l.description}
|
||||
key={`d:${l.description}`}
|
||||
onBlur={(e) => e.target.value !== l.description && patch.mutate({ description: e.target.value })}
|
||||
/>
|
||||
</header>
|
||||
<p className="sr-only" role="status" aria-live="polite">{said}</p>
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
|
||||
{n === 0 ? (
|
||||
<p className="empty-line list-empty">Nothing here yet. Search below to add something.</p>
|
||||
) : (
|
||||
<ol className="items">
|
||||
{l.items.map((it, i) => (
|
||||
<li key={it.id} className="item">
|
||||
<span className="pos tabular" aria-hidden="true">{i + 1}</span>
|
||||
<Cover url={it.work.coverUrl} mediaType={it.work.mediaType} draft={false} size="small" />
|
||||
<span className="item-main">
|
||||
<span className="t">{it.work.title}</span>
|
||||
<span className="m">{mediaTypeLabel(it.work.mediaType)}{it.work.year ? ` · ${it.work.year}` : ""}</span>
|
||||
</span>
|
||||
<span className="item-acts">
|
||||
<button type="button" className="btn ghost small icon-btn" aria-label={`Move ${it.work.title} up`} disabled={i === 0} onClick={() => (move.mutate({ itemId: it.id, to: i - 1 }), setSaid(`${it.work.title} moved to ${i}.`))}>
|
||||
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg>
|
||||
</button>
|
||||
<button type="button" className="btn ghost small icon-btn" aria-label={`Move ${it.work.title} down`} disabled={i === n - 1} onClick={() => (move.mutate({ itemId: it.id, to: i + 1 }), setSaid(`${it.work.title} moved to ${i + 2}.`))}>
|
||||
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<button type="button" className="btn ghost small icon-btn" aria-label={`Remove ${it.work.title}`} onClick={() => removeItem.mutate(it.id)}>
|
||||
<Icon name="x" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<AddWork listId={id} have={new Set(l.items.map((i) => i.workId))} onAdded={refresh} />
|
||||
|
||||
<div className="list-foot">
|
||||
{confirming ? (
|
||||
<div className="form-actions">
|
||||
<span className="hint">Delete “{l.name}”? The things in it stay in your library.</span>
|
||||
<button type="button" className="btn danger small" onClick={() => del.mutate()} disabled={del.isPending}>Delete list</button>
|
||||
<button type="button" className="btn ghost small" onClick={() => setConfirming(false)}>Keep it</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn ghost small danger" onClick={() => setConfirming(true)}>Delete list…</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function AddWork({ listId, have, onAdded }: { listId: string; have: Set<string>; onAdded: () => void }) {
|
||||
const [q, setQ] = useState("");
|
||||
const dq = useDebounced(q.trim());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const results = useQuery({ queryKey: ["works-search", dq], queryFn: () => api.searchWorks(dq), enabled: dq.length > 0 });
|
||||
const add = useMutation({
|
||||
mutationFn: (workId: string) => api.addListItem(listId, workId),
|
||||
onSuccess: () => (setError(null), onAdded()),
|
||||
onError: (e) => setError(friendlyError(e)),
|
||||
});
|
||||
useEffect(() => setError(null), [dq]);
|
||||
return (
|
||||
<section className="box add-work" aria-labelledby="add-work">
|
||||
<h2 id="add-work">Add to this list</h2>
|
||||
<div className="search">
|
||||
<Icon name="search" />
|
||||
<label className="sr-only" htmlFor="add-work-q">Search the catalogue and your library</label>
|
||||
<input id="add-work-q" className="input" placeholder="Search by title" value={q} onChange={(e) => setQ(e.target.value)} autoComplete="off" />
|
||||
</div>
|
||||
{results.error && <LoadError error={results.error} retry={() => void results.refetch()} />}
|
||||
{dq && results.data && (
|
||||
results.data.length === 0 ? (
|
||||
<p className="empty-line">No matches. Quick add it to your library first, then add it here.</p>
|
||||
) : (
|
||||
<ul className="results">
|
||||
{results.data.map((w) => (
|
||||
<li key={w.id}>
|
||||
<Cover url={w.cover_url} mediaType={w.media_type} draft={false} size="small" />
|
||||
<span>
|
||||
<span className="t">{w.title}</span>
|
||||
<span className="m"> {mediaTypeLabel(w.media_type)}{w.year ? ` · ${w.year}` : ""}</span>
|
||||
</span>
|
||||
{have.has(w.id) ? (
|
||||
<span className="hint">Added</span>
|
||||
) : (
|
||||
<button type="button" className="btn small" onClick={() => add.mutate(w.id)} disabled={add.isPending} aria-label={`Add ${w.title}`}>Add</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
{error && <p className="error-text" role="alert">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { api, type LogItem } from "../api.ts";
|
||||
import { Cover } from "../components/Cover.tsx";
|
||||
import { LoadError } from "../components/LoadError.tsx";
|
||||
import { describeRating, engagementNouns, formatDateRange, formatRating, mediaTypeLabel, MONTHS, shortDate } from "../format.ts";
|
||||
import { logMonth } from "../labels.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
|
||||
function listenTime(at: string): string {
|
||||
if (at.length <= 10) return shortDate(at);
|
||||
const d = new Date(at);
|
||||
if (Number.isNaN(d.getTime())) return at;
|
||||
return `${d.getDate()} ${MONTHS[d.getMonth()]}, ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function Log() {
|
||||
const scale = useRatingScale();
|
||||
const log = useInfiniteQuery({
|
||||
queryKey: ["log"],
|
||||
queryFn: ({ pageParam }) => api.log(pageParam),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (p) => p.nextCursor ?? undefined,
|
||||
});
|
||||
const items = log.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
const groups: { month: string; items: LogItem[] }[] = [];
|
||||
for (const it of items) {
|
||||
const month = logMonth(it.at);
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.month === month) last.items.push(it);
|
||||
else groups.push({ month, items: [it] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<div className="screen-inner narrow">
|
||||
<header className="screen-head">
|
||||
<h1>Log</h1>
|
||||
<p className="lede">Everything you've finished, started and listened to, newest first.</p>
|
||||
</header>
|
||||
{log.error && log.data && <LoadError error={log.error} retry={() => void log.refetch()} stale />}
|
||||
{log.isPending ? (
|
||||
<ul className="log" aria-busy="true" aria-label="Loading your log">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<li key={i} className="log-row skeleton" aria-hidden="true"><div className="cover small" /><div><div className="bar" /><div className="bar" /></div></li>
|
||||
))}
|
||||
</ul>
|
||||
) : log.error && !log.data ? (
|
||||
<LoadError error={log.error} retry={() => void log.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<strong>Nothing logged yet</strong>
|
||||
<p>Log a read, watch or play from any entry in your library, or connect a source to bring your history in.</p>
|
||||
<div className="form-actions" style={{ marginTop: 14 }}>
|
||||
<Link href="/" className="btn">Go to library</Link>
|
||||
<Link href="/sources" className="btn ghost">Connect a source</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{groups.map((g) => (
|
||||
<section key={g.month} className="log-month" aria-label={g.month}>
|
||||
<h2>{g.month}</h2>
|
||||
<ul className="log">
|
||||
{g.items.map((it) => (
|
||||
<li key={`${it.kind}:${it.id}`}>{it.kind === "engagement" ? <EngagementRow it={it} scale={scale} /> : <ListenRow it={it} />}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{log.hasNextPage && (
|
||||
<button type="button" className="btn small more-btn" disabled={log.isFetchingNextPage} onClick={() => void log.fetchNextPage()}>
|
||||
{log.isFetchingNextPage ? "Loading…" : "Show older"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EngagementRow({ it, scale }: { it: Extract<LogItem, { kind: "engagement" }>; scale: ReturnType<typeof useRatingScale> }) {
|
||||
const nouns = engagementNouns(it.mediaType);
|
||||
const dated = it.startedAt || it.finishedAt;
|
||||
const verb = it.finishedAt ? nouns.past : it.startedAt ? "Started" : "Logged";
|
||||
const when = dated ? formatDateRange(it.startedAt, it.finishedAt).replace(/^Started /, "") : shortDate(it.at);
|
||||
return (
|
||||
<Link href={`/entries/${it.entryId}`} className="log-row">
|
||||
<Cover url={it.coverUrl} mediaType={it.mediaType} draft={false} size="small" />
|
||||
<div className="log-main">
|
||||
<span className="log-title">{it.title}</span>
|
||||
<span className="log-meta">
|
||||
{mediaTypeLabel(it.mediaType)} · {verb} {when}
|
||||
{it.platform ? ` · ${it.platform}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{it.rating !== null && <span className="stars log-stars" role="img" aria-label={describeRating(it.rating, scale)}>{formatRating(it.rating, scale)}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ListenRow({ it }: { it: Extract<LogItem, { kind: "listen" }> }) {
|
||||
return (
|
||||
<div className="log-row listen">
|
||||
<div className="cover small listen-cover" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" className="icon"><path d="M9 18V5l11-2v13M9 18a3 3 0 1 1-6 0 3 3 0 0 1 6 0zM20 16a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" /></svg>
|
||||
</div>
|
||||
<div className="log-main">
|
||||
<span className="log-title">{it.track}</span>
|
||||
<span className="log-meta">
|
||||
{it.artist}
|
||||
{it.album ? ` · ${it.album}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<time className="log-when" dateTime={it.at}>{listenTime(it.at)}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,456 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -392,225 +392,3 @@ input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px;
|
||||
.alert { flex-direction: column; align-items: stretch; }
|
||||
.box form .two { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* =====================================================================================
|
||||
Task 18 screens: Changes, Sources, Log, Lists, Owned, Settings, About
|
||||
===================================================================================== */
|
||||
|
||||
/* ---------- Load errors ---------- */
|
||||
.load-error { padding: 18px 0; color: var(--text-muted); display: grid; gap: 10px; justify-items: start; }
|
||||
.stale-banner {
|
||||
display: flex; align-items: center; gap: 10px; justify-content: space-between;
|
||||
background: #3a1d24; border: 1px solid #f8717144; color: #ffd4d4;
|
||||
border-radius: var(--r-control); padding: 6px 6px 6px 12px; font-size: 0.8rem; margin: 0 0 12px;
|
||||
}
|
||||
.stale-banner .btn.ghost { color: #ffd4d4; }
|
||||
.stale-banner.in-list { margin: 4px 16px 8px; }
|
||||
|
||||
/* ---------- Screen frame ---------- */
|
||||
.screen { height: 100%; overflow-y: auto; }
|
||||
.screen-inner { padding: 26px 32px 64px; max-width: 900px; }
|
||||
.screen-head { margin-bottom: 20px; }
|
||||
.screen-head h1 { font-size: 1.35rem; line-height: 1.2; font-weight: 650; letter-spacing: -0.012em; text-wrap: balance; }
|
||||
.screen-head .lede { color: var(--text-muted); margin-top: 5px; max-width: 64ch; }
|
||||
.screen-head.with-action { display: flex; gap: 16px; align-items: flex-start; justify-content: space-between; }
|
||||
.section { margin-top: 30px; }
|
||||
.section > h2, .section-head h2 { font-size: 0.95rem; font-weight: 650; letter-spacing: -0.005em; }
|
||||
.section > .hint, .section-head + .hint { margin: 4px 0 12px; max-width: 64ch; display: block; }
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
|
||||
.empty-state { padding: 34px 0 12px; max-width: 46ch; color: var(--text-muted); }
|
||||
.empty-state strong { display: block; color: var(--text); font-size: 1rem; font-weight: 600; margin-bottom: 4px; }
|
||||
.empty-state .btn { margin-top: 14px; }
|
||||
.filter-note { font-size: 0.84rem; color: var(--text-muted); margin: -6px 0 18px; }
|
||||
.filter-note b { color: var(--text); font-weight: 600; }
|
||||
.more-note { margin-top: 14px; }
|
||||
.btn.primary-soft { background: #6d5dfc33; color: #ded9ff; }
|
||||
.btn.primary-soft:hover:not(:disabled) { background: #6d5dfc55; }
|
||||
.btn.danger { color: #ffb4a8; }
|
||||
.btn.danger:hover:not(:disabled) { background: #4a1f2a; color: #ffd4d4; }
|
||||
.badge { display: inline-flex; align-items: center; gap: 5px; font-size: 0.72rem; font-weight: 550; padding: 1px 8px; border-radius: var(--r-pill); background: var(--surface-3); color: var(--pill-text); white-space: nowrap; }
|
||||
.badge.ok::before, .badge.off::before, .badge.bad::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.badge.ok { color: var(--ok); background: #34d39914; }
|
||||
.badge.off { color: var(--text-muted); }
|
||||
.badge.bad { color: #ffb4a8; background: #f8717114; }
|
||||
.secret-once {
|
||||
margin-top: 12px; border: 1px solid #fbbf2455; background: #fbbf240d; border-radius: var(--r-control);
|
||||
padding: 10px 12px; display: grid; gap: 8px;
|
||||
}
|
||||
.secret-once p { font-size: 0.8rem; color: var(--text-muted); }
|
||||
.secret-once .copy-row { display: flex; gap: 8px; align-items: center; }
|
||||
.secret-once code { flex: 1; min-width: 0; overflow-wrap: anywhere; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.82rem; color: var(--rating); background: var(--surface-0); padding: 6px 9px; border-radius: 6px; }
|
||||
|
||||
/* ---------- Changes ---------- */
|
||||
.run-group { margin-top: 26px; }
|
||||
.run-group:first-of-type { margin-top: 6px; }
|
||||
.run-head { display: flex; flex-wrap: wrap; gap: 6px; align-items: baseline; font-size: 0.78rem; font-weight: 500; color: var(--text-faint); margin-bottom: 8px; }
|
||||
.run-head .who { color: var(--text-muted); font-weight: 600; }
|
||||
.changes { list-style: none; display: grid; gap: 10px; }
|
||||
.change { background: var(--surface-2); border-radius: var(--r-box); padding: 13px 16px 14px; display: grid; gap: 8px; }
|
||||
.change.skeleton { height: 128px; }
|
||||
.change-skeleton { display: grid; gap: 10px; }
|
||||
.change-top { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
|
||||
.change .kind { flex: none; display: inline-flex; align-items: center; gap: 6px; font-size: 0.72rem; font-weight: 600; color: #fdba74; }
|
||||
.change .kind::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
|
||||
.change-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; color: var(--text-muted); }
|
||||
.change-title a, .change-title b { color: var(--text); font-weight: 600; }
|
||||
.change-title a:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.change .when { flex: none; font-size: 0.74rem; color: var(--text-faint); }
|
||||
.change-msg { color: var(--warn-text); font-size: 0.86rem; }
|
||||
.diff { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--surface-3); border-radius: var(--r-control); overflow: hidden; }
|
||||
.diff > div { background: #221e40; padding: 8px 12px; min-width: 0; }
|
||||
.diff dt { font-size: 0.72rem; color: var(--text-faint); font-weight: 600; margin-bottom: 2px; }
|
||||
.diff dd { font-size: 0.86rem; overflow-wrap: anywhere; }
|
||||
.diff .was dd { color: var(--text-muted); }
|
||||
.diff .now dd { color: var(--text); }
|
||||
.diff dd.stars { color: var(--rating); font-size: 0.8rem; }
|
||||
.change-actions { display: flex; gap: 6px; flex-wrap: wrap; padding-top: 2px; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.screen-inner { padding: 18px 16px 48px; }
|
||||
.diff { grid-template-columns: 1fr; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
/* ---------- Log ---------- */
|
||||
.screen-inner.narrow { max-width: 720px; }
|
||||
.log-month { margin-top: 22px; }
|
||||
.log-month h2 { font-size: 0.78rem; font-weight: 600; color: var(--text-faint); margin-bottom: 4px; position: sticky; top: 0; background: var(--surface-1); padding: 6px 0; z-index: 1; }
|
||||
.log { list-style: none; }
|
||||
.log-row { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 7px 10px; margin: 0 -10px; border-radius: var(--r-control); }
|
||||
a.log-row { transition: background 0.12s; }
|
||||
a.log-row:hover { background: var(--surface-2); }
|
||||
.log-main { min-width: 0; display: grid; }
|
||||
.log-title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.log-meta { font-size: 0.76rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.log-stars { font-size: 0.72rem; }
|
||||
.log-when { font-size: 0.76rem; color: var(--text-faint); white-space: nowrap; }
|
||||
.listen-cover { display: grid; place-items: center; background: color-mix(in srgb, var(--media-music) 55%, var(--surface-2)); color: #f6d3e1; }
|
||||
.listen-cover .icon { width: 14px; height: 14px; }
|
||||
.log-row.skeleton .bar { background: var(--surface-2); height: 10px; border-radius: 4px; width: 60%; }
|
||||
.log-row.skeleton .bar + .bar { width: 35%; margin-top: 6px; height: 8px; }
|
||||
.log-row.skeleton .cover { background: var(--surface-2); }
|
||||
.more-btn { margin-top: 18px; }
|
||||
|
||||
/* ---------- Lists ---------- */
|
||||
.pane-title { font-size: 1.05rem; font-weight: 650; letter-spacing: -0.01em; }
|
||||
.new-list { display: grid; gap: 8px; }
|
||||
.new-list-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||
.kind-chips { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.chip[aria-checked="true"] { background: var(--surface-3); color: #fff; border-color: var(--surface-3); }
|
||||
.list-row { grid-template-columns: 10px minmax(0, 1fr) auto; }
|
||||
.kind-mark { width: 8px; height: 8px; border-radius: 2px; background: var(--dash); }
|
||||
.kind-mark.k-grouping { background: var(--rating); border-radius: 50%; }
|
||||
.kind-mark.k-thread { background: transparent; border: 1.5px dashed var(--pill-text); border-radius: 50%; }
|
||||
.list-head { display: grid; gap: 8px; margin-bottom: 18px; }
|
||||
.title-input, .desc-input {
|
||||
background: transparent; border: 1px solid transparent; border-radius: var(--r-control); color: var(--text);
|
||||
margin: 0 -8px; padding: 2px 8px; width: calc(100% + 16px); transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.title-input { font-size: 1.45rem; font-weight: 650; letter-spacing: -0.012em; line-height: 1.25; }
|
||||
.desc-input { resize: vertical; color: var(--text-muted); font-size: 0.88rem; line-height: 1.5; }
|
||||
.desc-input::placeholder { color: var(--text-faint); }
|
||||
.title-input:hover, .desc-input:hover { border-color: var(--surface-3); }
|
||||
.title-input:focus-visible, .desc-input:focus-visible { outline: none; border-color: var(--focus); background: #211d40; }
|
||||
.list-head .det-controls .m { color: var(--text-muted); font-size: 0.82rem; }
|
||||
.items { list-style: none; display: grid; gap: 2px; }
|
||||
.item { display: grid; grid-template-columns: 22px 28px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 6px 8px; margin: 0 -8px; border-radius: var(--r-control); }
|
||||
.item:hover { background: var(--surface-2); }
|
||||
.item .pos { color: var(--text-faint); font-size: 0.8rem; text-align: right; }
|
||||
.item-main { min-width: 0; display: grid; }
|
||||
.item-main .t { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item-main .m { font-size: 0.76rem; color: var(--text-muted); }
|
||||
.item-acts { display: flex; gap: 2px; opacity: 0.55; transition: opacity 0.15s; }
|
||||
.item:hover .item-acts, .item-acts:focus-within { opacity: 1; }
|
||||
@media (hover: none) { .item-acts { opacity: 1; } }
|
||||
.icon-btn { width: 28px; height: 28px; padding: 0; justify-content: center; }
|
||||
.list-empty { padding: 6px 0 4px; }
|
||||
.add-work { margin-top: 18px; }
|
||||
.add-work .results { list-style: none; margin-top: 10px; display: grid; gap: 2px; }
|
||||
.add-work .results li { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 4px 6px; border-radius: 6px; font-size: 0.84rem; }
|
||||
.add-work .results li:hover { background: var(--surface-3); }
|
||||
.add-work .results .m { color: var(--text-muted); font-size: 0.76rem; }
|
||||
.add-work .search { max-width: 420px; }
|
||||
.list-foot { margin-top: 28px; }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { draftFailedSave, draftIncoming, draftStartSave, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
|
||||
import { draftIncoming, draftSettled, draftStartSave, newDraft, type Draft, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
|
||||
|
||||
describe("formatRating", () => {
|
||||
test("5 stars with halves", () => {
|
||||
@@ -98,29 +98,51 @@ test("shortDate", () => {
|
||||
});
|
||||
|
||||
describe("review draft sync", () => {
|
||||
const typed = { text: "Second thoughts.", synced: "First thoughts.", saving: false };
|
||||
const draft = (text: string, synced: string): Draft => ({ ...newDraft(synced), text });
|
||||
const pick = (d: Draft) => [d.text, d.synced, d.pending];
|
||||
|
||||
test("a rollback after a failed save keeps the local text (rollback lands first)", () => {
|
||||
let d = draftStartSave(typed); // optimistic cache now says "Second thoughts."
|
||||
d = draftIncoming(d, "Second thoughts.");
|
||||
const was = typed.synced;
|
||||
d = draftIncoming(d, "First thoughts."); // onError rolled the cache back
|
||||
d = draftFailedSave(d, was);
|
||||
expect(d.text).toBe("Second thoughts.");
|
||||
expect(d.synced).toBe("First thoughts."); // so the text still counts as unsaved
|
||||
expect(d.saving).toBe(false);
|
||||
test("a rollback after a failed save keeps the local text", () => {
|
||||
const save = { was: "First thoughts.", stored: "Second thoughts." };
|
||||
let d = draftStartSave(draft("Second thoughts.", save.was), save);
|
||||
d = draftIncoming(d, save.stored); // optimistic update
|
||||
d = draftIncoming(d, save.was); // rollback
|
||||
d = draftSettled(d, save, false);
|
||||
expect(pick(d)).toEqual(["Second thoughts.", "First thoughts.", 0]);
|
||||
});
|
||||
|
||||
test("a rollback after a failed save keeps the local text (error callback first)", () => {
|
||||
let d = draftStartSave(typed);
|
||||
d = draftFailedSave(d, typed.synced);
|
||||
d = draftIncoming(d, "First thoughts.");
|
||||
expect(d.text).toBe("Second thoughts.");
|
||||
// Two overlapping saves: "a" (was "old"), then "b" (was "a"). Incoming values mirror what the cache does.
|
||||
const A = { was: "old", stored: "a" };
|
||||
const B = { was: "a", stored: "b" };
|
||||
const twoSaves = () => draftIncoming(draftIncoming(draftStartSave({ ...draftStartSave(draft("a", "old"), A), text: "b" }, B), "a"), "b");
|
||||
|
||||
test("two overlapping saves both settle and the flag clears (success, success)", () => {
|
||||
let d = twoSaves();
|
||||
expect(d.pending).toBe(2);
|
||||
d = draftSettled(draftSettled(d, A, true), B, true);
|
||||
expect(pick(d)).toEqual(["b", "b", 0]);
|
||||
expect(d.echoes).toEqual([]);
|
||||
});
|
||||
|
||||
test("a change from elsewhere (Restore) replaces untouched text but not edited text", () => {
|
||||
const clean = { text: "Second thoughts.", synced: "Second thoughts.", saving: false };
|
||||
expect(draftIncoming(clean, "First thoughts.").text).toBe("First thoughts.");
|
||||
expect(draftIncoming(typed, "Something else").text).toBe("Second thoughts.");
|
||||
test("two overlapping saves: fail then success ends synced; success then fail keeps text unsaved", () => {
|
||||
let d = draftIncoming(twoSaves(), "old"); // A's rollback
|
||||
d = draftSettled(draftSettled(d, A, false), B, true);
|
||||
expect(pick(d)).toEqual(["b", "b", 0]);
|
||||
let e = draftIncoming(twoSaves(), "a"); // B's rollback
|
||||
e = draftSettled(draftSettled(e, A, true), B, false);
|
||||
expect(pick(e)).toEqual(["b", "a", 0]);
|
||||
});
|
||||
|
||||
test("an incoming value that arrives while saving is applied once saving ends", () => {
|
||||
const save = { was: "x", stored: "y" };
|
||||
let d = draftStartSave(draft("y", "x"), save);
|
||||
d = draftIncoming(d, "Restored.");
|
||||
expect(d.text).toBe("y");
|
||||
d = draftSettled(d, save, true);
|
||||
expect(pick(d)).toEqual(["Restored.", "Restored.", 0]);
|
||||
});
|
||||
|
||||
test("a change from elsewhere replaces untouched text but not edited text", () => {
|
||||
expect(draftIncoming(draft("Second.", "Second."), "First.").text).toBe("First.");
|
||||
expect(draftIncoming(draft("Typed.", "Second."), "Other.").text).toBe("Typed.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { ChangeEvent } from "../src/api.ts";
|
||||
import { changeValue, describeChange, groupByRun, logMonth, scheduleLabel, SCHEDULES } from "../src/labels.ts";
|
||||
|
||||
const base: ChangeEvent = {
|
||||
id: "ev1",
|
||||
type: "changed",
|
||||
status: "pending",
|
||||
connectionId: "c1",
|
||||
runId: "r1",
|
||||
targetEntity: "entry",
|
||||
targetId: "e1",
|
||||
payload: {},
|
||||
createdAt: "2026-10-30T10:00:00.000Z",
|
||||
title: "Piranesi",
|
||||
};
|
||||
const sources = [
|
||||
{ id: "c1", pluginId: "hardcover", name: "Hardcover" },
|
||||
{ id: "c2", pluginId: "trakt", name: "Trakt" },
|
||||
];
|
||||
|
||||
describe("describeChange", () => {
|
||||
test("removed", () => {
|
||||
const d = describeChange({ ...base, type: "removed", payload: { key: "b:1", kind: "entry" } }, sources);
|
||||
expect(d.message).toBe("Hardcover no longer has this. Your copy here is safe.");
|
||||
expect(d.actions).toEqual([
|
||||
{ action: "accept", label: "Delete here too" },
|
||||
{ action: "ignore", label: "Keep it" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("merged, with and without the target title", () => {
|
||||
const ev = { ...base, type: "merged", payload: { key: "b:1", intoKey: "b:2" }, intoTitle: "Piranesi (Deluxe)" };
|
||||
const d = describeChange(ev, sources);
|
||||
expect(d.message).toBe("Hardcover merged this into Piranesi (Deluxe).");
|
||||
expect(d.actions).toEqual([
|
||||
{ action: "accept", label: "Follow the merge" },
|
||||
{ action: "ignore", label: "Ignore" },
|
||||
]);
|
||||
expect(describeChange({ ...ev, intoTitle: null }, sources).message).toBe("Hardcover merged this into another entry.");
|
||||
});
|
||||
|
||||
test("conflict shows both sources' values", () => {
|
||||
const d = describeChange(
|
||||
{
|
||||
...base,
|
||||
type: "conflict",
|
||||
connectionId: "c2",
|
||||
payload: { field: "rating", current: 80, currentSource: "plugin:hardcover:c1", incoming: 60, incomingSource: "plugin:trakt:c2" },
|
||||
},
|
||||
sources,
|
||||
);
|
||||
expect(d.message).toBe("Trakt and Hardcover disagree about the rating.");
|
||||
expect(d.field).toBe("rating");
|
||||
expect(d.before).toEqual({ label: "Hardcover", value: 80 });
|
||||
expect(d.after).toEqual({ label: "Trakt", value: 60 });
|
||||
expect(d.actions).toEqual([
|
||||
{ action: "accept", label: "Use the new value" },
|
||||
{ action: "keep_mine", label: "Keep mine" },
|
||||
{ action: "ignore", label: "Ignore" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("changed shows yours against the source's", () => {
|
||||
const d = describeChange({ ...base, payload: { field: "status", from: "completed", to: "in_progress" } }, sources);
|
||||
expect(d.message).toBe("Hardcover changed the status.");
|
||||
expect(d.before).toEqual({ label: "Yours", value: "completed" });
|
||||
expect(d.after).toEqual({ label: "Hardcover", value: "in_progress" });
|
||||
expect(d.actions.map((a) => a.label)).toEqual(["Use the new value", "Keep mine", "Ignore"]);
|
||||
});
|
||||
|
||||
test("an unknown source still reads well", () => {
|
||||
const d = describeChange({ ...base, type: "removed", connectionId: null, payload: {} }, []);
|
||||
expect(d.message).toBe("A source no longer has this. Your copy here is safe.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduleLabel", () => {
|
||||
test("the four choices", () => {
|
||||
expect(scheduleLabel(null)).toBe("Off");
|
||||
expect(scheduleLabel(60)).toBe("Every hour");
|
||||
expect(scheduleLabel(360)).toBe("Every 6 hours");
|
||||
expect(scheduleLabel(1440)).toBe("Daily");
|
||||
expect(SCHEDULES).toEqual([null, 60, 360, 1440]);
|
||||
});
|
||||
test("other intervals", () => {
|
||||
expect(scheduleLabel(30)).toBe("Every 30 minutes");
|
||||
expect(scheduleLabel(120)).toBe("Every 2 hours");
|
||||
expect(scheduleLabel(2880)).toBe("Every 2 days");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByRun", () => {
|
||||
test("keeps newest-first order and groups events of one run", () => {
|
||||
const g = groupByRun([
|
||||
{ ...base, id: "3", runId: "rB" },
|
||||
{ ...base, id: "2", runId: "rA" },
|
||||
{ ...base, id: "1", runId: "rB" },
|
||||
]);
|
||||
expect(g.map((x) => [x.runId, x.events.map((e) => e.id)])).toEqual([
|
||||
["rB", ["3", "1"]],
|
||||
["rA", ["2"]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeValue", () => {
|
||||
test("values read in the user's terms", () => {
|
||||
expect(changeValue("rating", 80, "5half")).toBe("★★★★☆");
|
||||
expect(changeValue("rating", 85, "10")).toBe("8.5/10");
|
||||
expect(changeValue("status", "in_progress", "5half", "book")).toBe("Reading");
|
||||
expect(changeValue("finishedAt", { value: "2025-03", precision: "month" }, "5half")).toBe("Mar 2025");
|
||||
expect(changeValue("tags", ["sci-fi", "re-read"], "5half")).toBe("sci-fi, re-read");
|
||||
expect(changeValue("favorite", true, "5half")).toBe("Yes");
|
||||
expect(changeValue("rating", null, "5half")).toBe("Nothing");
|
||||
expect(changeValue("tags", [], "5half")).toBe("Nothing");
|
||||
expect(changeValue("review", "x".repeat(300), "5half")).toHaveLength(160);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logMonth", () => {
|
||||
test("month headings from partial dates and timestamps", () => {
|
||||
expect(logMonth("2026-10-01")).toBe("October 2026");
|
||||
expect(logMonth("2025-03")).toBe("March 2025");
|
||||
expect(logMonth("2019")).toBe("2019");
|
||||
expect(logMonth("2026-01-31T23:59:00.000Z")).toBe("January 2026");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ user layer or files a Changes-inbox event.
|
||||
|---|---|---|
|
||||
| `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/<area>.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists |
|
||||
| `apps/web` | React 19 app served by the server as an HTML import (`index.html` → `src/main.tsx`, `src/theme.css`). wouter routes in `main.tsx` (`/login`, `/register`, `/`, `/entries/:id`; other sections are placeholders until Task 18); `api.ts` = fetch client (`request`, `ApiError`, cookie auth, CSRF header, mirrored wire types); `format.ts` = pure display helpers and `friendlyError`; `edits.ts` = next-value builders for entry edits; `hooks.ts` = TanStack Query hooks incl. optimistic `useEntryPatch`; `settings.ts` = preferences; `components/` (shell, detail boxes, history drawer, rating control) and `routes/`. Never imports `packages/*` at runtime | exists |
|
||||
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `preferences` (per-user rating scale and default visibility), `log` (the Log feed: engagements and listens, keyset-paged), `titles` (batched override-aware title lookups for list views), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
|
||||
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `preferences` (per-user rating scale and default visibility), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
|
||||
| `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) |
|
||||
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
|
||||
| `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
|
||||
|
||||
@@ -4,24 +4,6 @@ Append-only. Never edit or delete an entry. Supersede it with a new one that lin
|
||||
One entry per decision a future maintainer would otherwise have to reverse-engineer.
|
||||
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
|
||||
|
||||
## 2026-10-30: Log feed, titles on Changes, work titles on copies (Task 18 API additions)
|
||||
**Status:** accepted
|
||||
**Context:** Task 18's Log and Changes screens had nothing to read from (controller ruling).
|
||||
**Decision:**
|
||||
- **`GET log?cursor=&limit=`** (any:read) → `{ items, nextCursor }` from `packages/core/src/log.ts` `listLog`: the user's
|
||||
live engagements on live entries plus their listens, newest first. Each item's `at` is a listen's `listened_at`, or an
|
||||
engagement's finish date, else start date, else `created_at`. Partial dates sort as strings, never through `Date`.
|
||||
Keyset cursor over (`at`, `kind`, `id`) all descending, base64url JSON; default 50, max 200. Two queries (one per
|
||||
kind, each `limit + 1` past the cursor) are merged in code, so no dialect-specific UNION is needed.
|
||||
- **`listChanges` items carry `title`** (an entry's title with the user's override; an engagement resolves to its entry; a
|
||||
list to its name; null when gone) **and, on merged events, `intoTitle`**: the title of the entry that the same
|
||||
connection's `intoKey` record maps to, else null. Titles come from a fixed number of batched queries
|
||||
(`packages/core/src/titles.ts`), not one per event.
|
||||
- **`GET plugins` adds `deviceAuth: boolean`** to each manifest, so the Sources screen knows when to offer the
|
||||
device-code sign-in (the manifest itself has no such capability).
|
||||
- **`listCopies` items carry `workTitle`** (override-aware, null while unmatched) so Owned needs no per-row work lookups.
|
||||
The export bundle is unchanged.
|
||||
|
||||
## 2026-10-30: Web app (Task 17): preferences, first run, optimistic edits, AA on orange, self-hosted fonts
|
||||
**Status:** accepted
|
||||
**Context:** Task 17 and its fix round 1 (controller rulings).
|
||||
|
||||
@@ -7,7 +7,6 @@ import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
|
||||
import { checkKeys, checkText, fromStoredDate, resolveVisibleWork, toStoredDate } from "./entries.ts";
|
||||
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
|
||||
import { workTitles } from "./titles.ts";
|
||||
|
||||
/** An owned copy (spec §6.7). Always private. `workId` is null until matched; then `titleHint` names it. */
|
||||
export interface CopyPatch {
|
||||
@@ -225,16 +224,12 @@ export async function softDeleteCopy(
|
||||
await inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), {}, actor, { deleted_at: nowIso() }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's live copies, optionally of one work, newest first. `workTitle` is the matched work's title as
|
||||
* the user sees it (their entry's title override wins); null while unmatched.
|
||||
*/
|
||||
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<(Copy & { workTitle: string | null })[]> {
|
||||
/** The user's live copies, optionally of one work, newest first. */
|
||||
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<Copy[]> {
|
||||
let q = db.k.selectFrom("copies").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
|
||||
if (filter.workId) q = q.where("work_id", "=", filter.workId);
|
||||
const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").execute();
|
||||
const titles = await workTitles(db.k, userId, rows.map((r) => r.work_id));
|
||||
return rows.map((r) => ({ ...toCopy(r), workTitle: (r.work_id && titles.get(r.work_id)) || null }));
|
||||
return rows.map(toCopy);
|
||||
}
|
||||
|
||||
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* The Log: one chronological feed of a user's engagements (reads, plays, watches) and listens, newest first.
|
||||
*
|
||||
* Each item has an `at` sort key: a listen's `listened_at`; an engagement's finish date, else its start
|
||||
* date, else when it was logged. Partial dates sort as strings ("2025-03" sits after "2025-03-31" and
|
||||
* before "2025-04"), never through `Date`. Pages use a keyset cursor over (at, kind, id), all descending,
|
||||
* so equal timestamps across the two kinds page without gaps or repeats.
|
||||
*/
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { fromJson } from "@sttracker/db/json";
|
||||
import type { PartialDate } from "@sttracker/plugin-sdk/types";
|
||||
import { sql } from "kysely";
|
||||
import { fromStoredDate } from "./entries.ts";
|
||||
import { ValidationError } from "./errors.ts";
|
||||
|
||||
export type LogItem =
|
||||
| {
|
||||
kind: "engagement";
|
||||
id: string;
|
||||
at: string;
|
||||
entryId: string;
|
||||
workId: string;
|
||||
title: string;
|
||||
mediaType: string;
|
||||
coverUrl: string;
|
||||
startedAt: PartialDate | null;
|
||||
finishedAt: PartialDate | null;
|
||||
platform: string | null;
|
||||
rating: number | null;
|
||||
}
|
||||
| {
|
||||
kind: "listen";
|
||||
id: string;
|
||||
at: string;
|
||||
workId: string | null;
|
||||
title: string;
|
||||
mediaType: "track";
|
||||
artist: string;
|
||||
track: string;
|
||||
album: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type Kind = LogItem["kind"];
|
||||
interface Cursor {
|
||||
at: string;
|
||||
kind: Kind;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function encodeCursor(c: Cursor): string {
|
||||
return Buffer.from(JSON.stringify([c.at, c.kind, c.id])).toString("base64url");
|
||||
}
|
||||
|
||||
function decodeCursor(s: string): Cursor {
|
||||
try {
|
||||
const [at, kind, id] = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as unknown[];
|
||||
if (typeof at === "string" && (kind === "engagement" || kind === "listen") && typeof id === "string") return { at, kind, id };
|
||||
} catch {
|
||||
// Fall through to the validation error.
|
||||
}
|
||||
throw new ValidationError("cursor", "invalid");
|
||||
}
|
||||
|
||||
/** (at, kind, id) < cursor, with this query's fixed kind: kinds sort "listen" > "engagement". */
|
||||
function after(kind: Kind, c: Cursor): "lt" | "le" | "tie" {
|
||||
if (kind === c.kind) return "tie";
|
||||
return kind < c.kind ? "le" : "lt";
|
||||
}
|
||||
|
||||
export async function listLog(
|
||||
db: Db,
|
||||
userId: string,
|
||||
opts: { limit?: number; cursor?: string } = {},
|
||||
): Promise<{ items: LogItem[]; nextCursor: string | null }> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const cursor = opts.cursor ? decodeCursor(opts.cursor) : null;
|
||||
|
||||
const at = sql<string>`coalesce(nullif(g.finished_at, ''), nullif(g.started_at, ''), g.created_at)`;
|
||||
let eq = db.k
|
||||
.selectFrom("engagements as g")
|
||||
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||
.innerJoin("works as w", "w.id", "e.work_id")
|
||||
.select([
|
||||
"g.id",
|
||||
"g.entry_id",
|
||||
"g.started_at",
|
||||
"g.started_precision",
|
||||
"g.finished_at",
|
||||
"g.finished_precision",
|
||||
"g.platform",
|
||||
"g.rating",
|
||||
"e.work_id",
|
||||
"e.overrides",
|
||||
"e.media_type_override",
|
||||
"w.media_type",
|
||||
"w.title",
|
||||
"w.cover_url",
|
||||
at.as("at"),
|
||||
])
|
||||
.where("e.user_id", "=", userId)
|
||||
.where("e.deleted_at", "is", null)
|
||||
.where("g.deleted_at", "is", null);
|
||||
let lq = db.k
|
||||
.selectFrom("listens")
|
||||
.select(["id", "listened_at", "artist", "track", "album", "work_id", "source"])
|
||||
.where("user_id", "=", userId);
|
||||
|
||||
if (cursor) {
|
||||
const ge = after("engagement", cursor);
|
||||
eq = eq.where((x) =>
|
||||
ge === "lt" ? x(at, "<", cursor.at) : ge === "le" ? x(at, "<=", cursor.at) : x.or([x(at, "<", cursor.at), x.and([x(at, "=", cursor.at), x("g.id", "<", cursor.id)])]),
|
||||
);
|
||||
const gl = after("listen", cursor);
|
||||
lq = lq.where((x) =>
|
||||
gl === "lt"
|
||||
? x("listened_at", "<", cursor.at)
|
||||
: gl === "le"
|
||||
? x("listened_at", "<=", cursor.at)
|
||||
: x.or([x("listened_at", "<", cursor.at), x.and([x("listened_at", "=", cursor.at), x("id", "<", cursor.id)])]),
|
||||
);
|
||||
}
|
||||
|
||||
const [engagements, listens] = await Promise.all([
|
||||
eq.orderBy(at, "desc").orderBy("g.id", "desc").limit(limit + 1).execute(),
|
||||
lq.orderBy("listened_at", "desc").orderBy("id", "desc").limit(limit + 1).execute(),
|
||||
]);
|
||||
|
||||
const merged: LogItem[] = [
|
||||
...engagements.map((r): LogItem => {
|
||||
const o = fromJson<{ title?: unknown; coverUrl?: unknown }>(r.overrides, {});
|
||||
return {
|
||||
kind: "engagement",
|
||||
id: r.id,
|
||||
at: r.at,
|
||||
entryId: r.entry_id,
|
||||
workId: r.work_id,
|
||||
title: typeof o.title === "string" && o.title ? o.title : r.title,
|
||||
mediaType: r.media_type_override ?? r.media_type,
|
||||
coverUrl: typeof o.coverUrl === "string" ? o.coverUrl : r.cover_url,
|
||||
startedAt: fromStoredDate(r.started_at, r.started_precision),
|
||||
finishedAt: fromStoredDate(r.finished_at, r.finished_precision),
|
||||
platform: r.platform,
|
||||
rating: r.rating,
|
||||
};
|
||||
}),
|
||||
...listens.map(
|
||||
(r): LogItem => ({
|
||||
kind: "listen",
|
||||
id: r.id,
|
||||
at: r.listened_at,
|
||||
workId: r.work_id,
|
||||
title: r.track,
|
||||
mediaType: "track",
|
||||
artist: r.artist,
|
||||
track: r.track,
|
||||
album: r.album,
|
||||
source: r.source,
|
||||
}),
|
||||
),
|
||||
];
|
||||
// Same order as the SQL: at, then kind, then id, all descending. Plain code-unit comparison on both sides.
|
||||
const cmp = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
|
||||
merged.sort((a, b) => cmp(a.at, b.at) || cmp(a.kind, b.kind) || cmp(a.id, b.id));
|
||||
const items = merged.slice(0, limit);
|
||||
const last = items[items.length - 1];
|
||||
return { items, nextCursor: merged.length > limit && last ? encodeCursor(last) : null };
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { NotFoundError, ValidationError } from "../errors.ts";
|
||||
import type { Actor, Executor } from "../history.ts";
|
||||
import { softDeleteList } from "../lists.ts";
|
||||
import { matchWork } from "../matching.ts";
|
||||
import { entryTitles } from "../titles.ts";
|
||||
import { resolveAliasIn } from "../works.ts";
|
||||
import { applyPatch, engagementValues, entryValues } from "./apply.ts";
|
||||
import { decodeVersion } from "./snapshot.ts";
|
||||
@@ -28,14 +27,6 @@ export interface ChangeEvent {
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
/** An inbox item: the event plus the titles the Changes screen shows. */
|
||||
export interface ChangeItem extends ChangeEvent {
|
||||
/** The target's title: an entry's (override-aware), an engagement's entry's, a list's name. Null if gone. */
|
||||
title: string | null;
|
||||
/** Merged events only: the title of the entry the merge target record maps to, else null. */
|
||||
intoTitle?: string | null;
|
||||
}
|
||||
|
||||
function toChange(r: Database["change_events"]): ChangeEvent {
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -60,63 +51,15 @@ export async function listChanges(
|
||||
db: Db,
|
||||
userId: string,
|
||||
opts: { status?: "pending" | "all"; connectionId?: string; limit?: number; cursor?: string } = {},
|
||||
): Promise<{ items: ChangeItem[]; nextCursor: string | null }> {
|
||||
): Promise<{ items: ChangeEvent[]; nextCursor: string | null }> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
let q = db.k.selectFrom("change_events").selectAll().where("user_id", "=", userId);
|
||||
if ((opts.status ?? "pending") === "pending") q = q.where("status", "=", "pending");
|
||||
if (opts.connectionId) q = q.where("connection_id", "=", opts.connectionId);
|
||||
if (opts.cursor) q = q.where("id", "<", opts.cursor);
|
||||
const rows = await q.orderBy("id", "desc").limit(limit + 1).execute();
|
||||
const events = rows.slice(0, limit).map(toChange);
|
||||
return { items: await withTitles(db.k, userId, events), nextCursor: rows.length > limit ? events[events.length - 1]!.id : null };
|
||||
}
|
||||
|
||||
/** Attach titles with a fixed number of batched queries, whatever the page size. */
|
||||
async function withTitles(k: Executor, userId: string, events: ChangeEvent[]): Promise<ChangeItem[]> {
|
||||
const ids = (entity: string) => [...new Set(events.filter((e) => e.targetEntity === entity && e.targetId).map((e) => e.targetId!))];
|
||||
|
||||
// Merged events: the record their intoKey names (same connection) → its target.
|
||||
const merges = events.filter((e) => e.type === "merged" && e.connectionId && typeof e.payload.intoKey === "string");
|
||||
const intoRecords = merges.length
|
||||
? await k
|
||||
.selectFrom("source_records")
|
||||
.select(["connection_id", "key", "target_entity", "target_id"])
|
||||
.where("connection_id", "in", [...new Set(merges.map((e) => e.connectionId!))])
|
||||
.where("key", "in", [...new Set(merges.map((e) => String(e.payload.intoKey)))])
|
||||
.execute()
|
||||
: [];
|
||||
const intoTarget = new Map(intoRecords.map((r) => [`${r.connection_id}\u0000${r.key}`, r]));
|
||||
|
||||
const engagementIds = [...ids("engagement"), ...intoRecords.filter((r) => r.target_entity === "engagement").map((r) => r.target_id!)];
|
||||
const engagementEntry = new Map<string, string>();
|
||||
if (engagementIds.length) {
|
||||
const rows = await k.selectFrom("engagements").select(["id", "entry_id"]).where("id", "in", [...new Set(engagementIds)]).execute();
|
||||
for (const r of rows) engagementEntry.set(r.id, r.entry_id);
|
||||
}
|
||||
const entryOf = (entity: string | null, id: string | null): string | null =>
|
||||
!id ? null : entity === "entry" ? id : entity === "engagement" ? (engagementEntry.get(id) ?? null) : null;
|
||||
|
||||
const entryIds = [
|
||||
...ids("entry"),
|
||||
...engagementEntry.values(),
|
||||
...intoRecords.map((r) => entryOf(r.target_entity, r.target_id)),
|
||||
];
|
||||
const entryTitle = await entryTitles(k, userId, entryIds);
|
||||
const listIds = ids("list");
|
||||
const listName = new Map<string, string>();
|
||||
if (listIds.length) {
|
||||
const rows = await k.selectFrom("lists").select(["id", "name"]).where("user_id", "=", userId).where("id", "in", listIds).execute();
|
||||
for (const r of rows) listName.set(r.id, r.name);
|
||||
}
|
||||
|
||||
return events.map((e) => {
|
||||
const title =
|
||||
e.targetEntity === "list" ? (listName.get(e.targetId ?? "") ?? null) : (entryTitle.get(entryOf(e.targetEntity, e.targetId) ?? "") ?? null);
|
||||
if (e.type !== "merged") return { ...e, title };
|
||||
const into = intoTarget.get(`${e.connectionId}\u0000${String(e.payload.intoKey)}`);
|
||||
const intoEntry = into ? entryOf(into.target_entity, into.target_id) : null;
|
||||
return { ...e, title, intoTitle: (intoEntry && entryTitle.get(intoEntry)) || null };
|
||||
});
|
||||
const items = rows.slice(0, limit).map(toChange);
|
||||
return { items, nextCursor: rows.length > limit ? items[items.length - 1]!.id : null };
|
||||
}
|
||||
|
||||
const STATUS_FOR = { accept: "accepted", ignore: "ignored", keep_mine: "kept_mine" } as const;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Batched title lookups for views that list many things at once (Changes, Log, Owned). Titles are what
|
||||
* the user sees: their entry's `overrides.title` when set, else the work's title.
|
||||
*/
|
||||
import { fromJson } from "@sttracker/db/json";
|
||||
import type { Executor } from "./history.ts";
|
||||
|
||||
const unique = (ids: (string | null | undefined)[]) => [...new Set(ids.filter((x): x is string => !!x))];
|
||||
|
||||
function overrideTitle(overrides: string): string | undefined {
|
||||
const t = fromJson<{ title?: unknown }>(overrides, {}).title;
|
||||
return typeof t === "string" && t ? t : undefined;
|
||||
}
|
||||
|
||||
/** Work id → title as this user sees it (their live entry's title override, else the work's title). */
|
||||
export async function workTitles(k: Executor, userId: string, workIds: (string | null | undefined)[]): Promise<Map<string, string>> {
|
||||
const ids = unique(workIds);
|
||||
const out = new Map<string, string>();
|
||||
if (ids.length === 0) return out;
|
||||
const works = await k.selectFrom("works").select(["id", "title"]).where("id", "in", ids).execute();
|
||||
for (const w of works) out.set(w.id, w.title);
|
||||
const entries = await k
|
||||
.selectFrom("entries")
|
||||
.select(["work_id", "overrides"])
|
||||
.where("user_id", "=", userId)
|
||||
.where("work_id", "in", ids)
|
||||
.where("deleted_at", "is", null)
|
||||
.execute();
|
||||
for (const e of entries) {
|
||||
const t = overrideTitle(e.overrides);
|
||||
if (t) out.set(e.work_id, t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Entry id → title (override-aware). Deleted entries keep their title; other users' entries are absent. */
|
||||
export async function entryTitles(k: Executor, userId: string, entryIds: (string | null | undefined)[]): Promise<Map<string, string>> {
|
||||
const ids = unique(entryIds);
|
||||
const out = new Map<string, string>();
|
||||
if (ids.length === 0) return out;
|
||||
const rows = await k
|
||||
.selectFrom("entries as e")
|
||||
.innerJoin("works as w", "w.id", "e.work_id")
|
||||
.select(["e.id", "e.overrides", "w.title"])
|
||||
.where("e.user_id", "=", userId)
|
||||
.where("e.id", "in", ids)
|
||||
.execute();
|
||||
for (const r of rows) out.set(r.id, overrideTitle(r.overrides) ?? r.title);
|
||||
return out;
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { createCopy, listCopies, softDeleteCopy, updateCopy } from "../src/copies.ts";
|
||||
import { defineCustomField } from "../src/custom-fields.ts";
|
||||
import { upsertEntry } from "../src/entries.ts";
|
||||
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||
import { getHistory, restoreVersion } from "../src/history.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
@@ -74,23 +73,4 @@ forEachDialect("copies", (getDb) => {
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
test("listCopies carries the work title, with the user's title override", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const dune = await makeWork(db, { title: "Dune" });
|
||||
const hyperion = await makeWork(db, { title: "Hyperion" });
|
||||
await upsertEntry(db, userId, dune, { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const a = await createCopy(db, userId, { workId: dune });
|
||||
const b = await createCopy(db, userId, { workId: hyperion });
|
||||
const c = await createCopy(db, userId, { titleHint: "HALF-LIFE 2" });
|
||||
const byId = new Map((await listCopies(db, userId)).map((x) => [x.id, x]));
|
||||
expect(byId.get(a.id)!.workTitle).toBe("Dune (1965)");
|
||||
expect(byId.get(b.id)!.workTitle).toBe("Hyperion");
|
||||
expect(byId.get(c.id)!.workTitle).toBeNull();
|
||||
// Another user's override does not leak.
|
||||
const bob = await makeUser(db, "bob");
|
||||
await createCopy(db, bob, { workId: dune });
|
||||
expect((await listCopies(db, bob))[0]!.workTitle).toBe("Dune");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { addEngagement, softDeleteEngagement } from "../src/engagements.ts";
|
||||
import { softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
||||
import { ValidationError } from "../src/errors.ts";
|
||||
import { listLog } from "../src/log.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
|
||||
async function listen(db: Db, userId: string, listenedAt: string, track: string, workId: string | null = null): Promise<string> {
|
||||
const id = newId();
|
||||
await db.k
|
||||
.insertInto("listens")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
listened_at: listenedAt,
|
||||
artist: "Low",
|
||||
track,
|
||||
album: "Hey What",
|
||||
duration_ms: 200_000,
|
||||
mbids: "{}",
|
||||
work_id: workId,
|
||||
source: "listenbrainz",
|
||||
source_key: `${listenedAt}:${track}`,
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function setup(db: Db) {
|
||||
const userId = await makeUser(db);
|
||||
const dune = await makeWork(db, { title: "Dune" });
|
||||
const entry = await upsertEntry(db, userId, dune, { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const finished = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-10-01", precision: "day" } }, "user");
|
||||
const started = await addEngagement(db, userId, entry.id, { startedAt: { value: "2025-03", precision: "month" } }, "user");
|
||||
const undated = await addEngagement(db, userId, entry.id, {}, "user");
|
||||
const gone = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-01-01", precision: "day" } }, "user");
|
||||
await softDeleteEngagement(db, userId, gone.id, "user");
|
||||
|
||||
// An engagement on a deleted entry, and another user's data, never show.
|
||||
const game = await makeWork(db, { title: "Outer Wilds", mediaType: "game" });
|
||||
const deletedEntry = await upsertEntry(db, userId, game, {}, "user");
|
||||
await addEngagement(db, userId, deletedEntry.id, { finishedAt: { value: "2026-05-01", precision: "day" } }, "user");
|
||||
await softDeleteEntry(db, userId, deletedEntry.id, "user");
|
||||
const other = await makeUser(db, "bob");
|
||||
const otherEntry = await upsertEntry(db, other, dune, {}, "user");
|
||||
await addEngagement(db, other, otherEntry.id, { finishedAt: { value: "2026-06-01", precision: "day" } }, "user");
|
||||
await listen(db, other, "2026-06-01T10:00:00.000Z", "Not mine");
|
||||
|
||||
// Two listens tie with the finished read on `at`, so pages must break ties by kind, then id.
|
||||
const l1 = await listen(db, userId, "2026-10-01", "Days Like These");
|
||||
const l2 = await listen(db, userId, "2026-10-01", "White Horses");
|
||||
return { userId, entry, finished, started, undated, l1, l2 };
|
||||
}
|
||||
|
||||
forEachDialect("log", (getDb) => {
|
||||
test("newest first: engagements of live entries and listens, with titles", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
const { items, nextCursor } = await listLog(db, s.userId);
|
||||
expect(nextCursor).toBeNull();
|
||||
expect(items.map((i) => i.id)).toEqual([s.undated.id, s.l2, s.l1, s.finished.id, s.started.id]);
|
||||
|
||||
const read = items.find((i) => i.id === s.finished.id)!;
|
||||
expect(read).toMatchObject({
|
||||
kind: "engagement",
|
||||
at: "2026-10-01",
|
||||
entryId: s.entry.id,
|
||||
title: "Dune (1965)",
|
||||
mediaType: "book",
|
||||
finishedAt: { value: "2026-10-01", precision: "day" },
|
||||
startedAt: null,
|
||||
});
|
||||
const heard = items.find((i) => i.id === s.l1)!;
|
||||
expect(heard).toMatchObject({ kind: "listen", at: "2026-10-01", title: "Days Like These", artist: "Low", track: "Days Like These", album: "Hey What", mediaType: "track" });
|
||||
expect(items.find((i) => i.id === s.started.id)!.at).toBe("2025-03");
|
||||
});
|
||||
|
||||
test("keyset pages cross the kind boundary without gaps or repeats", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
const all = (await listLog(db, s.userId)).items.map((i) => i.id);
|
||||
for (const limit of [1, 2, 3]) {
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let guard = 0; guard < 10; guard++) {
|
||||
const page = await listLog(db, s.userId, { limit, cursor });
|
||||
expect(page.items.length).toBeLessThanOrEqual(limit);
|
||||
seen.push(...page.items.map((i) => i.id));
|
||||
if (!page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
expect(seen).toEqual(all);
|
||||
}
|
||||
});
|
||||
|
||||
test("limit is clamped and a bad cursor is a validation error", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
expect((await listLog(db, s.userId, { limit: 0 })).items).toHaveLength(1);
|
||||
expect((await listLog(db, s.userId, { limit: 1000 })).items).toHaveLength(5);
|
||||
await expect(listLog(db, s.userId, { cursor: "nonsense" })).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { toJson } from "@sttracker/db/json";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { addEngagement } from "../../src/engagements.ts";
|
||||
import { upsertEntry } from "../../src/entries.ts";
|
||||
import { createList } from "../../src/lists.ts";
|
||||
import { listChanges } from "../../src/sync/changes.ts";
|
||||
import { makeConnection, makeUser, makeWork } from "./helpers.ts";
|
||||
|
||||
async function event(
|
||||
db: Db,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
e: { type: "changed" | "removed" | "merged"; targetEntity: string; targetId: string; payload?: Record<string, unknown> },
|
||||
): Promise<string> {
|
||||
const id = newId();
|
||||
await db.k
|
||||
.insertInto("change_events")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
connection_id: connectionId,
|
||||
run_id: null,
|
||||
record_id: null,
|
||||
type: e.type,
|
||||
target_entity: e.targetEntity,
|
||||
target_id: e.targetId,
|
||||
payload: toJson(e.payload ?? {}),
|
||||
status: "pending",
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function record(db: Db, connectionId: string, key: string, targetEntity: string, targetId: string): Promise<void> {
|
||||
const now = nowIso();
|
||||
await db.k
|
||||
.insertInto("source_records")
|
||||
.values({
|
||||
id: newId(),
|
||||
connection_id: connectionId,
|
||||
key,
|
||||
kind: targetEntity,
|
||||
current_hash: "h",
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
deleted_at: null,
|
||||
merged_into_key: null,
|
||||
target_entity: targetEntity,
|
||||
target_id: targetId,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
forEachDialect("change titles", (getDb) => {
|
||||
test("events carry their target's title, and merged events the merge target's", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const conn = await makeConnection(db, userId, "hardcover");
|
||||
const dune = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const piranesi = await upsertEntry(db, userId, await makeWork(db, { title: "Piranesi" }), {}, "user");
|
||||
const deluxe = await upsertEntry(db, userId, await makeWork(db, { title: "Piranesi (Deluxe)" }), {}, "user");
|
||||
const read = await addEngagement(db, userId, piranesi.id, {}, "user");
|
||||
const list = await createList(db, userId, { name: "Want to read" });
|
||||
await record(db, conn, "b:deluxe", "entry", deluxe.id);
|
||||
|
||||
const onEntry = await event(db, userId, conn, { type: "changed", targetEntity: "entry", targetId: dune.id, payload: { field: "rating", from: 80, to: 60 } });
|
||||
const onRead = await event(db, userId, conn, { type: "removed", targetEntity: "engagement", targetId: read.id });
|
||||
const onList = await event(db, userId, conn, { type: "removed", targetEntity: "list", targetId: list.id });
|
||||
const merged = await event(db, userId, conn, { type: "merged", targetEntity: "entry", targetId: piranesi.id, payload: { key: "b:p", intoKey: "b:deluxe" } });
|
||||
const unknownMerge = await event(db, userId, conn, { type: "merged", targetEntity: "entry", targetId: piranesi.id, payload: { key: "b:p", intoKey: "b:nope" } });
|
||||
|
||||
const byId = new Map((await listChanges(db, userId)).items.map((c) => [c.id, c]));
|
||||
expect(byId.get(onEntry)!.title).toBe("Dune (1965)");
|
||||
expect(byId.get(onRead)!.title).toBe("Piranesi");
|
||||
expect(byId.get(onList)!.title).toBe("Want to read");
|
||||
expect(byId.get(merged)!.title).toBe("Piranesi");
|
||||
expect(byId.get(merged)!.intoTitle).toBe("Piranesi (Deluxe)");
|
||||
expect(byId.get(unknownMerge)!.intoTitle).toBeNull();
|
||||
expect(byId.get(onEntry)!.intoTitle).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a missing target gives a null title", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const conn = await makeConnection(db, userId, "hardcover");
|
||||
const id = await event(db, userId, conn, { type: "removed", targetEntity: "entry", targetId: newId() });
|
||||
const [c] = (await listChanges(db, userId)).items;
|
||||
expect(c!.id).toBe(id);
|
||||
expect(c!.title).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user