20 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5.5 bbd04a5577 feat(web): lists, groupings and threads with search-to-add, reorder and grouping rating
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:20:10 +01:00
Lucas WintherandClaude Opus 5.5 ec39491f47 feat(web): log of reads, plays, watches and listens, by month
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:19:02 +01:00
Lucas WintherandClaude Opus 5.5 b38a2674f4 feat(web): sources: connect from the manifest, device sign-in, file import, schedule, runs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:18:17 +01:00
Lucas WintherandClaude Opus 5.5 67d94f5107 feat(web): changes inbox grouped by run, with per-entry filter
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:16:06 +01:00
Lucas WintherandClaude Opus 5.5 4166ecb8f5 fix(web): keep cached library rows when a refresh fails
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:16:06 +01:00
Lucas Winther 5c837a6679 Merge branch 'task/17' into task/18 2026-10-30 22:14:56 +01:00
Lucas WintherandClaude Opus 5.5 4608c977b7 fix(web): keep review text on a failed save; review round 2
- Review draft sync is a pure helper (format.ts): incoming values are
  ignored while a save is in flight, and a failed save restores the
  previous synced value, so the typed text is kept whichever of the
  rollback and the error callback lands first. Tests cover both orders.
- A failed background refetch keeps showing the cached entry instead of
  the error screen (it unmounted the review box mid-outage).
- History restore joins the entry-patch mutation scope.
- updatePreferences: read-modify-write in a transaction, row locked on
  Postgres.
- Invalidate auth-status after register; out-of-range rating errors speak
  in the user's scale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:14:02 +01:00
Lucas WintherandClaude Opus 5.5 53d9e7c9c9 feat(server): plugins list says which sources sign in with a device code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:13:24 +01:00
Lucas WintherandClaude Opus 5.5 c2e8c83f86 feat(core,server): log feed, titles on changes, work titles on copies
GET log (engagements and listens, keyset over at/kind/id), listChanges
title and intoTitle, listCopies workTitle, all via batched lookups.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:11:53 +01:00
Lucas WintherandClaude Opus 5.5 5fa59310f3 fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio
- Tags and custom fields update the cached entry optimistically and build
  from the latest cache; one entry's PATCHes run in order (mutation scope).
- The review textarea follows server changes (Restore) unless edited.
- friendlyError() maps validation reasons and 413 to plain copy everywhere.
- Rating scale from me/preferences; /login and /register use auth/status;
  Attachments lists from the API.
- Gelasio vendored (official OFL variable TTFs); minors from the review.
- Docs: DESIGN (AA on orange, faint labels), DECISIONS, TODO, ARCHITECTURE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:03:51 +01:00
Lucas WintherandClaude Opus 5.5 d204775235 feat(server): auth status, attachments list, user preferences
GET auth/status ({ hasUsers }), GET attachments?entity=&entityId= (owner
scoped, 404 for foreign targets) and GET/PATCH me/preferences (ratingScale,
defaultVisibility in users.preferences).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:57:08 +01:00
Lucas Winther d547355e5c Merge branch 'feat/phase1' into task/17 2026-10-30 21:55:33 +01:00
Lucas WintherandClaude Opus 5.5 42fea7d54e merge: task 16 server API
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:40:38 +01:00
Lucas WintherandClaude Opus 5.5 439d138967 feat(web): app shell, library, entry detail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:37:56 +01:00
Lucas WintherandClaude Opus 5.5 70bc6c36d3 feat(web): theme tokens, self-hosted Inter, HTML entry
DESIGN.md tokens as CSS custom properties. Inter (OFL) is vendored so the
app makes no third-party font requests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:37:56 +01:00
Lucas WintherandClaude Opus 5.5 0b34ede62e docs: token actor, connection deleted_at and CSRF decisions; follow-ups
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:30:59 +01:00
Lucas WintherandClaude Opus 5.5 5f7d54b805 fix(server): token actor, login CSRF, 415, cookie decoding, admin invites, production default
- API writes pass token:<name> for Bearer requests and "user" for sessions.
- X-Requested-With: sttracker on every non-Bearer write, login and register included.
- JSON-body routes reject non-JSON content types with 415.
- A malformed stt_session cookie counts as no cookie (401), not 500.
- DELETE me/tokens/:id is 404 for unknown or foreign ids; admin/invites is admin-only.
- Bun.serve development mode only with NODE_ENV=development or --hot.
- OpenAPI documents multipart fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:30:43 +01:00
Lucas WintherandClaude Opus 5.5 aa8e4db070 fix(core): token writes keep their actor in history but are user-owned in field_sources
fieldOwner(actor) maps every non-plugin actor to "user" wherever field_sources is written
(entries, engagements, list ownership from history), so syncs never overwrite a field an API
token edited. authenticateAppToken also returns the token name; revokeAppToken reports whether
it revoked; listSuggestions queries the canonical work id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:29:38 +01:00
Lucas WintherandClaude Opus 5.5 e3a759d768 fix(core): soft-delete source connections with deleted_at
Migration 0003 adds source_connections.deleted_at. deleteConnection sets it; listing, lookups,
updates, the scheduler and the sync handler ignore deleted connections, so a paused
credential-less connection stays visible and can be resumed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:25:59 +01:00
Lucas WintherandClaude Opus 5.5 0bb871cd19 feat(web): api client and display formatters
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:25:41 +01:00
74 changed files with 6386 additions and 97 deletions
+4
View File
@@ -23,6 +23,10 @@ Dark, calm, dense. Three panes on desktop (nav · library list · entry detail);
- Type: Inter for the UI; Gelasio for long-form review text.
- Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers.
- Orange means "a platform changed something". Never use it decoratively.
- Text on `--warn` (the Changes count, the "See what changed" button) is dark `#1c0f04` (6.4:1), never white: white on `#f97316` is 2.8:1 and fails WCAG AA.
- Sidebar group labels ("Sources") use `--text-faint`. The mockup's `#6f69a3` is 3.6:1 on `--surface-0` and fails AA.
- Placeholders on `--surface-3` inputs use `--text-muted`: `--text-faint` there is 4.15:1.
- Fonts are self-hosted in `apps/web/src/fonts/` (Inter, Gelasio; both OFL). The app makes no third-party font requests.
- Drafts (entries with no catalogue link) get a small amber outlined "Draft" badge and a striped placeholder cover.
## Media type colours (shared by both surfaces)
+2
View File
@@ -8,6 +8,7 @@ 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";
@@ -26,6 +27,7 @@ export function createApp(deps: AppDeps): App {
...engagementRoutes,
...workRoutes,
...listRoutes,
...logRoutes,
...copyRoutes,
...customFieldRoutes,
...attachmentRoutes,
+32 -12
View File
@@ -1,5 +1,6 @@
import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
import { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
import type { Actor } from "@sttracker/core/history";
import { isUniqueViolation } from "@sttracker/core/matching";
import type { PluginRegistry } from "@sttracker/core/registry";
import type { Db } from "@sttracker/db/db";
@@ -33,6 +34,8 @@ export interface Ctx<B, Q> {
query: Q;
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
user: User;
/** Who writes: `token:<name>` for Bearer requests, `"user"` for sessions. Pass it to every core write. */
actor: Actor;
/** The session token, when the request was authenticated by cookie. */
sessionToken: string | null;
/** Parsed multipart body, for `multipart: true` routes. */
@@ -51,6 +54,8 @@ export interface RouteDef<B = any, Q = any> {
query?: z.ZodType<Q>;
/** Parse the body as multipart form data (upload limit) instead of JSON. */
multipart?: boolean;
/** Non-file multipart fields, for the OpenAPI document (handlers validate them). Every multipart route also takes `file`. */
formFields?: z.ZodObject;
/** Rate limit per client IP (login, register). */
rateLimited?: boolean;
/** Only admins may call it (403 otherwise). */
@@ -132,7 +137,13 @@ function readCookie(req: Request, name: string): string | null {
if (!header) return null;
for (const part of header.split(";")) {
const i = part.indexOf("=");
if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
if (i > 0 && part.slice(0, i).trim() === name) {
try {
return decodeURIComponent(part.slice(i + 1).trim()) || null;
} catch {
return null; // malformed percent-encoding: treat as no cookie
}
}
}
return null;
}
@@ -185,16 +196,20 @@ export function rateLimiter(limit: number, windowMs: number): (key: string) => b
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> {
if (mode === "none") return { user: null, sessionToken: null };
async function authenticate(
req: Request,
mode: AuthMode,
db: Db,
): Promise<{ user: User | null; sessionToken: string | null; actor: Actor }> {
if (mode === "none") return { user: null, sessionToken: null, actor: "user" };
const authz = req.headers.get("authorization");
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
if (kind !== "session" && bearer) {
const scope = scopeName!;
const user = await verifyAppToken(db, bearer, scope);
if (user) return { user, sessionToken: null };
const auth = await authenticateAppToken(db, bearer, scope);
if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` };
for (const other of SCOPES) {
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
@@ -207,11 +222,7 @@ async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ use
const token = readCookie(req, SESSION_COOKIE);
const user = token ? await getSessionUser(db, token) : null;
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
// CSRF: browsers cannot set this header cross-site without a CORS preflight, which we never grant.
if (req.method !== "GET" && req.method !== "HEAD" && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
throw new HttpError(403, "forbidden", `Cookie-authenticated requests must send the header X-Requested-With: ${CSRF_VALUE}.`);
}
return { user, sessionToken: token };
return { user, sessionToken: token, actor: "user" };
}
interface Compiled {
@@ -261,7 +272,13 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
}
const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
// CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it
// cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt.
const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false;
if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`);
}
const { user, sessionToken, actor } = await authenticate(req, def.auth, deps.db);
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
let body: unknown;
@@ -279,6 +296,8 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
const bytes = await readLimited(req, MAX_JSON_BYTES);
let raw: unknown;
if (bytes.byteLength > 0) {
const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
if (type !== "application/json") throw new HttpError(415, "unsupported_media_type", "Send the body as Content-Type: application/json.");
try {
raw = JSON.parse(new TextDecoder().decode(bytes));
} catch {
@@ -299,6 +318,7 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
return user;
},
actor,
sessionToken,
get form(): FormData {
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
+3 -1
View File
@@ -40,7 +40,9 @@ const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS);
const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl });
const server = Bun.serve({
port: config.port,
development: process.env.NODE_ENV !== "production",
// Production unless asked: dev mode adds HMR and verbose error pages.
// `bun --hot` puts the flag in execArgv, not Bun.argv.
development: process.env.NODE_ENV === "development" || process.execArgv.includes("--hot"),
routes: {
"/api/*": (req, srv) => app.fetch(req, srv),
"/ingest/*": (req, srv) => app.fetch(req, srv),
+9 -1
View File
@@ -46,7 +46,15 @@ export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema
};
if (parameters.length > 0) op.parameters = parameters;
if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } };
if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } };
if (r.multipart) {
const fields = r.formFields ? (toSchema(r.formFields) as { properties?: JsonSchema; required?: string[] }) : {};
const schema = {
type: "object",
properties: { file: { type: "string", format: "binary" }, ...fields.properties },
required: ["file", ...(fields.required ?? [])],
};
op.requestBody = { required: true, content: { "multipart/form-data": { schema } } };
}
(paths[path] ??= {})[r.method.toLowerCase()] = op;
}
return {
+14 -1
View File
@@ -1,4 +1,4 @@
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
import { listAttachmentLinks, listAttachments, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
import { NotFoundError } from "@sttracker/core/errors";
import type { AttachmentEntity } from "@sttracker/db/schema";
import { z } from "zod";
@@ -18,12 +18,25 @@ export function safeFilename(name: string): string {
}
export const attachmentRoutes = [
route({
method: "GET",
path: "attachments",
summary: "Attachments linked to one of your records (oldest first), each with its links.",
auth: "any:read",
query: z.object({ entity: fields.shape.entity, entityId: z.string().min(1) }),
handler: async ({ deps, user, query }) => {
await requireLinkTarget(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
const atts = await listAttachments(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
return Promise.all(atts.map(async (a) => ({ ...a, links: await listAttachmentLinks(deps.db, user.id, a.id) })));
},
}),
route({
method: "POST",
path: "attachments",
summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.",
auth: "any:write",
multipart: true,
formFields: fields,
handler: async ({ deps, user, form }) => {
const file = formFile(form);
const f = fields.parse({
+28 -2
View File
@@ -1,6 +1,9 @@
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
import { createAppToken, createInvite, hasUsers, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
import { getPreferences, RATING_SCALES, updatePreferences } from "@sttracker/core/preferences";
import { NotFoundError } from "@sttracker/core/errors";
import { z } from "zod";
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
import { visibility } from "./schemas.ts";
const credentials = z.object({
username: z.string().min(1).max(64),
@@ -8,6 +11,13 @@ const credentials = z.object({
});
export const authRoutes = [
route({
method: "GET",
path: "auth/status",
summary: "Whether any account exists yet (the web app shows first-run registration when not).",
auth: "none",
handler: async ({ deps }) => ({ hasUsers: await hasUsers(deps.db) }),
}),
route({
method: "POST",
path: "auth/register",
@@ -40,6 +50,21 @@ export const authRoutes = [
},
}),
route({ method: "GET", path: "me", summary: "The signed-in user.", auth: "any:read", handler: async ({ user }) => user }),
route({
method: "GET",
path: "me/preferences",
summary: "Your preferences: rating scale and default visibility.",
auth: "any:read",
handler: async ({ deps, user }) => getPreferences(deps.db, user.id),
}),
route({
method: "PATCH",
path: "me/preferences",
summary: "Update your preferences (partial).",
auth: "any:write",
body: z.object({ ratingScale: z.enum(RATING_SCALES), defaultVisibility: visibility }).partial().strict(),
handler: async ({ deps, user, body }) => updatePreferences(deps.db, user.id, body as Parameters<typeof updatePreferences>[2]),
}),
route({
method: "GET",
path: "me/tokens",
@@ -61,7 +86,7 @@ export const authRoutes = [
summary: "Revoke an app token.",
auth: "session",
handler: async ({ deps, user, params }) => {
await revokeAppToken(deps.db, user.id, params.id!);
if (!(await revokeAppToken(deps.db, user.id, params.id!))) throw new NotFoundError("app token");
return noContent();
},
}),
@@ -70,6 +95,7 @@ export const authRoutes = [
path: "admin/invites",
summary: "Create an invite code (admin). The code is returned once.",
auth: "session",
admin: true,
body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(),
handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201),
}),
+4 -4
View File
@@ -35,7 +35,7 @@ export const copyRoutes = [
summary: "Record an owned copy.",
auth: "any:write",
body: copyPatch,
handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201),
handler: async ({ deps, user, body, actor }) => json(await createCopy(deps.db, user.id, body as CopyArg, actor), 201),
}),
route({
method: "PATCH",
@@ -43,15 +43,15 @@ export const copyRoutes = [
summary: "Edit an owned copy.",
auth: "any:write",
body: copyPatch,
handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg),
handler: async ({ deps, user, params, body, actor }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg, actor),
}),
route({
method: "DELETE",
path: "copies/:id",
summary: "Soft-delete an owned copy.",
auth: "any:write",
handler: async ({ deps, user, params }) => {
await softDeleteCopy(deps.db, user.id, params.id!);
handler: async ({ deps, user, params, actor }) => {
await softDeleteCopy(deps.db, user.id, params.id!, actor);
return noContent();
},
}),
+6 -6
View File
@@ -30,8 +30,8 @@ export const engagementRoutes = [
summary: "Log a read, watch or play.",
auth: "any:write",
body: engagementPatch,
handler: async ({ deps, user, params, body }) =>
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], "user"), 201),
handler: async ({ deps, user, params, body, actor }) =>
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], actor), 201),
}),
route({
method: "PATCH",
@@ -39,16 +39,16 @@ export const engagementRoutes = [
summary: "Edit an engagement.",
auth: "any:write",
body: engagementPatch,
handler: async ({ deps, user, params, body }) =>
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], "user"),
handler: async ({ deps, user, params, body, actor }) =>
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], actor),
}),
route({
method: "DELETE",
path: "engagements/:id",
summary: "Soft-delete an engagement.",
auth: "any:write",
handler: async ({ deps, user, params }) => {
await softDeleteEngagement(deps.db, user.id, params.id!, "user");
handler: async ({ deps, user, params, actor }) => {
await softDeleteEngagement(deps.db, user.id, params.id!, actor);
return noContent();
},
}),
+6 -6
View File
@@ -60,9 +60,9 @@ export const entryRoutes = [
summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).",
auth: "any:write",
body: entryPatch,
handler: async ({ deps, user, params, body }) => {
handler: async ({ deps, user, params, body, actor }) => {
const cur = await getEntry(deps.db, user.id, params.id!);
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], "user");
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], actor);
return getEntry(deps.db, user.id, params.id!);
},
}),
@@ -71,8 +71,8 @@ export const entryRoutes = [
path: "entries/:id",
summary: "Soft-delete an entry.",
auth: "any:write",
handler: async ({ deps, user, params }) => {
await softDeleteEntry(deps.db, user.id, params.id!, "user");
handler: async ({ deps, user, params, actor }) => {
await softDeleteEntry(deps.db, user.id, params.id!, actor);
return noContent();
},
}),
@@ -99,8 +99,8 @@ export const entryRoutes = [
summary: "Move an entry to another work (for you only).",
auth: "any:write",
body: z.object({ workId: z.string().min(1) }).strict(),
handler: async ({ deps, user, params, body }) => {
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user");
handler: async ({ deps, user, params, body, actor }) => {
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, actor);
return getEntry(deps.db, user.id, moved.id);
},
}),
+9 -9
View File
@@ -25,7 +25,7 @@ export const listRoutes = [
summary: "Create a list.",
auth: "any:write",
body: listPatch.required({ name: true }),
handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201),
handler: async ({ deps, user, body, actor }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }, actor), 201),
}),
route({
method: "GET",
@@ -40,15 +40,15 @@ export const listRoutes = [
summary: "Edit a list.",
auth: "any:write",
body: listPatch,
handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg),
handler: async ({ deps, user, params, body, actor }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg, actor),
}),
route({
method: "DELETE",
path: "lists/:id",
summary: "Soft-delete a list.",
auth: "any:write",
handler: async ({ deps, user, params }) => {
await softDeleteList(deps.db, user.id, params.id!);
handler: async ({ deps, user, params, actor }) => {
await softDeleteList(deps.db, user.id, params.id!, actor);
return noContent();
},
}),
@@ -58,7 +58,7 @@ export const listRoutes = [
summary: "Add a work to a list.",
auth: "any:write",
body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(),
handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201),
handler: async ({ deps, user, params, body, actor }) => json(await addListItem(deps.db, user.id, params.id!, body, actor), 201),
}),
route({
method: "PATCH",
@@ -66,8 +66,8 @@ export const listRoutes = [
summary: "Move a list item to a new 0-based position.",
auth: "any:write",
body: z.object({ position: z.number().int().min(0) }).strict(),
handler: async ({ deps, user, params, body }) => {
await moveListItem(deps.db, user.id, params.id!, body.position);
handler: async ({ deps, user, params, body, actor }) => {
await moveListItem(deps.db, user.id, params.id!, body.position, actor);
return noContent();
},
}),
@@ -76,8 +76,8 @@ export const listRoutes = [
path: "list-items/:id",
summary: "Remove an item from a list.",
auth: "any:write",
handler: async ({ deps, user, params }) => {
await removeListItem(deps.db, user.id, params.id!);
handler: async ({ deps, user, params, actor }) => {
await removeListItem(deps.db, user.id, params.id!, actor);
return noContent();
},
}),
+15
View File
@@ -0,0 +1,15 @@
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),
}),
];
+3 -4
View File
@@ -4,7 +4,6 @@ import {
devicePoll,
deviceStart,
getConnection,
isDeletedConnection,
listConnections,
listRuns,
updateConnection,
@@ -39,16 +38,16 @@ export const sourceRoutes = [
route({
method: "GET",
path: "plugins",
summary: "Installed source plugins (manifests).",
summary: "Installed source plugins (manifests, plus deviceAuth: whether sign-in uses a device code).",
auth: "any:read",
handler: async ({ deps }) => deps.registry.list(),
handler: async ({ deps }) => deps.registry.list().map((m) => ({ ...m, deviceAuth: !!deps.registry.get(m.id)?.deviceAuth })),
}),
route({
method: "GET",
path: "sources",
summary: "Your source connections (never includes secrets).",
auth: "any:read",
handler: async ({ deps, user }) => (await listConnections(deps.db, user.id)).filter((c) => !isDeletedConnection(c)),
handler: async ({ deps, user }) => listConnections(deps.db, user.id),
}),
route({
method: "POST",
+169 -6
View File
@@ -23,15 +23,15 @@ interface CallOpts {
token?: string;
headers?: Record<string, string>;
form?: FormData;
/** Omit the X-Requested-With header that browsers (the web app) send on writes. */
noCsrf?: boolean;
}
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
const headers: Record<string, string> = { ...o.headers };
if (o.cookie) {
headers.cookie = o.cookie;
headers["x-requested-with"] ??= "sttracker";
}
if (o.cookie) headers.cookie = o.cookie;
if (o.token) headers.authorization = `Bearer ${o.token}`;
else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker";
let body: BodyInit | undefined;
if (o.form) body = o.form;
else if (o.body !== undefined) {
@@ -184,8 +184,16 @@ forEachDialect("api", (getDb) => {
test("cookie-authenticated writes need the CSRF header", async () => {
const cookie = await signUp(app, "ana");
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } });
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, noCsrf: true });
expect(res.status).toBe(403);
// Login and register too (login CSRF).
const login = await call(app, "POST", "auth/login", { body: { username: "ana", password: "correct horse" }, noCsrf: true });
expect(login.status).toBe(403);
expect(login.headers.get("set-cookie")).toBeNull();
expect((await call(app, "POST", "auth/register", { body: { username: "zed", password: "correct horse" }, noCsrf: true })).status).toBe(403);
// Bearer requests are exempt.
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "w", scopes: ["write"] } })).json()) as { token: string };
expect((await call(app, "POST", "entries/quick", { token, body: { title: "Dune" } })).status).toBe(201);
});
test("login is rate limited to 10 per minute", async () => {
@@ -264,16 +272,165 @@ forEachDialect("api", (getDb) => {
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"');
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
const bobs = (await (await call(app, "POST", "entries/quick", { cookie: bob, body: { title: "Bob's" } })).json()) as { id: string };
const form2 = new FormData();
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
form2.set("entity", "entry");
form2.set("entityId", "0190f000-0000-7000-8000-000000000000");
form2.set("entityId", bobs.id);
expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404);
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: bob })).status).toBe(404);
expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204);
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
});
test("auth/status says whether any user exists, without auth", async () => {
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: false });
await signUp(app, "ana");
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: true });
});
test("attachments list: owner-scoped per entity; a foreign or unknown entity is 404", async () => {
const ana = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
const q = `attachments?entity=entry&entityId=${add.id}`;
expect(await (await call(app, "GET", q, { cookie: ana })).json()).toEqual([]);
const form = new FormData();
form.set("file", new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }));
form.set("entity", "entry");
form.set("entityId", add.id);
const up = (await (await call(app, "POST", "attachments", { cookie: ana, form })).json()) as { id: string };
const list = (await (await call(app, "GET", q, { cookie: ana })).json()) as { id: string; mime: string; filename: string; links: { id: string }[] }[];
expect(list.map((a) => [a.id, a.mime, a.filename])).toEqual([[up.id, "image/png", "shot.png"]]);
expect(list[0]!.links.length).toBe(1);
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
expect((await call(app, "GET", q, { cookie: bob })).status).toBe(404);
expect((await call(app, "GET", "attachments?entity=entry&entityId=nope", { cookie: ana })).status).toBe(404);
expect((await call(app, "GET", `attachments?entity=bogus&entityId=${add.id}`, { cookie: ana })).status).toBe(400);
expect((await call(app, "GET", q)).status).toBe(401);
});
test("me/preferences: defaults, validated PATCH, and quick add uses defaultVisibility", async () => {
const ana = await signUp(app, "ana");
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "5half", defaultVisibility: "private" });
const bad = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "7" } });
expect(bad.status).toBe(400);
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { extra: 1 } })).status).toBe(400);
const ok = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "10" } });
expect(await ok.json()).toEqual({ ratingScale: "10", defaultVisibility: "private" });
await call(app, "PATCH", "me/preferences", { cookie: ana, body: { defaultVisibility: "friends" } });
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "10", defaultVisibility: "friends" });
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { visibility: string };
expect(e.visibility).toBe("friends");
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`, {
method: "POST",
headers: { "content-type": "text/plain", "x-requested-with": "sttracker" },
body: JSON.stringify({ username: "ana", password: "correct horse" }),
}),
);
expect(res.status).toBe(415);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unsupported_media_type");
});
test("a malformed session cookie is 401, not 500", async () => {
expect((await call(app, "GET", "me", { headers: { cookie: "stt_session=%" } })).status).toBe(401);
});
test("cookie plus a bogus Bearer on a write is 401", async () => {
const cookie = await signUp(app, "ana");
const res = await call(app, "POST", "entries/quick", { cookie, token: "stt_bogus", body: { title: "Dune" } });
expect(res.status).toBe(401);
});
test("a write-scope token can write; history records the token, field_sources the user", async () => {
const cookie = await signUp(app, "ana");
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["write", "read"] } })).json()) as { token: string };
const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 70 } });
expect(res.status).toBe(200);
expect(((await res.json()) as { fieldSources: Record<string, string> }).fieldSources.rating).toBe("user");
const hist = (await (await call(app, "GET", `entries/${add.id}/history`, { token })).json()) as { actor: string }[];
expect(hist[0]!.actor).toBe("token:script");
});
test("DELETE me/tokens/:id is 404 for unknown or foreign tokens", async () => {
const ana = await signUp(app, "ana");
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
const { id } = (await (await call(app, "POST", "me/tokens", { cookie: ana, body: { name: "t", scopes: ["read"] } })).json()) as { id: string };
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: bob })).status).toBe(404);
expect((await call(app, "DELETE", "me/tokens/nope", { cookie: ana })).status).toBe(404);
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: ana })).status).toBe(204);
});
test("admin-only routes are 403 for members", async () => {
const ana = await signUp(app, "ana");
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
const bob = await signUp(app, "bob", code);
expect((await call(app, "POST", "admin/plugins/reload", { cookie: bob })).status).toBe(403);
expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403);
const warn = spyOn(console, "warn").mockImplementation(() => {});
try {
expect((await call(app, "POST", "admin/plugins/reload", { cookie: ana })).status).toBe(200);
} finally {
warn.mockRestore();
}
});
test("a paused source without credentials stays listed and can be resumed", async () => {
const cookie = await signUp(app, "ana");
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
expect((await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "paused" } })).status).toBe(200);
const listed = (await (await call(app, "GET", "sources", { cookie })).json()) as { id: string; status: string }[];
expect(listed).toMatchObject([{ id, status: "paused" }]);
const resumed = await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "active" } });
expect(((await resumed.json()) as { status: string }).status).toBe("active");
});
test("a multipart upload over 50 MB is 413", async () => {
const cookie = await signUp(app, "ana");
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
const form = new FormData();
form.set("file", new File([new Uint8Array(50 * 1024 * 1024 + 1)], "big.csv", { type: "text/csv" }));
const res = await call(app, "POST", `sources/${id}/import`, { cookie, form });
expect(res.status).toBe(413);
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation");
});
test("openapi.json lists /api/v1/entries", async () => {
const res = await call(app, "GET", "openapi.json");
expect(res.status).toBe(200);
@@ -281,6 +438,12 @@ forEachDialect("api", (getDb) => {
expect(doc.openapi).toStartWith("3.");
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy();
const up = doc.paths["/api/v1/attachments"]?.post as unknown as {
requestBody: { content: Record<string, { schema: { properties: Record<string, unknown>; required: string[] } }> };
};
const mp = up.requestBody.content["multipart/form-data"]!.schema;
expect(Object.keys(mp.properties).sort()).toEqual(["caption", "entity", "entityId", "file"]);
expect(mp.required).toEqual(["file", "entity", "entityId"]);
});
test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
+15 -1
View File
@@ -1 +1,15 @@
<!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#17152b" />
<title>STTracker</title>
<link rel="stylesheet" href="./src/theme.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
+428
View File
@@ -0,0 +1,428 @@
/**
* The `/api/v1` client. Cookie auth (`credentials: "same-origin"`); every non-GET request sends
* `X-Requested-With: sttracker` (the server's CSRF rule) and, unless it is multipart, a JSON content type.
* Wire types mirror the server's responses; the web app cannot import `@sttracker/core`.
*/
import type { PartialDate, RatingScale, Status } from "./format.ts";
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
type Method = "GET" | "POST" | "PATCH" | "DELETE";
type QueryValue = string | number | boolean | null | undefined;
export interface RequestOpts {
body?: unknown;
form?: FormData;
query?: Record<string, QueryValue>;
fetchImpl?: typeof fetch;
}
export async function request<T = unknown>(method: Method, path: string, opts: RequestOpts = {}): Promise<T> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(opts.query ?? {})) if (v !== undefined && v !== null && v !== "") qs.set(k, String(v));
const url = `/api/v1/${path}${qs.size ? `?${qs}` : ""}`;
const headers: Record<string, string> = { accept: "application/json" };
let body: BodyInit | undefined;
if (method !== "GET") {
headers["x-requested-with"] = "sttracker";
if (opts.form) body = opts.form;
else {
headers["content-type"] = "application/json";
if (opts.body !== undefined) body = JSON.stringify(opts.body);
}
}
let res: Response;
try {
res = await (opts.fetchImpl ?? fetch)(url, { method, headers, body, credentials: "same-origin" });
} catch {
throw new ApiError(0, "network", "Can't reach the server. Check your connection and try again.");
}
if (res.status === 204) return undefined as T;
const text = await res.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : undefined;
} catch {
data = undefined;
}
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string } } | undefined)?.error;
if (err?.code) throw new ApiError(res.status, err.code, err.message ?? err.code);
throw new ApiError(res.status, `http_${res.status}`, `The server answered ${res.status}. Try again in a moment.`);
}
return data as T;
}
// ---------------------------------------------------------------------------------------------
// Wire types
// ---------------------------------------------------------------------------------------------
export interface User {
id: string;
username: string;
displayName: string;
role: "admin" | "member";
}
export interface EntryView {
id: string;
workId: string;
status: Status;
rating: number | null;
review: string | null;
privateNotes: string | null;
favorite: boolean;
tags: string[];
customFields: Record<string, unknown>;
visibility: "private" | "friends" | "public";
overrides: { title?: string; coverUrl?: string; year?: number | null };
mediaTypeOverride: string | null;
fieldSources: Record<string, string>;
createdAt: string;
updatedAt: string;
work: { id: string; mediaType: string; title: string; originalTitle: string; year: number | null; coverUrl: string; parentId: string | null };
draft: boolean;
childSummary: { total: number; completed: number } | null;
}
export type EntryPatch = Partial<
Pick<EntryView, "status" | "rating" | "review" | "privateNotes" | "favorite" | "tags" | "customFields" | "visibility" | "overrides">
>;
export interface Page<T> {
items: T[];
nextCursor: string | null;
}
export interface Engagement {
id: string;
entryId: string;
startedAt: PartialDate | null;
finishedAt: PartialDate | null;
progress: Record<string, number | string>;
platform: string | null;
rating: number | null;
notes: string | null;
fieldSources: Record<string, string>;
createdAt: string;
}
export interface Copy {
id: string;
workId: string | null;
titleHint: string | null;
format: string | null;
acquiredFrom: string | null;
acquiredAt: PartialDate | null;
priceCents: number | null;
currency: string | null;
notes: string | null;
}
export interface Attachment {
id: string;
mime: string;
filename: string;
caption: string | null;
size: number;
}
export interface Preferences {
ratingScale: RatingScale;
defaultVisibility: "private" | "friends" | "public";
}
export interface Connection {
id: string;
pluginId: string;
name: string;
status: "active" | "paused" | "error";
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 {
id: string;
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 {
id: string;
entity: string;
entityId: string;
before: Record<string, unknown>;
after: Record<string, unknown>;
actor: string;
createdAt: string;
}
export interface CustomFieldDef {
id: string;
name: string;
type: CustomFieldType;
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;
title: string;
year: number | null;
cover_url: string;
creators: { name: string; role?: string }[];
}
export interface Suggestion {
id: string;
workId: string;
candidateWorkId: string;
score: number;
}
// ---------------------------------------------------------------------------------------------
// Endpoints
// ---------------------------------------------------------------------------------------------
export interface EntryQuery {
q?: string;
mediaType?: string;
status?: Status;
draft?: boolean;
parentId?: string;
cursor?: string;
limit?: number;
}
export const api = {
authStatus: () => request<{ hasUsers: boolean }>("GET", "auth/status"),
me: () => request<User>("GET", "me"),
preferences: () => request<Preferences>("GET", "me/preferences"),
updatePreferences: (patch: Partial<Preferences>) => request<Preferences>("PATCH", "me/preferences", { body: patch }),
login: (username: string, password: string) => request<User>("POST", "auth/login", { body: { username, password } }),
register: (body: { username: string; password: string; displayName?: string; inviteCode?: string }) =>
request<User>("POST", "auth/register", { body }),
logout: () => request<void>("POST", "auth/logout"),
entries: (query: EntryQuery) => request<Page<EntryView>>("GET", "entries", { query: { ...query } }),
entry: (id: string) => request<EntryView>("GET", `entries/${id}`),
quickAdd: (title: string) => request<EntryView>("POST", "entries/quick", { body: { title } }),
patchEntry: (id: string, patch: EntryPatch) => request<EntryView>("PATCH", `entries/${id}`, { body: patch }),
moveEntry: (id: string, workId: string) => request<EntryView>("POST", `entries/${id}/move`, { body: { workId } }),
history: (id: string) => request<HistoryRecord[]>("GET", `entries/${id}/history`),
restore: (historyId: string) => request<void>("POST", `history/${historyId}/restore`),
engagements: (entryId: string) => request<Engagement[]>("GET", `entries/${entryId}/engagements`),
addEngagement: (entryId: string, body: { startedAt?: PartialDate | null; finishedAt?: PartialDate | null }) =>
request<Engagement>("POST", `entries/${entryId}/engagements`, { body }),
deleteEngagement: (id: string) => request<void>("DELETE", `engagements/${id}`),
copies: (workId: string) => request<Copy[]>("GET", "copies", { query: { workId } }),
addCopy: (body: Partial<Omit<Copy, "id">>) => request<Copy>("POST", "copies", { body }),
attachments: (entity: "entry" | "engagement" | "copy", entityId: string) =>
request<Attachment[]>("GET", "attachments", { query: { entity, entityId } }),
upload: (file: File, entityId: string) => {
const form = new FormData();
form.set("file", file);
form.set("entity", "entry");
form.set("entityId", entityId);
return request<Attachment>("POST", "attachments", { form });
},
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}`),
searchWorks: (q: string, mediaType?: string) => request<Work[]>("GET", "works/search", { query: { q, mediaType, limit: 8 } }),
suggestions: (workId: string) => request<Suggestion[]>("GET", `works/${workId}/suggestions`),
acceptSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/accept`),
rejectSuggestion: (id: string) => request<void>("POST", `suggestions/${id}/reject`),
};
+18
View File
@@ -0,0 +1,18 @@
import { useState } from "react";
import { mediaColour } from "../format.ts";
/** A cover image, the media-type gradient when there is none, or the striped draft placeholder. */
export function Cover({ url, mediaType, draft, size }: { url: string; mediaType: string; draft: boolean; size?: "large" | "small" }) {
const [failed, setFailed] = useState(false);
const cls = `cover${size ? ` ${size}` : ""}`;
if (url && !failed) {
return (
<div className={cls}>
<img src={url} alt="" loading="lazy" onError={() => setFailed(true)} />
</div>
);
}
if (draft) return <div className={`${cls} is-draft`} aria-hidden="true" />;
const c = mediaColour(mediaType);
return <div className={cls} aria-hidden="true" style={{ background: `linear-gradient(160deg, ${c}, color-mix(in srgb, ${c} 25%, #0b0716))` }} />;
}
+557
View File
@@ -0,0 +1,557 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Link } from "wouter";
import { api, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts";
import { orderEngagements, setCustomValue } from "../edits.ts";
import {
actorConnectionId,
describeRating,
draftFailedSave,
draftIncoming,
draftSaved,
draftStartSave,
engagementNouns,
formatDateRange,
formatPartialDate,
formatRating,
friendlyError,
ordinal,
parsePartialDate,
rollupLabel,
statusLabel,
timeAgo,
type Draft,
type PartialDate,
} from "../format.ts";
import { useEntryPatch, useSources } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
import { Icon } from "./Icon.tsx";
import { StarRating } from "./StarRating.tsx";
function Box({ title, id, action, wide, className, children }: { title: string; id: string; action?: ReactNode; wide?: boolean; className?: string; children: ReactNode }) {
return (
<section className={`box${wide ? " wide" : ""}${className ? ` ${className}` : ""}`} aria-labelledby={id}>
<div className="box-head">
<h2 id={id}>{title}</h2>
{action}
</div>
{children}
</section>
);
}
// ---------------------------------------------------------------------------------------------
// Reads / plays
// ---------------------------------------------------------------------------------------------
export function EngagementsBox({
entry,
nouns,
engagements,
loading,
}: {
entry: EntryView;
nouns: ReturnType<typeof engagementNouns>;
engagements: Engagement[] | undefined;
loading: boolean;
}) {
const qc = useQueryClient();
const [adding, setAdding] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const refresh = () => {
void qc.invalidateQueries({ queryKey: ["engagements", entry.id] });
void qc.invalidateQueries({ queryKey: ["history", entry.id] });
};
const add = useMutation({
mutationFn: (b: { startedAt: PartialDate | null; finishedAt: PartialDate | null }) => api.addEngagement(entry.id, b),
onSuccess: () => {
setAdding(false);
refresh();
},
onError: (e) => setFormError(friendlyError(e)),
});
const [removeError, setRemoveError] = useState<string | null>(null);
const remove = useMutation({
mutationFn: api.deleteEngagement,
onSuccess: () => (setRemoveError(null), refresh()),
onError: (e) => setRemoveError(friendlyError(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const read = (k: string) => {
const raw = String(f.get(k) ?? "").trim();
if (!raw) return { ok: true as const, v: null };
const pd = parsePartialDate(raw);
return pd ? { ok: true as const, v: pd } : { ok: false as const, v: null };
};
const s = read("startedAt");
const d = read("finishedAt");
if (!s.ok || !d.ok) return setFormError("Use a year, a month or a day: 2025, 2025-03 or 2025-03-14.");
setFormError(null);
add.mutate({ startedAt: s.v, finishedAt: d.v });
}
// Oldest is the 1st; show newest first, like the mockup.
const ordered = orderEngagements(engagements ?? []);
const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse();
return (
<Box
title={nouns.box}
id="box-engagements"
action={
!adding && (
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>
<Icon name="plus" /> {nouns.log}
</button>
)
}
>
{loading ? null : rows.length === 0 && !adding ? (
<p className="empty-line">Nothing logged yet.</p>
) : (
rows.map(({ g, n }) => (
<div className="r" key={g.id}>
<span>{ordinal(n)} {nouns.one}</span>
<span style={{ display: "inline-flex", gap: 6, alignItems: "center" }}>
<span className="tabular">{formatDateRange(g.startedAt, g.finishedAt)}</span>
<button
type="button"
className="btn ghost small x"
aria-label={`Remove ${ordinal(n)} ${nouns.one}`}
disabled={remove.isPending}
onClick={() => remove.mutate(g.id)}
>
<Icon name="x" />
</button>
</span>
</div>
))
)}
{removeError && <p className="error-text" role="alert">{removeError}</p>}
{adding && (
<form onSubmit={submit} aria-label={nouns.log}>
<div className="two">
<label className="field">
<span>Started</span>
<input className="input tabular" name="startedAt" placeholder="2025-03-14" inputMode="numeric" autoFocus aria-describedby="date-hint" />
</label>
<label className="field">
<span>Finished</span>
<input className="input tabular" name="finishedAt" placeholder="2025-03" inputMode="numeric" aria-describedby="date-hint" />
</label>
</div>
<small className="hint" id="date-hint">A year, month or day is fine. Leave blank if you don't know.</small>
{formError && <p className="error-text" role="alert">{formError}</p>}
<div className="actions">
<button className="btn primary small" disabled={add.isPending}>Save</button>
<button type="button" className="btn ghost small" onClick={() => (setAdding(false), setFormError(null))}>Cancel</button>
</div>
</form>
)}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Your copy
// ---------------------------------------------------------------------------------------------
function formatPrice(c: Copy): string {
if (c.priceCents === null) return "";
const amount = c.priceCents / 100;
if (!c.currency) return amount.toFixed(2);
try {
return new Intl.NumberFormat(undefined, { style: "currency", currency: c.currency, minimumFractionDigits: c.priceCents % 100 ? 2 : 0 }).format(amount);
} catch {
return `${amount.toFixed(2)} ${c.currency}`;
}
}
export function CopyBox({ entry }: { entry: EntryView }) {
const qc = useQueryClient();
const copies = useQuery({ queryKey: ["copies", entry.workId], queryFn: () => api.copies(entry.workId) });
const [adding, setAdding] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const add = useMutation({
mutationFn: api.addCopy,
onSuccess: () => {
setAdding(false);
void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
},
onError: (e) => setFormError(friendlyError(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const s = (k: string) => String(f.get(k) ?? "").trim() || null;
const when = s("acquiredAt");
const acquiredAt = when ? parsePartialDate(when) : null;
if (when && !acquiredAt) return setFormError("Use a year, a month or a day for when: 2025, 2025-02 or 2025-02-14.");
const price = s("price");
const priceNum = price ? Number(price.replace(",", ".")) : null;
if (price && (priceNum === null || Number.isNaN(priceNum))) return setFormError("Price should be a number, like 14 or 14.99.");
const currency = s("currency")?.toUpperCase() ?? null;
if (currency && !/^[A-Z]{3}$/.test(currency)) return setFormError("Currency is a three-letter code, like GBP or EUR.");
setFormError(null);
add.mutate({
workId: entry.workId,
format: s("format"),
acquiredFrom: s("acquiredFrom"),
acquiredAt,
priceCents: priceNum === null ? null : Math.round(priceNum * 100),
currency,
});
}
const list = copies.data ?? [];
return (
<Box
title="Your copy"
id="box-copy"
action={
!adding && list.length > 0 && (
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>
<Icon name="plus" /> Another
</button>
)
}
>
{copies.isPending ? null : list.length === 0 && !adding ? (
<div className="r">
<span>You haven't recorded a copy.</span>
<button type="button" className="btn ghost small" onClick={() => setAdding(true)}>Add your copy</button>
</div>
) : (
list.map((c) => {
const when = [formatPartialDate(c.acquiredAt), formatPrice(c)].filter(Boolean).join(" · ");
return (
<div className="copy" key={c.id}>
{c.acquiredFrom && <div className="r"><span>Bought</span><span>{c.acquiredFrom}</span></div>}
{when && <div className="r"><span>When / price</span><span className="tabular">{when}</span></div>}
{c.format && <div className="r"><span>Format</span><span>{c.format}</span></div>}
{!c.acquiredFrom && !when && !c.format && <div className="r"><span>Owned</span><span>No details yet</span></div>}
</div>
);
})
)}
{adding && (
<form onSubmit={submit} aria-label="Add your copy">
<div className="two">
<label className="field"><span>Bought at</span><input className="input" name="acquiredFrom" placeholder="Waterstones, Leeds" autoFocus /></label>
<label className="field"><span>Format</span><input className="input" name="format" placeholder="Hardback" /></label>
</div>
<div className="two">
<label className="field"><span>When</span><input className="input tabular" name="acquiredAt" placeholder="2025-02" /></label>
<div className="two" style={{ gridTemplateColumns: "1fr 72px" }}>
<label className="field"><span>Price</span><input className="input tabular" name="price" inputMode="decimal" placeholder="14" /></label>
<label className="field"><span>Currency</span><input className="input" name="currency" placeholder="GBP" maxLength={3} /></label>
</div>
</div>
{formError && <p className="error-text" role="alert">{formError}</p>}
<div className="actions">
<button className="btn primary small" disabled={add.isPending}>Save</button>
<button type="button" className="btn ghost small" onClick={() => (setAdding(false), setFormError(null))}>Cancel</button>
</div>
</form>
)}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Attachments
// ---------------------------------------------------------------------------------------------
export function AttachmentsBox({ entry }: { entry: EntryView }) {
const qc = useQueryClient();
const list = useQuery({ queryKey: ["attachments", entry.id], queryFn: () => api.attachments("entry", entry.id) });
const items = list.data ?? [];
const [error, setError] = useState<string | null>(null);
const upload = useMutation({
mutationFn: (file: File) => api.upload(file, entry.id),
onSuccess: () => {
setError(null);
void qc.invalidateQueries({ queryKey: ["attachments", entry.id] });
},
onError: (e) => setError(friendlyError(e)),
});
return (
<Box title="Attachments" id="box-attachments">
<div className="thumbs">
{items.map((a) => {
const url = `/api/v1/attachments/${a.id}`;
const image = a.mime.startsWith("image/");
return (
<a
key={a.id}
className="thumb"
href={url}
target="_blank"
rel="noreferrer"
title={a.filename}
aria-label={`Open ${a.filename}`}
style={image ? { backgroundImage: `url("${url}")` } : undefined}
>
{!image && <Icon name="file" />}
</a>
);
})}
<label className="thumb upload" title="Add screenshots, photos or receipts">
<span className="sr-only">Upload an attachment</span>
{upload.isPending ? "…" : <Icon name="upload" />}
<input
type="file"
className="sr-only"
accept="image/*,application/pdf,text/plain"
disabled={upload.isPending}
onChange={(e) => {
const f = e.currentTarget.files?.[0];
if (f) upload.mutate(f);
e.currentTarget.value = "";
}}
/>
</label>
</div>
{error && <p className="error-text" role="alert">{error}</p>}
{list.error && <p className="error-text" role="alert">{friendlyError(list.error)}</p>}
{items.length === 0 && !error && !list.isPending && <p className="hint">Screenshots, photos of your copy, receipts.</p>}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Linked sources
// ---------------------------------------------------------------------------------------------
export function SourcesBox({ entry, engagements }: { entry: EntryView; engagements: Engagement[] }) {
const sources = useSources();
// A source is linked when it has written any field of the entry or its reads.
const ids = new Set<string>();
for (const fs of [entry.fieldSources, ...engagements.map((g) => g.fieldSources)]) {
for (const actor of Object.values(fs ?? {})) {
const id = actorConnectionId(actor);
if (id) ids.add(id);
}
}
const linked = (sources.data ?? []).filter((s) => ids.has(s.id));
return (
<Box title="Linked sources" id="box-sources">
{linked.length === 0 ? (
<p className="empty-line">No source syncs this entry. Everything here is yours.</p>
) : (
linked.map((s) => (
<div className="src" key={s.id}>
<i className={s.status === "active" ? "" : "paused"} aria-hidden="true" />
{s.name} · {s.status !== "active" ? "paused" : s.lastRunAt ? `synced ${timeAgo(s.lastRunAt)}` : "not synced yet"}
</div>
))
)}
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Custom fields
// ---------------------------------------------------------------------------------------------
export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) {
const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
const patch = useEntryPatch(entry.id);
const scale = useRatingScale();
if (!defs.data?.length) return null;
const save = (def: CustomFieldDef, value: unknown) =>
patch.edit((cur) => {
const next = setCustomValue(cur.customFields, def.id, value);
return JSON.stringify(next) === JSON.stringify(cur.customFields) ? null : { customFields: next };
});
return (
<Box title="Details" id="box-custom" wide>
<div className="custom">
{defs.data.map((d) => (
<CustomField key={d.id} def={d} value={entry.customFields[d.id]} onSave={(v) => save(d, v)} />
))}
</div>
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
</Box>
);
}
function CustomField({ def, value, onSave }: { def: CustomFieldDef; value: unknown; onSave: (v: unknown) => void }) {
const scale = useRatingScale();
const str = value === undefined || value === null ? "" : String(value);
const text = (multiline: boolean, inputType = "text") => (
<label className="field">
<span>{def.name}</span>
{multiline ? (
<textarea className="textarea" defaultValue={str} key={str} rows={3} onBlur={(e) => onSave(e.target.value.trim())} />
) : (
<input
className="input"
type={inputType}
defaultValue={str}
key={str}
placeholder={def.type === "date" ? "2025-03-14" : undefined}
onBlur={(e) => {
const v = e.target.value.trim();
if (def.type === "number") onSave(v === "" ? null : Number(v));
else if (def.type === "date") onSave(v === "" || parsePartialDate(v) ? v : str);
else onSave(v);
}}
/>
)}
</label>
);
switch (def.type) {
case "long_text":
return text(true);
case "number":
return text(false, "number");
case "url":
return text(false, "url");
case "boolean":
return (
<label className="inline">
<input type="checkbox" checked={value === true} onChange={(e) => onSave(e.target.checked ? true : null)} />
{def.name}
</label>
);
case "single_select":
return (
<label className="field">
<span>{def.name}</span>
<select className="select" value={str} onChange={(e) => onSave(e.target.value || null)}>
<option value="">—</option>
{def.options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</label>
);
case "multi_select": {
const cur = Array.isArray(value) ? (value as string[]) : [];
return (
<fieldset className="field" style={{ border: 0 }}>
<legend className="hint" style={{ color: "var(--text-muted)", fontWeight: 500, marginBottom: 5 }}>{def.name}</legend>
<div className="checks">
{def.options.map((o) => (
<label key={o} className="inline">
<input type="checkbox" checked={cur.includes(o)} onChange={(e) => onSave(e.target.checked ? [...cur, o] : cur.filter((x) => x !== o))} />
{o}
</label>
))}
</div>
</fieldset>
);
}
case "rating":
return (
<div className="field">
<span>{def.name}</span>
<StarRating value={typeof value === "number" ? value : null} scale={scale} label={def.name} onChange={onSave} />
</div>
);
default:
return text(false);
}
}
// ---------------------------------------------------------------------------------------------
// Children (seasons, episodes, volumes)
// ---------------------------------------------------------------------------------------------
export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) {
const scale = useRatingScale();
const children = useQuery({
queryKey: ["entries", { parentId: entry.workId }],
queryFn: () => api.entries({ parentId: entry.workId, limit: 200 }),
});
const label = rollupLabel(type, entry.childSummary ? { ...entry.childSummary, completed: 0 } : null) ?? "Parts";
const title = label.replace(/^\d+\s+/, "");
return (
<Box title={title.charAt(0).toUpperCase() + title.slice(1)} id="box-children" wide>
<ul className="children">
{(children.data?.items ?? []).map((c) => (
<li key={c.id}>
<Link href={`/entries/${c.id}`}>
<span className="t">
{c.work.title}
<span className="s">{statusLabel(c.status, c.mediaTypeOverride ?? c.work.mediaType)}</span>
</span>
{c.rating !== null ? (
<span className="stars" role="img" aria-label={describeRating(c.rating, scale)}>{formatRating(c.rating, scale)}</span>
) : (
<span className="s">Not rated</span>
)}
</Link>
</li>
))}
</ul>
</Box>
);
}
// ---------------------------------------------------------------------------------------------
// Review
// ---------------------------------------------------------------------------------------------
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);
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)));
},
},
);
};
return (
<Box
title="Your review"
id="box-review"
wide
className="review"
action={<span className="saved" aria-live="polite">{patch.isPending ? "Saving…" : saved ? "Saved" : ""}</span>}
>
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
<textarea
id="review-text"
value={draft.text}
placeholder="What stayed with you? Markdown works here."
onChange={(e) => {
const text = e.target.value;
setDraft((d) => ({ ...d, text }));
setSaved(false);
}}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save();
}}
/>
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
</Box>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { api, type DeviceStart } from "../api.ts";
import { friendlyError } from "../format.ts";
type Phase = { k: "starting" } | { k: "waiting"; d: DeviceStart } | { k: "connected" } | { k: "expired" } | { k: "error"; msg: string };
/**
* Device-code sign-in: shows the code and the link, then polls at the plugin's interval until the user
* approves, the code expires, or the dialog closes. A modal, because the user leaves for another site and
* comes back to exactly this.
*/
export function DeviceSignIn({ connectionId, sourceName, onClose }: { connectionId: string; sourceName: string; onClose: () => void }) {
const qc = useQueryClient();
const ref = useRef<HTMLDialogElement>(null);
const [phase, setPhase] = useState<Phase>({ k: "starting" });
const [attempt, setAttempt] = useState(0);
const [copied, setCopied] = useState(false);
useEffect(() => {
const dlg = ref.current;
if (dlg && !dlg.open) dlg.showModal();
}, []);
useEffect(() => {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | undefined;
setPhase({ k: "starting" });
api
.deviceStart(connectionId)
.then((d) => {
if (stopped) return;
setPhase({ k: "waiting", d });
const deadline = Date.now() + d.expiresInSec * 1000;
const every = Math.max(1, d.intervalSec) * 1000;
const tick = async () => {
if (stopped) return;
if (Date.now() > deadline) return setPhase({ k: "expired" });
try {
const r = await api.devicePoll(connectionId, d.deviceCode);
if (stopped) return;
if (r.status === "connected") {
setPhase({ k: "connected" });
void qc.invalidateQueries({ queryKey: ["sources"] });
return;
}
timer = setTimeout(() => void tick(), every);
} catch (e) {
if (!stopped) setPhase({ k: "error", msg: friendlyError(e) });
}
};
timer = setTimeout(() => void tick(), every);
})
.catch((e) => !stopped && setPhase({ k: "error", msg: friendlyError(e) }));
return () => {
stopped = true;
if (timer) clearTimeout(timer);
};
}, [connectionId, attempt, qc]);
async function copy(code: string) {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
} catch {
setCopied(false);
}
}
return (
<dialog ref={ref} className="modal" aria-labelledby="device-title" onClose={onClose} onCancel={onClose}>
<h2 id="device-title">Sign in to {sourceName}</h2>
{phase.k === "starting" && <p className="lede" aria-busy="true">Asking {sourceName} for a code…</p>}
{phase.k === "waiting" && (
<>
<p className="lede">
Open the link, sign in to {sourceName} if asked, and enter this code. This window updates by itself once you approve.
</p>
<div className="device-code">
<span className="code tabular" aria-label={`Code: ${phase.d.userCode.split("").join(" ")}`}>{phase.d.userCode}</span>
<button type="button" className="btn small" onClick={() => void copy(phase.d.userCode)}>{copied ? "Copied" : "Copy"}</button>
</div>
<a className="btn primary device-link" href={phase.d.verificationUrl} target="_blank" rel="noopener noreferrer">
Open {phase.d.verificationUrl.replace(/^https?:\/\//, "")}
</a>
<p className="hint waiting" role="status">Waiting for you to approve…</p>
</>
)}
{phase.k === "connected" && (
<p className="lede" role="status">Connected. {sourceName} will sync on its schedule, or use “Sync now”.</p>
)}
{phase.k === "expired" && (
<p className="lede" role="alert">The code expired before it was approved. Get a new one and try again.</p>
)}
{phase.k === "error" && <p className="form-error" role="alert">{phase.msg}</p>}
<div className="modal-actions">
{(phase.k === "expired" || phase.k === "error") && (
<button type="button" className="btn primary" onClick={() => setAttempt((n) => n + 1)}>Get a new code</button>
)}
<button type="button" className="btn" onClick={() => ref.current?.close()}>{phase.k === "connected" ? "Done" : "Cancel"}</button>
</div>
</dialog>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { Link, useLocation } from "wouter";
import { ApiError, api, type EntryView } from "../api.ts";
import { addTag, removeTag } from "../edits.ts";
import { engagementNouns, formatPartialDate, friendlyError, mediaTypeLabel, shortDate, STATUSES, statusLabel } from "../format.ts";
import { changesFor, useEntryPatch, usePendingChanges, useSources } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
import { Cover } from "./Cover.tsx";
import { AttachmentsBox, ChildrenBox, CopyBox, CustomFieldsBox, EngagementsBox, ReviewBox, SourcesBox } from "./DetailBoxes.tsx";
import { HistoryDrawer } from "./HistoryDrawer.tsx";
import { Icon } from "./Icon.tsx";
import { LinkToCatalogue } from "./LinkToCatalogue.tsx";
import { StarRating } from "./StarRating.tsx";
const TIMES = ["", "once", "twice", "three times"];
export function EntryDetail({ id }: { id: string }) {
const entry = useQuery({ queryKey: ["entry", id], queryFn: () => api.entry(id) });
if (entry.isPending) return <div className="detail" aria-busy="true" />;
// A failed background refetch (server briefly down) keeps showing the cached entry, so nothing the
// user is typing gets unmounted; only a first load that fails shows the error screen.
if (!entry.data) {
const gone = entry.error instanceof ApiError && entry.error.status === 404;
return (
<div className="detail-empty" role="alert">
<p>
<strong>{gone ? "This entry isn't here" : "Couldn't load this entry"}</strong>
{gone ? "It may have been deleted or moved. Pick another from your library." : friendlyError(entry.error)}
</p>
</div>
);
}
return <Detail entry={entry.data} />;
}
function Detail({ entry }: { entry: EntryView }) {
const scale = useRatingScale();
const patch = useEntryPatch(entry.id);
const [, navigate] = useLocation();
const [historyOpen, setHistoryOpen] = useState(false);
const historyBtn = useRef<HTMLButtonElement>(null);
const type = entry.mediaTypeOverride ?? entry.work.mediaType;
const nouns = engagementNouns(type);
const engagements = useQuery({ queryKey: ["engagements", entry.id], queryFn: () => api.engagements(entry.id) });
const changes = usePendingChanges();
const sources = useSources();
const engagementIds = new Set((engagements.data ?? []).map((g) => g.id));
const pending = changesFor(changes.data?.items, entry.id, engagementIds);
// "Book · Read twice · last finished 14 Sep 2026"
const finished = (engagements.data ?? []).filter((g) => g.finishedAt && g.finishedAt.precision !== "unknown");
const meta = [mediaTypeLabel(type)];
if (entry.work.year) meta.push(String(entry.work.year));
if (finished.length) {
meta.push(`${nouns.past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
const last = finished.map((g) => g.finishedAt!).sort((a, b) => (a.value < b.value ? 1 : -1))[0]!;
meta.push(`last finished ${formatPartialDate(last)}`);
}
return (
<article className="detail" aria-labelledby="entry-title">
<Link href="/" className="btn ghost back"><Icon name="back" /> Library</Link>
<header className="det-head">
<Cover url={entry.work.coverUrl} mediaType={type} draft={entry.draft} size="large" />
<div style={{ minWidth: 0 }}>
<h1 id="entry-title">
{entry.work.title}
{entry.draft && <span className="draft">Draft</span>}
</h1>
<p className="m">{meta.join(" · ")}</p>
<div className="det-controls">
<StarRating value={entry.rating} scale={scale} label="Your rating" onChange={(rating) => patch.mutate({ rating })} />
<label>
<span className="sr-only">Status</span>
<select className="select" value={entry.status} onChange={(e) => patch.mutate({ status: e.target.value as EntryView["status"] })}>
{STATUSES.map((s) => (
<option key={s} value={s}>{statusLabel(s, type)}</option>
))}
</select>
</label>
<div className="det-tools">
<button
ref={historyBtn}
type="button"
className="btn ghost small"
aria-expanded={historyOpen}
aria-controls="history-drawer"
onClick={() => setHistoryOpen((o) => !o)}
>
<Icon name="history" /> History
</button>
</div>
</div>
<Tags
tags={entry.tags}
onAdd={(t) => patch.edit((cur) => ({ tags: addTag(cur.tags, t) }))}
onRemove={(t) => patch.edit((cur) => ({ tags: removeTag(cur.tags, t) }))}
/>
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error, { ratingScale: scale })}</p>}
</div>
</header>
{pending.length > 0 && (
<div className="alert" role="status">
<p>
<b>{whoChanged(pending, sources.data)} changed this {mediaTypeLabel(type).toLowerCase()}.</b>{" "}
{pending.length === 1 ? `On ${shortDate(pending[0]!.createdAt)}.` : `${pending.length} changes are waiting for you.`} Your rating,{" "}
{nouns.box.toLowerCase()} and review here are unchanged.
</p>
<button type="button" className="btn" onClick={() => navigate(`/changes?entry=${entry.id}`)}>See what changed</button>
</div>
)}
{entry.draft && <LinkToCatalogue entry={entry} />}
<div className="det-grid">
<EngagementsBox entry={entry} nouns={nouns} engagements={engagements.data} loading={engagements.isPending} />
<CopyBox entry={entry} />
<AttachmentsBox entry={entry} />
<SourcesBox entry={entry} engagements={engagements.data ?? []} />
<CustomFieldsBox entry={entry} type={type} />
{entry.childSummary && <ChildrenBox entry={entry} type={type} />}
<ReviewBox entry={entry} />
</div>
{historyOpen && (
<HistoryDrawer
entry={entry}
onClose={() => {
setHistoryOpen(false);
historyBtn.current?.focus();
}}
/>
)}
</article>
);
}
function whoChanged(pending: { connectionId: string | null }[], sources: { id: string; name: string }[] | undefined): string {
const ids = new Set(pending.map((p) => p.connectionId));
if (ids.size === 1) {
const name = sources?.find((s) => s.id === [...ids][0])?.name;
if (name) return name;
}
return ids.size > 1 ? "Your sources" : "A source";
}
function Tags({ tags, onAdd, onRemove }: { tags: string[]; onAdd: (t: string) => void; onRemove: (t: string) => void }) {
const [draft, setDraft] = useState("");
function add() {
if (draft.trim()) onAdd(draft);
setDraft("");
}
return (
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
{tags.map((t) => (
<li key={t} className="pill">
{t}
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onRemove(t)}>
<Icon name="x" />
</button>
</li>
))}
<li>
<label>
<span className="sr-only">Add a tag</span>
<input
className="tag-input"
placeholder="+ tag"
value={draft}
maxLength={100}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
add();
}
}}
onBlur={add}
/>
</label>
</li>
</ul>
);
}
+117
View File
@@ -0,0 +1,117 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { api, type EntryView, type HistoryRecord } from "../api.ts";
import { actorConnectionId, formatPartialDate, formatRating, friendlyError, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
import { useSources } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
import { Icon } from "./Icon.tsx";
const LABELS: Record<string, string> = {
status: "Status",
rating: "Rating",
review: "Review",
privateNotes: "Private notes",
favorite: "Favourite",
tags: "Tags",
customFields: "Details",
visibility: "Visibility",
overrides: "Title and cover",
mediaTypeOverride: "Media type",
workId: "Catalogue link",
deletedAt: "Removed",
};
const SKIP = new Set(["id", "userId", "fieldSources", "createdAt", "updatedAt"]);
/** History of an entry, newest first, with "Restore" to put back what a change replaced. */
export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: () => void }) {
const qc = useQueryClient();
const scale = useRatingScale();
const sources = useSources();
const closeBtn = useRef<HTMLButtonElement>(null);
const history = useQuery({ queryKey: ["history", entry.id], queryFn: () => api.history(entry.id) });
const restore = useMutation({
// Same scope as the entry's PATCHes (useEntryPatch), so a restore never races a pending blur-save.
scope: { id: `entry-patch:${entry.id}` },
mutationFn: api.restore,
onSuccess: () => {
for (const key of [["entry", entry.id], ["history", entry.id], ["engagements", entry.id], ["entries"]]) void qc.invalidateQueries({ queryKey: key });
},
});
useEffect(() => {
closeBtn.current?.focus();
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const who = (actor: string) => {
if (actor === "user") return "You";
if (actor.startsWith("token:")) return `App token “${actor.slice(6)}”`;
const id = actorConnectionId(actor);
return sources.data?.find((s) => s.id === id)?.name ?? actor.split(":")[1] ?? "A source";
};
const show = (field: string, v: unknown): string => {
if (v === null || v === undefined || v === "") return "empty";
if (field === "rating") return formatRating(v as number, scale);
if (field === "status") return statusLabel(v as Status, entry.mediaTypeOverride ?? entry.work.mediaType);
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "object" && "precision" in (v as object)) return formatPartialDate(v as PartialDate) || "unknown";
if (typeof v === "object") return Object.keys(v as object).length ? "edited" : "empty";
if (typeof v === "boolean") return v ? "yes" : "no";
const s = String(v);
return s.length > 80 ? `${s.slice(0, 80)}…` : s;
};
const diffs = (h: HistoryRecord) =>
[...new Set([...Object.keys(h.before ?? {}), ...Object.keys(h.after ?? {})])]
.filter((k) => !SKIP.has(k) && JSON.stringify(h.before?.[k]) !== JSON.stringify(h.after?.[k]))
.map((k) => ({ k, before: show(k, h.before?.[k]), after: show(k, h.after?.[k]) }));
return (
<aside className="drawer" id="history-drawer" role="dialog" aria-modal="false" aria-labelledby="history-title">
<header>
<h2 id="history-title">History</h2>
<button ref={closeBtn} type="button" className="btn ghost small" onClick={onClose} aria-label="Close history">
<Icon name="x" />
</button>
</header>
{history.isPending ? null : history.error ? (
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{friendlyError(history.error)}</p>
) : (
<ol>
{history.data.map((h) => {
const d = diffs(h);
const created = Object.keys(h.before ?? {}).length === 0;
return (
<li key={h.id}>
<div className="when">
<span><span className="who">{who(h.actor)}</span> · {timeAgo(h.createdAt)}</span>
{!created && d.length > 0 && (
<button type="button" className="btn small" disabled={restore.isPending} onClick={() => restore.mutate(h.id)}>
Restore
</button>
)}
</div>
{created ? (
<p className="hint">Added to your library.</p>
) : d.length === 0 ? (
<p className="hint">No visible change.</p>
) : (
<dl>
{d.map(({ k, before, after }) => (
<div key={k} style={{ display: "contents" }}>
<dt>{LABELS[k] ?? k}</dt>
<dd><s>{before}</s><span className="arrow" aria-label="changed to">→</span>{after}</dd>
</div>
))}
</dl>
)}
</li>
);
})}
</ol>
)}
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{friendlyError(restore.error)}</p>}
</aside>
);
}
+21
View File
@@ -0,0 +1,21 @@
/** A small authored icon set: 24px grid, 1.75 stroke, round caps. */
const PATHS = {
plus: "M12 5v14M5 12h14",
search: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4",
x: "M6 6l12 12M18 6L6 18",
history: "M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2",
back: "M15 18l-6-6 6-6",
upload: "M12 16V4M7 9l5-5 5 5M5 20h14",
link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
file: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5",
} as const;
export type IconName = keyof typeof PATHS;
export function Icon({ name }: { name: IconName }) {
return (
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d={PATHS[name]} />
</svg>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { useLocation } from "wouter";
import { api, type EntryView, type Work } from "../api.ts";
import { friendlyError, mediaTypeLabel } from "../format.ts";
import { useDebounced } from "../hooks.ts";
import { Cover } from "./Cover.tsx";
import { Icon } from "./Icon.tsx";
/** Drafts only: suggested matches and a catalogue search. Linking keeps the user's rating, reads and review. */
export function LinkToCatalogue({ entry }: { entry: EntryView }) {
const qc = useQueryClient();
const [, navigate] = useLocation();
const [q, setQ] = useState("");
const term = useDebounced(q.trim());
const suggestions = useQuery({ queryKey: ["suggestions", entry.workId], queryFn: () => api.suggestions(entry.workId) });
const candidates = useQueries({
queries: (suggestions.data ?? []).slice(0, 5).map((s) => ({ queryKey: ["work", s.candidateWorkId], queryFn: () => api.work(s.candidateWorkId), staleTime: 300_000 })),
});
const search = useQuery({
queryKey: ["works-search", term],
queryFn: () => api.searchWorks(term),
enabled: term.length >= 2,
});
const done = (newId?: string) => {
void qc.invalidateQueries({ queryKey: ["entries"] });
void qc.invalidateQueries({ queryKey: ["entry", entry.id] });
void qc.invalidateQueries({ queryKey: ["suggestions", entry.workId] });
if (newId && newId !== entry.id) navigate(`/entries/${newId}`, { replace: true });
};
const move = useMutation({ mutationFn: (workId: string) => api.moveEntry(entry.id, workId), onSuccess: (e) => done(e.id) });
const accept = useMutation({ mutationFn: api.acceptSuggestion, onSuccess: () => done() });
const reject = useMutation({ mutationFn: api.rejectSuggestion, onSuccess: () => done() });
const busy = move.isPending || accept.isPending || reject.isPending;
const error = move.error ?? accept.error ?? reject.error;
const row = (w: Work, actions: ReactNode) => (
<li key={w.id}>
<Cover url={w.cover_url} mediaType={w.media_type} draft={false} size="small" />
<div style={{ minWidth: 0 }}>
<div>{w.title}</div>
<div className="m">{[mediaTypeLabel(w.media_type), w.year, w.creators?.[0]?.name].filter(Boolean).join(" · ")}</div>
</div>
<div className="acts">{actions}</div>
</li>
);
const results = (search.data ?? []).filter((w) => w.id !== entry.workId);
return (
<section className="box link-box" aria-labelledby="link-title">
<div className="box-head">
<h2 id="link-title"><Icon name="link" /> Link to catalogue</h2>
</div>
<p>This is a draft. Link it to a catalogue entry to fill in the cover and details. Your rating, reads and review stay as they are.</p>
{candidates.length > 0 && (
<>
<h3 className="sub">Suggested</h3>
<ul className="results">
{suggestions.data!.slice(0, 5).map((s, i) => {
const w = candidates[i]?.data;
if (!w) return null;
return row(
w,
<>
<button type="button" className="btn primary small" disabled={busy} onClick={() => accept.mutate(s.id)}>Link</button>
<button type="button" className="btn ghost small" disabled={busy} onClick={() => reject.mutate(s.id)}>Not this</button>
</>,
);
})}
</ul>
</>
)}
<div className="search">
<Icon name="search" />
<label htmlFor="catalogue-search" className="sr-only">Search the catalogue</label>
<input
id="catalogue-search"
className="input"
type="search"
placeholder="Search the catalogue…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{term.length >= 2 && !search.isPending && (
results.length === 0 ? (
<p className="hint">Nothing in the catalogue matches “{term}”. Connect a source to bring in more.</p>
) : (
<ul className="results" aria-label="Search results">
{results.map((w) =>
row(w, <button type="button" className="btn primary small" disabled={busy} onClick={() => move.mutate(w.id)}>Link</button>),
)}
</ul>
)
)}
{error && <p className="error-text" role="alert">{friendlyError(error)}</p>}
</section>
);
}
+22
View File
@@ -0,0 +1,22 @@
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>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { api } from "../api.ts";
import { useMe, usePendingChanges, useSources } from "../hooks.ts";
import { resetLibraryState } from "../routes/library.tsx";
function NavLink({ href, active, children, className }: { href: string; active: boolean; children: ReactNode; className?: string }) {
return (
<Link href={href} className={`nav${className ? ` ${className}` : ""}`} aria-current={active ? "page" : undefined}>
{children}
</Link>
);
}
/** Sidebar navigation plus the main area. Stacks into a top bar on narrow screens. */
export function Shell({ children }: { children: ReactNode }) {
const [path, navigate] = useLocation();
const qc = useQueryClient();
const me = useMe();
const changes = usePendingChanges();
const sources = useSources();
const pending = changes.data?.items.length ?? 0;
const at = (p: string) => path === p || path.startsWith(`${p}/`);
async function signOut() {
try {
await api.logout();
} catch {
// The session may already be gone; signing out locally is still right.
}
qc.clear();
resetLibraryState();
navigate("/login", { replace: true });
}
return (
<div className="shell">
<aside className="side">
<Link href="/" className="logo">STTracker</Link>
<nav aria-label="Main">
<NavLink href="/" active={path === "/" || at("/entries")}>Library</NavLink>
<NavLink href="/log" active={at("/log")}>Log</NavLink>
<NavLink href="/lists" active={at("/lists")}>Lists</NavLink>
<NavLink href="/owned" active={at("/owned")}>Owned</NavLink>
<NavLink href="/changes" active={at("/changes")}>
Changes
{pending > 0 && (
<span className="count">
{changes.data?.nextCursor ? `${pending}+` : pending}
<span className="sr-only"> waiting</span>
</span>
)}
</NavLink>
</nav>
<h2 className="grp">Sources</h2>
<nav aria-label="Sources" className="src-nav">
{(sources.data ?? []).map((s) => (
<NavLink key={s.id} href={`/sources/${s.id}`} active={at(`/sources/${s.id}`)}>
<span className="name">{s.name}</span>
<span className={`dot${s.status === "active" ? "" : " paused"}`} role="img" aria-label={s.status === "active" ? "Active" : "Paused"} />
</NavLink>
))}
<NavLink href="/sources" active={path === "/sources"}>
{sources.data?.length ? "All sources" : "Connect a source"}
</NavLink>
</nav>
<nav aria-label="Account" style={{ marginTop: 14 }}>
<NavLink href="/settings" active={at("/settings")}>Settings</NavLink>
</nav>
<div className="side-foot">
{me.data && <span className="who">{me.data.displayName || me.data.username}</span>}
<button type="button" onClick={() => void signOut()}>Sign out</button>
</div>
</aside>
<main className="main">{children}</main>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { useId } from "react";
import type { ConfigField } from "../api.ts";
/**
* Inputs rendered from a plugin manifest's `config` fields. Secrets are password inputs and are never
* filled in: on edit, an empty secret keeps the saved one.
*/
export function ConfigFields({
fields,
values,
savedSecrets,
}: {
fields: ConfigField[];
values: Record<string, unknown>;
savedSecrets: boolean;
}) {
const base = useId();
return (
<>
{fields.map((f) => {
const id = `${base}-${f.key}`;
const help = f.type === "secret" && savedSecrets ? "Saved. Leave empty to keep it." : f.help;
const describedBy = help ? `${id}-help` : undefined;
const v = values[f.key];
let input;
if (f.type === "boolean") {
return (
<div key={f.key} className="field">
<label className="inline-check" htmlFor={id}>
<input id={id} name={f.key} type="checkbox" defaultChecked={v === true} aria-describedby={describedBy} />
{f.label}
</label>
{help && <span className="hint" id={describedBy}>{help}</span>}
</div>
);
}
if (f.type === "select") {
input = (
<select id={id} name={f.key} className="select" defaultValue={typeof v === "string" ? v : ""} required={f.required} aria-describedby={describedBy}>
{!f.required && <option value="">Not set</option>}
{(f.options ?? []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
);
} else if (f.type === "secret") {
input = (
<input
id={id}
name={f.key}
className="input"
type="password"
autoComplete="new-password"
spellCheck={false}
required={f.required && !savedSecrets}
placeholder={savedSecrets ? "Saved" : undefined}
aria-describedby={describedBy}
/>
);
} else {
input = (
<input
id={id}
name={f.key}
className="input"
type={f.type === "number" ? "number" : "text"}
inputMode={f.type === "number" ? "decimal" : undefined}
defaultValue={v === undefined || v === null ? "" : String(v)}
required={f.required}
spellCheck={false}
aria-describedby={describedBy}
/>
);
}
return (
<div key={f.key} className="field">
<label htmlFor={id}>
{f.label}
{f.required && <span className="req"> (required)</span>}
</label>
{input}
{help && <span className="hint" id={describedBy}>{help}</span>}
</div>
);
})}
</>
);
}
/**
* Read a connect/edit form into the API's `config`. Plain fields are sent whole (the server replaces
* them); secrets only when typed, so an empty one keeps what is saved.
*/
export function readConfig(fields: ConfigField[], form: FormData): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of fields) {
const raw = form.get(f.key);
if (f.type === "boolean") out[f.key] = raw === "on";
else if (f.type === "secret") {
if (typeof raw === "string" && raw !== "") out[f.key] = raw;
} else if (typeof raw === "string" && raw.trim() !== "") out[f.key] = f.type === "number" ? Number(raw) : raw.trim();
}
return out;
}
+148
View File
@@ -0,0 +1,148 @@
import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { describeRating, ratingStep, snapRating, type RatingScale } from "../format.ts";
const STAR = "M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z";
/**
* Editable rating in the user's scale. Stars are a slider: arrows step (half a star on "5half"),
* Home/End jump, Delete clears. The 10 and 100 point scales use a number field.
*/
export function StarRating({
value,
scale,
label,
onChange,
}: {
value: number | null;
scale: RatingScale;
label: string;
onChange: (v: number | null) => void;
}) {
const [hover, setHover] = useState<number | null>(null);
// Arrow keys step a local value; it is saved once the user pauses (or leaves), not on every step.
const [stepping, setStepping] = useState<{ v: number | null } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const clipBase = useId();
const step = ratingStep(scale);
// Leaving mid-step (navigating to another entry) still saves the stepped value.
const latest = useRef({ stepping, value, onChange });
latest.current = { stepping, value, onChange };
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
const { stepping: st, value: v, onChange: save } = latest.current;
if (st && st.v !== v) save(st.v);
},
[],
);
if (scale === "10" || scale === "100") {
const max = scale === "10" ? 10 : 100;
const shown = value === null ? "" : String(scale === "10" ? snapRating(value, scale) / 10 : value);
return (
<label className="rating-number">
<span className="sr-only">{label}</span>
<input
className="input tabular"
type="number"
min={0}
max={max}
step={scale === "10" ? 0.5 : 1}
defaultValue={shown}
key={shown}
onBlur={(e) => {
const raw = e.currentTarget.value.trim();
if (raw === "") return value !== null && onChange(null);
const n = Number(raw);
if (Number.isNaN(n)) return;
const v = snapRating(scale === "10" ? n * 10 : n, scale);
if (v !== value) onChange(v);
}}
/>
<span>/ {max}</span>
</label>
);
}
const set = (v: number | null) => {
if (timer.current) clearTimeout(timer.current);
timer.current = null;
setStepping(null);
if (v !== value) onChange(v === null ? null : snapRating(v, scale));
};
const flush = () => stepping && set(stepping.v);
const current = stepping ? stepping.v : value;
const onKey = (e: KeyboardEvent) => {
const cur = current ?? 0;
const map: Record<string, number | null> = {
ArrowRight: Math.min(100, cur + step),
ArrowUp: Math.min(100, cur + step),
ArrowLeft: Math.max(0, cur - step),
ArrowDown: Math.max(0, cur - step),
Home: 0,
End: 100,
Delete: null,
Backspace: null,
};
if (!(e.key in map)) return;
e.preventDefault();
const next = map[e.key]!;
setStepping({ v: next });
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => set(next), 500);
};
const valueAt = (e: PointerEvent<HTMLSpanElement>, i: number) => {
const r = e.currentTarget.getBoundingClientRect();
const half = scale === "5half" && e.clientX - r.left < r.width / 2;
return (i + (half ? 0.5 : 1)) * 20;
};
const shown = hover ?? current ?? 0;
return (
<span className="rating-wrap" style={{ display: "inline-flex", alignItems: "center" }}>
<span
className="rating"
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={current ?? undefined}
aria-valuetext={describeRating(current, scale)}
onKeyDown={onKey}
onBlur={flush}
onPointerLeave={() => setHover(null)}
>
{[0, 1, 2, 3, 4].map((i) => {
const fill = Math.max(0, Math.min(1, shown / 20 - i));
const id = `${clipBase}-${i}`;
return (
<span
key={i}
onPointerMove={(e) => setHover(valueAt(e, i))}
onClick={(e) => {
const v = valueAt(e as unknown as PointerEvent<HTMLSpanElement>, i);
set(v === value ? null : v);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<defs>
<clipPath id={id}>
<rect x="0" y="0" width={24 * fill} height="24" />
</clipPath>
</defs>
<path className="empty" d={STAR} />
{fill > 0 && <path className="full" d={STAR} clipPath={`url(#${id})`} />}
</svg>
</span>
);
})}
</span>
{current !== null && (
<button type="button" className="rating-clear" onClick={() => set(null)}>
Clear
</button>
)}
</span>
);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Next-value builders for entry edits. Callers build from the latest cached entry (not a render's
* props), so quick successive edits (two tag adds) compose instead of overwriting each other.
*/
import type { PartialDate } from "./format.ts";
export function addTag(tags: string[], tag: string): string[] {
const t = tag.trim();
if (!t || tags.some((x) => x.toLowerCase() === t.toLowerCase())) return tags;
return [...tags, t];
}
export function removeTag(tags: string[], tag: string): string[] {
return tags.filter((x) => x !== tag);
}
/** Set a custom field value by definition id; null, "" and [] remove it. */
export function setCustomValue(fields: Record<string, unknown>, id: string, value: unknown): Record<string, unknown> {
const next = { ...fields };
if (value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) delete next[id];
else next[id] = value;
return next;
}
/** The optimistic view of an entry after a PATCH. */
export function applyEntryPatch<T extends object>(entry: T, patch: Partial<T>): T {
return { ...entry, ...patch };
}
/**
* Reads/plays oldest first: by `finishedAt ?? startedAt` (partial dates compare as strings), then by
* when they were logged. Undated ones count as oldest.
*/
export function orderEngagements<G extends { createdAt: string; startedAt: PartialDate | null; finishedAt: PartialDate | null }>(list: G[]): G[] {
const key = (g: G) => (g.finishedAt?.value || g.startedAt?.value || "");
return [...list].sort((a, b) => {
const ka = key(a);
const kb = key(b);
if (ka !== kb) return ka < kb ? -1 : 1;
return a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0;
});
}
+93
View File
@@ -0,0 +1,93 @@
Copyright 2022 The Gelasio Project Authors (https://github.com/SorkinType/Gelasio)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter) Inter-Italic[opsz,wght].ttf: Copyright 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+303
View File
@@ -0,0 +1,303 @@
/** Display helpers. Pure functions: no DOM, no network. Partial dates never go through `Date`. */
export type Precision = "day" | "month" | "year" | "unknown";
export interface PartialDate {
value: string;
precision: Precision;
}
/** How ratings are shown and entered. Stored ratings are always integers 0–100. */
export type RatingScale = "5half" | "5" | "10" | "100";
export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Size of one step of the scale, in 0–100 units. */
export function ratingStep(scale: RatingScale): number {
return scale === "5half" ? 10 : scale === "5" ? 20 : scale === "10" ? 5 : 1;
}
/** Snap a 0–100 value to the scale's nearest step. */
export function snapRating(value: number, scale: RatingScale): number {
const step = ratingStep(scale);
return Math.min(100, Math.max(0, Math.round(value / step) * step));
}
/** `formatRating(90, "5half")` is `"★★★★½"`; null is `""`. */
export function formatRating(value: number | null | undefined, scale: RatingScale): string {
if (value === null || value === undefined) return "";
const v = snapRating(value, scale);
if (scale === "10") return `${v / 10}/10`;
if (scale === "100") return `${v}/100`;
const stars = v / 20;
const full = Math.floor(stars);
const half = stars - full >= 0.5;
return "★".repeat(full) + (half ? "½" : "") + "☆".repeat(5 - full - (half ? 1 : 0));
}
/** Screen-reader text for a rating: "4½ stars", "9 out of 10", "Not rated". */
export function describeRating(value: number | null | undefined, scale: RatingScale): string {
if (value === null || value === undefined) return "Not rated";
const v = snapRating(value, scale);
if (scale === "10") return `${v / 10} out of 10`;
if (scale === "100") return `${v} out of 100`;
const stars = v / 20;
const whole = Math.floor(stars);
const label = stars % 1 ? `${whole === 0 ? "" : whole}½` : String(whole);
return `${label} ${stars === 1 ? "star" : "stars"}`;
}
/** `{value:"2025-03",precision:"month"}` is `"Mar 2025"`. Unknown or missing dates are `""`. */
export function formatPartialDate(pd: PartialDate | null | undefined): string {
if (!pd || pd.precision === "unknown" || !pd.value) return "";
const y = pd.value.slice(0, 4);
if (pd.precision === "year") return y;
const month = MONTHS[Number(pd.value.slice(5, 7)) - 1] ?? "";
if (pd.precision === "month") return `${month} ${y}`;
return `${Number(pd.value.slice(8, 10))} ${month} ${y}`;
}
const isExact = (pd: PartialDate | null | undefined) => !pd || pd.precision === "day";
/**
* A read/play's dates: "2 Sep – 14 Sep 2026", "Mar 2025 (no exact date)", "Started 2 Sep 2026".
* Anything coarser than a day is flagged so a month never passes for a precise date.
*/
export function formatDateRange(start: PartialDate | null | undefined, end: PartialDate | null | undefined): string {
const s = formatPartialDate(start);
const e = formatPartialDate(end);
const suffix = isExact(start) && isExact(end) ? "" : " (no exact date)";
if (!s && !e) return "No dates";
if (s && !e) return `Started ${s}${suffix}`;
if (!s) return `${e}${suffix}`;
if (s === e) return `${e}${suffix}`;
// Drop the repeated year: "2 Sep – 14 Sep 2026".
const sameYear = start!.value.slice(0, 4) === end!.value.slice(0, 4) && start!.precision !== "year";
const left = sameYear ? s.slice(0, -5) : s;
return `${left} – ${e}${suffix}`;
}
/** Accepts `YYYY`, `YYYY-MM` and `YYYY-MM-DD`. Anything else (including `03/04/2019`) is null. */
export function parsePartialDate(input: string): PartialDate | null {
const s = input.trim();
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(s);
if (!m) return null;
if (m[2] === undefined) return { value: s, precision: "year" };
const month = Number(m[2]);
if (month < 1 || month > 12) return null;
if (m[3] === undefined) return { value: s, precision: "month" };
const day = Number(m[3]);
const y = Number(m[1]);
const leap = (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
const dim = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1]!;
return day >= 1 && day <= dim ? { value: s, precision: "day" } : null;
}
const TYPE_LABELS: Record<string, string> = {
book: "Book",
comic: "Comic",
manga: "Manga",
game: "Game",
movie: "Movie",
show: "Show",
season: "Season",
episode: "Episode",
podcast: "Podcast",
podcast_episode: "Podcast episode",
audio_drama: "Audio drama",
artist: "Artist",
album: "Album",
track: "Track",
video: "Video",
other: "Other",
};
export function mediaTypeLabel(type: string): string {
return TYPE_LABELS[type] ?? type.charAt(0).toUpperCase() + type.slice(1).replace(/_/g, " ");
}
/** The cover colour family for a media type (DESIGN.md "Media type colours"). */
export function mediaColour(type: string): string {
if (["book", "comic", "manga"].includes(type)) return "#6b3fa0";
if (["podcast", "podcast_episode", "audio_drama"].includes(type)) return "#2f6b58";
if (type === "game") return "#c1652f";
if (["movie", "show", "season", "episode", "video"].includes(type)) return "#344b7a";
if (["artist", "album", "track"].includes(type)) return "#a33b62";
return "#4b4485";
}
const CHILD_NOUN: Record<string, [string, string]> = {
show: ["season", "seasons"],
season: ["episode", "episodes"],
podcast: ["episode", "episodes"],
audio_drama: ["episode", "episodes"],
manga: ["volume", "volumes"],
comic: ["issue", "issues"],
book: ["volume", "volumes"],
album: ["track", "tracks"],
artist: ["album", "albums"],
};
/** Children roll up as "season 3 of 5", "5 seasons" (none finished) or "all 200 episodes". */
export function rollupLabel(type: string, summary: { total: number; completed: number } | null): string | null {
if (!summary || summary.total === 0) return null;
const [one, many] = CHILD_NOUN[type] ?? ["part", "parts"];
const { total, completed } = summary;
if (completed === 0) return `${total} ${total === 1 ? one : many}`;
if (completed >= total) return total === 1 ? `1 ${one}` : `all ${total} ${many}`;
return `${one} ${completed} of ${total}`;
}
export type Status = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
export const STATUSES: Status[] = ["planned", "in_progress", "paused", "completed", "dropped", "none"];
/** Status in the words of the medium: "Reading", "Playing", "Watching", "Listening". */
export function statusLabel(status: Status, type = "other"): string {
if (status === "in_progress") {
const verb = engagementNouns(type).verb;
return verb;
}
return { planned: "Planned", paused: "Paused", completed: "Finished", dropped: "Dropped", none: "No status" }[status];
}
/** What one time through a work is called: reads, plays, watches, listens. */
export function engagementNouns(type: string): { box: string; one: string; verb: string; past: string; log: string } {
if (["book", "comic", "manga"].includes(type)) return { box: "Reads", one: "read", verb: "Reading", past: "Read", log: "Log a read" };
if (type === "game") return { box: "Plays", one: "play", verb: "Playing", past: "Played", log: "Log a play" };
if (["movie", "show", "season", "episode", "video"].includes(type))
return { box: "Watches", one: "watch", verb: "Watching", past: "Watched", log: "Log a watch" };
if (["podcast", "podcast_episode", "audio_drama", "artist", "album", "track"].includes(type))
return { box: "Listens", one: "listen", verb: "Listening", past: "Listened", log: "Log a listen" };
return { box: "Times through", one: "time", verb: "In progress", past: "Finished", log: "Log a time" };
}
/** "12 Oct" for a real timestamp, in local time. */
export function shortDate(iso: string): string {
const d = new Date(iso);
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
}
/** 1 → "1st", 2 → "2nd", 11 → "11th". */
export function ordinal(n: number): string {
const tens = n % 100;
if (tens >= 11 && tens <= 13) return `${n}th`;
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
}
/** "4 min ago", "3 h ago", "2 days ago", else "14 Sep 2026". For real timestamps only. */
export function timeAgo(iso: string, now: Date = new Date()): string {
const t = new Date(iso);
const s = Math.round((now.getTime() - t.getTime()) / 1000);
if (Number.isNaN(s)) return "";
if (s < 60) return "just now";
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
if (s < 86400 * 7) return `${Math.floor(s / 86400)} ${s < 86400 * 2 ? "day" : "days"} ago`;
return `${t.getDate()} ${MONTHS[t.getMonth()]} ${t.getFullYear()}`;
}
/** The connection id in a plugin actor `plugin:<pluginId>:<connectionId>`, else null. */
export function actorConnectionId(actor: string): string | null {
const m = /^plugin:[^:]+:(.+)$/.exec(actor);
return m ? m[1]! : null;
}
// ---------------------------------------------------------------------------------------------
// Errors in plain words
// ---------------------------------------------------------------------------------------------
const FIELD_LABELS: Record<string, string> = {
startedAt: "Started",
finishedAt: "Finished",
acquiredAt: "When",
acquiredFrom: "Bought at",
priceCents: "Price",
privateNotes: "Private notes",
inviteCode: "Invite code",
coverUrl: "Cover",
workId: "Catalogue entry",
};
/** "customFields.Website" → "Website"; "displayName" → "Display name". */
function fieldLabel(field: string): string {
if (field.startsWith("customFields.")) return field.slice("customFields.".length);
const last = field.split(".").pop() ?? field;
if (FIELD_LABELS[last]) return FIELD_LABELS[last]!;
const words = last.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/_/g, " ").toLowerCase();
return words.charAt(0).toUpperCase() + words.slice(1);
}
const TOO_LARGE = "That file is too large. The limit is 25 MB.";
/**
* An API error in the product's voice: what happened and how to fix it. Validation messages arrive as
* `field: reason` (core) or `field: Zod message`, several joined by "; "; the first one is described.
* Pass the user's rating scale so out-of-range ratings are described in the scale they see.
*/
export function friendlyError(err: unknown, opts: { ratingScale?: RatingScale } = {}): string {
if (!err || typeof err !== "object" || !("code" in err) || !("message" in err)) return "Something went wrong. Try again.";
const { status, code, message } = err as { status?: number; code: string; message: string };
if (status === 413) return TOO_LARGE;
if (code !== "validation") return message;
const first = message.split("; ")[0] ?? "";
const i = first.indexOf(": ");
const field = i > 0 ? first.slice(0, i) : "";
const reason = i > 0 ? first.slice(i + 2) : first;
const label = fieldLabel(field || "value");
if (field === "file" && /larger|large/.test(reason)) return TOO_LARGE;
if (field === "mime") return "That file type isn't supported. Use an image, a PDF or a text file.";
if (field === "password") return "Passwords need at least 8 characters.";
if (field === "username") return "Usernames are 2–32 characters: lowercase letters, digits and underscores.";
if (field === "currency" || reason === "not_cents") return "Price needs a number like 14 or 14.99, and a three-letter currency like GBP.";
if (reason === "not_url") return `${label} needs a full web address, starting with https://.`;
if (reason === "not_date" || reason === "bad_precision") return `${label} needs a date like 2025, 2025-03 or 2025-03-14.`;
if (reason === "out_of_range") {
const scale = opts.ratingScale ?? "5half";
const range = scale === "10" ? "0 and 10" : scale === "100" ? "0 and 100" : "0 and 5 stars";
return label === "Rating" ? `Pick a rating between ${range}.` : `Pick a rating for ${label} between ${range}.`;
}
if (reason === "required" || /^Too small: .*>=1 (character|item)/.test(reason) || /received undefined$/.test(reason)) return `${label} can't be empty.`;
if (reason === "too_long" || reason.startsWith("Too big")) return `${label} is too long. Shorten it and try again.`;
if (reason.startsWith("Too small")) return `${label} is too short.`;
if (reason === "not_number" || reason === "not_integer") return `${label} needs a number.`;
if (reason === "unknown_option" || reason === "duplicate_option") return `${label} has an option that no longer exists. Pick one from the list.`;
if (reason === "duplicate") return `${label} is already in use. Pick another.`;
return `${label} wasn't accepted. Check it and try again.`;
}
// ---------------------------------------------------------------------------------------------
// Review draft sync
// ---------------------------------------------------------------------------------------------
/**
* 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.
*/
export interface Draft {
text: string;
synced: string;
saving: boolean;
}
/** A server value arrived (refetch, Restore, rollback). Untouched text follows it; edited text is kept. */
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 };
}
/** 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 };
}
/** 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 };
}
+74
View File
@@ -0,0 +1,74 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { api, type ChangeEvent, type EntryPatch, type EntryView } from "./api.ts";
import { applyEntryPatch } from "./edits.ts";
/** Pending Changes-inbox events (first 200; the badge shows "200+" beyond that). */
export function usePendingChanges() {
return useQuery({ queryKey: ["changes", "pending"], queryFn: api.changes, staleTime: 30_000 });
}
export function useMe() {
return useQuery({ queryKey: ["me"], queryFn: api.me, retry: false, staleTime: Infinity });
}
export function useSources() {
return useQuery({ queryKey: ["sources"], queryFn: api.sources, staleTime: 60_000 });
}
/** Pending events that touch an entry or one of its reads/plays. */
export function changesFor(items: ChangeEvent[] | undefined, entryId: string, engagementIds: Set<string>): ChangeEvent[] {
return (items ?? []).filter(
(c) => c.targetId !== null && ((c.targetEntity === "entry" && c.targetId === entryId) || (c.targetEntity === "engagement" && engagementIds.has(c.targetId))),
);
}
export function useDebounced<T>(value: T, ms = 220): T {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
/**
* Shared save path for every detail edit. The cached entry is updated optimistically (rolled back on
* 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.
*/
export function useEntryPatch(id: string) {
const qc = useQueryClient();
const key = ["entry", id];
const mutationKey = ["entry-patch", id];
const m = useMutation({
mutationKey,
scope: { id: `entry-patch:${id}` },
mutationFn: (patch: EntryPatch) => api.patchEntry(id, patch),
onMutate: async (patch) => {
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<EntryView>(key);
if (prev) qc.setQueryData<EntryView>(key, applyEntryPatch<EntryView>(prev, patch));
return { prev };
},
onError: (_e, _patch, ctx) => {
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
void qc.invalidateQueries({ queryKey: key });
},
onSuccess: (entry) => {
// 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: () => {
void qc.invalidateQueries({ queryKey: ["entries"] });
void qc.invalidateQueries({ queryKey: ["history", id] });
},
});
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
const cur = qc.getQueryData<EntryView>(key);
const patch = cur ? build(cur) : null;
if (patch) m.mutate(patch);
};
return { ...m, edit };
}
+158
View File
@@ -0,0 +1,158 @@
/** 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;
}
+84
View File
@@ -0,0 +1,84 @@
import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { Redirect, Route, Switch } from "wouter";
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({
// A 401 anywhere means the session ended: re-check who we are, which sends the user to sign in.
onError: (e, query) => {
if (e instanceof ApiError && e.status === 401 && query.queryKey[0] !== "me") void queryClient.invalidateQueries({ queryKey: ["me"] });
},
}),
defaultOptions: {
queries: {
staleTime: 15_000,
retry: (n, e) => !(e instanceof ApiError && e.status > 0 && e.status < 500) && n < 2,
},
},
});
function RequireAuth({ children }: { children: ReactNode }) {
const me = useMe();
if (me.isPending) return <div className="boot"><span className="logo">STTracker</span></div>;
if (me.error instanceof ApiError && me.error.status === 401) return <Redirect to="/login" replace />;
if (me.error) {
return (
<div className="auth">
<div className="auth-card" role="alert">
<span className="logo">STTracker</span>
<h1>Can't load your library</h1>
<p className="lede">{friendlyError(me.error)}</p>
<button className="btn primary" onClick={() => void me.refetch()}>Try again</button>
</div>
</div>
);
}
return <>{children}</>;
}
function App() {
return (
<Switch>
<Route path="/login" component={Login} />
<Route path="/register" component={Register} />
<Route>
<RequireAuth>
<Shell>
<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>
</RequireAuth>
</Route>
</Switch>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
+170
View File
@@ -0,0 +1,170 @@
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>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
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";
import { describeRating, formatRating, friendlyError, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
import { useDebounced, usePendingChanges } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
const TYPE_FILTERS = ["book", "manga", "comic", "game", "movie", "show", "podcast", "audio_drama", "album", "artist", "video", "other"];
const STATUS_FILTERS: Status[] = ["in_progress", "planned", "completed", "paused", "dropped"];
/** The library: list pane (search, quick add, filters, rows) and the detail pane. */
export function Library({ selectedId }: { selectedId: string | null }) {
return (
<div className={`library${selectedId ? " has-selection" : ""}`}>
<ListPane selectedId={selectedId} />
<section className="detail-pane" aria-label="Entry">
{selectedId ? (
<EntryDetail key={selectedId} id={selectedId} />
) : (
<div className="detail-empty">
<p>
<strong>Nothing selected</strong>
Pick something from your library, or quick add a title and sort out the details later.
</p>
</div>
)}
</section>
</div>
);
}
// Filters survive navigating between entries (the list pane remounts on route change).
type Filters = { q: string; mediaType?: string; status?: Status; draft: boolean };
let savedFilters: Filters = { q: "", draft: false };
/** Forget per-session library state (on sign-out). */
export function resetLibraryState(): void {
savedFilters = { q: "", draft: false };
}
function ListPane({ selectedId }: { selectedId: string | null }) {
const [, navigate] = useLocation();
const qc = useQueryClient();
const scale = useRatingScale();
const [filters, setFiltersState] = useState(savedFilters);
const setFilters = (f: typeof filters) => {
savedFilters = f;
setFiltersState(f);
};
const q = useDebounced(filters.q.trim());
const query = { q: q || undefined, mediaType: filters.mediaType, status: filters.status, draft: filters.draft || undefined };
const entries = useInfiniteQuery({
queryKey: ["entries", query],
queryFn: ({ pageParam }) => api.entries({ ...query, cursor: pageParam, limit: 50 }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
const changes = usePendingChanges();
const flagged = new Set((changes.data?.items ?? []).filter((c) => c.targetEntity === "entry").map((c) => c.targetId));
const [quickError, setQuickError] = useState<string | null>(null);
const quickInput = useRef<HTMLInputElement>(null);
const quick = useMutation({
mutationFn: api.quickAdd,
onSuccess: (entry, _t) => {
qc.setQueryData(["entry", entry.id], entry);
void qc.invalidateQueries({ queryKey: ["entries"] });
navigate(`/entries/${entry.id}`);
if (quickInput.current) quickInput.current.value = "";
quickInput.current?.focus();
},
onError: (e) => setQuickError(friendlyError(e)),
});
function onQuick(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const title = (quickInput.current?.value ?? "").trim();
if (!title || quick.isPending) return;
setQuickError(null);
quick.mutate(title);
}
const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
const filtering = Boolean(q || filters.mediaType || filters.status || filters.draft);
return (
<section className="list-pane" aria-label="Library">
<div className="list-tools">
<form role="search" className="search" onSubmit={(e) => e.preventDefault()}>
<Icon name="search" />
<label className="sr-only" htmlFor="lib-search">Search your library</label>
<input
id="lib-search"
className="input"
type="search"
placeholder="Search your library…"
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
/>
</form>
<form className="quick" onSubmit={onQuick}>
<span className="plus"><Icon name="plus" /></span>
<label className="sr-only" htmlFor="quick-add">Quick add</label>
<input
id="quick-add"
ref={quickInput}
name="title"
className="input"
placeholder="Quick add: type a title, sort it out later"
autoComplete="off"
maxLength={1000}
readOnly={quick.isPending}
aria-busy={quick.isPending}
aria-describedby={quickError ? "quick-error" : undefined}
/>
</form>
{quickError && <p className="error-text" id="quick-error" role="alert">{quickError}</p>}
<div className="filters">
<div className="chips" role="group" aria-label="Filter by status">
<button
type="button"
className="chip draft-chip"
aria-pressed={filters.draft}
onClick={() => setFilters({ ...filters, draft: !filters.draft })}
>
Draft
</button>
{STATUS_FILTERS.map((s) => (
<button
key={s}
type="button"
className="chip"
aria-pressed={filters.status === s}
onClick={() => setFilters({ ...filters, status: filters.status === s ? undefined : s })}
>
{statusLabel(s)}
</button>
))}
</div>
<div className="chips" role="group" aria-label="Filter by media type">
{TYPE_FILTERS.map((t) => (
<button
key={t}
type="button"
className="chip"
aria-pressed={filters.mediaType === t}
onClick={() => setFilters({ ...filters, mediaType: filters.mediaType === t ? undefined : t })}
>
{mediaTypeLabel(t)}
</button>
))}
</div>
</div>
</div>
{entries.isPending ? (
<ul className="list" aria-busy="true" aria-label="Loading your library">
{[0, 1, 2, 3].map((i) => (
<li key={i} className="row skeleton" aria-hidden="true">
<div className="cover" />
<div><div className="bar" /><div className="bar" /></div>
</li>
))}
</ul>
) : entries.error && !entries.data ? (
<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 ? (
<div className="list-note">
{filtering ? (
<>
<p>Nothing matches these filters.</p>
<button className="btn small" onClick={() => setFilters({ q: "", draft: false })}>Clear filters</button>
</>
) : (
<p>
<strong>Your library is empty.</strong> Type a title into Quick add and press Enter. A title alone is
enough; you can link it to a catalogue entry later.
</p>
)}
</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} />
</li>
))}
{entries.hasNextPage && (
<li className="more">
<button className="btn small" disabled={entries.isFetchingNextPage} onClick={() => void entries.fetchNextPage()}>
{entries.isFetchingNextPage ? "Loading…" : "Show more"}
</button>
</li>
)}
</ul>
)}
</section>
);
}
/** `media type · meta`: children roll-up, else status, plus the year. */
export function rowMeta(e: EntryView): string {
const type = e.mediaTypeOverride ?? e.work.mediaType;
const parts = [mediaTypeLabel(type)];
const rollup = rollupLabel(type, e.childSummary);
if (rollup) parts.push(rollup);
else if (e.status !== "none") parts.push(statusLabel(e.status, type).toLowerCase());
if (e.work.year) parts.push(String(e.work.year));
return parts.join(" · ");
}
function Row({ entry: e, selected, flagged, scale }: { entry: EntryView; selected: boolean; flagged: boolean; scale: ReturnType<typeof useRatingScale> }) {
return (
<Link href={`/entries/${e.id}`} className="row" aria-current={selected ? "page" : undefined}>
<Cover url={e.work.coverUrl} mediaType={e.mediaTypeOverride ?? e.work.mediaType} draft={e.draft} />
<div style={{ minWidth: 0 }}>
<span className="title">{e.work.title}</span>
<div className="meta">{rowMeta(e)}</div>
</div>
<span className="end">
{flagged && <span className="flag" role="img" aria-label="A source changed this" title="A source changed this" />}
{e.draft ? (
<span className="draft">Draft</span>
) : e.rating !== null ? (
<span className="stars" role="img" aria-label={describeRating(e.rating, scale)}>{formatRating(e.rating, scale)}</span>
) : e.status === "in_progress" ? (
<span className="flag active" role="img" aria-label="In progress" title="In progress" />
) : null}
</span>
</Link>
);
}
+316
View File
@@ -0,0 +1,316 @@
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>
);
}
+119
View File
@@ -0,0 +1,119 @@
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>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { Link, Redirect, useLocation } from "wouter";
import { api } from "../api.ts";
import { friendlyError } from "../format.ts";
export function Login() {
const qc = useQueryClient();
const [, navigate] = useLocation();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const status = useQuery({ queryKey: ["auth-status"], queryFn: api.authStatus, staleTime: 0 });
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
setBusy(true);
setError(null);
try {
const user = await api.login(String(f.get("username")), String(f.get("password")));
qc.setQueryData(["me"], user);
navigate("/", { replace: true });
} catch (err) {
setError(friendlyError(err));
setBusy(false);
}
}
// A fresh instance has nobody to sign in as: go straight to creating the admin account.
if (status.data?.hasUsers === false) return <Redirect to="/register" replace />;
return (
<main className="auth">
<div className="auth-card">
<span className="logo">STTracker</span>
<h1>Sign in</h1>
<p className="lede">Everything you read, watch, play and listen to, in one place that's yours.</p>
<form onSubmit={submit}>
{error && <p className="form-error" role="alert">{error}</p>}
<label className="field">
<span>Username</span>
<input className="input" name="username" autoComplete="username" autoCapitalize="none" spellCheck={false} required autoFocus />
</label>
<label className="field">
<span>Password</span>
<input className="input" name="password" type="password" autoComplete="current-password" required />
</label>
<button className="btn primary" disabled={busy}>{busy ? "Signing in…" : "Sign in"}</button>
</form>
<p className="alt">
Got an invite? <Link className="text-link" href="/register">Create your account</Link>
</p>
</div>
</main>
);
}
+24
View File
@@ -0,0 +1,24 @@
const NAMES: Record<string, string> = {
log: "Log",
lists: "Lists",
owned: "Owned",
changes: "Changes",
sources: "Sources",
settings: "Settings",
about: "About",
};
/** Sections that arrive in the next update. Unknown paths say so plainly. */
export function NotYet({ page }: { page: string }) {
const name = NAMES[page];
return (
<section className="page">
<h1>{name ?? "Nothing here"}</h1>
<p>
{name
? `${name} isn't built yet. Your data is safe; this screen arrives in the next update.`
: "This address doesn't match any page. Head back to your library."}
</p>
</section>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent } from "react";
import { Link, useLocation, useSearch } from "wouter";
import { ApiError, api } from "../api.ts";
import { friendlyError } from "../format.ts";
/**
* First-run screen. The first account becomes the admin; later accounts need an invite code.
* `GET auth/status` says which; `?invite=` prefills the code from an invite link.
*/
export function Register() {
const qc = useQueryClient();
const [, navigate] = useLocation();
const invite = new URLSearchParams(useSearch()).get("invite") ?? "";
const status = useQuery({ queryKey: ["auth-status"], queryFn: api.authStatus, staleTime: 0 });
const [inviteRequired, setInviteRequired] = useState(false);
const needInvite = invite !== "" || inviteRequired || status.data?.hasUsers === true;
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const username = String(f.get("username")).trim().toLowerCase();
const password = String(f.get("password"));
const displayName = String(f.get("displayName") ?? "").trim();
const inviteCode = String(f.get("inviteCode") ?? "").trim();
setBusy(true);
setError(null);
try {
await api.register({ username, password, displayName: displayName || undefined, inviteCode: inviteCode || undefined });
void qc.invalidateQueries({ queryKey: ["auth-status"] });
qc.setQueryData(["me"], await api.login(username, password));
navigate("/", { replace: true });
} catch (err) {
if (err instanceof ApiError && err.code === "invite_required") {
setInviteRequired(true);
setError(inviteCode ? "That invite code didn't work. Check it, or ask the admin for a new one." : "This STTracker already has an admin. Ask them for an invite code and enter it below.");
} else setError(friendlyError(err));
setBusy(false);
}
}
if (status.isPending && !invite) return <div className="boot"><span className="logo">STTracker</span></div>;
return (
<main className="auth">
<div className="auth-card">
<span className="logo">STTracker</span>
<h1>{needInvite ? "Create your account" : "Create the admin account"}</h1>
<p className="lede">
{needInvite
? "You've been invited to this STTracker. Pick a username and password."
: "This is the first account on this STTracker. It can invite friends later."}
</p>
<form onSubmit={submit}>
{error && <p className="form-error" role="alert">{error}</p>}
<label className="field">
<span>Your name (optional)</span>
<input className="input" name="displayName" autoComplete="name" maxLength={100} />
</label>
<label className="field">
<span>Username</span>
<input
className="input"
name="username"
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
minLength={2}
maxLength={32}
pattern="[a-zA-Z0-9_]+"
aria-describedby="username-hint"
/>
<small className="hint" id="username-hint">2–32 lowercase letters, digits and underscores.</small>
</label>
<label className="field">
<span>Password</span>
<input className="input" name="password" type="password" autoComplete="new-password" required minLength={8} aria-describedby="password-hint" />
<small className="hint" id="password-hint">At least 8 characters.</small>
</label>
{needInvite && (
<label className="field">
<span>Invite code</span>
<input className="input" name="inviteCode" defaultValue={invite} required autoComplete="off" spellCheck={false} />
</label>
)}
<button className="btn primary" disabled={busy}>{busy ? "Creating…" : "Create account"}</button>
</form>
<p className="alt">
Already have an account? <Link className="text-link" href="/login">Sign in</Link>
</p>
</div>
</main>
);
}
+456
View File
@@ -0,0 +1,456 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { Link, useLocation, useSearch } from "wouter";
import { api, type Connection, type PluginInfo, type Run } from "../api.ts";
import { DeviceSignIn } from "../components/DeviceSignIn.tsx";
import { LoadError } from "../components/LoadError.tsx";
import { ConfigFields, readConfig } from "../components/SourceForm.tsx";
import { friendlyError, mediaTypeLabel, timeAgo } from "../format.ts";
import { useSources } from "../hooks.ts";
import { scheduleLabel, SCHEDULES } from "../labels.ts";
export function usePlugins() {
return useQuery({ queryKey: ["plugins"], queryFn: api.plugins, staleTime: 5 * 60_000 });
}
const syncs = (p: PluginInfo | undefined) => !!p && (p.capabilities.includes("pull") || p.capabilities.includes("pullChanges"));
const imports = (p: PluginInfo | undefined) => !!p && p.capabilities.includes("importFile");
function StatusBadge({ c }: { c: Connection }) {
if (c.status === "active") return <span className="badge ok">Active</span>;
if (c.status === "error") return <span className="badge bad">Needs attention</span>;
return <span className="badge off">Paused</span>;
}
/** What a plugin does, in plain words. */
function pluginAbilities(p: PluginInfo): string {
const out: string[] = [];
if (syncs(p)) out.push("Syncs on a schedule");
if (imports(p)) out.push("Imports an export file");
if (p.deviceAuth) out.push("Signs in with a code");
return out.join(" · ");
}
function ScheduleSelect({ id, value, onChange, disabled }: { id: string; value: number | null; onChange: (v: number | null) => void; disabled?: boolean }) {
const options = SCHEDULES.includes(value) ? SCHEDULES : [...SCHEDULES, value];
return (
<select id={id} className="select" value={value === null ? "" : String(value)} disabled={disabled} onChange={(e) => onChange(e.target.value === "" ? null : Number(e.target.value))}>
{options.map((m) => (
<option key={m ?? "off"} value={m === null ? "" : String(m)}>{scheduleLabel(m)}</option>
))}
</select>
);
}
// ---------------------------------------------------------------------------------------------
// /sources
// ---------------------------------------------------------------------------------------------
export function Sources() {
const sources = useSources();
const plugins = usePlugins();
const [connecting, setConnecting] = useState<string | null>(null);
const byId = new Map((plugins.data ?? []).map((p) => [p.id, p]));
return (
<div className="screen">
<div className="screen-inner">
<header className="screen-head">
<h1>Sources</h1>
<p className="lede">
The places STTracker watches for what you read, watch, play and listen to. Sources only read: nothing you do here is sent back to them.
</p>
</header>
{sources.error && sources.data && <LoadError error={sources.error} retry={() => void sources.refetch()} stale />}
{sources.error && !sources.data ? (
<LoadError error={sources.error} retry={() => void sources.refetch()} />
) : (sources.data ?? []).length > 0 ? (
<section className="section" aria-labelledby="your-sources">
<h2 id="your-sources">Your sources</h2>
<ul className="conn-list">
{sources.data!.map((c) => (
<li key={c.id}>
<Link href={`/sources/${c.id}`} className="conn-row">
<span className="conn-name">{c.name}</span>
<span className="conn-meta">{byId.get(c.pluginId)?.name ?? c.pluginId}</span>
<span className="conn-meta">{c.lastRunAt ? `Last sync ${timeAgo(c.lastRunAt)}` : "Never synced"}</span>
<span className="conn-meta">{syncs(byId.get(c.pluginId)) ? scheduleLabel(c.scheduleMinutes) : "Import only"}</span>
<StatusBadge c={c} />
</Link>
</li>
))}
</ul>
</section>
) : null}
<section className="section" aria-labelledby="connect">
<h2 id="connect">Connect a source</h2>
<span className="hint">Each source can be connected more than once, for example for two accounts.</span>
{plugins.error && !plugins.data ? (
<LoadError error={plugins.error} retry={() => void plugins.refetch()} />
) : (
<ul className="plugin-grid">
{(plugins.data ?? []).map((p) => (
<li key={p.id} className={`plugin${connecting === p.id ? " open" : ""}`}>
<div className="plugin-head">
<div>
<h3>{p.name}</h3>
<p className="plugin-types">{p.mediaTypes.slice(0, 6).map(mediaTypeLabel).join(", ")}{p.mediaTypes.length > 6 ? "…" : ""}</p>
</div>
{connecting !== p.id && (
<button type="button" className="btn small" onClick={() => setConnecting(p.id)} aria-label={`Connect ${p.name}`}>
Connect
</button>
)}
</div>
<p className="plugin-does">{pluginAbilities(p)}</p>
{connecting === p.id && <ConnectForm plugin={p} onCancel={() => setConnecting(null)} />}
</li>
))}
</ul>
)}
</section>
</div>
</div>
);
}
function ConnectForm({ plugin, onCancel }: { plugin: PluginInfo; onCancel: () => void }) {
const qc = useQueryClient();
const [, navigate] = useLocation();
const [error, setError] = useState<string | null>(null);
const [schedule, setSchedule] = useState<number | null>(syncs(plugin) ? 1440 : null);
const first = useRef<HTMLInputElement>(null);
useEffect(() => first.current?.focus(), []);
const create = useMutation({
mutationFn: api.createSource,
onSuccess: (c) => {
void qc.invalidateQueries({ queryKey: ["sources"] });
navigate(`/sources/${c.id}${plugin.deviceAuth ? "?signin=1" : ""}`);
},
onError: (e) => setError(friendlyError(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
setError(null);
create.mutate({
pluginId: plugin.id,
name: String(f.get("name") ?? "").trim() || plugin.name,
config: readConfig(plugin.config, f),
scheduleMinutes: syncs(plugin) ? schedule : null,
});
}
return (
<form className="connect-form" onSubmit={submit} aria-label={`Connect ${plugin.name}`}>
<div className="field">
<label htmlFor={`name-${plugin.id}`}>Name</label>
<input ref={first} id={`name-${plugin.id}`} name="name" className="input" defaultValue={plugin.name} maxLength={200} />
</div>
<ConfigFields fields={plugin.config} values={{}} savedSecrets={false} />
{syncs(plugin) && (
<div className="field">
<label htmlFor={`sched-${plugin.id}`}>Sync</label>
<ScheduleSelect id={`sched-${plugin.id}`} value={schedule} onChange={setSchedule} />
</div>
)}
{plugin.deviceAuth && <p className="hint">Next, you'll sign in to {plugin.name} with a short code.</p>}
{error && <p className="form-error" role="alert">{error}</p>}
<div className="form-actions">
<button type="submit" className="btn primary" disabled={create.isPending}>{create.isPending ? "Connecting…" : `Connect ${plugin.name}`}</button>
<button type="button" className="btn ghost" onClick={onCancel}>Cancel</button>
</div>
</form>
);
}
// ---------------------------------------------------------------------------------------------
// /sources/:id
// ---------------------------------------------------------------------------------------------
export function SourceDetail({ id }: { id: string }) {
const qc = useQueryClient();
const [, navigate] = useLocation();
const search = new URLSearchParams(useSearch());
const sources = useSources();
const plugins = usePlugins();
const conn = sources.data?.find((c) => c.id === id);
const plugin = plugins.data?.find((p) => p.id === conn?.pluginId);
const [signingIn, setSigningIn] = useState(search.get("signin") === "1");
const [actionError, setActionError] = useState<string | null>(null);
const [said, setSaid] = useState("");
const [pollUntil, setPollUntil] = useState(0);
const runs = useQuery({
queryKey: ["runs", id],
queryFn: () => api.runs(id),
enabled: !!conn,
refetchInterval: (q) => (q.state.data?.some((r) => r.status === "running") || Date.now() < pollUntil ? 2000 : false),
});
// When a run finishes, its results show up elsewhere: refresh them.
const lastFinished = runs.data?.find((r) => r.status !== "running")?.id;
const seen = useRef<string | undefined>(undefined);
useEffect(() => {
if (seen.current !== undefined && lastFinished !== seen.current) {
for (const k of ["changes", "entries", "sources", "log"]) void qc.invalidateQueries({ queryKey: [k] });
setPollUntil(0);
}
seen.current = lastFinished;
}, [lastFinished, qc]);
const done = (msg: string) => () => {
setActionError(null);
setSaid(msg);
void qc.invalidateQueries({ queryKey: ["sources"] });
};
const fail = (e: unknown) => setActionError(friendlyError(e));
const run = useMutation({
mutationFn: () => api.runSource(id),
onSuccess: () => {
done("Sync queued.")();
setPollUntil(Date.now() + 60_000);
void qc.invalidateQueries({ queryKey: ["runs", id] });
},
onError: fail,
});
const patch = useMutation({
mutationFn: (b: Parameters<typeof api.patchSource>[1]) => api.patchSource(id, b),
onSuccess: (_c, b) => done(b.status ? (b.status === "paused" ? "Paused." : "Resumed.") : "Saved.")(),
onError: fail,
});
const remove = useMutation({
mutationFn: () => api.deleteSource(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["sources"] });
navigate("/sources", { replace: true });
},
onError: fail,
});
const [confirming, setConfirming] = useState(false);
if (sources.isPending || plugins.isPending) return <div className="screen"><div className="screen-inner" aria-busy="true" /></div>;
if (!conn) {
return (
<div className="screen">
<div className="screen-inner">
{sources.error ? (
<LoadError error={sources.error} retry={() => void sources.refetch()} />
) : (
<div className="empty-state">
<strong>This source isn't connected</strong>
<p>It may have been disconnected. Anything it imported is still in your library.</p>
<Link href="/sources" className="btn">All sources</Link>
</div>
)}
</div>
</div>
);
}
const canSync = syncs(plugin);
const running = runs.data?.some((r) => r.status === "running") ?? false;
return (
<div className="screen">
<div className="screen-inner">
<Link href="/sources" className="btn ghost back-link">‹ All sources</Link>
{sources.error && <LoadError error={sources.error} retry={() => void sources.refetch()} stale />}
<header className="screen-head with-action">
<div>
<h1>{conn.name}</h1>
<p className="lede src-sub">
{plugin?.name ?? conn.pluginId} <StatusBadge c={conn} />
</p>
</div>
<div className="head-actions">
{canSync && (
<button type="button" className="btn primary" onClick={() => run.mutate()} disabled={run.isPending || running || conn.status !== "active"}>
{running ? "Syncing…" : "Sync now"}
</button>
)}
{conn.status === "active" ? (
<button type="button" className="btn" onClick={() => patch.mutate({ status: "paused" })} disabled={patch.isPending}>Pause</button>
) : (
<button type="button" className="btn" onClick={() => patch.mutate({ status: "active" })} disabled={patch.isPending}>Resume</button>
)}
</div>
</header>
<p className="sr-only" role="status" aria-live="polite">{said}</p>
{actionError && <p className="form-error" role="alert">{actionError}</p>}
<div className="src-grid">
<section className="box" aria-labelledby="src-status">
<h2 id="src-status">Status</h2>
<div className="r"><span>Last sync</span><span>{conn.lastRunAt ? timeAgo(conn.lastRunAt) : "Never"}</span></div>
{canSync && (
<div className="r">
<label htmlFor="schedule">Sync</label>
<span>
<ScheduleSelect id="schedule" value={conn.scheduleMinutes} onChange={(v) => patch.mutate({ scheduleMinutes: v })} disabled={patch.isPending} />
</span>
</div>
)}
<div className="r"><span>Sign-in</span><span>{conn.hasCredentials ? "Saved" : plugin?.deviceAuth ? "Not signed in" : "Not needed"}</span></div>
<div className="r"><span>Write-back</span><span className="muted-val">Not available in this version</span></div>
<p className="hint">STTracker only reads from {conn.name}. Your edits here stay here.</p>
{conn.status === "paused" && canSync && <p className="hint">Paused: scheduled syncs and “Sync now” are off until you resume.</p>}
</section>
{plugin?.deviceAuth && (
<section className="box" aria-labelledby="src-signin">
<h2 id="src-signin">Sign in</h2>
<p className="box-text">
{conn.hasCredentials ? `Signed in. Sign in again if ${plugin.name} stops syncing.` : `Connect your ${plugin.name} account with a short code.`}
</p>
<button type="button" className="btn" onClick={() => setSigningIn(true)}>
{conn.hasCredentials ? "Sign in again" : `Sign in to ${plugin.name}`}
</button>
</section>
)}
{imports(plugin) && <ImportBox conn={conn} onQueued={() => (setPollUntil(Date.now() + 120_000), void qc.invalidateQueries({ queryKey: ["runs", id] }))} />}
<SettingsBox conn={conn} plugin={plugin} />
<section className="box wide" aria-labelledby="src-runs">
<h2 id="src-runs">Recent syncs</h2>
{runs.error && runs.data && <LoadError error={runs.error} retry={() => void runs.refetch()} stale />}
{runs.error && !runs.data ? (
<LoadError error={runs.error} retry={() => void runs.refetch()} />
) : runs.data && runs.data.length === 0 ? (
<p className="empty-line">No syncs yet.{canSync ? " Use “Sync now” to run the first one." : ""}</p>
) : (
<ol className="runs">{(runs.data ?? []).map((r) => <RunRow key={r.id} r={r} />)}</ol>
)}
</section>
<section className="box wide danger-zone" aria-labelledby="src-remove">
<h2 id="src-remove">Disconnect</h2>
<p className="box-text">Stops syncing and forgets the sign-in. Everything it imported stays in your library.</p>
{confirming ? (
<div className="form-actions">
<button type="button" className="btn danger" onClick={() => remove.mutate()} disabled={remove.isPending}>Disconnect {conn.name}</button>
<button type="button" className="btn ghost" onClick={() => setConfirming(false)}>Keep it</button>
</div>
) : (
<button type="button" className="btn danger" onClick={() => setConfirming(true)}>Disconnect…</button>
)}
</section>
</div>
</div>
{signingIn && plugin && <DeviceSignIn connectionId={conn.id} sourceName={plugin.name} onClose={() => (setSigningIn(false), navigate(`/sources/${id}`, { replace: true }))} />}
</div>
);
}
function ImportBox({ conn, onQueued }: { conn: Connection; onQueued: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const input = useRef<HTMLInputElement>(null);
const upload = useMutation({
mutationFn: (f: File) => api.importToSource(conn.id, f),
onSuccess: () => {
setMsg({ ok: true, text: "Import queued. New entries appear in your library as it runs; anything that clashes with your edits waits in Changes." });
setFile(null);
if (input.current) input.current.value = "";
onQueued();
},
onError: (e) => setMsg({ ok: false, text: friendlyError(e) }),
});
return (
<section className="box" aria-labelledby="src-import">
<h2 id="src-import">Import a file</h2>
<form
className="import-form"
onSubmit={(e) => {
e.preventDefault();
if (file) upload.mutate(file);
}}
>
<label className="file-pick">
<span className="sr-only">File to import</span>
<input ref={input} type="file" accept=".csv,.json,.zip,text/csv,application/json" onChange={(e) => (setMsg(null), setFile(e.target.files?.[0] ?? null))} />
</label>
<button type="submit" className="btn" disabled={!file || upload.isPending}>{upload.isPending ? "Uploading…" : "Import"}</button>
</form>
<p className="hint">Up to 25 MB. Importing the same file twice doesn't create duplicates.</p>
{msg && <p className={msg.ok ? "ok-text" : "error-text"} role={msg.ok ? "status" : "alert"}>{msg.text}</p>}
</section>
);
}
function SettingsBox({ conn, plugin }: { conn: Connection; plugin: PluginInfo | undefined }) {
const qc = useQueryClient();
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [formKey, setFormKey] = useState(0);
const save = useMutation({
mutationFn: (b: Parameters<typeof api.patchSource>[1]) => api.patchSource(conn.id, b),
onSuccess: () => {
setMsg({ ok: true, text: "Saved." });
setFormKey((k) => k + 1);
void qc.invalidateQueries({ queryKey: ["sources"] });
},
onError: (e) => setMsg({ ok: false, text: friendlyError(e) }),
});
const fields = plugin?.config ?? [];
return (
<section className="box" aria-labelledby="src-settings">
<h2 id="src-settings">Settings</h2>
<form
key={formKey}
className="stack-form"
onSubmit={(e) => {
e.preventDefault();
const f = new FormData(e.currentTarget);
setMsg(null);
save.mutate({ name: String(f.get("name") ?? "").trim() || conn.name, config: readConfig(fields, f) });
}}
>
<div className="field">
<label htmlFor="src-name">Name</label>
<input id="src-name" name="name" className="input" defaultValue={conn.name} maxLength={200} />
</div>
<ConfigFields fields={fields} values={conn.config} savedSecrets={conn.hasCredentials} />
<div className="form-actions">
<button type="submit" className="btn" disabled={save.isPending}>{save.isPending ? "Saving…" : "Save"}</button>
</div>
{msg && <p className={msg.ok ? "ok-text" : "error-text"} role={msg.ok ? "status" : "alert"}>{msg.text}</p>}
</form>
</section>
);
}
const COUNT_WORDS: [string, string][] = [
["seen", "seen"],
["added", "added"],
["changed", "updated"],
["pending", "waiting in Changes"],
["conflicts", "conflicts"],
["removed", "gone at source"],
["listens", "listens"],
];
function RunRow({ r }: { r: Run }) {
const counts = COUNT_WORDS.filter(([k]) => (r.counts[k] ?? 0) > 0).map(([k, w]) => `${r.counts[k]} ${w}`);
const mode = r.mode === "file" ? "File import" : r.mode === "full" ? "Full sync" : "Sync";
return (
<li className={`run run-${r.status}`}>
<div className="run-line">
<span className={`badge ${r.status === "ok" ? "ok" : r.status === "failed" ? "bad" : "off"}`}>
{r.status === "ok" ? "Done" : r.status === "failed" ? "Failed" : "Running"}
</span>
<span className="run-mode">{mode}</span>
<time dateTime={r.startedAt} className="run-when">{timeAgo(r.startedAt)}</time>
</div>
{counts.length > 0 && <p className="run-counts tabular">{counts.join(" · ")}</p>}
{r.error && <p className="error-text">{r.error}</p>}
{r.log && (
<details>
<summary>Log</summary>
<pre>{r.log}</pre>
</details>
)}
</li>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { api } from "./api.ts";
import type { RatingScale } from "./format.ts";
/** The signed-in user's preferences (GET me/preferences). Settings updates them with `api.updatePreferences`. */
export function usePreferences() {
return useQuery({ queryKey: ["preferences"], queryFn: api.preferences, staleTime: 5 * 60_000 });
}
/** The rating scale preference; five stars with halves until preferences load. */
export function useRatingScale(): RatingScale {
return usePreferences().data?.ratingScale ?? "5half";
}
+616
View File
@@ -0,0 +1,616 @@
/* STTracker app theme. Tokens are DESIGN.md's, verbatim; derived values sit below them. */
@font-face {
font-family: "Inter";
src: url("./fonts/inter-latin-wght-normal.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
/* Gelasio (OFL), official variable fonts from github.com/google/fonts ofl/gelasio, unmodified. */
@font-face {
font-family: "Gelasio";
src: url("./fonts/gelasio-wght.ttf") format("truetype");
font-weight: 400 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Gelasio";
src: url("./fonts/gelasio-italic-wght.ttf") format("truetype");
font-weight: 400 700;
font-style: italic;
font-display: swap;
}
:root {
/* DESIGN.md */
--surface-0: #17152b;
--surface-1: #1e1b36;
--surface-2: #262247;
--surface-3: #2d2852;
--text: #ece9ff;
--text-muted: #a9a3d6;
--text-faint: #8d87c2;
--accent: #6d5dfc;
--rating: #fbbf24;
--warn: #f97316;
--ok: #34d399;
--media-books: #6b3fa0;
--media-audio: #2f6b58;
--media-games: #c1652f;
--media-film: #344b7a;
--media-music: #a33b62;
/* Derived (from the approved mockup) */
--nav-text: #b8b3e0;
--pill-text: #c9c4f0;
--dash: #4b4485;
--focus: #9d92ff;
--warn-bg: #3a2a1a;
--warn-line: #f9731666;
--warn-text: #fde2c8;
--on-warn: #1c0f04; /* white on --warn is 2.8:1; this is 6.4:1 */
--review-text: #e3e0fb;
--draft-stripes: repeating-linear-gradient(45deg, #3a356b 0 6px, #2d2852 6px 12px);
--r-control: 8px;
--r-box: 10px;
--r-pill: 999px;
--r-cover: 4px;
--font-ui: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-review: "Gelasio", Georgia, "Times New Roman", serif;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
color-scheme: dark;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body, #root { height: 100%; }
body {
background: var(--surface-1);
color: var(--text);
font-family: var(--font-ui);
font-size: 0.9rem;
line-height: 1.45;
font-feature-settings: "cv11", "ss01";
-webkit-font-smoothing: antialiased;
caret-color: var(--text);
}
::selection { background: #6d5dfc80; color: #fff; }
* { scrollbar-width: thin; scrollbar-color: var(--surface-3) transparent; }
button, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; }
button:disabled { cursor: default; opacity: 0.55; }
a { color: inherit; text-decoration: none; }
a.text-link, .text-link { color: var(--focus); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 4px; }
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.tabular { font-variant-numeric: tabular-nums; }
svg.icon { width: 16px; height: 16px; flex: none; stroke: currentColor; fill: none; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
/* ---------- Controls ---------- */
.input, .select, .textarea {
width: 100%;
background: var(--surface-3);
border: 1px solid transparent;
border-radius: var(--r-control);
padding: 8px 11px;
color: var(--text);
transition: border-color 0.15s, background 0.15s;
}
.input::placeholder, .textarea::placeholder { color: var(--text-muted); }
.input:hover, .select:hover, .textarea:hover { border-color: var(--dash); }
.input:focus-visible, .select:focus-visible, .textarea:focus-visible { outline: none; border-color: var(--focus); }
.select { appearance: none; padding-right: 30px; background-image: linear-gradient(45deg, transparent 50%, var(--text-muted) 50%), linear-gradient(135deg, var(--text-muted) 50%, transparent 50%); background-position: calc(100% - 15px) 52%, calc(100% - 10px) 52%; background-size: 5px 5px; background-repeat: no-repeat; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
border-radius: var(--r-control); padding: 7px 13px; font-size: 0.82rem; font-weight: 500;
background: var(--surface-3); color: var(--text);
transition: background 0.15s;
}
.btn:hover:not(:disabled) { background: #3a346a; }
.btn.primary { background: var(--accent); color: #fff; }
.btn.primary:hover:not(:disabled) { background: #7c6dff; }
.btn.ghost { background: transparent; color: var(--text-muted); padding: 5px 8px; }
.btn.ghost:hover:not(:disabled) { background: var(--surface-3); color: var(--text); }
.btn.small { padding: 4px 9px; font-size: 0.76rem; }
.field { display: grid; gap: 5px; }
.field > span { font-size: 0.78rem; color: var(--text-muted); font-weight: 500; }
.hint { font-size: 0.76rem; color: var(--text-faint); }
.error-text { font-size: 0.8rem; color: #ffb4a8; }
.box .hint, .box .error-text { margin-top: 6px; }
/* ---------- Auth screens ---------- */
.auth {
min-height: 100%;
display: grid; place-items: center;
padding: 40px 20px;
background:
radial-gradient(60rem 40rem at 85% -10%, #6d5dfc22, transparent 60%),
radial-gradient(40rem 30rem at -10% 110%, #fbbf2412, transparent 60%),
var(--surface-0);
}
.auth-card { width: min(400px, 100%); }
.auth .logo { font-size: 1.15rem; margin: 0 0 28px; }
.auth h1 { font-size: 1.6rem; line-height: 1.2; font-weight: 650; letter-spacing: -0.015em; text-wrap: balance; }
.auth .lede { color: var(--text-muted); margin: 8px 0 26px; max-width: 42ch; }
.auth form { display: grid; gap: 14px; }
.auth form .btn.primary { justify-content: center; padding: 10px 14px; font-size: 0.9rem; margin-top: 6px; }
.auth .alt { margin-top: 22px; color: var(--text-muted); font-size: 0.85rem; }
.form-error { background: #3a1d24; border: 1px solid #f8717155; color: #ffd4d4; border-radius: var(--r-control); padding: 9px 12px; font-size: 0.84rem; }
/* ---------- Shell ---------- */
.logo { font-weight: 700; font-size: 1.05rem; color: var(--rating); letter-spacing: -0.01em; display: inline-block; }
.shell { display: grid; grid-template-columns: 190px minmax(0, 1fr); height: 100%; }
.side {
background: var(--surface-0); padding: 20px 14px 14px;
display: flex; flex-direction: column; overflow-y: auto;
}
.side .logo { margin: 0 8px 20px; }
.side nav { display: flex; flex-direction: column; gap: 1px; }
.nav {
padding: 7px 10px; border-radius: var(--r-control); color: var(--nav-text);
display: flex; justify-content: space-between; align-items: center; gap: 8px;
transition: background 0.15s, color 0.15s;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.nav:hover { background: #221f40; color: var(--text); }
.nav[aria-current="page"] { background: var(--surface-3); color: #fff; }
.nav .count { background: var(--warn); color: var(--on-warn); font-weight: 650; border-radius: var(--r-pill); font-size: 0.72rem; padding: 1px 7px; line-height: 1.4; }
.nav .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); flex: none; }
.nav .dot.paused { background: var(--dash); }
.nav .name { overflow: hidden; text-overflow: ellipsis; }
.grp { color: var(--text-faint); font-size: 0.78rem; margin: 18px 10px 6px; font-weight: 500; }
.side-foot { margin-top: auto; padding: 14px 10px 0; display: grid; gap: 2px; font-size: 0.8rem; }
.side-foot .who { color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.side-foot button { justify-self: start; color: var(--text-faint); padding: 2px 0; }
.side-foot button:hover { color: var(--text); }
.main { min-width: 0; height: 100%; overflow: hidden; }
/* ---------- Library ---------- */
.library { display: grid; grid-template-columns: 360px minmax(0, 1fr); height: 100%; }
.list-pane { border-right: 1px solid var(--surface-3); display: flex; flex-direction: column; min-height: 0; }
.list-tools { padding: 18px 16px 10px; display: grid; gap: 12px; }
.search { position: relative; }
.search .icon { position: absolute; left: 11px; top: 50%; translate: 0 -50%; color: var(--text-muted); pointer-events: none; }
.search .input { padding-left: 33px; }
.quick { position: relative; }
.quick .input {
background: transparent; border: 1px dashed var(--dash); color: var(--pill-text); padding-left: 32px;
}
.quick .input::placeholder { color: var(--pill-text); }
.quick .input:hover { border-color: #6a62b0; }
.quick .input:focus-visible { border-style: solid; border-color: var(--rating); background: #221f40; }
.quick .plus { position: absolute; left: 11px; top: 50%; translate: 0 -50%; color: var(--rating); pointer-events: none; }
.quick .plus .icon { stroke-width: 2.25; }
.chips { display: flex; gap: 5px; overflow-x: auto; scrollbar-width: none; padding: 2px 24px 2px 2px; margin: -2px -16px -2px -2px; mask-image: linear-gradient(90deg, #000 calc(100% - 40px), transparent); }
.chips::-webkit-scrollbar { display: none; }
.chip {
flex: none; border-radius: var(--r-pill); padding: 3px 10px; font-size: 0.76rem;
background: transparent; color: var(--text-muted); border: 1px solid var(--surface-3);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.chip:hover { color: var(--text); border-color: var(--dash); }
.chip[aria-pressed="true"] { background: var(--surface-3); color: #fff; border-color: var(--surface-3); }
.chip.draft-chip[aria-pressed="true"] { color: var(--rating); border-color: #fbbf2466; background: #fbbf2414; }
.filters { display: grid; gap: 6px; }
.list { list-style: none; overflow-y: auto; flex: 1; padding-bottom: 12px; }
.row {
display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: 12px; align-items: center;
padding: 9px 16px; transition: background 0.12s;
}
.row:hover { background: #24204a; }
.row[aria-current="page"] { background: var(--surface-3); }
.row:focus-visible { outline-offset: -2px; border-radius: 0; }
.row .title { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.row .meta { color: var(--text-muted); font-size: 0.76rem; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .end { display: flex; align-items: center; gap: 8px; }
.row .stars { font-size: 0.75rem; }
.flag { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); flex: none; }
.flag.active { background: var(--accent); }
.draft { font-size: 0.7rem; color: var(--rating); border: 1px solid #fbbf2466; border-radius: 4px; padding: 1px 5px; line-height: 1.35; }
.stars { color: var(--rating); letter-spacing: 2px; white-space: nowrap; }
.list-note { padding: 18px 16px; color: var(--text-muted); font-size: 0.85rem; display: grid; gap: 10px; justify-items: start; }
.list-note strong { color: var(--text); font-weight: 600; }
.more { margin: 10px 16px; }
.skeleton .cover, .skeleton .bar { background: var(--surface-2); }
.skeleton .bar { height: 10px; border-radius: 4px; width: 70%; }
.skeleton .bar + .bar { width: 40%; margin-top: 7px; height: 8px; }
@media (prefers-reduced-motion: no-preference) {
.skeleton { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: 0.55; } }
}
/* ---------- Covers ---------- */
.cover { width: 40px; height: 56px; border-radius: 3px; overflow: hidden; flex: none; background: var(--surface-2); }
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover.is-draft { background: var(--draft-stripes); }
.cover.large { width: 110px; height: 160px; border-radius: 5px; box-shadow: 0 6px 20px #0006; }
.cover.small { width: 28px; height: 40px; border-radius: 3px; }
/* ---------- Detail ---------- */
.detail-pane { overflow-y: auto; min-width: 0; position: relative; }
.detail { padding: 22px 26px 40px; max-width: 980px; }
.detail-empty { height: 100%; display: grid; place-items: center; padding: 40px; text-align: center; color: var(--text-muted); }
.detail-empty p { max-width: 34ch; }
.detail-empty strong { display: block; color: var(--text); font-weight: 600; font-size: 1rem; margin-bottom: 6px; }
.back { display: none; }
.det-head { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 20px; }
.det-head h1 { font-size: 1.45rem; line-height: 1.2; font-weight: 650; letter-spacing: -0.012em; text-wrap: balance; }
.det-head .m { color: var(--text-muted); margin: 4px 0 12px; }
.det-head .draft { margin-left: 8px; vertical-align: 3px; }
.det-controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.det-controls .select { width: auto; padding-top: 5px; padding-bottom: 5px; font-size: 0.82rem; }
.det-tools { margin-left: auto; display: flex; gap: 4px; }
.pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 12px; align-items: center; }
.pill { background: var(--surface-3); padding: 3px 4px 3px 10px; border-radius: var(--r-pill); font-size: 0.76rem; color: var(--pill-text); display: inline-flex; align-items: center; gap: 2px; }
.pill button { width: 18px; height: 18px; border-radius: 50%; display: inline-grid; place-items: center; color: var(--text-muted); }
.pill button:hover { background: #ffffff14; color: var(--text); }
.pill button .icon { width: 11px; height: 11px; stroke-width: 2.25; }
.tag-input { background: transparent; border: 1px dashed var(--dash); border-radius: var(--r-pill); padding: 2px 10px; font-size: 0.76rem; width: 96px; color: var(--pill-text); }
.tag-input::placeholder { color: var(--text-muted); }
.tag-input:focus-visible { outline: none; border-style: solid; border-color: var(--focus); width: 150px; }
/* Rating control */
.rating { display: inline-flex; align-items: center; gap: 2px; border-radius: 6px; padding: 2px; cursor: pointer; }
.rating svg { width: 20px; height: 20px; display: block; }
.rating .empty { fill: none; stroke: #6a62b0; stroke-width: 1.4; }
.rating .full { fill: var(--rating); stroke: var(--rating); stroke-width: 1.4; }
.rating-clear { margin-left: 6px; font-size: 0.74rem; color: var(--text-faint); padding: 2px 4px; border-radius: 4px; }
.rating-clear:hover { color: var(--text); }
.rating-number { display: inline-flex; align-items: center; gap: 6px; color: var(--text-muted); font-size: 0.82rem; }
.rating-number .input { width: 72px; padding: 5px 8px; }
/* Change alert */
.alert {
margin: 18px 0 14px; background: var(--warn-bg); border: 1px solid var(--warn-line);
border-radius: var(--r-box); padding: 11px 14px; display: flex; gap: 12px; align-items: center;
}
.alert p { flex: 1; color: var(--warn-text); font-size: 0.85rem; line-height: 1.4; }
.alert p b { font-weight: 650; }
.alert .btn { background: var(--warn); color: var(--on-warn); font-weight: 600; border-radius: 6px; padding: 6px 12px; font-size: 0.8rem; white-space: nowrap; }
.alert .btn:hover { background: #fb8a3c; }
/* Boxes */
.det-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 18px; }
.alert + .det-grid, .alert + .link-box { margin-top: 0; }
.box { background: var(--surface-2); border-radius: var(--r-box); padding: 12px 14px; min-width: 0; }
.box.wide { grid-column: 1 / -1; }
.box-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; min-height: 22px; }
.box h2 { font-size: 0.78rem; color: var(--text-faint); font-weight: 600; }
.box .r { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 4px 0; font-size: 0.84rem; }
.box .r > :first-child { color: var(--text-muted); }
.box .r > :last-child { text-align: right; }
.box .r .x { opacity: 0; transition: opacity 0.15s; }
.box .r:hover .x, .box .r .x:focus-visible { opacity: 1; }
@media (hover: none) { .box .r .x { opacity: 1; } }
.box .empty-line { color: var(--text-muted); font-size: 0.84rem; }
.box form { display: grid; gap: 10px; margin-top: 8px; }
.box form .two { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.box form .actions { display: flex; gap: 6px; }
.box .input, .box .select { padding: 6px 9px; font-size: 0.84rem; }
.box .search .input { padding-left: 33px; }
.copy + .copy { border-top: 1px solid var(--surface-3); margin-top: 6px; padding-top: 6px; }
.thumbs { display: flex; gap: 6px; flex-wrap: wrap; }
.thumb { width: 58px; height: 44px; border-radius: 4px; background-color: var(--surface-3); background-size: cover; background-position: center; display: grid; place-items: center; font-size: 0.66rem; color: var(--text-muted); overflow: hidden; text-align: center; padding: 2px; }
.thumb.upload { border: 1px dashed var(--dash); background: transparent; color: var(--text-muted); cursor: pointer; }
.thumb.upload:hover { color: var(--text); border-color: #6a62b0; }
.thumb.upload:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
.src { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 0.84rem; }
.src i { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); flex: none; }
.src i.paused { background: var(--dash); }
.children { list-style: none; }
.children a { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 5px 6px; margin: 0 -6px; border-radius: 6px; font-size: 0.84rem; }
.children a:hover { background: var(--surface-3); }
.children .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.children .s { color: var(--text-muted); font-size: 0.76rem; margin-left: 8px; }
.children .stars { font-size: 0.72rem; }
.review textarea {
width: 100%; min-height: 7.5em; resize: vertical; background: transparent; border: 1px solid transparent;
border-radius: var(--r-control); padding: 6px 8px; margin: -6px -8px -4px; width: calc(100% + 16px);
font-family: var(--font-review); font-size: 0.95rem; line-height: 1.55; color: var(--review-text);
}
.review textarea::placeholder { color: var(--text-faint); font-style: italic; }
.review textarea:hover { border-color: var(--surface-3); }
.review textarea:focus-visible { outline: none; border-color: var(--focus); background: #211d40; }
.saved { font-size: 0.74rem; color: var(--text-faint); }
.custom { display: grid; gap: 10px; }
.custom .checks { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 0.84rem; }
.custom label.inline { display: inline-flex; gap: 6px; align-items: center; font-size: 0.84rem; }
input[type="checkbox"] { accent-color: var(--accent); width: 15px; height: 15px; }
/* Link to catalogue */
.link-box { margin: 18px 0 14px; border: 1px solid #fbbf2440; background: #fbbf240a; }
.link-box p { color: var(--text-muted); font-size: 0.84rem; max-width: 70ch; }
.link-box .results { list-style: none; margin-top: 10px; display: grid; gap: 4px; }
.link-box .results li { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 5px 6px; border-radius: 6px; font-size: 0.84rem; }
.link-box .results li:hover { background: var(--surface-3); }
.link-box .results .m { color: var(--text-muted); font-size: 0.76rem; }
.link-box .results .acts { display: flex; gap: 4px; }
.link-box .search { margin-top: 12px; max-width: 420px; }
.sub { font-size: 0.74rem; color: var(--text-faint); font-weight: 600; margin-top: 12px; }
/* History drawer */
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: min(400px, 100vw); z-index: 20;
background: var(--surface-0); border-left: 1px solid var(--surface-3);
box-shadow: -18px 0 40px #0008; display: flex; flex-direction: column;
}
@media (prefers-reduced-motion: no-preference) {
.drawer { animation: drawer-in 0.32s var(--ease-out); }
@keyframes drawer-in { from { translate: 24px 0; opacity: 0; } }
}
.drawer header { display: flex; justify-content: space-between; align-items: center; padding: 18px 20px 12px; }
.drawer h2 { font-size: 1rem; font-weight: 650; }
.drawer ol { list-style: none; overflow-y: auto; padding: 0 20px 24px; }
.drawer li { border-top: 1px solid var(--surface-3); padding: 12px 0; display: grid; gap: 6px; }
.drawer .when { display: flex; justify-content: space-between; gap: 10px; font-size: 0.78rem; color: var(--text-muted); }
.drawer .who { color: var(--text); font-weight: 550; }
.drawer dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 3px 10px; font-size: 0.8rem; }
.drawer dt { color: var(--text-faint); }
.drawer dd { overflow-wrap: anywhere; }
.drawer dd s { color: var(--text-faint); text-decoration-color: #8d87c299; }
.drawer .arrow { color: var(--text-faint); margin: 0 4px; }
/* Placeholder pages */
.page { padding: 28px 32px; max-width: 60ch; }
.page h1 { font-size: 1.3rem; font-weight: 650; margin-bottom: 8px; }
.page p { color: var(--text-muted); }
.boot { height: 100%; display: grid; place-items: center; background: var(--surface-0); }
/* ---------- Responsive ---------- */
@media (max-width: 1180px) {
.library { grid-template-columns: 320px minmax(0, 1fr); }
.det-grid { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.side { flex-direction: row; align-items: center; gap: 4px; padding: 10px 12px; overflow-x: auto; overflow-y: hidden; border-bottom: 1px solid var(--surface-3); scrollbar-width: none; }
.side::-webkit-scrollbar { display: none; }
.side .logo { margin: 0 10px 0 4px; }
.side nav { flex-direction: row; }
.side .grp, .side .src-nav, .side-foot .who { display: none; }
.side-foot { margin: 0 0 0 auto; padding: 0 4px; }
.side-foot button { white-space: nowrap; }
.library { grid-template-columns: 1fr; }
.library.has-selection .list-pane { display: none; }
.library:not(.has-selection) .detail-pane { display: none; }
.list-pane { border-right: 0; }
.back { display: inline-flex; margin: 0 0 14px -8px; }
.detail { padding: 16px 16px 40px; }
.det-head { grid-template-columns: 84px minmax(0, 1fr); gap: 14px; }
.cover.large { width: 84px; height: 122px; }
.det-head h1 { font-size: 1.2rem; }
.det-tools { margin-left: 0; }
.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; }
+92
View File
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test";
import { ApiError, request } from "../src/api.ts";
function fakeFetch(res: Response | (() => never)) {
const calls: { url: string; init: RequestInit }[] = [];
const fn = (async (url: string, init: RequestInit) => {
calls.push({ url, init });
if (typeof res === "function") return res();
return res.clone();
}) as unknown as typeof fetch;
return { fn, calls };
}
describe("request", () => {
test("throws an ApiError carrying code on a non-2xx JSON error", async () => {
const { fn } = fakeFetch(
new Response(JSON.stringify({ error: { code: "validation", message: "title: required" } }), {
status: 400,
headers: { "content-type": "application/json" },
}),
);
const err = (await request("POST", "entries/quick", { body: { title: "" }, fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe("validation");
expect(err.status).toBe(400);
expect(err.message).toBe("title: required");
});
test("non-GET requests send the CSRF header, JSON and same-origin cookies", async () => {
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "e1" }), { status: 201 }));
const out = await request<{ id: string }>("POST", "entries/quick", { body: { title: "Dune" }, fetchImpl: fn });
expect(out).toEqual({ id: "e1" });
const { url, init } = calls[0]!;
expect(url).toBe("/api/v1/entries/quick");
const h = new Headers(init.headers);
expect(h.get("x-requested-with")).toBe("sttracker");
expect(h.get("content-type")).toBe("application/json");
expect(init.credentials).toBe("same-origin");
expect(init.body).toBe(JSON.stringify({ title: "Dune" }));
});
test("login and register also send the CSRF header and JSON content type", async () => {
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "u1" })));
await request("POST", "auth/login", { body: { username: "a", password: "b" }, fetchImpl: fn });
await request("POST", "history/h1/restore", { fetchImpl: fn });
for (const c of calls) {
const h = new Headers(c.init.headers);
expect(h.get("x-requested-with")).toBe("sttracker");
expect(h.get("content-type")).toBe("application/json");
}
});
test("multipart uploads send the CSRF header but let fetch set the content type", async () => {
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ id: "a1" }), { status: 201 }));
const form = new FormData();
form.set("entity", "entry");
await request("POST", "attachments", { form, fetchImpl: fn });
const h = new Headers(calls[0]!.init.headers);
expect(h.get("x-requested-with")).toBe("sttracker");
expect(h.get("content-type")).toBeNull();
expect(calls[0]!.init.body).toBe(form);
});
test("GET builds the query string and skips empty values", async () => {
const { fn, calls } = fakeFetch(new Response(JSON.stringify({ items: [] })));
await request("GET", "entries", { query: { q: "dune", status: undefined, draft: true, limit: 50 }, fetchImpl: fn });
expect(calls[0]!.url).toBe("/api/v1/entries?q=dune&draft=true&limit=50");
});
test("204 resolves to undefined", async () => {
const { fn } = fakeFetch(new Response(null, { status: 204 }));
expect(await request("DELETE", "engagements/x", { fetchImpl: fn })).toBeUndefined();
});
test("a non-JSON error still becomes an ApiError", async () => {
const { fn } = fakeFetch(new Response("<html>Bad gateway</html>", { status: 502 }));
const err = (await request("GET", "me", { fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe("http_502");
expect(err.status).toBe(502);
});
test("a network failure becomes an ApiError with code network", async () => {
const { fn } = fakeFetch(() => {
throw new TypeError("fetch failed");
});
const err = (await request("GET", "me", { fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe("network");
expect(err.status).toBe(0);
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test";
import { addTag, applyEntryPatch, orderEngagements, removeTag, setCustomValue } from "../src/edits.ts";
describe("entry edit builders", () => {
test("two quick tag adds built from the latest cache keep both", () => {
// Each edit reads the cache as left by the previous (optimistic) edit, not the render's props.
let cache = { tags: [] as string[], rating: null as number | null };
const edit = (build: (cur: typeof cache) => Partial<typeof cache>) => (cache = applyEntryPatch(cache, build(cache)));
edit((cur) => ({ tags: addTag(cur.tags, "cosmic horror") }));
edit((cur) => ({ tags: addTag(cur.tags, "re-read") }));
expect(cache.tags).toEqual(["cosmic horror", "re-read"]);
});
test("addTag trims and ignores case duplicates; removeTag removes one", () => {
expect(addTag(["Horror"], " horror ")).toEqual(["Horror"]);
expect(addTag(["a"], " ")).toEqual(["a"]);
expect(removeTag(["a", "b"], "a")).toEqual(["b"]);
});
test("setCustomValue sets, and removes empty values", () => {
expect(setCustomValue({ a: 1 }, "b", "x")).toEqual({ a: 1, b: "x" });
expect(setCustomValue({ a: 1, b: "x" }, "b", "")).toEqual({ a: 1 });
expect(setCustomValue({ a: [1] }, "a", [])).toEqual({});
expect(setCustomValue({ a: 1 }, "a", null)).toEqual({});
});
});
describe("orderEngagements", () => {
const g = (id: string, createdAt: string, finished?: string, started?: string) => ({
id,
createdAt,
finishedAt: finished ? { value: finished, precision: "day" as const } : null,
startedAt: started ? { value: started, precision: "day" as const } : null,
});
test("sorts by finishedAt ?? startedAt, then createdAt; undated first", () => {
const list = [
g("late", "2026-01-01T00:00:00Z", "2026-09-14"),
g("early-logged-later", "2026-10-01T00:00:00Z", "2025-03"),
g("started-only", "2026-02-01T00:00:00Z", undefined, "2025-06-01"),
g("undated", "2026-03-01T00:00:00Z"),
];
expect(orderEngagements(list).map((x) => x.id)).toEqual(["undated", "early-logged-later", "started-only", "late"]);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { describe, expect, test } from "bun:test";
import { draftFailedSave, draftIncoming, draftStartSave, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
describe("formatRating", () => {
test("5 stars with halves", () => {
expect(formatRating(90, "5half")).toBe("★★★★½");
expect(formatRating(100, "5half")).toBe("★★★★★");
expect(formatRating(80, "5half")).toBe("★★★★☆");
expect(formatRating(0, "5half")).toBe("☆☆☆☆☆");
});
test("null is empty on every scale", () => {
expect(formatRating(null, "5half")).toBe("");
expect(formatRating(null, "10")).toBe("");
expect(formatRating(undefined, "100")).toBe("");
});
test("whole stars, 10 and 100 point scales", () => {
expect(formatRating(90, "5")).toBe("★★★★★");
expect(formatRating(70, "5")).toBe("★★★★☆");
expect(formatRating(90, "10")).toBe("9/10");
expect(formatRating(85, "10")).toBe("8.5/10");
expect(formatRating(73, "100")).toBe("73/100");
});
});
describe("formatPartialDate", () => {
test("renders by precision", () => {
expect(formatPartialDate({ value: "2025-03", precision: "month" })).toBe("Mar 2025");
expect(formatPartialDate({ value: "2019", precision: "year" })).toBe("2019");
expect(formatPartialDate({ value: "2026-09-14", precision: "day" })).toBe("14 Sep 2026");
expect(formatPartialDate({ value: "2026-09-04T23:30:00-05:00", precision: "day" })).toBe("4 Sep 2026");
expect(formatPartialDate({ value: "", precision: "unknown" })).toBe("");
expect(formatPartialDate(null)).toBe("");
});
});
describe("formatDateRange", () => {
test("flags dates that are not exact", () => {
expect(formatDateRange(null, { value: "2025-03", precision: "month" })).toBe("Mar 2025 (no exact date)");
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, { value: "2026-09-14", precision: "day" })).toBe(
"2 Sep – 14 Sep 2026",
);
expect(formatDateRange({ value: "2025-12-30", precision: "day" }, { value: "2026-01-02", precision: "day" })).toBe(
"30 Dec 2025 – 2 Jan 2026",
);
expect(formatDateRange({ value: "2026-09-02", precision: "day" }, null)).toBe("Started 2 Sep 2026");
expect(formatDateRange(null, null)).toBe("No dates");
});
});
describe("parsePartialDate", () => {
test("accepts year, month and day, rejects the rest", () => {
expect(parsePartialDate("2019")).toEqual({ value: "2019", precision: "year" });
expect(parsePartialDate(" 2025-03 ")).toEqual({ value: "2025-03", precision: "month" });
expect(parsePartialDate("2024-02-29")).toEqual({ value: "2024-02-29", precision: "day" });
expect(parsePartialDate("2023-02-29")).toBeNull();
expect(parsePartialDate("03/04/2019")).toBeNull();
expect(parsePartialDate("")).toBeNull();
});
});
describe("rollupLabel", () => {
test("children roll up as 'season 3 of 5'", () => {
expect(rollupLabel("show", { total: 5, completed: 3 })).toBe("season 3 of 5");
expect(rollupLabel("show", { total: 5, completed: 0 })).toBe("5 seasons");
expect(rollupLabel("podcast", { total: 200, completed: 200 })).toBe("all 200 episodes");
expect(rollupLabel("book", null)).toBeNull();
});
});
describe("friendlyError", () => {
const v = (message: string, status = 400) => ({ status, code: "validation", message });
test("maps validation reasons to plain copy", () => {
expect(friendlyError(v("customFields.Website: not_url"))).toBe("Website needs a full web address, starting with https://.");
expect(friendlyError(v("finishedAt: not_date"))).toBe("Finished needs a date like 2025, 2025-03 or 2025-03-14.");
expect(friendlyError(v("rating: out_of_range"))).toBe("Pick a rating between 0 and 5 stars.");
expect(friendlyError(v("rating: out_of_range"), { ratingScale: "10" })).toBe("Pick a rating between 0 and 10.");
expect(friendlyError(v("customFields.Mood: out_of_range"), { ratingScale: "5" })).toBe("Pick a rating for Mood between 0 and 5 stars.");
expect(friendlyError(v("title: required"))).toBe("Title can't be empty.");
expect(friendlyError(v("review: too_long"))).toBe("Review is too long. Shorten it and try again.");
expect(friendlyError(v("password: Too small: expected string to have >=8 characters"))).toBe("Passwords need at least 8 characters.");
expect(friendlyError(v("title: Too big: expected string to have <=1000 characters"))).toBe("Title is too long. Shorten it and try again.");
expect(friendlyError(v("mime: unsupported file type"))).toBe("That file type isn't supported. Use an image, a PDF or a text file.");
expect(friendlyError(v("customFields.Mood: unknown_option"))).toBe("Mood has an option that no longer exists. Pick one from the list.");
expect(friendlyError(v("displayName: weird_reason"))).toBe("Display name wasn't accepted. Check it and try again.");
});
test("413 means the file is too large", () => {
expect(friendlyError({ status: 413, code: "validation", message: "Request body is too large (max 50 MB)." })).toBe("That file is too large. The limit is 25 MB.");
expect(friendlyError(v("file: larger than 25 MB"))).toBe("That file is too large. The limit is 25 MB.");
});
test("other API errors keep the server's message; unknown errors get a generic line", () => {
expect(friendlyError({ status: 401, code: "unauthenticated", message: "Wrong username or password." })).toBe("Wrong username or password.");
expect(friendlyError(new Error("boom"))).toBe("Something went wrong. Try again.");
});
});
test("shortDate", () => {
expect(shortDate("2026-10-12T10:00:00")).toBe("12 Oct");
});
describe("review draft sync", () => {
const typed = { text: "Second thoughts.", synced: "First thoughts.", saving: false };
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 (error callback first)", () => {
let d = draftStartSave(typed);
d = draftFailedSave(d, typed.synced);
d = draftIncoming(d, "First thoughts.");
expect(d.text).toBe("Second thoughts.");
});
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.");
});
});
+128
View File
@@ -0,0 +1,128 @@
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");
});
});
+7 -5
View File
@@ -15,8 +15,8 @@ user layer or files a Changes-inbox event.
| Path | Responsibility | State |
|---|---|---|
| `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 app (HTML imports); `index.html` is a placeholder until Task 17 | placeholder |
| `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`, `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) |
| `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/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 |
@@ -26,9 +26,11 @@ user layer or files a Changes-inbox event.
HTTP request (`apps/server`):
1. `Bun.serve` routes `/api/*` and `/ingest/*` to `app.fetch`; everything else gets the web app (HTML import).
2. `apiHandler` matches method + path in the route table, applies the per-IP rate limit (login/register), authenticates
(`session` cookie `stt_session`, Bearer app token with a scope, or either), checks `X-Requested-With: sttracker` on
cookie-authenticated writes, reads the body (JSON ≤ 1 MB, multipart ≤ 50 MB) and validates it with zod.
3. The handler calls one core function with the caller's `userId` and actor `"user"`. Core enforces ownership
(`session` cookie `stt_session`, Bearer app token with a scope, or either), requires `X-Requested-With: sttracker` on
every non-Bearer write (login included), reads the body (JSON ≤ 1 MB with `Content-Type: application/json`,
multipart ≤ 50 MB) and validates it with zod.
3. The handler calls one core function with the caller's `userId` and actor (`token:<name>` for Bearer, else `"user"`;
`field_sources` stores both as `"user"`). Core enforces ownership
(`NotFoundError` → 404). Errors become `{ error: { code, message } }`; unknown errors are logged and return 500 `internal`.
4. `POST sources/:id/run|import` only enqueue a `sync` job (lockKey `sync:<id>`, 409 when held); the worker runs it.
+62
View File
@@ -4,6 +4,68 @@ 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).
**Decision:**
- **Three small API routes for the web app.** `GET auth/status` (no auth) → `{ hasUsers }`: `/login` redirects a fresh
instance to `/register`, which shows "Create the admin account", or invite mode when users exist (also on
`invite_required`, and with `?invite=` prefilled). `GET attachments?entity=&entityId=` lists an owned record's
attachments with their links (404 when the record is not the caller's). `GET/PATCH me/preferences` →
`{ ratingScale: "5half"|"5"|"10"|"100", defaultVisibility }`, stored in `users.preferences` under the same keys
`defaultVisibility()` already reads (`packages/core/src/preferences.ts`); unknown stored keys are kept.
- **Rating scale** comes from preferences (default five stars with halves). Stored ratings stay integers 0–100; the web
formats them with `formatRating(value, scale)`.
- **Entry edits are optimistic and serial.** `useEntryPatch` updates the cached entry in `onMutate` (rollback on error),
runs one entry's PATCHes in order through a TanStack mutation `scope`, and `edit(cur => patch)` builds from the latest
cache, so two quick tag adds keep both. Only the last queued edit writes the server's answer back.
- **Keyboard rating saves once:** arrow steps update locally and save 500 ms after the last key, on blur, or on unmount,
so stepping does not write a history row per keypress.
- **Errors in the product's voice:** every error display goes through `friendlyError()` (`apps/web/src/format.ts`), which maps
`validation` field/reason pairs and 413 to plain copy; other API errors keep the server's message.
- **AA contrast on the approved palette:** dark text on `--warn`, `--text-faint` group labels, `--text-muted` placeholders on
`--surface-3` (recorded in DESIGN.md).
- **Fonts are vendored** (Inter woff2 from fontsource; Gelasio as the official variable TTFs from github.com/google/fonts
`ofl/gelasio`, unmodified, because no woff2 tooling was available offline) with their OFL texts.
- **The web app mirrors wire types** in `apps/web/src/api.ts` instead of importing `@sttracker/core` (apps/web has no
workspace dependency on it, and the browser bundle must not pull server code).
## 2026-10-30: Token actor in history, connection deleted_at, CSRF on every browser write
**Status:** accepted. Supersedes the **Actor**, **CSRF** and **Deleted connections** bullets of
"HTTP API: auth modes, CSRF header, error mapping, actor" below.
**Context:** Task 16 fix round 1 (controller rulings).
**Decision:**
- **Actor vs. owner.** API writes pass actor `token:<name>` for Bearer requests and `"user"` for sessions, so history
shows which script changed what (spec §6.9). `field_sources` stores `fieldOwner(actor)`: every actor that does not start
with `plugin:` is stored as `"user"`. A token edit is the user's edit, and syncs file a pending change instead of
overwriting it. List ownership, which is read from history, applies the same mapping.
- **CSRF.** `X-Requested-With: sttracker` is required on every non-GET request that has no Bearer token, including the
`auth: "none"` routes login and register (login CSRF). JSON-body routes also require `Content-Type: application/json`
(415 `unsupported_media_type` otherwise), so a cross-site simple form post cannot pass as JSON.
- **Deleted connections** carry `source_connections.deleted_at` (migration 0003). `deleteConnection` sets it and still pauses
the connection and wipes its credentials. Listing, lookups, updates, the scheduler and the sync handler ignore deleted rows.
A user-paused connection without credentials is a normal, visible, resumable connection.
- **Dev mode** (`Bun.serve({ development })`) is on only with `NODE_ENV=development` or `bun --hot` (detected through
`process.execArgv`; `Bun.argv` does not carry runtime flags). Everything else runs in production mode.
## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor
**Status:** accepted
**Context:** Task 16 (`apps/server`).
+6 -1
View File
@@ -11,12 +11,16 @@ Small, specific, independently actionable. Remove only when actually done.
## Next
- [ ] API: response schemas in the OpenAPI document (requests only today).
- [ ] Deployment docs (Task 19): the server runs `Bun.serve` in production mode unless `NODE_ENV=development` or `bun --hot`, so Docker needs no `NODE_ENV`. Document `NODE_ENV=development` for local debugging.
- [ ] API: `restoreVersion`, `acceptSuggestion` and `resolveChange` take no actor, so history records `"user"` for them even when an app token calls them. Thread the actor through if script provenance matters there.
- [ ] API: login/register rate limit is per process and keyed on the socket IP; behind a reverse proxy every client shares one key. Add a trusted-proxy setting for `X-Forwarded-For`.
- [ ] API: file imports are base64 in the `jobs.payload` row (up to ~67 MB of text). Move uploads to `DATA_DIR/uploads/` and pass a path.
- [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function.
- [ ] Connections: a user-paused connection that never had credentials looks deleted (`paused` + no credentials) and disappears from `GET sources`. Add an explicit `deleted_at`.
- [ ] Phase 2 and Phase 3 plans (spec §12).
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).
- [ ] Web: convert the vendored Gelasio variable TTFs (~170 KB each) to latin-subset woff2 once fonttools/woff2 is available; Bun inlines fonts into the CSS bundle.
- [ ] Web/API: "Linked sources" is inferred from `plugin:` actors in `fieldSources`, so a source that matched an entry but never wrote a field is not shown. Add a per-entry source-links route.
- [ ] Web: the detail pane only lists attachments of the entry itself, not those linked to its reads or copies.
## Someday
@@ -31,6 +35,7 @@ Small, specific, independently actionable. Remove only when actually done.
file, and compare steps and token use.
## Known issues
- [ ] Web: in production the bundled HTML references `/../../chunk-*.css|js` (Bun bundles `../../web/index.html` from `apps/server`). Browsers normalise it to `/chunk-*` and it loads; check it behind a reverse proxy.
- [ ] Plugins: reload re-imports entry files only; edits to a plugin's other modules need a server restart.
- [ ] Plugins: rate limiting is per run (each run builds its own client), not per plugin (spec §11); share a limiter per plugin in Phase 2.
- [ ] Plugins: `ctx.log` reaches `source_runs.log` only after the run ends, so a crash mid-run loses it.
+17 -4
View File
@@ -111,6 +111,11 @@ export async function login(
return { user: toUser(row), sessionToken };
}
/** True once any account exists (the first one becomes admin). */
export async function hasUsers(db: Db): Promise<boolean> {
return (await db.k.selectFrom("users").select("id").limit(1).executeTakeFirst()) !== undefined;
}
export async function getSessionUser(db: Db, token: string): Promise<User | null> {
const row = await db.k
.selectFrom("sessions")
@@ -170,11 +175,17 @@ export async function createAppToken(
}
export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise<User | null> {
return (await authenticateAppToken(db, token, scope))?.user ?? null;
}
/** Like `verifyAppToken`, also returning the token's name (API writes record actor `token:<name>`). */
export async function authenticateAppToken(db: Db, token: string, scope: TokenScope): Promise<{ user: User; tokenName: string } | null> {
const row = await db.k
.selectFrom("app_tokens")
.innerJoin("users", "users.id", "app_tokens.user_id")
.select([
"app_tokens.id as token_id",
"app_tokens.name as token_name",
"app_tokens.scopes",
"users.id",
"users.username",
@@ -186,17 +197,19 @@ export async function verifyAppToken(db: Db, token: string, scope: TokenScope):
.executeTakeFirst();
if (!row || !fromJson<string[]>(row.scopes, []).includes(scope)) return null;
await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute();
return toUser(row);
return { user: toUser(row), tokenName: row.token_name };
}
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<void> {
await db.k
/** Revoke one of the user's live tokens. Returns false when no such live token exists (unknown, foreign or revoked). */
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<boolean> {
const r = await db.k
.updateTable("app_tokens")
.set({ revoked_at: nowIso() })
.where("id", "=", id)
.where("user_id", "=", userId)
.where("revoked_at", "is", null)
.execute();
.executeTakeFirst();
return Number(r.numUpdatedRows) === 1;
}
export interface AppTokenInfo {
+22 -12
View File
@@ -77,6 +77,7 @@ export async function createConnection(db: Db, secret: string, userId: string, i
status: "active",
last_run_at: null,
created_at: nowIso(),
deleted_at: null,
})
.execute();
return id;
@@ -94,7 +95,7 @@ export interface ConnectionPatch {
export async function updateConnection(db: Db, secret: string, userId: string, id: string, patch: ConnectionPatch, manifest: PluginManifest): Promise<void> {
checkSchedule(patch.scheduleMinutes);
await db.k.transaction().execute(async (tx) => {
let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId);
let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).where("deleted_at", "is", null);
if (db.dialect === "postgres") q = q.forUpdate();
const row = await q.executeTakeFirst();
if (!row) throw new NotFoundError("connection");
@@ -116,20 +117,27 @@ export async function updateConnection(db: Db, secret: string, userId: string, i
});
}
/** Soft delete: the connection is paused and its credentials are wiped. Records and runs stay. */
/** Soft delete: `deleted_at` is set, the connection is paused and its credentials are wiped. Records and runs stay. */
export async function deleteConnection(db: Db, userId: string, id: string): Promise<void> {
const r = await db.k
.updateTable("source_connections")
.set({ status: "paused", credentials_enc: null })
.set({ status: "paused", credentials_enc: null, deleted_at: nowIso() })
.where("id", "=", id)
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (r.numUpdatedRows !== 1n) throw new NotFoundError("connection");
}
/** Never returns secrets: plain config plus `hasCredentials`. */
/** The user's live (not deleted) connections. Never returns secrets: plain config plus `hasCredentials`. */
export async function listConnections(db: Db, userId: string): Promise<ConnectionView[]> {
const rows = await db.k.selectFrom("source_connections").selectAll().where("user_id", "=", userId).orderBy("created_at").execute();
const rows = await db.k
.selectFrom("source_connections")
.selectAll()
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.orderBy("created_at")
.execute();
return rows.map((r) => ({
id: r.id,
pluginId: r.plugin_id,
@@ -143,14 +151,15 @@ export async function listConnections(db: Db, userId: string): Promise<Connectio
}));
}
/** A deleted connection is paused with its credentials wiped (`deleteConnection`). */
export function isDeletedConnection(c: Pick<ConnectionView, "status" | "hasCredentials">): boolean {
return c.status === "paused" && !c.hasCredentials;
}
async function ownedRow(db: Db, userId: string, id: string) {
const r = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).executeTakeFirst();
if (!r || (r.status === "paused" && r.credentials_enc === null)) throw new NotFoundError("connection");
const r = await db.k
.selectFrom("source_connections")
.selectAll()
.where("id", "=", id)
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (!r) throw new NotFoundError("connection");
return r;
}
@@ -342,6 +351,7 @@ export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?:
const { db } = deps;
const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst();
if (!full) throw new DropJobError("connection not found");
if (full.deleted_at !== null) throw new DropJobError("connection is deleted");
if (full.status === "paused") throw new DropJobError("connection is paused");
const plugin = deps.registry.get(full.plugin_id);
if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`);
+8 -3
View File
@@ -7,6 +7,7 @@ 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 {
@@ -224,12 +225,16 @@ 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. */
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<Copy[]> {
/**
* 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 })[]> {
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();
return rows.map(toCopy);
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 }));
}
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
+3 -3
View File
@@ -15,7 +15,7 @@ import {
toStoredDate,
} from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts";
/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */
export interface EngagementPatch {
@@ -144,7 +144,7 @@ async function write(
): Promise<Engagement> {
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources = { ...current.fieldSources };
for (const key of Object.keys(patch)) fieldSources[key] = actor;
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
await k
.updateTable("engagements")
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
@@ -175,7 +175,7 @@ export async function addEngagement(
const { mediaType } = await requireEntry(k, userId, entryId);
const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources: FieldSources = {};
for (const key of Object.keys(patch)) fieldSources[key] = actor;
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
const now = nowIso();
const row: Row = {
id: newId(),
+8 -5
View File
@@ -7,7 +7,7 @@ import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types";
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts";
// ---------------------------------------------------------------------------------------------
// Shared user-layer helpers (also used by engagements, lists and copies).
@@ -320,7 +320,7 @@ async function writeEntry(
): Promise<Entry> {
const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources };
for (const key of Object.keys(patch)) {
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor;
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : (fieldOwner(actor) as Actor);
if (fieldSources[key] === undefined) delete fieldSources[key];
}
const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() };
@@ -985,14 +985,17 @@ export interface MatchSuggestion {
createdAt: string;
}
/** The user's pending match suggestions for a work, best first. Throws `NotFoundError` if the work is not visible. */
/**
* The user's pending match suggestions for a work (after following aliases and merges to the canonical work), best first.
* Throws `NotFoundError` if the work is not visible.
*/
export async function listSuggestions(db: Db, userId: string, workId: string): Promise<MatchSuggestion[]> {
await resolveVisibleWork(db.k, userId, workId);
const work = await resolveVisibleWork(db.k, userId, workId);
const rows = await db.k
.selectFrom("match_suggestions")
.selectAll()
.where("user_id", "=", userId)
.where("work_id", "=", workId)
.where("work_id", "=", work.id)
.where("status", "=", "pending")
.orderBy("score", "desc")
.orderBy("id")
+9
View File
@@ -12,6 +12,15 @@ import { restoreListFromHistory } from "./lists.ts";
/** Who made a change. Plugin actors are `plugin:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`;
/**
* Who owns a field in `field_sources` after `actor` writes it. Only plugin actors keep their name; the user,
* through the UI or an API token, is always `"user"`, so syncs never overwrite a field a script edited.
* History rows keep the full actor.
*/
export function fieldOwner(actor: string): string {
return actor.startsWith("plugin:") ? actor : "user";
}
/** User-layer entities that write `user_history`. */
export type HistoryEntity = "entry" | "engagement" | "copy" | "list";
+1
View File
@@ -198,6 +198,7 @@ export function startScheduler(db: Db, opts: { tickMs?: number } = {}): { stop()
.selectFrom("source_connections")
.select(["id", "schedule_minutes", "last_run_at"])
.where("status", "!=", "paused")
.where("deleted_at", "is", null)
.where("schedule_minutes", "is not", null)
.execute();
const now = Date.now();
+168
View File
@@ -0,0 +1,168 @@
/**
* 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 };
}
+54
View File
@@ -0,0 +1,54 @@
import type { Db } from "@sttracker/db/db";
import type { Executor } from "./history.ts";
import { fromJson, toJson } from "@sttracker/db/json";
import { nowIso } from "@sttracker/db/ids";
import type { Visibility } from "@sttracker/db/schema";
import { VISIBILITIES } from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts";
export const RATING_SCALES = ["5half", "5", "10", "100"] as const;
export type RatingScale = (typeof RATING_SCALES)[number];
/** Per-user settings, stored as JSON in `users.preferences`. Keys match what `defaultVisibility()` reads. */
export interface Preferences {
ratingScale: RatingScale;
defaultVisibility: Visibility;
}
export const DEFAULT_PREFERENCES: Preferences = { ratingScale: "5half", defaultVisibility: "private" };
async function readRaw(k: Executor, userId: string, lock = false): Promise<Record<string, unknown>> {
let q = k.selectFrom("users").select("preferences").where("id", "=", userId);
if (lock) q = q.forUpdate();
const u = await q.executeTakeFirst();
if (!u) throw new NotFoundError("user");
return fromJson<Record<string, unknown>>(u.preferences, {});
}
/** The user's preferences; missing or invalid stored values fall back to the defaults. */
export async function getPreferences(db: Db, userId: string): Promise<Preferences> {
return toPreferences(await readRaw(db.k, userId));
}
function toPreferences(raw: Record<string, unknown>): Preferences {
return {
ratingScale: (RATING_SCALES as readonly unknown[]).includes(raw.ratingScale) ? (raw.ratingScale as RatingScale) : DEFAULT_PREFERENCES.ratingScale,
defaultVisibility: (VISIBILITIES as readonly unknown[]).includes(raw.defaultVisibility)
? (raw.defaultVisibility as Visibility)
: DEFAULT_PREFERENCES.defaultVisibility,
};
}
/** Merge a partial update into the stored preferences (unknown stored keys are kept). */
export async function updatePreferences(db: Db, userId: string, patch: Partial<Preferences>): Promise<Preferences> {
if (patch.ratingScale !== undefined && !RATING_SCALES.includes(patch.ratingScale)) throw new ValidationError("ratingScale", "invalid");
if (patch.defaultVisibility !== undefined && !VISIBILITIES.includes(patch.defaultVisibility)) throw new ValidationError("defaultVisibility", "invalid");
// Read-modify-write in one transaction, with the user row locked on Postgres (SQLite runs one
// connection), so concurrent updates of different keys don't drop each other.
return db.k.transaction().execute(async (tx) => {
const raw = await readRaw(tx, userId, db.dialect === "postgres");
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
await tx.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
return toPreferences(next);
});
}
+3 -3
View File
@@ -6,7 +6,7 @@ import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema";
import type { EngagementFields, EntryFields, PartialDate, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts";
import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts";
import type { Actor, Executor } from "../history.ts";
import { type Actor, type Executor, fieldOwner } from "../history.ts";
import { createList, replaceListItems, updateList } from "../lists.ts";
import { matchWork } from "../matching.ts";
import { resolveAliasIn, updateWorkFromHintIn } from "../works.ts";
@@ -146,8 +146,8 @@ export async function listFieldSources(k: Executor, listId: string, sourceActor:
const fs: Record<string, string> = { items: sourceActor };
for (const r of rows) {
const after = fromJson<Fields>(r.after, {});
for (const f of ["name", "description"]) if (f in after) fs[f] = r.actor;
if (r.actor === "user") fs.items = "user";
for (const f of ["name", "description"]) if (f in after) fs[f] = fieldOwner(r.actor);
if (fieldOwner(r.actor) === "user") fs.items = "user";
}
return fs;
}
+60 -3
View File
@@ -9,6 +9,7 @@ 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";
@@ -27,6 +28,14 @@ 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,
@@ -51,15 +60,63 @@ export async function listChanges(
db: Db,
userId: string,
opts: { status?: "pending" | "all"; connectionId?: string; limit?: number; cursor?: string } = {},
): Promise<{ items: ChangeEvent[]; nextCursor: string | null }> {
): Promise<{ items: ChangeItem[]; 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 items = rows.slice(0, limit).map(toChange);
return { items, nextCursor: rows.length > limit ? items[items.length - 1]!.id : null };
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 STATUS_FOR = { accept: "accepted", ignore: "ignored", keep_mine: "kept_mine" } as const;
+50
View File
@@ -0,0 +1,50 @@
/**
* 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;
}
+113 -4
View File
@@ -6,8 +6,14 @@ import { newId, nowIso } from "@sttracker/db/ids";
import { forEachDialect } from "@sttracker/db/testing";
import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types";
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts";
import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listRuns, syncHandler } from "../src/connections.ts";
import { authenticateAppToken, createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
import { getHistory } from "../src/history.ts";
import { fieldOwner } from "../src/history.ts";
import { getEntry, upsertEntry } from "../src/entries.ts";
import { listEngagements } from "../src/engagements.ts";
import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, syncHandler, updateConnection } from "../src/connections.ts";
import { startScheduler } from "../src/jobs.ts";
import { DropJobError } from "../src/jobs.ts";
import { listSuggestions, quickAdd } from "../src/entries.ts";
import { addEngagement } from "../src/engagements.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts";
@@ -37,7 +43,21 @@ const fileish: SourcePlugin = definePlugin({
poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"),
},
});
const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} };
let flipStatus: "planned" | "completed" = "planned";
const flip: SourcePlugin = definePlugin({
manifest: { id: "flip", name: "Flip", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["pull"], config: [] },
async *pull() {
yield {
kind: "entry",
key: "flip-1",
raw: flipStatus,
work: { title: "Flip", mediaType: "book", externalIds: { isbn13: "9780000000010" } },
entry: { status: flipStatus, rating: flipStatus === "planned" ? 40 : 90 },
};
},
});
const extra: Record<string, SourcePlugin> = { fileish, flip };
const reg2: PluginRegistry = { get: (id) => extra[id] ?? reg.get(id), list: () => [...reg.list(), fileish.manifest, flip.manifest], reload: async () => {} };
forEachDialect("api support", (getDb) => {
test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => {
@@ -46,7 +66,9 @@ forEachDialect("api support", (getDb) => {
const other = await makeUser(db, "bob");
const t = await createAppToken(db, u, "script", ["read"]);
await createAppToken(db, other, "x", ["write"]);
await revokeAppToken(db, u, t.id);
expect(await revokeAppToken(db, other, t.id)).toBe(false);
expect(await revokeAppToken(db, u, t.id)).toBe(true);
expect(await revokeAppToken(db, u, t.id)).toBe(false);
const list = await listAppTokens(db, u);
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null });
@@ -54,6 +76,53 @@ forEachDialect("api support", (getDb) => {
expect(JSON.stringify(list)).not.toContain("hash");
});
test("fieldOwner: only plugin actors keep their name in field_sources", () => {
expect(fieldOwner("user")).toBe("user");
expect(fieldOwner("token:script")).toBe("user");
expect(fieldOwner("plugin:echo:c1")).toBe("plugin:echo:c1");
});
test("authenticateAppToken returns the user and the token name", async () => {
const db = getDb();
const u = await makeUser(db);
const t = await createAppToken(db, u, "script", ["write"]);
expect(await authenticateAppToken(db, t.token, "write")).toMatchObject({ user: { id: u }, tokenName: "script" });
expect(await authenticateAppToken(db, t.token, "read")).toBeNull();
});
test("a token write keeps its actor in history, is owned by the user, and a later sync files a pending change", async () => {
const db = getDb();
const u = await makeUser(db);
flipStatus = "planned";
const id = await createConnection(db, SECRET, u, { pluginId: "flip", name: "F", config: {} }, flip.manifest);
const sync = syncHandler({ db, secret: SECRET, registry: reg2 });
await sync({ connectionId: id, mode: "full" });
const entry = await db.k.selectFrom("entries").selectAll().where("user_id", "=", u).executeTakeFirstOrThrow();
await upsertEntry(db, u, entry.work_id, { status: "dropped" }, "token:script");
const view = await getEntry(db, u, entry.id);
expect(view.fieldSources.status).toBe("user");
expect(view.fieldSources.rating).toBe(`plugin:flip:${id}`);
const hist = await getHistory(db, u, "entry", entry.id);
expect(hist[0]!.actor).toBe("token:script");
flipStatus = "completed";
await sync({ connectionId: id, mode: "full" });
const after = await getEntry(db, u, entry.id);
expect(after.status).toBe("dropped");
expect(after.rating).toBe(90);
const pending = await db.k.selectFrom("change_events").selectAll().where("user_id", "=", u).where("status", "=", "pending").execute();
expect(pending.map((p) => [p.type, JSON.parse(p.payload).field])).toEqual([["changed", "status"]]);
});
test("token-authored engagement fields are owned by the user", async () => {
const db = getDb();
const u = await makeUser(db);
const e = await quickAdd(db, u, { title: "Dune" });
await addEngagement(db, u, e.id, { platform: "kindle" }, "token:script");
const [g] = await listEngagements(db, u, e.id);
expect(g!.fieldSources).toEqual({ platform: "user" });
});
test("listSuggestions lists pending suggestions for a visible work", async () => {
const db = getDb();
const u = await makeUser(db);
@@ -68,6 +137,11 @@ forEachDialect("api support", (getDb) => {
expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" });
const other = await makeUser(db, "bob");
await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError);
// Asking by an id the user aliased away lists the canonical work's suggestions.
const old = await makeWork(db, { title: "Old Dune", ownerUserId: u });
await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: old, to_work_id: draft.workId }).execute();
expect((await listSuggestions(db, u, old)).map((x) => x.workId)).toEqual([draft.workId]);
});
test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => {
@@ -114,6 +188,41 @@ forEachDialect("api support", (getDb) => {
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
});
test("a paused connection without credentials stays listed and can be unpaused; a deleted one is gone", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
await updateConnection(db, SECRET, u, id, { status: "paused" }, echo.manifest);
expect((await listConnections(db, u)).map((c) => c.id)).toEqual([id]);
expect((await getConnection(db, u, id)).status).toBe("paused");
await updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest);
expect((await getConnection(db, u, id)).status).toBe("active");
await deleteConnection(db, u, id);
const row = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
expect(row).toMatchObject({ status: "paused", credentials_enc: null });
expect(row.deleted_at).not.toBeNull();
expect(await listConnections(db, u)).toEqual([]);
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
await expect(updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest)).rejects.toBeInstanceOf(NotFoundError);
await expect(deleteConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
});
test("deleted connections are never scheduled and their queued syncs are dropped", async () => {
const db = getDb();
const u = await makeUser(db);
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {}, scheduleMinutes: 1 }, echo.manifest);
// Simulate a delete that left status "error" (e.g. a row edited by hand): deleted_at alone must exclude it.
await deleteConnection(db, u, id);
await db.k.updateTable("source_connections").set({ status: "error" }).where("id", "=", id).execute();
const sched = startScheduler(db, { tickMs: 3_600_000 });
await sched.tickNow();
sched.stop();
expect(await db.k.selectFrom("jobs").select("id").execute()).toEqual([]);
const sync = syncHandler({ db, secret: SECRET, registry: reg });
await expect(sync({ connectionId: id })).rejects.toBeInstanceOf(DropJobError);
});
test("syncHandler runs file mode from a base64 payload", async () => {
const db = getDb();
const u = await makeUser(db);
+2 -1
View File
@@ -55,7 +55,8 @@ forEachDialect("connections", (getDb) => {
r = await raw(id);
expect(r.status).toBe("paused");
expect(r.credentials_enc).toBeNull();
expect((await listConnections(db, u))[0]!.hasCredentials).toBe(false);
expect(r.deleted_at).not.toBeNull();
expect(await listConnections(db, u)).toEqual([]);
});
test("cursor persists on set; http uses manifest rate limit", async () => {
+20
View File
@@ -2,6 +2,7 @@ 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";
@@ -73,4 +74,23 @@ 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");
});
});
+107
View File
@@ -0,0 +1,107 @@
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);
});
});
@@ -0,0 +1,95 @@
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();
});
});
@@ -0,0 +1,13 @@
import type { Kysely } from "kysely";
import type { Migration } from "kysely/migration";
/** Soft delete for source connections: a deleted connection is distinct from one the user merely paused. */
export const sourceConnectionsDeletedAt: Migration = {
async up(db: Kysely<any>) {
await db.schema.alterTable("source_connections").addColumn("deleted_at", "text").execute();
},
async down(db: Kysely<any>) {
await db.schema.alterTable("source_connections").dropColumn("deleted_at").execute();
},
};
+2
View File
@@ -1,9 +1,11 @@
import type { Migration } from "kysely/migration";
import { initial } from "./0001_initial.ts";
import { worksPositionUnique } from "./0002_works_position_unique.ts";
import { sourceConnectionsDeletedAt } from "./0003_source_connections_deleted_at.ts";
/** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */
export const migrations: Record<string, Migration> = {
"0001_initial": initial,
"0002_works_position_unique": worksPositionUnique,
"0003_source_connections_deleted_at": sourceConnectionsDeletedAt,
};
+2
View File
@@ -269,6 +269,8 @@ export interface SourceConnectionsTable {
status: ConnectionStatus;
last_run_at: string | null;
created_at: string;
/** Set by `deleteConnection`; deleted connections are hidden, never scheduled and never synced. */
deleted_at: string | null;
}
export interface SourceRunsTable {