1 Commits
Author SHA1 Message Date
Lucas WintherandClaude Opus 5.5 b76d071d87 wip(docs): reorder DECISIONS newest-first (task 19, unfinished)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:55:00 +01:00
56 changed files with 123 additions and 3886 deletions
-4
View File
@@ -23,10 +23,6 @@ Dark, calm, dense. Three panes on desktop (nav · library list · entry detail);
- Type: Inter for the UI; Gelasio for long-form review text. - 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. - Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers.
- Orange means "a platform changed something". Never use it decoratively. - 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. - 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) ## Media type colours (shared by both surfaces)
+12 -32
View File
@@ -1,6 +1,5 @@
import { authenticateAppToken, getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth"; import { getSessionUser, verifyAppToken, type User } from "@sttracker/core/auth";
import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors"; import { AuthError, ImportError, NotFoundError, ValidationError } from "@sttracker/core/errors";
import type { Actor } from "@sttracker/core/history";
import { isUniqueViolation } from "@sttracker/core/matching"; import { isUniqueViolation } from "@sttracker/core/matching";
import type { PluginRegistry } from "@sttracker/core/registry"; import type { PluginRegistry } from "@sttracker/core/registry";
import type { Db } from "@sttracker/db/db"; import type { Db } from "@sttracker/db/db";
@@ -34,8 +33,6 @@ export interface Ctx<B, Q> {
query: Q; query: Q;
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */ /** The authenticated user. Throws on `auth: "none"` routes, which have none. */
user: User; 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. */ /** The session token, when the request was authenticated by cookie. */
sessionToken: string | null; sessionToken: string | null;
/** Parsed multipart body, for `multipart: true` routes. */ /** Parsed multipart body, for `multipart: true` routes. */
@@ -54,8 +51,6 @@ export interface RouteDef<B = any, Q = any> {
query?: z.ZodType<Q>; query?: z.ZodType<Q>;
/** Parse the body as multipart form data (upload limit) instead of JSON. */ /** Parse the body as multipart form data (upload limit) instead of JSON. */
multipart?: boolean; 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). */ /** Rate limit per client IP (login, register). */
rateLimited?: boolean; rateLimited?: boolean;
/** Only admins may call it (403 otherwise). */ /** Only admins may call it (403 otherwise). */
@@ -137,13 +132,7 @@ function readCookie(req: Request, name: string): string | null {
if (!header) return null; if (!header) return null;
for (const part of header.split(";")) { for (const part of header.split(";")) {
const i = part.indexOf("="); const i = part.indexOf("=");
if (i > 0 && part.slice(0, i).trim() === name) { if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
try {
return decodeURIComponent(part.slice(i + 1).trim()) || null;
} catch {
return null; // malformed percent-encoding: treat as no cookie
}
}
} }
return null; return null;
} }
@@ -196,20 +185,16 @@ export function rateLimiter(limit: number, windowMs: number): (key: string) => b
const SCOPES: TokenScope[] = ["read", "write", "ingest"]; const SCOPES: TokenScope[] = ["read", "write", "ingest"];
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */ /** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
async function authenticate( async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> {
req: Request, if (mode === "none") return { user: null, sessionToken: null };
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 authz = req.headers.get("authorization");
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null; const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined]; const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
if (kind !== "session" && bearer) { if (kind !== "session" && bearer) {
const scope = scopeName!; const scope = scopeName!;
const auth = await authenticateAppToken(db, bearer, scope); const user = await verifyAppToken(db, bearer, scope);
if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` }; if (user) return { user, sessionToken: null };
for (const other of SCOPES) { for (const other of SCOPES) {
if (other !== scope && (await verifyAppToken(db, bearer, other))) { if (other !== scope && (await verifyAppToken(db, bearer, other))) {
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`); throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
@@ -222,7 +207,11 @@ async function authenticate(
const token = readCookie(req, SESSION_COOKIE); const token = readCookie(req, SESSION_COOKIE);
const user = token ? await getSessionUser(db, token) : null; const user = token ? await getSessionUser(db, token) : null;
if (!user) throw new HttpError(401, "unauthenticated", "Sign in first."); if (!user) throw new HttpError(401, "unauthenticated", "Sign in first.");
return { user, sessionToken: token, actor: "user" }; // 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 };
} }
interface Compiled { interface Compiled {
@@ -272,13 +261,7 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
if (def.rateLimited && !limit(`${def.path}|${ip}`)) { if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" }); return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
} }
// CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
// 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."); if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
let body: unknown; let body: unknown;
@@ -296,8 +279,6 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
const bytes = await readLimited(req, MAX_JSON_BYTES); const bytes = await readLimited(req, MAX_JSON_BYTES);
let raw: unknown; let raw: unknown;
if (bytes.byteLength > 0) { 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 { try {
raw = JSON.parse(new TextDecoder().decode(bytes)); raw = JSON.parse(new TextDecoder().decode(bytes));
} catch { } catch {
@@ -318,7 +299,6 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`); if (!user) throw new Error(`route ${def.method} ${def.path} has no authenticated user`);
return user; return user;
}, },
actor,
sessionToken, sessionToken,
get form(): FormData { get form(): FormData {
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`); if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
+1 -3
View File
@@ -40,9 +40,7 @@ const backupTimer = setInterval(() => void ensureBackup(), BACKUP_CHECK_MS);
const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl }); const app = createApp({ db, registry, secret: config.secret, dataDir: config.dataDir, publicUrl: config.publicUrl });
const server = Bun.serve({ const server = Bun.serve({
port: config.port, port: config.port,
// Production unless asked: dev mode adds HMR and verbose error pages. development: process.env.NODE_ENV !== "production",
// `bun --hot` puts the flag in execArgv, not Bun.argv.
development: process.env.NODE_ENV === "development" || process.execArgv.includes("--hot"),
routes: { routes: {
"/api/*": (req, srv) => app.fetch(req, srv), "/api/*": (req, srv) => app.fetch(req, srv),
"/ingest/*": (req, srv) => app.fetch(req, srv), "/ingest/*": (req, srv) => app.fetch(req, srv),
+1 -9
View File
@@ -46,15 +46,7 @@ export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema
}; };
if (parameters.length > 0) op.parameters = parameters; 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.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } };
if (r.multipart) { if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } };
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; (paths[path] ??= {})[r.method.toLowerCase()] = op;
} }
return { return {
+1 -14
View File
@@ -1,4 +1,4 @@
import { listAttachmentLinks, listAttachments, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments"; import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment, unlinkAttachment } from "@sttracker/core/attachments";
import { NotFoundError } from "@sttracker/core/errors"; import { NotFoundError } from "@sttracker/core/errors";
import type { AttachmentEntity } from "@sttracker/db/schema"; import type { AttachmentEntity } from "@sttracker/db/schema";
import { z } from "zod"; import { z } from "zod";
@@ -18,25 +18,12 @@ export function safeFilename(name: string): string {
} }
export const attachmentRoutes = [ 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({ route({
method: "POST", method: "POST",
path: "attachments", path: "attachments",
summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.", summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.",
auth: "any:write", auth: "any:write",
multipart: true, multipart: true,
formFields: fields,
handler: async ({ deps, user, form }) => { handler: async ({ deps, user, form }) => {
const file = formFile(form); const file = formFile(form);
const f = fields.parse({ const f = fields.parse({
+2 -28
View File
@@ -1,9 +1,6 @@
import { createAppToken, createInvite, hasUsers, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth"; import { createAppToken, createInvite, 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 { z } from "zod";
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts"; import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
import { visibility } from "./schemas.ts";
const credentials = z.object({ const credentials = z.object({
username: z.string().min(1).max(64), username: z.string().min(1).max(64),
@@ -11,13 +8,6 @@ const credentials = z.object({
}); });
export const authRoutes = [ 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({ route({
method: "POST", method: "POST",
path: "auth/register", path: "auth/register",
@@ -50,21 +40,6 @@ 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", 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({ route({
method: "GET", method: "GET",
path: "me/tokens", path: "me/tokens",
@@ -86,7 +61,7 @@ export const authRoutes = [
summary: "Revoke an app token.", summary: "Revoke an app token.",
auth: "session", auth: "session",
handler: async ({ deps, user, params }) => { handler: async ({ deps, user, params }) => {
if (!(await revokeAppToken(deps.db, user.id, params.id!))) throw new NotFoundError("app token"); await revokeAppToken(deps.db, user.id, params.id!);
return noContent(); return noContent();
}, },
}), }),
@@ -95,7 +70,6 @@ export const authRoutes = [
path: "admin/invites", path: "admin/invites",
summary: "Create an invite code (admin). The code is returned once.", summary: "Create an invite code (admin). The code is returned once.",
auth: "session", auth: "session",
admin: true,
body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(), 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), 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.", summary: "Record an owned copy.",
auth: "any:write", auth: "any:write",
body: copyPatch, body: copyPatch,
handler: async ({ deps, user, body, actor }) => json(await createCopy(deps.db, user.id, body as CopyArg, actor), 201), handler: async ({ deps, user, body }) => json(await createCopy(deps.db, user.id, body as CopyArg), 201),
}), }),
route({ route({
method: "PATCH", method: "PATCH",
@@ -43,15 +43,15 @@ export const copyRoutes = [
summary: "Edit an owned copy.", summary: "Edit an owned copy.",
auth: "any:write", auth: "any:write",
body: copyPatch, body: copyPatch,
handler: async ({ deps, user, params, body, actor }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg, actor), handler: async ({ deps, user, params, body }) => updateCopy(deps.db, user.id, params.id!, body as CopyArg),
}), }),
route({ route({
method: "DELETE", method: "DELETE",
path: "copies/:id", path: "copies/:id",
summary: "Soft-delete an owned copy.", summary: "Soft-delete an owned copy.",
auth: "any:write", auth: "any:write",
handler: async ({ deps, user, params, actor }) => { handler: async ({ deps, user, params }) => {
await softDeleteCopy(deps.db, user.id, params.id!, actor); await softDeleteCopy(deps.db, user.id, params.id!);
return noContent(); return noContent();
}, },
}), }),
+6 -6
View File
@@ -30,8 +30,8 @@ export const engagementRoutes = [
summary: "Log a read, watch or play.", summary: "Log a read, watch or play.",
auth: "any:write", auth: "any:write",
body: engagementPatch, body: engagementPatch,
handler: async ({ deps, user, params, body, actor }) => handler: async ({ deps, user, params, body }) =>
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], actor), 201), json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], "user"), 201),
}), }),
route({ route({
method: "PATCH", method: "PATCH",
@@ -39,16 +39,16 @@ export const engagementRoutes = [
summary: "Edit an engagement.", summary: "Edit an engagement.",
auth: "any:write", auth: "any:write",
body: engagementPatch, body: engagementPatch,
handler: async ({ deps, user, params, body, actor }) => handler: async ({ deps, user, params, body }) =>
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], actor), updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], "user"),
}), }),
route({ route({
method: "DELETE", method: "DELETE",
path: "engagements/:id", path: "engagements/:id",
summary: "Soft-delete an engagement.", summary: "Soft-delete an engagement.",
auth: "any:write", auth: "any:write",
handler: async ({ deps, user, params, actor }) => { handler: async ({ deps, user, params }) => {
await softDeleteEngagement(deps.db, user.id, params.id!, actor); await softDeleteEngagement(deps.db, user.id, params.id!, "user");
return noContent(); 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).", summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).",
auth: "any:write", auth: "any:write",
body: entryPatch, body: entryPatch,
handler: async ({ deps, user, params, body, actor }) => { handler: async ({ deps, user, params, body }) => {
const cur = await getEntry(deps.db, user.id, params.id!); const cur = await getEntry(deps.db, user.id, params.id!);
await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], actor); await upsertEntry(deps.db, user.id, cur.workId, body as Parameters<typeof upsertEntry>[3], "user");
return getEntry(deps.db, user.id, params.id!); return getEntry(deps.db, user.id, params.id!);
}, },
}), }),
@@ -71,8 +71,8 @@ export const entryRoutes = [
path: "entries/:id", path: "entries/:id",
summary: "Soft-delete an entry.", summary: "Soft-delete an entry.",
auth: "any:write", auth: "any:write",
handler: async ({ deps, user, params, actor }) => { handler: async ({ deps, user, params }) => {
await softDeleteEntry(deps.db, user.id, params.id!, actor); await softDeleteEntry(deps.db, user.id, params.id!, "user");
return noContent(); return noContent();
}, },
}), }),
@@ -99,8 +99,8 @@ export const entryRoutes = [
summary: "Move an entry to another work (for you only).", summary: "Move an entry to another work (for you only).",
auth: "any:write", auth: "any:write",
body: z.object({ workId: z.string().min(1) }).strict(), body: z.object({ workId: z.string().min(1) }).strict(),
handler: async ({ deps, user, params, body, actor }) => { handler: async ({ deps, user, params, body }) => {
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, actor); const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user");
return getEntry(deps.db, user.id, moved.id); return getEntry(deps.db, user.id, moved.id);
}, },
}), }),
+9 -9
View File
@@ -25,7 +25,7 @@ export const listRoutes = [
summary: "Create a list.", summary: "Create a list.",
auth: "any:write", auth: "any:write",
body: listPatch.required({ name: true }), body: listPatch.required({ name: true }),
handler: async ({ deps, user, body, actor }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }, actor), 201), handler: async ({ deps, user, body }) => json(await createList(deps.db, user.id, body as ListPatchArg & { name: string }), 201),
}), }),
route({ route({
method: "GET", method: "GET",
@@ -40,15 +40,15 @@ export const listRoutes = [
summary: "Edit a list.", summary: "Edit a list.",
auth: "any:write", auth: "any:write",
body: listPatch, body: listPatch,
handler: async ({ deps, user, params, body, actor }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg, actor), handler: async ({ deps, user, params, body }) => updateList(deps.db, user.id, params.id!, body as ListPatchArg),
}), }),
route({ route({
method: "DELETE", method: "DELETE",
path: "lists/:id", path: "lists/:id",
summary: "Soft-delete a list.", summary: "Soft-delete a list.",
auth: "any:write", auth: "any:write",
handler: async ({ deps, user, params, actor }) => { handler: async ({ deps, user, params }) => {
await softDeleteList(deps.db, user.id, params.id!, actor); await softDeleteList(deps.db, user.id, params.id!);
return noContent(); return noContent();
}, },
}), }),
@@ -58,7 +58,7 @@ export const listRoutes = [
summary: "Add a work to a list.", summary: "Add a work to a list.",
auth: "any:write", 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(), 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, actor }) => json(await addListItem(deps.db, user.id, params.id!, body, actor), 201), handler: async ({ deps, user, params, body }) => json(await addListItem(deps.db, user.id, params.id!, body), 201),
}), }),
route({ route({
method: "PATCH", method: "PATCH",
@@ -66,8 +66,8 @@ export const listRoutes = [
summary: "Move a list item to a new 0-based position.", summary: "Move a list item to a new 0-based position.",
auth: "any:write", auth: "any:write",
body: z.object({ position: z.number().int().min(0) }).strict(), body: z.object({ position: z.number().int().min(0) }).strict(),
handler: async ({ deps, user, params, body, actor }) => { handler: async ({ deps, user, params, body }) => {
await moveListItem(deps.db, user.id, params.id!, body.position, actor); await moveListItem(deps.db, user.id, params.id!, body.position);
return noContent(); return noContent();
}, },
}), }),
@@ -76,8 +76,8 @@ export const listRoutes = [
path: "list-items/:id", path: "list-items/:id",
summary: "Remove an item from a list.", summary: "Remove an item from a list.",
auth: "any:write", auth: "any:write",
handler: async ({ deps, user, params, actor }) => { handler: async ({ deps, user, params }) => {
await removeListItem(deps.db, user.id, params.id!, actor); await removeListItem(deps.db, user.id, params.id!);
return noContent(); return noContent();
}, },
}), }),
+2 -1
View File
@@ -4,6 +4,7 @@ import {
devicePoll, devicePoll,
deviceStart, deviceStart,
getConnection, getConnection,
isDeletedConnection,
listConnections, listConnections,
listRuns, listRuns,
updateConnection, updateConnection,
@@ -47,7 +48,7 @@ export const sourceRoutes = [
path: "sources", path: "sources",
summary: "Your source connections (never includes secrets).", summary: "Your source connections (never includes secrets).",
auth: "any:read", auth: "any:read",
handler: async ({ deps, user }) => listConnections(deps.db, user.id), handler: async ({ deps, user }) => (await listConnections(deps.db, user.id)).filter((c) => !isDeletedConnection(c)),
}), }),
route({ route({
method: "POST", method: "POST",
+6 -144
View File
@@ -23,15 +23,15 @@ interface CallOpts {
token?: string; token?: string;
headers?: Record<string, string>; headers?: Record<string, string>;
form?: FormData; 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> { async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
const headers: Record<string, string> = { ...o.headers }; const headers: Record<string, string> = { ...o.headers };
if (o.cookie) headers.cookie = o.cookie; if (o.cookie) {
headers.cookie = o.cookie;
headers["x-requested-with"] ??= "sttracker";
}
if (o.token) headers.authorization = `Bearer ${o.token}`; if (o.token) headers.authorization = `Bearer ${o.token}`;
else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker";
let body: BodyInit | undefined; let body: BodyInit | undefined;
if (o.form) body = o.form; if (o.form) body = o.form;
else if (o.body !== undefined) { else if (o.body !== undefined) {
@@ -184,16 +184,8 @@ forEachDialect("api", (getDb) => {
test("cookie-authenticated writes need the CSRF header", async () => { test("cookie-authenticated writes need the CSRF header", async () => {
const cookie = await signUp(app, "ana"); const cookie = await signUp(app, "ana");
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, noCsrf: true }); const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } });
expect(res.status).toBe(403); 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 () => { test("login is rate limited to 10 per minute", async () => {
@@ -272,140 +264,16 @@ forEachDialect("api", (getDb) => {
expect(got.headers.get("x-content-type-options")).toBe("nosniff"); expect(got.headers.get("x-content-type-options")).toBe("nosniff");
expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"'); 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(); const form2 = new FormData();
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" })); form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
form2.set("entity", "entry"); form2.set("entity", "entry");
form2.set("entityId", bobs.id); form2.set("entityId", "0190f000-0000-7000-8000-000000000000");
expect((await call(app, "POST", "attachments", { cookie: ana, form: form2 })).status).toBe(404); 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, "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); 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("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 () => { test("openapi.json lists /api/v1/entries", async () => {
const res = await call(app, "GET", "openapi.json"); const res = await call(app, "GET", "openapi.json");
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -413,12 +281,6 @@ forEachDialect("api", (getDb) => {
expect(doc.openapi).toStartWith("3."); expect(doc.openapi).toStartWith("3.");
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy(); expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
expect(doc.paths["/api/v1/entries/{id}"]?.patch).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 () => { test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
+1 -15
View File
@@ -1,15 +1 @@
<!doctype html> <!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
<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>
-258
View File
@@ -1,258 +0,0 @@
/**
* 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: string;
lastRunAt: string | null;
hasCredentials: boolean;
}
export interface ChangeEvent {
id: string;
type: string;
status: string;
connectionId: string | null;
targetEntity: string;
targetId: string | null;
payload: Record<string, unknown>;
createdAt: string;
}
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: "text" | "long_text" | "number" | "date" | "boolean" | "single_select" | "multi_select" | "url" | "rating";
options: string[];
}
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"),
changes: () => request<Page<ChangeEvent>>("GET", "changes", { query: { limit: 200 } }),
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
@@ -1,18 +0,0 @@
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))` }} />;
}
-551
View File
@@ -1,551 +0,0 @@
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,
draftIncoming,
draftSettled,
draftStartSave,
engagementNouns,
formatDateRange,
formatPartialDate,
formatRating,
friendlyError,
newDraft,
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 [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>(() => newDraft(entry.review ?? ""));
// Saves in flight, oldest first. The entry-patch scope runs them serially, so they settle in order.
const saves = useRef<{ was: string; stored: string }[]>([]);
const patch = useEntryPatch(entry.id, {
onSettled: (_p, error) => {
const save = saves.current.shift();
if (!save) return;
setDraft((d) => draftSettled(d, save, error === null));
setSaved(error === null && saves.current.length === 0);
},
});
useEffect(() => setDraft((d) => draftIncoming(d, entry.review ?? "")), [entry.review]);
const save = () => {
const next = draft.text.trim() === "" ? null : draft.text;
const stored = next ?? "";
if (stored === draft.synced && stored === (entry.review ?? "")) return;
const s = { was: draft.synced, stored };
saves.current.push(s);
setDraft((d) => draftStartSave(d, s));
patch.mutate({ review: next });
};
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>
);
}
-187
View File
@@ -1,187 +0,0 @@
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
@@ -1,117 +0,0 @@
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
@@ -1,21 +0,0 @@
/** 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
@@ -1,102 +0,0 @@
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>
);
}
-79
View File
@@ -1,79 +0,0 @@
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>
);
}
-148
View File
@@ -1,148 +0,0 @@
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
@@ -1,42 +0,0 @@
/**
* 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
@@ -1,93 +0,0 @@
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
@@ -1,93 +0,0 @@
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.
-314
View File
@@ -1,314 +0,0 @@
/** 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. `pending` counts saves in flight
* (one entry's saves run serially, so they settle in order). While any is pending, incoming server
* values are only remembered in `incoming`. Values that are our own saves' echoes (optimistic updates
* show a save's `stored`, rollbacks show its `was`) are listed in `echoes` and ignored; any other
* value (an outside change, a Restore) is applied once the saves settle. So the outcome does not
* depend on the order in which rollbacks, refetches and settle callbacks land.
*/
export interface Draft {
text: string;
synced: string;
pending: number;
incoming: string;
echoes: string[];
}
export function newDraft(value: string): Draft {
return { text: value, synced: value, pending: 0, incoming: value, echoes: [] };
}
/** A server value arrived. Untouched text follows it; edited text is kept. Deferred while saving. */
export function draftIncoming(d: Draft, incoming: string): Draft {
if (d.pending > 0 || incoming === d.synced) return { ...d, incoming };
return { ...d, text: d.text === d.synced ? incoming : d.text, synced: incoming, incoming };
}
/** A save of `stored` started (`was` is the synced value before it): it counts as synced unless it fails. */
export function draftStartSave(d: Draft, save: { was: string; stored: string }): Draft {
return { ...d, synced: save.stored, pending: d.pending + 1, echoes: [...d.echoes, save.was, save.stored] };
}
/**
* The oldest pending save settled. Success: `stored` is on the server. Failure: back to `was`, so the
* local text still counts as unsaved. When the last pending save settles, a remembered incoming value
* that is not one of our own echoes is applied.
*/
export function draftSettled(d: Draft, save: { was: string; stored: string }, ok: boolean): Draft {
const pending = Math.max(0, d.pending - 1);
const next: Draft = { ...d, synced: ok ? save.stored : save.was, pending };
if (pending > 0) return next;
const settled: Draft = { ...next, echoes: [] };
return d.echoes.includes(d.incoming) ? settled : draftIncoming(settled, d.incoming);
}
-81
View File
@@ -1,81 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, 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.
*
* `onSettled` fires once for EVERY mutation of this hook (it is a hook-level option). Per-call
* `mutate(patch, { onSuccess })` callbacks only fire for the observer's latest call, so never use
* them for bookkeeping.
*/
export function useEntryPatch(id: string, opts: { onSettled?: (patch: EntryPatch, error: Error | null) => void } = {}) {
const qc = useQueryClient();
const onSettledRef = useRef(opts.onSettled);
onSettledRef.current = opts.onSettled;
const key = ["entry", id];
const mutationKey = ["entry-patch", id];
const m = useMutation({
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: (_entry, error, patch) => {
void qc.invalidateQueries({ queryKey: ["entries"] });
void qc.invalidateQueries({ queryKey: ["history", id] });
onSettledRef.current?.(patch, error);
},
});
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
const cur = qc.getQueryData<EntryView>(key);
const patch = cur ? build(cur) : null;
if (patch) m.mutate(patch);
};
return { ...m, edit };
}
-74
View File
@@ -1,74 +0,0 @@
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 { Library } from "./routes/library.tsx";
import { Login } from "./routes/login.tsx";
import { NotYet } from "./routes/not-yet.tsx";
import { Register } from "./routes/register.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="/:page/*?">{(p) => <NotYet page={p.page} />}</Route>
</Switch>
</Shell>
</RequireAuth>
</Route>
</Switch>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
-238
View File
@@ -1,238 +0,0 @@
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 { 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 ? (
<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 ? (
<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">
{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>
);
}
-56
View File
@@ -1,56 +0,0 @@
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
@@ -1,24 +0,0 @@
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
@@ -1,97 +0,0 @@
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>
);
}
-13
View File
@@ -1,13 +0,0 @@
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";
}
-394
View File
@@ -1,394 +0,0 @@
/* 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; }
}
-92
View File
@@ -1,92 +0,0 @@
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
@@ -1,44 +0,0 @@
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"]);
});
});
-148
View File
@@ -1,148 +0,0 @@
import { describe, expect, test } from "bun:test";
import { draftIncoming, draftSettled, draftStartSave, newDraft, type Draft, formatDateRange, formatPartialDate, formatRating, friendlyError, parsePartialDate, rollupLabel, shortDate } from "../src/format.ts";
describe("formatRating", () => {
test("5 stars with halves", () => {
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 draft = (text: string, synced: string): Draft => ({ ...newDraft(synced), text });
const pick = (d: Draft) => [d.text, d.synced, d.pending];
test("a rollback after a failed save keeps the local text", () => {
const save = { was: "First thoughts.", stored: "Second thoughts." };
let d = draftStartSave(draft("Second thoughts.", save.was), save);
d = draftIncoming(d, save.stored); // optimistic update
d = draftIncoming(d, save.was); // rollback
d = draftSettled(d, save, false);
expect(pick(d)).toEqual(["Second thoughts.", "First thoughts.", 0]);
});
// Two overlapping saves: "a" (was "old"), then "b" (was "a"). Incoming values mirror what the cache does.
const A = { was: "old", stored: "a" };
const B = { was: "a", stored: "b" };
const twoSaves = () => draftIncoming(draftIncoming(draftStartSave({ ...draftStartSave(draft("a", "old"), A), text: "b" }, B), "a"), "b");
test("two overlapping saves both settle and the flag clears (success, success)", () => {
let d = twoSaves();
expect(d.pending).toBe(2);
d = draftSettled(draftSettled(d, A, true), B, true);
expect(pick(d)).toEqual(["b", "b", 0]);
expect(d.echoes).toEqual([]);
});
test("two overlapping saves: fail then success ends synced; success then fail keeps text unsaved", () => {
let d = draftIncoming(twoSaves(), "old"); // A's rollback
d = draftSettled(draftSettled(d, A, false), B, true);
expect(pick(d)).toEqual(["b", "b", 0]);
let e = draftIncoming(twoSaves(), "a"); // B's rollback
e = draftSettled(draftSettled(e, A, true), B, false);
expect(pick(e)).toEqual(["b", "a", 0]);
});
test("an incoming value that arrives while saving is applied once saving ends", () => {
const save = { was: "x", stored: "y" };
let d = draftStartSave(draft("y", "x"), save);
d = draftIncoming(d, "Restored.");
expect(d.text).toBe("y");
d = draftSettled(d, save, true);
expect(pick(d)).toEqual(["Restored.", "Restored.", 0]);
});
test("a change from elsewhere replaces untouched text but not edited text", () => {
expect(draftIncoming(draft("Second.", "Second."), "First.").text).toBe("First.");
expect(draftIncoming(draft("Typed.", "Second."), "Other.").text).toBe("Typed.");
});
});
+5 -7
View File
@@ -15,8 +15,8 @@ user layer or files a Changes-inbox event.
| Path | Responsibility | State | | 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/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/<area>.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists |
| `apps/web` | React 19 app served by the server as an HTML import (`index.html` → `src/main.tsx`, `src/theme.css`). wouter routes in `main.tsx` (`/login`, `/register`, `/`, `/entries/:id`; other sections are placeholders until Task 18); `api.ts` = fetch client (`request`, `ApiError`, cookie auth, CSRF header, mirrored wire types); `format.ts` = pure display helpers and `friendlyError`; `edits.ts` = next-value builders for entry edits; `hooks.ts` = TanStack Query hooks incl. optimistic `useEntryPatch`; `settings.ts` = preferences; `components/` (shell, detail boxes, history drawer, rating control) and `routes/`. Never imports `packages/*` at runtime | exists | | `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`, `preferences` (per-user rating scale and default visibility), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) | | `packages/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) |
| `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) | | `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 | | `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 | | `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
@@ -26,11 +26,9 @@ user layer or files a Changes-inbox event.
HTTP request (`apps/server`): HTTP request (`apps/server`):
1. `Bun.serve` routes `/api/*` and `/ingest/*` to `app.fetch`; everything else gets the web app (HTML import). 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 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), requires `X-Requested-With: sttracker` on (`session` cookie `stt_session`, Bearer app token with a scope, or either), checks `X-Requested-With: sttracker` on
every non-Bearer write (login included), reads the body (JSON ≤ 1 MB with `Content-Type: application/json`, cookie-authenticated writes, reads the body (JSON ≤ 1 MB, multipart ≤ 50 MB) and validates it with zod.
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
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`. (`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. 4. `POST sources/:id/run|import` only enqueue a `sync` job (lockKey `sync:<id>`, 409 when held); the worker runs it.
+34 -65
View File
@@ -4,49 +4,18 @@ 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. 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). No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
## 2026-10-30: Web app (Task 17): preferences, first run, optimistic edits, AA on orange, self-hosted fonts ## 2026-10-30: Deployment: one image on `oven/bun:1.3`, source run directly, compose with an optional Postgres profile
**Status:** accepted **Status:** accepted
**Context:** Task 17 and its fix round 1 (controller rulings). **Context:** Task 19 packages the server. `Bun.serve` bundles the `apps/web` HTML import at runtime, so there is no build step to ship.
**Decision:** **Decision:**
- **Three small API routes for the web app.** `GET auth/status` (no auth) → `{ hasUsers }`: `/login` redirects a fresh - The image is `oven/bun:1.3` (tag, not digest) running `bun apps/server/src/main.ts` from source, as the non-root `bun` user.
instance to `/register`, which shows "Create the admin account", or invite mode when users exist (also on It installs production dependencies only and copies `apps/web` too, because the server bundles it at startup.
`invite_required`, and with `?invite=` prefilled). `GET attachments?entity=&entityId=` lists an owned record's - `/data` is the volume (`DATA_DIR=/data`): SQLite file, attachments, backups, user plugins. It is created and owned by `bun` in the image.
attachments with their links (404 when the record is not the caller's). `GET/PATCH me/preferences` → - `docker-compose.yml` has the `sttracker` service (`./data:/data`, `env_file: .env`) and a `postgres:17-alpine` service under
`{ ratingScale: "5half"|"5"|"10"|"100", defaultVisibility }`, stored in `users.preferences` under the same keys the `postgres` profile with a named volume. SQLite stays the default.
`defaultVisibility()` already reads (`packages/core/src/preferences.ts`); unknown stored keys are kept. - CI is `.gitea/workflows/ci.yml` (GitHub-compatible syntax): typecheck, then `bun test` against a Postgres service, plus the `AGENTS.md` 150-line guard.
- **Rating scale** comes from preferences (default five stars with halves). Stored ratings stay integers 0–100; the web **Alternatives:** A compiled single binary (`bun build --compile`) was rejected: user plugins load from disk at runtime and the web bundle is built on demand.
formats them with `formatRating(value, scale)`. **Consequences:** Image start time includes bundling the web app. Agent permission files for the new Docker commands are pending the owner (`docs/TODO.md`).
- **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 ## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor
**Status:** accepted **Status:** accepted
@@ -203,7 +172,7 @@ are the mechanics a maintainer would otherwise reverse-engineer.
Ids are remapped (UUIDs inside history `before`/`after` and custom-field value keys too). Rows are inserted directly, so no Ids are remapped (UUIDs inside history `before`/`after` and custom-field value keys too). Rows are inserted directly, so no
new history is written. The import validates first (attachment mime/size/sha256, rating 0-100, status, visibility, 2 GB new history is written. The import validates first (attachment mime/size/sha256, rating 0-100, status, visibility, 2 GB
uncompressed cap) and writes all rows, attachments included, in one transaction; errors are uncompressed cap) and writes all rows, attachments included, in one transaction; errors are
`invalid_attachment`, `corrupt_bundle`, `bundle_too_large`, `conflict`. Works merged away at export time are exported as the tombstone row without `mergedIntoId`. `invalid_attachment`, `corrupt_bundle`, `bundle_too_large`, `conflict`. Works merged away at export time are exported as the tombstone row with its `mergedIntoId`.
- **Backups** write `<dataDir>/backups/<ISO with ':'->'-'>/<username>.zip` per user plus `sttracker.db` (`VACUUM INTO`, SQLite only), - **Backups** write `<dataDir>/backups/<ISO with ':'->'-'>/<username>.zip` per user plus `sttracker.db` (`VACUUM INTO`, SQLite only),
are built in `<stamp>.tmp` then renamed (modes 0700/0600), and prune to the newest `keep` (default 14). are built in `<stamp>.tmp` then renamed (modes 0700/0600), and prune to the newest `keep` (default 14).
**Consequences:** Any change to these files after release needs `v2` and a migration path for old bundles. **Consequences:** Any change to these files after release needs `v2` and a migration path for old bundles.
@@ -252,6 +221,29 @@ Several behaviours are not obvious from the spec and would otherwise have to be
**Consequences:** Undoing a move does not move copies or list items back, and hierarchy deeper than three **Consequences:** Undoing a move does not move copies or list items back, and hierarchy deeper than three
levels is not rolled up (both in `docs/TODO.md`). levels is not rolled up (both in `docs/TODO.md`).
## 2026-10-30: Catalogue matching is race-safe through unique keys plus whole-transaction retry
**Status:** accepted
**Context:** Two syncs matching the same new work at once both miss the lookup and both insert. On
Postgres the loser hit the `external_ids (namespace, value)` unique key and crashed the sync; seasons and
episodes had no key at all and were duplicated.
**Decision:**
- Migration `0002_works_position_unique` adds a unique index on `works (parent_id, position)`. NULLs are
distinct on both dialects, so top-level works and position-less children are unaffected.
- `matchWork` re-runs the whole resolve in a fresh transaction on a unique violation (SQLite
"UNIQUE constraint failed", Postgres SQLSTATE 23505), up to 3 attempts. Postgres aborts the transaction
on the error, so a statement-level retry is not possible.
- Adding missing external IDs to an existing work uses `ON CONFLICT DO NOTHING`.
- An ID match only counts if the work is shared or owned by the matching user, and private works can
never carry external IDs (`createWork` refuses; global merges accept only shared works).
- A global merge repoints children of `from` to `to` unless `to` already has a child at that position.
**Alternatives:**
- Advisory or table locks: dialect-specific, and SQLite already serialises writes.
- Upserts throughout `createWork`: would hide genuine conflicts and need per-dialect `RETURNING` handling.
**Consequences:** Parents resolved without IDs or position (title/year reuse) still have no unique key
and can be duplicated under concurrency; tracked in `docs/TODO.md`.
## 2026-10-30: A single Kysely dialect over Bun.SQL for SQLite and Postgres ## 2026-10-30: A single Kysely dialect over Bun.SQL for SQLite and Postgres
**Status:** accepted **Status:** accepted
**Context:** Kysely ships no driver for Bun. `Bun.SQL` speaks both `sqlite://` and `postgres://` behind one API. **Context:** Kysely ships no driver for Bun. `Bun.SQL` speaks both `sqlite://` and `postgres://` behind one API.
@@ -329,26 +321,3 @@ instead of 26. Where `AGENTS.md` or the spec say "ULID", read "UUIDv7".
**Consequences:** Rule changes happen in one file, and the pointer files must stay thin. Plans are working **Consequences:** Rule changes happen in one file, and the pointer files must stay thin. Plans are working
artefacts: kept after merge, they go stale and contradict the code, so the spec is the record. artefacts: kept after merge, they go stale and contradict the code, so the spec is the record.
## 2026-10-30: Catalogue matching is race-safe through unique keys plus whole-transaction retry
**Status:** accepted
**Context:** Two syncs matching the same new work at once both miss the lookup and both insert. On
Postgres the loser hit the `external_ids (namespace, value)` unique key and crashed the sync; seasons and
episodes had no key at all and were duplicated.
**Decision:**
- Migration `0002_works_position_unique` adds a unique index on `works (parent_id, position)`. NULLs are
distinct on both dialects, so top-level works and position-less children are unaffected.
- `matchWork` re-runs the whole resolve in a fresh transaction on a unique violation (SQLite
"UNIQUE constraint failed", Postgres SQLSTATE 23505), up to 3 attempts. Postgres aborts the transaction
on the error, so a statement-level retry is not possible.
- Adding missing external IDs to an existing work uses `ON CONFLICT DO NOTHING`.
- An ID match only counts if the work is shared or owned by the matching user, and private works can
never carry external IDs (`createWork` refuses; global merges accept only shared works).
- A global merge repoints children of `from` to `to` unless `to` already has a child at that position.
**Alternatives:**
- Advisory or table locks: dialect-specific, and SQLite already serialises writes.
- Upserts throughout `createWork`: would hide genuine conflicts and need per-dialect `RETURNING` handling.
**Consequences:** Parents resolved without IDs or position (title/year reuse) still have no unique key
and can be duplicated under concurrency; tracked in `docs/TODO.md`.
+1 -6
View File
@@ -11,16 +11,12 @@ Small, specific, independently actionable. Remove only when actually done.
## Next ## Next
- [ ] API: response schemas in the OpenAPI document (requests only today). - [ ] 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: 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: 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. - [ ] 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). - [ ] Phase 2 and Phase 3 plans (spec §12).
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS). - [ ] 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 ## Someday
@@ -35,7 +31,6 @@ Small, specific, independently actionable. Remove only when actually done.
file, and compare steps and token use. file, and compare steps and token use.
## Known issues ## 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: 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: 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. - [ ] Plugins: `ctx.log` reaches `source_runs.log` only after the run ends, so a crash mid-run loses it.
+4 -17
View File
@@ -111,11 +111,6 @@ export async function login(
return { user: toUser(row), sessionToken }; 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> { export async function getSessionUser(db: Db, token: string): Promise<User | null> {
const row = await db.k const row = await db.k
.selectFrom("sessions") .selectFrom("sessions")
@@ -175,17 +170,11 @@ export async function createAppToken(
} }
export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise<User | null> { 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 const row = await db.k
.selectFrom("app_tokens") .selectFrom("app_tokens")
.innerJoin("users", "users.id", "app_tokens.user_id") .innerJoin("users", "users.id", "app_tokens.user_id")
.select([ .select([
"app_tokens.id as token_id", "app_tokens.id as token_id",
"app_tokens.name as token_name",
"app_tokens.scopes", "app_tokens.scopes",
"users.id", "users.id",
"users.username", "users.username",
@@ -197,19 +186,17 @@ export async function authenticateAppToken(db: Db, token: string, scope: TokenSc
.executeTakeFirst(); .executeTakeFirst();
if (!row || !fromJson<string[]>(row.scopes, []).includes(scope)) return null; 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(); await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute();
return { user: toUser(row), tokenName: row.token_name }; return toUser(row);
} }
/** 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<void> {
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<boolean> { await db.k
const r = await db.k
.updateTable("app_tokens") .updateTable("app_tokens")
.set({ revoked_at: nowIso() }) .set({ revoked_at: nowIso() })
.where("id", "=", id) .where("id", "=", id)
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where("revoked_at", "is", null) .where("revoked_at", "is", null)
.executeTakeFirst(); .execute();
return Number(r.numUpdatedRows) === 1;
} }
export interface AppTokenInfo { export interface AppTokenInfo {
+12 -22
View File
@@ -77,7 +77,6 @@ export async function createConnection(db: Db, secret: string, userId: string, i
status: "active", status: "active",
last_run_at: null, last_run_at: null,
created_at: nowIso(), created_at: nowIso(),
deleted_at: null,
}) })
.execute(); .execute();
return id; return id;
@@ -95,7 +94,7 @@ export interface ConnectionPatch {
export async function updateConnection(db: Db, secret: string, userId: string, id: string, patch: ConnectionPatch, manifest: PluginManifest): Promise<void> { export async function updateConnection(db: Db, secret: string, userId: string, id: string, patch: ConnectionPatch, manifest: PluginManifest): Promise<void> {
checkSchedule(patch.scheduleMinutes); checkSchedule(patch.scheduleMinutes);
await db.k.transaction().execute(async (tx) => { await db.k.transaction().execute(async (tx) => {
let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).where("deleted_at", "is", null); let q = tx.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId);
if (db.dialect === "postgres") q = q.forUpdate(); if (db.dialect === "postgres") q = q.forUpdate();
const row = await q.executeTakeFirst(); const row = await q.executeTakeFirst();
if (!row) throw new NotFoundError("connection"); if (!row) throw new NotFoundError("connection");
@@ -117,27 +116,20 @@ export async function updateConnection(db: Db, secret: string, userId: string, i
}); });
} }
/** Soft delete: `deleted_at` is set, the connection is paused and its credentials are wiped. Records and runs stay. */ /** Soft delete: 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> { export async function deleteConnection(db: Db, userId: string, id: string): Promise<void> {
const r = await db.k const r = await db.k
.updateTable("source_connections") .updateTable("source_connections")
.set({ status: "paused", credentials_enc: null, deleted_at: nowIso() }) .set({ status: "paused", credentials_enc: null })
.where("id", "=", id) .where("id", "=", id)
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where("deleted_at", "is", null)
.executeTakeFirst(); .executeTakeFirst();
if (r.numUpdatedRows !== 1n) throw new NotFoundError("connection"); if (r.numUpdatedRows !== 1n) throw new NotFoundError("connection");
} }
/** The user's live (not deleted) connections. Never returns secrets: plain config plus `hasCredentials`. */ /** Never returns secrets: plain config plus `hasCredentials`. */
export async function listConnections(db: Db, userId: string): Promise<ConnectionView[]> { export async function listConnections(db: Db, userId: string): Promise<ConnectionView[]> {
const rows = await db.k const rows = await db.k.selectFrom("source_connections").selectAll().where("user_id", "=", userId).orderBy("created_at").execute();
.selectFrom("source_connections")
.selectAll()
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.orderBy("created_at")
.execute();
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, id: r.id,
pluginId: r.plugin_id, pluginId: r.plugin_id,
@@ -151,15 +143,14 @@ 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) { async function ownedRow(db: Db, userId: string, id: string) {
const r = await db.k const r = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).executeTakeFirst();
.selectFrom("source_connections") if (!r || (r.status === "paused" && r.credentials_enc === null)) throw new NotFoundError("connection");
.selectAll()
.where("id", "=", id)
.where("user_id", "=", userId)
.where("deleted_at", "is", null)
.executeTakeFirst();
if (!r) throw new NotFoundError("connection");
return r; return r;
} }
@@ -351,7 +342,6 @@ export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?:
const { db } = deps; const { db } = deps;
const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst(); const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst();
if (!full) throw new DropJobError("connection not found"); 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"); if (full.status === "paused") throw new DropJobError("connection is paused");
const plugin = deps.registry.get(full.plugin_id); 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.`); if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`);
+3 -3
View File
@@ -15,7 +15,7 @@ import {
toStoredDate, toStoredDate,
} from "./entries.ts"; } from "./entries.ts";
import { NotFoundError, ValidationError } from "./errors.ts"; import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts"; import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
/** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */ /** One time through a work (spec §6.3). Dates are partial; `null` means unknown. */
export interface EngagementPatch { export interface EngagementPatch {
@@ -144,7 +144,7 @@ async function write(
): Promise<Engagement> { ): Promise<Engagement> {
const cols = await toColumns(k, userId, mediaType, patch); const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources = { ...current.fieldSources }; const fieldSources = { ...current.fieldSources };
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor; for (const key of Object.keys(patch)) fieldSources[key] = actor;
await k await k
.updateTable("engagements") .updateTable("engagements")
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() }) .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 { mediaType } = await requireEntry(k, userId, entryId);
const cols = await toColumns(k, userId, mediaType, patch); const cols = await toColumns(k, userId, mediaType, patch);
const fieldSources: FieldSources = {}; const fieldSources: FieldSources = {};
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor; for (const key of Object.keys(patch)) fieldSources[key] = actor;
const now = nowIso(); const now = nowIso();
const row: Row = { const row: Row = {
id: newId(), id: newId(),
+5 -8
View File
@@ -7,7 +7,7 @@ import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types"; import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types";
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts"; import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
import { NotFoundError, ValidationError } from "./errors.ts"; import { NotFoundError, ValidationError } from "./errors.ts";
import { type Actor, type Executor, fieldOwner, inTx, recordHistory } from "./history.ts"; import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// Shared user-layer helpers (also used by engagements, lists and copies). // Shared user-layer helpers (also used by engagements, lists and copies).
@@ -320,7 +320,7 @@ async function writeEntry(
): Promise<Entry> { ): Promise<Entry> {
const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources }; const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources };
for (const key of Object.keys(patch)) { for (const key of Object.keys(patch)) {
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : (fieldOwner(actor) as Actor); fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor;
if (fieldSources[key] === undefined) delete fieldSources[key]; if (fieldSources[key] === undefined) delete fieldSources[key];
} }
const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() }; const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() };
@@ -985,17 +985,14 @@ export interface MatchSuggestion {
createdAt: string; 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[]> { export async function listSuggestions(db: Db, userId: string, workId: string): Promise<MatchSuggestion[]> {
const work = await resolveVisibleWork(db.k, userId, workId); await resolveVisibleWork(db.k, userId, workId);
const rows = await db.k const rows = await db.k
.selectFrom("match_suggestions") .selectFrom("match_suggestions")
.selectAll() .selectAll()
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where("work_id", "=", work.id) .where("work_id", "=", workId)
.where("status", "=", "pending") .where("status", "=", "pending")
.orderBy("score", "desc") .orderBy("score", "desc")
.orderBy("id") .orderBy("id")
-9
View File
@@ -12,15 +12,6 @@ import { restoreListFromHistory } from "./lists.ts";
/** Who made a change. Plugin actors are `plugin:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */ /** Who made a change. Plugin actors are `plugin:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`; 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`. */ /** User-layer entities that write `user_history`. */
export type HistoryEntity = "entry" | "engagement" | "copy" | "list"; export type HistoryEntity = "entry" | "engagement" | "copy" | "list";
-1
View File
@@ -198,7 +198,6 @@ export function startScheduler(db: Db, opts: { tickMs?: number } = {}): { stop()
.selectFrom("source_connections") .selectFrom("source_connections")
.select(["id", "schedule_minutes", "last_run_at"]) .select(["id", "schedule_minutes", "last_run_at"])
.where("status", "!=", "paused") .where("status", "!=", "paused")
.where("deleted_at", "is", null)
.where("schedule_minutes", "is not", null) .where("schedule_minutes", "is not", null)
.execute(); .execute();
const now = Date.now(); const now = Date.now();
-54
View File
@@ -1,54 +0,0 @@
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 type { EngagementFields, EntryFields, PartialDate, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts"; import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts";
import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts"; import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts";
import { type Actor, type Executor, fieldOwner } from "../history.ts"; import type { Actor, Executor } from "../history.ts";
import { createList, replaceListItems, updateList } from "../lists.ts"; import { createList, replaceListItems, updateList } from "../lists.ts";
import { matchWork } from "../matching.ts"; import { matchWork } from "../matching.ts";
import { resolveAliasIn, updateWorkFromHintIn } from "../works.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 }; const fs: Record<string, string> = { items: sourceActor };
for (const r of rows) { for (const r of rows) {
const after = fromJson<Fields>(r.after, {}); const after = fromJson<Fields>(r.after, {});
for (const f of ["name", "description"]) if (f in after) fs[f] = fieldOwner(r.actor); for (const f of ["name", "description"]) if (f in after) fs[f] = r.actor;
if (fieldOwner(r.actor) === "user") fs.items = "user"; if (r.actor === "user") fs.items = "user";
} }
return fs; return fs;
} }
+4 -113
View File
@@ -6,14 +6,8 @@ import { newId, nowIso } from "@sttracker/db/ids";
import { forEachDialect } from "@sttracker/db/testing"; import { forEachDialect } from "@sttracker/db/testing";
import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types"; import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types";
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts"; import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts";
import { authenticateAppToken, createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts"; import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
import { getHistory } from "../src/history.ts"; import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listRuns, syncHandler } from "../src/connections.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 { listSuggestions, quickAdd } from "../src/entries.ts";
import { addEngagement } from "../src/engagements.ts"; import { addEngagement } from "../src/engagements.ts";
import { NotFoundError, ValidationError } from "../src/errors.ts"; import { NotFoundError, ValidationError } from "../src/errors.ts";
@@ -43,21 +37,7 @@ const fileish: SourcePlugin = definePlugin({
poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"), poll: async (_ctx, deviceCode) => (deviceCode === "dc" ? { access_token: "tok" } : "pending"),
}, },
}); });
let flipStatus: "planned" | "completed" = "planned"; const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} };
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) => { forEachDialect("api support", (getDb) => {
test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => { test("listAppTokens returns metadata only, revoked flagged, scoped to user", async () => {
@@ -66,9 +46,7 @@ forEachDialect("api support", (getDb) => {
const other = await makeUser(db, "bob"); const other = await makeUser(db, "bob");
const t = await createAppToken(db, u, "script", ["read"]); const t = await createAppToken(db, u, "script", ["read"]);
await createAppToken(db, other, "x", ["write"]); await createAppToken(db, other, "x", ["write"]);
expect(await revokeAppToken(db, other, t.id)).toBe(false); await revokeAppToken(db, u, t.id);
expect(await revokeAppToken(db, u, t.id)).toBe(true);
expect(await revokeAppToken(db, u, t.id)).toBe(false);
const list = await listAppTokens(db, u); const list = await listAppTokens(db, u);
expect(list).toHaveLength(1); expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null }); expect(list[0]).toMatchObject({ id: t.id, name: "script", scopes: ["read"], lastUsedAt: null });
@@ -76,53 +54,6 @@ forEachDialect("api support", (getDb) => {
expect(JSON.stringify(list)).not.toContain("hash"); 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 () => { test("listSuggestions lists pending suggestions for a visible work", async () => {
const db = getDb(); const db = getDb();
const u = await makeUser(db); const u = await makeUser(db);
@@ -137,11 +68,6 @@ forEachDialect("api support", (getDb) => {
expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" }); expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" });
const other = await makeUser(db, "bob"); const other = await makeUser(db, "bob");
await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError); 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 () => { test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => {
@@ -188,41 +114,6 @@ forEachDialect("api support", (getDb) => {
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError); 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 () => { test("syncHandler runs file mode from a base64 payload", async () => {
const db = getDb(); const db = getDb();
const u = await makeUser(db); const u = await makeUser(db);
+1 -2
View File
@@ -55,8 +55,7 @@ forEachDialect("connections", (getDb) => {
r = await raw(id); r = await raw(id);
expect(r.status).toBe("paused"); expect(r.status).toBe("paused");
expect(r.credentials_enc).toBeNull(); expect(r.credentials_enc).toBeNull();
expect(r.deleted_at).not.toBeNull(); expect((await listConnections(db, u))[0]!.hasCredentials).toBe(false);
expect(await listConnections(db, u)).toEqual([]);
}); });
test("cursor persists on set; http uses manifest rate limit", async () => { test("cursor persists on set; http uses manifest rate limit", async () => {
@@ -1,13 +0,0 @@
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,11 +1,9 @@
import type { Migration } from "kysely/migration"; import type { Migration } from "kysely/migration";
import { initial } from "./0001_initial.ts"; import { initial } from "./0001_initial.ts";
import { worksPositionUnique } from "./0002_works_position_unique.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. */ /** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */
export const migrations: Record<string, Migration> = { export const migrations: Record<string, Migration> = {
"0001_initial": initial, "0001_initial": initial,
"0002_works_position_unique": worksPositionUnique, "0002_works_position_unique": worksPositionUnique,
"0003_source_connections_deleted_at": sourceConnectionsDeletedAt,
}; };
-2
View File
@@ -269,8 +269,6 @@ export interface SourceConnectionsTable {
status: ConnectionStatus; status: ConnectionStatus;
last_run_at: string | null; last_run_at: string | null;
created_at: string; created_at: string;
/** Set by `deleteConnection`; deleted connections are hidden, never scheduled and never synced. */
deleted_at: string | null;
} }
export interface SourceRunsTable { export interface SourceRunsTable {