Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b76d071d87 |
@@ -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.
|
||||
- Radii: 8px for controls, 10px for boxes, 999px for pills, 3–5px for covers.
|
||||
- Orange means "a platform changed something". Never use it decoratively.
|
||||
- Text on `--warn` (the Changes count, the "See what changed" button) is dark `#1c0f04` (6.4:1), never white: white on `#f97316` is 2.8:1 and fails WCAG AA.
|
||||
- Sidebar group labels ("Sources") use `--text-faint`. The mockup's `#6f69a3` is 3.6:1 on `--surface-0` and fails AA.
|
||||
- Placeholders on `--surface-3` inputs use `--text-muted`: `--text-faint` there is 4.15:1.
|
||||
- Fonts are self-hosted in `apps/web/src/fonts/` (Inter, Gelasio; both OFL). The app makes no third-party font requests.
|
||||
- Drafts (entries with no catalogue link) get a small amber outlined "Draft" badge and a striped placeholder cover.
|
||||
|
||||
## Media type colours (shared by both surfaces)
|
||||
|
||||
@@ -8,7 +8,6 @@ import { engagementRoutes } from "./routes/engagements.ts";
|
||||
import { entryRoutes } from "./routes/entries.ts";
|
||||
import { exportRoutes } from "./routes/export.ts";
|
||||
import { listRoutes } from "./routes/lists.ts";
|
||||
import { logRoutes } from "./routes/log.ts";
|
||||
import { metaRoutes } from "./routes/meta.ts";
|
||||
import { sourceRoutes } from "./routes/sources.ts";
|
||||
import { workRoutes } from "./routes/works.ts";
|
||||
@@ -27,7 +26,6 @@ export function createApp(deps: AppDeps): App {
|
||||
...engagementRoutes,
|
||||
...workRoutes,
|
||||
...listRoutes,
|
||||
...logRoutes,
|
||||
...copyRoutes,
|
||||
...customFieldRoutes,
|
||||
...attachmentRoutes,
|
||||
|
||||
+12
-32
@@ -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 type { Actor } from "@sttracker/core/history";
|
||||
import { isUniqueViolation } from "@sttracker/core/matching";
|
||||
import type { PluginRegistry } from "@sttracker/core/registry";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
@@ -34,8 +33,6 @@ export interface Ctx<B, Q> {
|
||||
query: Q;
|
||||
/** The authenticated user. Throws on `auth: "none"` routes, which have none. */
|
||||
user: User;
|
||||
/** Who writes: `token:<name>` for Bearer requests, `"user"` for sessions. Pass it to every core write. */
|
||||
actor: Actor;
|
||||
/** The session token, when the request was authenticated by cookie. */
|
||||
sessionToken: string | null;
|
||||
/** Parsed multipart body, for `multipart: true` routes. */
|
||||
@@ -54,8 +51,6 @@ export interface RouteDef<B = any, Q = any> {
|
||||
query?: z.ZodType<Q>;
|
||||
/** Parse the body as multipart form data (upload limit) instead of JSON. */
|
||||
multipart?: boolean;
|
||||
/** Non-file multipart fields, for the OpenAPI document (handlers validate them). Every multipart route also takes `file`. */
|
||||
formFields?: z.ZodObject;
|
||||
/** Rate limit per client IP (login, register). */
|
||||
rateLimited?: boolean;
|
||||
/** Only admins may call it (403 otherwise). */
|
||||
@@ -137,13 +132,7 @@ function readCookie(req: Request, name: string): string | null {
|
||||
if (!header) return null;
|
||||
for (const part of header.split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i > 0 && part.slice(0, i).trim() === name) {
|
||||
try {
|
||||
return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
||||
} catch {
|
||||
return null; // malformed percent-encoding: treat as no cookie
|
||||
}
|
||||
}
|
||||
if (i > 0 && part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()) || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -196,20 +185,16 @@ export function rateLimiter(limit: number, windowMs: number): (key: string) => b
|
||||
const SCOPES: TokenScope[] = ["read", "write", "ingest"];
|
||||
|
||||
/** Resolve the caller for `mode`. Returns the user and the session token used, if any. */
|
||||
async function authenticate(
|
||||
req: Request,
|
||||
mode: AuthMode,
|
||||
db: Db,
|
||||
): Promise<{ user: User | null; sessionToken: string | null; actor: Actor }> {
|
||||
if (mode === "none") return { user: null, sessionToken: null, actor: "user" };
|
||||
async function authenticate(req: Request, mode: AuthMode, db: Db): Promise<{ user: User | null; sessionToken: string | null }> {
|
||||
if (mode === "none") return { user: null, sessionToken: null };
|
||||
const authz = req.headers.get("authorization");
|
||||
const bearer = authz?.startsWith("Bearer ") ? authz.slice(7).trim() : null;
|
||||
const [kind, scopeName] = mode.split(":") as ["session" | "token" | "any", TokenScope | undefined];
|
||||
|
||||
if (kind !== "session" && bearer) {
|
||||
const scope = scopeName!;
|
||||
const auth = await authenticateAppToken(db, bearer, scope);
|
||||
if (auth) return { user: auth.user, sessionToken: null, actor: `token:${auth.tokenName}` };
|
||||
const user = await verifyAppToken(db, bearer, scope);
|
||||
if (user) return { user, sessionToken: null };
|
||||
for (const other of SCOPES) {
|
||||
if (other !== scope && (await verifyAppToken(db, bearer, other))) {
|
||||
throw new HttpError(403, "forbidden", `This app token lacks the '${scope}' scope.`);
|
||||
@@ -222,7 +207,11 @@ async function authenticate(
|
||||
const token = readCookie(req, SESSION_COOKIE);
|
||||
const user = token ? await getSessionUser(db, token) : null;
|
||||
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 {
|
||||
@@ -272,13 +261,7 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
||||
if (def.rateLimited && !limit(`${def.path}|${ip}`)) {
|
||||
return errorResponse(429, "rate_limited", "Too many attempts. Wait a minute and try again.", { "retry-after": "60" });
|
||||
}
|
||||
// CSRF: every non-Bearer write (login and register included) must carry this header. Browsers cannot add it
|
||||
// cross-site without a CORS preflight, which the server never grants. Bearer requests are not ambient, so exempt.
|
||||
const bearer = req.headers.get("authorization")?.startsWith("Bearer ") ?? false;
|
||||
if (req.method !== "GET" && req.method !== "HEAD" && !bearer && req.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
||||
throw new HttpError(403, "forbidden", `Browser requests that change data must send the header X-Requested-With: ${CSRF_VALUE}.`);
|
||||
}
|
||||
const { user, sessionToken, actor } = await authenticate(req, def.auth, deps.db);
|
||||
const { user, sessionToken } = await authenticate(req, def.auth, deps.db);
|
||||
if (def.admin && user?.role !== "admin") throw new HttpError(403, "forbidden", "Only an admin can do that.");
|
||||
|
||||
let body: unknown;
|
||||
@@ -296,8 +279,6 @@ export function apiHandler(routes: RouteDef[], deps: AppDeps, prefix = "/api/v1/
|
||||
const bytes = await readLimited(req, MAX_JSON_BYTES);
|
||||
let raw: unknown;
|
||||
if (bytes.byteLength > 0) {
|
||||
const type = (req.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
|
||||
if (type !== "application/json") throw new HttpError(415, "unsupported_media_type", "Send the body as Content-Type: application/json.");
|
||||
try {
|
||||
raw = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
@@ -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`);
|
||||
return user;
|
||||
},
|
||||
actor,
|
||||
sessionToken,
|
||||
get form(): FormData {
|
||||
if (!form) throw new Error(`route ${def.method} ${def.path} is not multipart`);
|
||||
|
||||
@@ -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 server = Bun.serve({
|
||||
port: config.port,
|
||||
// Production unless asked: dev mode adds HMR and verbose error pages.
|
||||
// `bun --hot` puts the flag in execArgv, not Bun.argv.
|
||||
development: process.env.NODE_ENV === "development" || process.execArgv.includes("--hot"),
|
||||
development: process.env.NODE_ENV !== "production",
|
||||
routes: {
|
||||
"/api/*": (req, srv) => app.fetch(req, srv),
|
||||
"/ingest/*": (req, srv) => app.fetch(req, srv),
|
||||
|
||||
@@ -46,15 +46,7 @@ export function buildOpenApi(routes: RouteDef[], prefix = "/api/v1"): JsonSchema
|
||||
};
|
||||
if (parameters.length > 0) op.parameters = parameters;
|
||||
if (r.body) op.requestBody = { required: !r.body.safeParse(undefined).success, content: { "application/json": { schema: toSchema(r.body) } } };
|
||||
if (r.multipart) {
|
||||
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 } } };
|
||||
}
|
||||
if (r.multipart) op.requestBody = { required: true, content: { "multipart/form-data": { schema: { type: "object" } } } };
|
||||
(paths[path] ??= {})[r.method.toLowerCase()] = op;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -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 type { AttachmentEntity } from "@sttracker/db/schema";
|
||||
import { z } from "zod";
|
||||
@@ -18,25 +18,12 @@ export function safeFilename(name: string): string {
|
||||
}
|
||||
|
||||
export const attachmentRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "attachments",
|
||||
summary: "Attachments linked to one of your records (oldest first), each with its links.",
|
||||
auth: "any:read",
|
||||
query: z.object({ entity: fields.shape.entity, entityId: z.string().min(1) }),
|
||||
handler: async ({ deps, user, query }) => {
|
||||
await requireLinkTarget(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
|
||||
const atts = await listAttachments(deps.db, user.id, query.entity as AttachmentEntity, query.entityId);
|
||||
return Promise.all(atts.map(async (a) => ({ ...a, links: await listAttachmentLinks(deps.db, user.id, a.id) })));
|
||||
},
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "attachments",
|
||||
summary: "Upload a file (multipart: file, entity, entityId, caption) and link it to one of your records.",
|
||||
auth: "any:write",
|
||||
multipart: true,
|
||||
formFields: fields,
|
||||
handler: async ({ deps, user, form }) => {
|
||||
const file = formFile(form);
|
||||
const f = fields.parse({
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createAppToken, createInvite, hasUsers, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||
import { getPreferences, RATING_SCALES, updatePreferences } from "@sttracker/core/preferences";
|
||||
import { NotFoundError } from "@sttracker/core/errors";
|
||||
import { createAppToken, createInvite, listAppTokens, login, logout, registerUser, revokeAppToken } from "@sttracker/core/auth";
|
||||
import { z } from "zod";
|
||||
import { clearSessionCookie, json, noContent, route, sessionCookie } from "../http.ts";
|
||||
import { visibility } from "./schemas.ts";
|
||||
|
||||
const credentials = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
@@ -11,13 +8,6 @@ const credentials = z.object({
|
||||
});
|
||||
|
||||
export const authRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "auth/status",
|
||||
summary: "Whether any account exists yet (the web app shows first-run registration when not).",
|
||||
auth: "none",
|
||||
handler: async ({ deps }) => ({ hasUsers: await hasUsers(deps.db) }),
|
||||
}),
|
||||
route({
|
||||
method: "POST",
|
||||
path: "auth/register",
|
||||
@@ -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/preferences",
|
||||
summary: "Your preferences: rating scale and default visibility.",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps, user }) => getPreferences(deps.db, user.id),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
path: "me/preferences",
|
||||
summary: "Update your preferences (partial).",
|
||||
auth: "any:write",
|
||||
body: z.object({ ratingScale: z.enum(RATING_SCALES), defaultVisibility: visibility }).partial().strict(),
|
||||
handler: async ({ deps, user, body }) => updatePreferences(deps.db, user.id, body as Parameters<typeof updatePreferences>[2]),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "me/tokens",
|
||||
@@ -86,7 +61,7 @@ export const authRoutes = [
|
||||
summary: "Revoke an app token.",
|
||||
auth: "session",
|
||||
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();
|
||||
},
|
||||
}),
|
||||
@@ -95,7 +70,6 @@ export const authRoutes = [
|
||||
path: "admin/invites",
|
||||
summary: "Create an invite code (admin). The code is returned once.",
|
||||
auth: "session",
|
||||
admin: true,
|
||||
body: z.object({ ttlDays: z.number().int().min(1).max(365).optional() }).strict().optional(),
|
||||
handler: async ({ deps, user, body }) => json({ code: await createInvite(deps.db, user.id, body?.ttlDays) }, 201),
|
||||
}),
|
||||
|
||||
@@ -35,7 +35,7 @@ export const copyRoutes = [
|
||||
summary: "Record an owned copy.",
|
||||
auth: "any:write",
|
||||
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({
|
||||
method: "PATCH",
|
||||
@@ -43,15 +43,15 @@ export const copyRoutes = [
|
||||
summary: "Edit an owned copy.",
|
||||
auth: "any:write",
|
||||
body: copyPatch,
|
||||
handler: async ({ deps, user, params, body, 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({
|
||||
method: "DELETE",
|
||||
path: "copies/:id",
|
||||
summary: "Soft-delete an owned copy.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params, actor }) => {
|
||||
await softDeleteCopy(deps.db, user.id, params.id!, actor);
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteCopy(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -30,8 +30,8 @@ export const engagementRoutes = [
|
||||
summary: "Log a read, watch or play.",
|
||||
auth: "any:write",
|
||||
body: engagementPatch,
|
||||
handler: async ({ deps, user, params, body, actor }) =>
|
||||
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], actor), 201),
|
||||
handler: async ({ deps, user, params, body }) =>
|
||||
json(await addEngagement(deps.db, user.id, params.id!, body as Parameters<typeof addEngagement>[3], "user"), 201),
|
||||
}),
|
||||
route({
|
||||
method: "PATCH",
|
||||
@@ -39,16 +39,16 @@ export const engagementRoutes = [
|
||||
summary: "Edit an engagement.",
|
||||
auth: "any:write",
|
||||
body: engagementPatch,
|
||||
handler: async ({ deps, user, params, body, actor }) =>
|
||||
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], actor),
|
||||
handler: async ({ deps, user, params, body }) =>
|
||||
updateEngagement(deps.db, user.id, params.id!, body as Parameters<typeof updateEngagement>[3], "user"),
|
||||
}),
|
||||
route({
|
||||
method: "DELETE",
|
||||
path: "engagements/:id",
|
||||
summary: "Soft-delete an engagement.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params, actor }) => {
|
||||
await softDeleteEngagement(deps.db, user.id, params.id!, actor);
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteEngagement(deps.db, user.id, params.id!, "user");
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -60,9 +60,9 @@ export const entryRoutes = [
|
||||
summary: "Edit an entry. Edited fields become user-owned (syncs stop overwriting them).",
|
||||
auth: "any:write",
|
||||
body: entryPatch,
|
||||
handler: async ({ deps, user, params, body, actor }) => {
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
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!);
|
||||
},
|
||||
}),
|
||||
@@ -71,8 +71,8 @@ export const entryRoutes = [
|
||||
path: "entries/:id",
|
||||
summary: "Soft-delete an entry.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params, actor }) => {
|
||||
await softDeleteEntry(deps.db, user.id, params.id!, actor);
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteEntry(deps.db, user.id, params.id!, "user");
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
@@ -99,8 +99,8 @@ export const entryRoutes = [
|
||||
summary: "Move an entry to another work (for you only).",
|
||||
auth: "any:write",
|
||||
body: z.object({ workId: z.string().min(1) }).strict(),
|
||||
handler: async ({ deps, user, params, body, actor }) => {
|
||||
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, actor);
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
const moved = await moveEntryToWork(deps.db, user.id, params.id!, body.workId, "user");
|
||||
return getEntry(deps.db, user.id, moved.id);
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -25,7 +25,7 @@ export const listRoutes = [
|
||||
summary: "Create a list.",
|
||||
auth: "any:write",
|
||||
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({
|
||||
method: "GET",
|
||||
@@ -40,15 +40,15 @@ export const listRoutes = [
|
||||
summary: "Edit a list.",
|
||||
auth: "any:write",
|
||||
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({
|
||||
method: "DELETE",
|
||||
path: "lists/:id",
|
||||
summary: "Soft-delete a list.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params, actor }) => {
|
||||
await softDeleteList(deps.db, user.id, params.id!, actor);
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await softDeleteList(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
@@ -58,7 +58,7 @@ export const listRoutes = [
|
||||
summary: "Add a work to a list.",
|
||||
auth: "any:write",
|
||||
body: z.object({ workId: z.string().min(1), note: text(10_000).nullable().optional(), position: z.number().int().min(0).optional() }).strict(),
|
||||
handler: async ({ deps, user, params, body, 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({
|
||||
method: "PATCH",
|
||||
@@ -66,8 +66,8 @@ export const listRoutes = [
|
||||
summary: "Move a list item to a new 0-based position.",
|
||||
auth: "any:write",
|
||||
body: z.object({ position: z.number().int().min(0) }).strict(),
|
||||
handler: async ({ deps, user, params, body, actor }) => {
|
||||
await moveListItem(deps.db, user.id, params.id!, body.position, actor);
|
||||
handler: async ({ deps, user, params, body }) => {
|
||||
await moveListItem(deps.db, user.id, params.id!, body.position);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
@@ -76,8 +76,8 @@ export const listRoutes = [
|
||||
path: "list-items/:id",
|
||||
summary: "Remove an item from a list.",
|
||||
auth: "any:write",
|
||||
handler: async ({ deps, user, params, actor }) => {
|
||||
await removeListItem(deps.db, user.id, params.id!, actor);
|
||||
handler: async ({ deps, user, params }) => {
|
||||
await removeListItem(deps.db, user.id, params.id!);
|
||||
return noContent();
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { listLog } from "@sttracker/core/log";
|
||||
import { z } from "zod";
|
||||
import { route } from "../http.ts";
|
||||
import { qInt } from "./schemas.ts";
|
||||
|
||||
export const logRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "log",
|
||||
summary: "Your Log: engagements and listens, newest first (keyset cursor).",
|
||||
auth: "any:read",
|
||||
query: z.object({ limit: qInt.optional(), cursor: z.string().max(2000).optional() }),
|
||||
handler: async ({ deps, user, query }) => listLog(deps.db, user.id, query),
|
||||
}),
|
||||
];
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
devicePoll,
|
||||
deviceStart,
|
||||
getConnection,
|
||||
isDeletedConnection,
|
||||
listConnections,
|
||||
listRuns,
|
||||
updateConnection,
|
||||
@@ -38,16 +39,16 @@ export const sourceRoutes = [
|
||||
route({
|
||||
method: "GET",
|
||||
path: "plugins",
|
||||
summary: "Installed source plugins (manifests, plus deviceAuth: whether sign-in uses a device code).",
|
||||
summary: "Installed source plugins (manifests).",
|
||||
auth: "any:read",
|
||||
handler: async ({ deps }) => deps.registry.list().map((m) => ({ ...m, deviceAuth: !!deps.registry.get(m.id)?.deviceAuth })),
|
||||
handler: async ({ deps }) => deps.registry.list(),
|
||||
}),
|
||||
route({
|
||||
method: "GET",
|
||||
path: "sources",
|
||||
summary: "Your source connections (never includes secrets).",
|
||||
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({
|
||||
method: "POST",
|
||||
|
||||
@@ -23,15 +23,15 @@ interface CallOpts {
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
form?: FormData;
|
||||
/** Omit the X-Requested-With header that browsers (the web app) send on writes. */
|
||||
noCsrf?: boolean;
|
||||
}
|
||||
|
||||
async function call(app: App, method: string, path: string, o: CallOpts = {}): Promise<Response> {
|
||||
const headers: Record<string, string> = { ...o.headers };
|
||||
if (o.cookie) headers.cookie = o.cookie;
|
||||
if (o.cookie) {
|
||||
headers.cookie = o.cookie;
|
||||
headers["x-requested-with"] ??= "sttracker";
|
||||
}
|
||||
if (o.token) headers.authorization = `Bearer ${o.token}`;
|
||||
else if (!o.noCsrf) headers["x-requested-with"] ??= "sttracker";
|
||||
let body: BodyInit | undefined;
|
||||
if (o.form) body = o.form;
|
||||
else if (o.body !== undefined) {
|
||||
@@ -184,16 +184,8 @@ forEachDialect("api", (getDb) => {
|
||||
|
||||
test("cookie-authenticated writes need the CSRF header", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, noCsrf: true });
|
||||
const res = await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" }, headers: { "x-requested-with": "" } });
|
||||
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 () => {
|
||||
@@ -272,165 +264,16 @@ forEachDialect("api", (getDb) => {
|
||||
expect(got.headers.get("x-content-type-options")).toBe("nosniff");
|
||||
expect(got.headers.get("content-disposition")).toBe('inline; filename="we_ird_na_me_.png"');
|
||||
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
const bobs = (await (await call(app, "POST", "entries/quick", { cookie: bob, body: { title: "Bob's" } })).json()) as { id: string };
|
||||
const form2 = new FormData();
|
||||
form2.set("file", new File([new Uint8Array([1])], "a.png", { type: "image/png" }));
|
||||
form2.set("entity", "entry");
|
||||
form2.set("entityId", 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, "GET", `attachments/${att.id}`, { cookie: bob })).status).toBe(404);
|
||||
|
||||
expect((await call(app, "DELETE", `attachments/${att.id}/links/${att.links[0]!.id}`, { cookie: ana })).status).toBe(204);
|
||||
expect((await call(app, "GET", `attachments/${att.id}`, { cookie: ana })).status).toBe(404);
|
||||
});
|
||||
|
||||
test("auth/status says whether any user exists, without auth", async () => {
|
||||
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: false });
|
||||
await signUp(app, "ana");
|
||||
expect(await (await call(app, "GET", "auth/status")).json()).toEqual({ hasUsers: true });
|
||||
});
|
||||
|
||||
test("attachments list: owner-scoped per entity; a foreign or unknown entity is 404", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const add = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string };
|
||||
const q = `attachments?entity=entry&entityId=${add.id}`;
|
||||
expect(await (await call(app, "GET", q, { cookie: ana })).json()).toEqual([]);
|
||||
const form = new FormData();
|
||||
form.set("file", new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }));
|
||||
form.set("entity", "entry");
|
||||
form.set("entityId", add.id);
|
||||
const up = (await (await call(app, "POST", "attachments", { cookie: ana, form })).json()) as { id: string };
|
||||
const list = (await (await call(app, "GET", q, { cookie: ana })).json()) as { id: string; mime: string; filename: string; links: { id: string }[] }[];
|
||||
expect(list.map((a) => [a.id, a.mime, a.filename])).toEqual([[up.id, "image/png", "shot.png"]]);
|
||||
expect(list[0]!.links.length).toBe(1);
|
||||
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
expect((await call(app, "GET", q, { cookie: bob })).status).toBe(404);
|
||||
expect((await call(app, "GET", "attachments?entity=entry&entityId=nope", { cookie: ana })).status).toBe(404);
|
||||
expect((await call(app, "GET", `attachments?entity=bogus&entityId=${add.id}`, { cookie: ana })).status).toBe(400);
|
||||
expect((await call(app, "GET", q)).status).toBe(401);
|
||||
});
|
||||
|
||||
test("me/preferences: defaults, validated PATCH, and quick add uses defaultVisibility", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "5half", defaultVisibility: "private" });
|
||||
const bad = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "7" } });
|
||||
expect(bad.status).toBe(400);
|
||||
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { extra: 1 } })).status).toBe(400);
|
||||
const ok = await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "10" } });
|
||||
expect(await ok.json()).toEqual({ ratingScale: "10", defaultVisibility: "private" });
|
||||
await call(app, "PATCH", "me/preferences", { cookie: ana, body: { defaultVisibility: "friends" } });
|
||||
expect(await (await call(app, "GET", "me/preferences", { cookie: ana })).json()).toEqual({ ratingScale: "10", defaultVisibility: "friends" });
|
||||
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { visibility: string };
|
||||
expect(e.visibility).toBe("friends");
|
||||
expect((await call(app, "PATCH", "me/preferences", { cookie: ana, body: { ratingScale: "5" }, noCsrf: true })).status).toBe(403);
|
||||
});
|
||||
|
||||
test("log: engagements newest first with keyset paging; copies carry workTitle", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const e = (await (await call(app, "POST", "entries/quick", { cookie: ana, body: { title: "Dune" } })).json()) as { id: string; workId: string };
|
||||
for (const value of ["2026-01-02", "2026-03-04"]) {
|
||||
const r = await call(app, "POST", `entries/${e.id}/engagements`, { cookie: ana, body: { finishedAt: { value, precision: "day" } } });
|
||||
expect(r.status).toBe(201);
|
||||
}
|
||||
const page1 = (await (await call(app, "GET", "log?limit=1", { cookie: ana })).json()) as { items: { at: string; title: string; kind: string }[]; nextCursor: string };
|
||||
expect(page1.items).toEqual([expect.objectContaining({ kind: "engagement", at: "2026-03-04", title: "Dune" })]);
|
||||
const page2 = (await (await call(app, "GET", `log?limit=1&cursor=${page1.nextCursor}`, { cookie: ana })).json()) as { items: { at: string }[]; nextCursor: string | null };
|
||||
expect(page2.items.map((i) => i.at)).toEqual(["2026-01-02"]);
|
||||
expect(page2.nextCursor).toBeNull();
|
||||
expect((await call(app, "GET", "log?cursor=bad", { cookie: ana })).status).toBe(400);
|
||||
expect((await call(app, "GET", "log")).status).toBe(401);
|
||||
|
||||
await call(app, "POST", "copies", { cookie: ana, body: { workId: e.workId, format: "Paperback" } });
|
||||
expect(await (await call(app, "GET", "copies", { cookie: ana })).json()).toEqual([expect.objectContaining({ workTitle: "Dune", format: "Paperback" })]);
|
||||
});
|
||||
|
||||
test("plugins: manifests say whether the source signs in with a device code", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const list = (await (await call(app, "GET", "plugins", { cookie: ana })).json()) as { id: string; deviceAuth: boolean; config: unknown[] }[];
|
||||
expect(list.find((p) => p.id === "echo")).toEqual(expect.objectContaining({ deviceAuth: false, config: expect.any(Array) }));
|
||||
});
|
||||
|
||||
test("JSON bodies need Content-Type application/json (415)", async () => {
|
||||
const res = await app.fetch(
|
||||
new Request(`${BASE}/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "text/plain", "x-requested-with": "sttracker" },
|
||||
body: JSON.stringify({ username: "ana", password: "correct horse" }),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(415);
|
||||
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("unsupported_media_type");
|
||||
});
|
||||
|
||||
test("a malformed session cookie is 401, not 500", async () => {
|
||||
expect((await call(app, "GET", "me", { headers: { cookie: "stt_session=%" } })).status).toBe(401);
|
||||
});
|
||||
|
||||
test("cookie plus a bogus Bearer on a write is 401", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const res = await call(app, "POST", "entries/quick", { cookie, token: "stt_bogus", body: { title: "Dune" } });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test("a write-scope token can write; history records the token, field_sources the user", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const add = (await (await call(app, "POST", "entries/quick", { cookie, body: { title: "Dune" } })).json()) as { id: string };
|
||||
const { token } = (await (await call(app, "POST", "me/tokens", { cookie, body: { name: "script", scopes: ["write", "read"] } })).json()) as { token: string };
|
||||
const res = await call(app, "PATCH", `entries/${add.id}`, { token, body: { rating: 70 } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as { fieldSources: Record<string, string> }).fieldSources.rating).toBe("user");
|
||||
const hist = (await (await call(app, "GET", `entries/${add.id}/history`, { token })).json()) as { actor: string }[];
|
||||
expect(hist[0]!.actor).toBe("token:script");
|
||||
});
|
||||
|
||||
test("DELETE me/tokens/:id is 404 for unknown or foreign tokens", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
const { id } = (await (await call(app, "POST", "me/tokens", { cookie: ana, body: { name: "t", scopes: ["read"] } })).json()) as { id: string };
|
||||
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: bob })).status).toBe(404);
|
||||
expect((await call(app, "DELETE", "me/tokens/nope", { cookie: ana })).status).toBe(404);
|
||||
expect((await call(app, "DELETE", `me/tokens/${id}`, { cookie: ana })).status).toBe(204);
|
||||
});
|
||||
|
||||
test("admin-only routes are 403 for members", async () => {
|
||||
const ana = await signUp(app, "ana");
|
||||
const { code } = (await (await call(app, "POST", "admin/invites", { cookie: ana })).json()) as { code: string };
|
||||
const bob = await signUp(app, "bob", code);
|
||||
expect((await call(app, "POST", "admin/plugins/reload", { cookie: bob })).status).toBe(403);
|
||||
expect((await call(app, "POST", "admin/invites", { cookie: bob })).status).toBe(403);
|
||||
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
expect((await call(app, "POST", "admin/plugins/reload", { cookie: ana })).status).toBe(200);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("a paused source without credentials stays listed and can be resumed", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
|
||||
expect((await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "paused" } })).status).toBe(200);
|
||||
const listed = (await (await call(app, "GET", "sources", { cookie })).json()) as { id: string; status: string }[];
|
||||
expect(listed).toMatchObject([{ id, status: "paused" }]);
|
||||
const resumed = await call(app, "PATCH", `sources/${id}`, { cookie, body: { status: "active" } });
|
||||
expect(((await resumed.json()) as { status: string }).status).toBe("active");
|
||||
});
|
||||
|
||||
test("a multipart upload over 50 MB is 413", async () => {
|
||||
const cookie = await signUp(app, "ana");
|
||||
const { id } = (await (await call(app, "POST", "sources", { cookie, body: { pluginId: "echo", name: "E" } })).json()) as { id: string };
|
||||
const form = new FormData();
|
||||
form.set("file", new File([new Uint8Array(50 * 1024 * 1024 + 1)], "big.csv", { type: "text/csv" }));
|
||||
const res = await call(app, "POST", `sources/${id}/import`, { cookie, form });
|
||||
expect(res.status).toBe(413);
|
||||
expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation");
|
||||
});
|
||||
|
||||
test("openapi.json lists /api/v1/entries", async () => {
|
||||
const res = await call(app, "GET", "openapi.json");
|
||||
expect(res.status).toBe(200);
|
||||
@@ -438,12 +281,6 @@ forEachDialect("api", (getDb) => {
|
||||
expect(doc.openapi).toStartWith("3.");
|
||||
expect(doc.paths["/api/v1/entries"]?.get?.summary).toBeTruthy();
|
||||
expect(doc.paths["/api/v1/entries/{id}"]?.patch).toBeTruthy();
|
||||
const up = doc.paths["/api/v1/attachments"]?.post as unknown as {
|
||||
requestBody: { content: Record<string, { schema: { properties: Record<string, unknown>; required: string[] } }> };
|
||||
};
|
||||
const mp = up.requestBody.content["multipart/form-data"]!.schema;
|
||||
expect(Object.keys(mp.properties).sort()).toEqual(["caption", "entity", "entityId", "file"]);
|
||||
expect(mp.required).toEqual(["file", "entity", "entityId"]);
|
||||
});
|
||||
|
||||
test("about lists attributions; ingest and unknown routes are 404 JSON", async () => {
|
||||
|
||||
+1
-15
@@ -1,15 +1 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#17152b" />
|
||||
<title>STTracker</title>
|
||||
<link rel="stylesheet" href="./src/theme.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html><title>STTracker</title><div id="root">STTracker is starting…</div>
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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))` }} />;
|
||||
}
|
||||
@@ -1,535 +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,
|
||||
engagementNouns,
|
||||
formatDateRange,
|
||||
formatPartialDate,
|
||||
formatRating,
|
||||
friendlyError,
|
||||
ordinal,
|
||||
parsePartialDate,
|
||||
rollupLabel,
|
||||
statusLabel,
|
||||
timeAgo,
|
||||
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);
|
||||
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)}</p>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomField({ def, value, onSave }: { def: CustomFieldDef; value: unknown; onSave: (v: unknown) => void }) {
|
||||
const scale = useRatingScale();
|
||||
const str = value === undefined || value === null ? "" : String(value);
|
||||
const text = (multiline: boolean, inputType = "text") => (
|
||||
<label className="field">
|
||||
<span>{def.name}</span>
|
||||
{multiline ? (
|
||||
<textarea className="textarea" defaultValue={str} key={str} rows={3} onBlur={(e) => onSave(e.target.value.trim())} />
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type={inputType}
|
||||
defaultValue={str}
|
||||
key={str}
|
||||
placeholder={def.type === "date" ? "2025-03-14" : undefined}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (def.type === "number") onSave(v === "" ? null : Number(v));
|
||||
else if (def.type === "date") onSave(v === "" || parsePartialDate(v) ? v : str);
|
||||
else onSave(v);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
switch (def.type) {
|
||||
case "long_text":
|
||||
return text(true);
|
||||
case "number":
|
||||
return text(false, "number");
|
||||
case "url":
|
||||
return text(false, "url");
|
||||
case "boolean":
|
||||
return (
|
||||
<label className="inline">
|
||||
<input type="checkbox" checked={value === true} onChange={(e) => onSave(e.target.checked ? true : null)} />
|
||||
{def.name}
|
||||
</label>
|
||||
);
|
||||
case "single_select":
|
||||
return (
|
||||
<label className="field">
|
||||
<span>{def.name}</span>
|
||||
<select className="select" value={str} onChange={(e) => onSave(e.target.value || null)}>
|
||||
<option value="">—</option>
|
||||
{def.options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
case "multi_select": {
|
||||
const cur = Array.isArray(value) ? (value as string[]) : [];
|
||||
return (
|
||||
<fieldset className="field" style={{ border: 0 }}>
|
||||
<legend className="hint" style={{ color: "var(--text-muted)", fontWeight: 500, marginBottom: 5 }}>{def.name}</legend>
|
||||
<div className="checks">
|
||||
{def.options.map((o) => (
|
||||
<label key={o} className="inline">
|
||||
<input type="checkbox" checked={cur.includes(o)} onChange={(e) => onSave(e.target.checked ? [...cur, o] : cur.filter((x) => x !== o))} />
|
||||
{o}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
case "rating":
|
||||
return (
|
||||
<div className="field">
|
||||
<span>{def.name}</span>
|
||||
<StarRating value={typeof value === "number" ? value : null} scale={scale} label={def.name} onChange={onSave} />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return text(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Children (seasons, episodes, volumes)
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) {
|
||||
const scale = useRatingScale();
|
||||
const children = useQuery({
|
||||
queryKey: ["entries", { parentId: entry.workId }],
|
||||
queryFn: () => api.entries({ parentId: entry.workId, limit: 200 }),
|
||||
});
|
||||
const label = rollupLabel(type, entry.childSummary ? { ...entry.childSummary, completed: 0 } : null) ?? "Parts";
|
||||
const title = label.replace(/^\d+\s+/, "");
|
||||
return (
|
||||
<Box title={title.charAt(0).toUpperCase() + title.slice(1)} id="box-children" wide>
|
||||
<ul className="children">
|
||||
{(children.data?.items ?? []).map((c) => (
|
||||
<li key={c.id}>
|
||||
<Link href={`/entries/${c.id}`}>
|
||||
<span className="t">
|
||||
{c.work.title}
|
||||
<span className="s">{statusLabel(c.status, c.mediaTypeOverride ?? c.work.mediaType)}</span>
|
||||
</span>
|
||||
{c.rating !== null ? (
|
||||
<span className="stars" role="img" aria-label={describeRating(c.rating, scale)}>{formatRating(c.rating, scale)}</span>
|
||||
) : (
|
||||
<span className="s">Not rated</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Review
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
export function ReviewBox({ entry }: { entry: EntryView }) {
|
||||
const patch = useEntryPatch(entry.id);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [text, setText] = useState(entry.review ?? "");
|
||||
// The server value this text was last in sync with. When the review changes elsewhere (Restore,
|
||||
// another tab) and the user has not typed since, show the new value, so a later blur can't undo it.
|
||||
const synced = useRef(entry.review ?? "");
|
||||
useEffect(() => {
|
||||
const incoming = entry.review ?? "";
|
||||
if (incoming === synced.current) return;
|
||||
setText((cur) => (cur === synced.current ? incoming : cur));
|
||||
synced.current = incoming;
|
||||
}, [entry.review]);
|
||||
const save = () => {
|
||||
const next = text.trim() === "" ? null : text;
|
||||
if ((next ?? "") === (entry.review ?? "")) return;
|
||||
synced.current = next ?? "";
|
||||
patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
|
||||
};
|
||||
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={text}
|
||||
placeholder="What stayed with you? Markdown works here."
|
||||
onChange={(e) => (setText(e.target.value), 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>
|
||||
);
|
||||
}
|
||||
@@ -1,185 +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" />;
|
||||
if (entry.error) {
|
||||
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)}</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>
|
||||
);
|
||||
}
|
||||
@@ -1,115 +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({
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
@@ -1,260 +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.
|
||||
*/
|
||||
export function friendlyError(err: unknown): 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") return `${label} is out of range. Ratings go from 0 to 100.`;
|
||||
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.`;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type ChangeEvent, type EntryPatch, type EntryView } from "./api.ts";
|
||||
import { applyEntryPatch } from "./edits.ts";
|
||||
|
||||
/** Pending Changes-inbox events (first 200; the badge shows "200+" beyond that). */
|
||||
export function usePendingChanges() {
|
||||
return useQuery({ queryKey: ["changes", "pending"], queryFn: api.changes, staleTime: 30_000 });
|
||||
}
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({ queryKey: ["me"], queryFn: api.me, retry: false, staleTime: Infinity });
|
||||
}
|
||||
|
||||
export function useSources() {
|
||||
return useQuery({ queryKey: ["sources"], queryFn: api.sources, staleTime: 60_000 });
|
||||
}
|
||||
|
||||
/** Pending events that touch an entry or one of its reads/plays. */
|
||||
export function changesFor(items: ChangeEvent[] | undefined, entryId: string, engagementIds: Set<string>): ChangeEvent[] {
|
||||
return (items ?? []).filter(
|
||||
(c) => c.targetId !== null && ((c.targetEntity === "entry" && c.targetId === entryId) || (c.targetEntity === "engagement" && engagementIds.has(c.targetId))),
|
||||
);
|
||||
}
|
||||
|
||||
export function useDebounced<T>(value: T, ms = 220): T {
|
||||
const [v, setV] = useState(value);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setV(value), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [value, ms]);
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared save path for every detail edit. The cached entry is updated optimistically (rolled back on
|
||||
* error) and PATCHes for one entry run one at a time (mutation scope), in the order they were made.
|
||||
* Use `edit(cur => patch)` when the next value depends on the current one (tags, custom fields): it
|
||||
* builds from the latest cache, so quick successive edits compose.
|
||||
*/
|
||||
export function useEntryPatch(id: string) {
|
||||
const qc = useQueryClient();
|
||||
const key = ["entry", id];
|
||||
const mutationKey = ["entry-patch", id];
|
||||
const m = useMutation({
|
||||
mutationKey,
|
||||
scope: { id: `entry-patch:${id}` },
|
||||
mutationFn: (patch: EntryPatch) => api.patchEntry(id, patch),
|
||||
onMutate: async (patch) => {
|
||||
await qc.cancelQueries({ queryKey: key });
|
||||
const prev = qc.getQueryData<EntryView>(key);
|
||||
if (prev) qc.setQueryData<EntryView>(key, applyEntryPatch<EntryView>(prev, patch));
|
||||
return { prev };
|
||||
},
|
||||
onError: (_e, _patch, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
|
||||
void qc.invalidateQueries({ queryKey: key });
|
||||
},
|
||||
onSuccess: (entry) => {
|
||||
// Only the last queued edit writes the server's answer, so earlier answers don't flash old values.
|
||||
if (qc.isMutating({ mutationKey }) <= 1) qc.setQueryData(key, entry);
|
||||
},
|
||||
onSettled: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
void qc.invalidateQueries({ queryKey: ["history", id] });
|
||||
},
|
||||
});
|
||||
const edit = (build: (cur: EntryView) => EntryPatch | null) => {
|
||||
const cur = qc.getQueryData<EntryView>(key);
|
||||
const patch = cur ? build(cur) : null;
|
||||
if (patch) m.mutate(patch);
|
||||
};
|
||||
return { ...m, edit };
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +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 });
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { 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("Rating is out of range. Ratings go from 0 to 100.");
|
||||
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");
|
||||
});
|
||||
@@ -15,8 +15,8 @@ user layer or files a Changes-inbox event.
|
||||
| Path | Responsibility | State |
|
||||
|---|---|---|
|
||||
| `apps/server` | `main.ts` boots (config → `createDb` → `migrateToLatest` → `loadPlugins` → worker + scheduler + daily backup → `Bun.serve`). `app.ts` `createApp(deps)` = `/api/v1` route table + `/ingest/*` mount (404 in Phase 1), testable via `app.fetch`. `http.ts` = `route()`, auth (session cookie / Bearer app token), CSRF header, body caps, rate limit, error mapping. `routes/<area>.ts` one file per area; `openapi.ts` builds `/api/v1/openapi.json` from the table | exists |
|
||||
| `apps/web` | React 19 app served by the server as an HTML import (`index.html` → `src/main.tsx`, `src/theme.css`). wouter routes in `main.tsx` (`/login`, `/register`, `/`, `/entries/:id`; other sections are placeholders until Task 18); `api.ts` = fetch client (`request`, `ApiError`, cookie auth, CSRF header, mirrored wire types); `format.ts` = pure display helpers and `friendlyError`; `edits.ts` = next-value builders for entry edits; `hooks.ts` = TanStack Query hooks incl. optimistic `useEntryPatch`; `settings.ts` = preferences; `components/` (shell, detail boxes, history drawer, rating control) and `routes/`. Never imports `packages/*` at runtime | exists |
|
||||
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `preferences` (per-user rating scale and default visibility), `log` (the Log feed: engagements and listens, keyset-paged), `titles` (batched override-aware title lookups for list views), `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
|
||||
| `apps/web` | React app (HTML imports); `index.html` is a placeholder until Task 17 | placeholder |
|
||||
| `packages/core` | domain services, one module per area (import by path, no barrel): `auth`, `crypto`, `works`, `matching`, `entries` (entries, library listing, quick add, per-user moves, match suggestions), `engagements`, `history` (`user_history`, restore), `lists`, `copies`, `custom-fields`, `attachments`, `export` (per-user bundle, CSV, restore), `backup` (scheduled bundles + SQLite `VACUUM INTO`), `sync/` (the change engine: `snapshot` hashing and versions, `apply` per-record diff and apply, `run` = `runSync`, `changes` = Changes inbox `listChanges`/`resolveChange`, `listens` = `ingestListens`), `jobs` (queue, worker, scheduler), `registry` (`loadPlugins`), `connections` (CRUD, plugin context, `syncHandler`) | exists (partial) |
|
||||
| `packages/db` | Kysely over one Bun.SQL dialect (SQLite + Postgres), ids, JSON helpers, migrations, `forEachDialect` test harness | exists (schema pending) |
|
||||
| `packages/plugin-sdk` | plugin contract, `SourceRecord`, http/rate-limit helpers, fixture test harness | planned |
|
||||
| `plugins/<id>` | one source integration each (hardcover, trakt, mangabaka, listenbrainz, lastfm, csv) | planned |
|
||||
@@ -26,11 +26,9 @@ user layer or files a Changes-inbox event.
|
||||
HTTP request (`apps/server`):
|
||||
1. `Bun.serve` routes `/api/*` and `/ingest/*` to `app.fetch`; everything else gets the web app (HTML import).
|
||||
2. `apiHandler` matches method + path in the route table, applies the per-IP rate limit (login/register), authenticates
|
||||
(`session` cookie `stt_session`, Bearer app token with a scope, or either), requires `X-Requested-With: sttracker` on
|
||||
every non-Bearer write (login included), reads the body (JSON ≤ 1 MB with `Content-Type: application/json`,
|
||||
multipart ≤ 50 MB) and validates it with zod.
|
||||
3. The handler calls one core function with the caller's `userId` and actor (`token:<name>` for Bearer, else `"user"`;
|
||||
`field_sources` stores both as `"user"`). Core enforces ownership
|
||||
(`session` cookie `stt_session`, Bearer app token with a scope, or either), checks `X-Requested-With: sttracker` on
|
||||
cookie-authenticated writes, reads the body (JSON ≤ 1 MB, multipart ≤ 50 MB) and validates it with zod.
|
||||
3. The handler calls one core function with the caller's `userId` and actor `"user"`. Core enforces ownership
|
||||
(`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.
|
||||
|
||||
|
||||
+34
-83
@@ -4,67 +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.
|
||||
No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, line number).
|
||||
|
||||
## 2026-10-30: Log feed, titles on Changes, work titles on copies (Task 18 API additions)
|
||||
## 2026-10-30: Deployment: one image on `oven/bun:1.3`, source run directly, compose with an optional Postgres profile
|
||||
**Status:** accepted
|
||||
**Context:** Task 18's Log and Changes screens had nothing to read from (controller ruling).
|
||||
**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:**
|
||||
- **`GET log?cursor=&limit=`** (any:read) → `{ items, nextCursor }` from `packages/core/src/log.ts` `listLog`: the user's
|
||||
live engagements on live entries plus their listens, newest first. Each item's `at` is a listen's `listened_at`, or an
|
||||
engagement's finish date, else start date, else `created_at`. Partial dates sort as strings, never through `Date`.
|
||||
Keyset cursor over (`at`, `kind`, `id`) all descending, base64url JSON; default 50, max 200. Two queries (one per
|
||||
kind, each `limit + 1` past the cursor) are merged in code, so no dialect-specific UNION is needed.
|
||||
- **`listChanges` items carry `title`** (an entry's title with the user's override; an engagement resolves to its entry; a
|
||||
list to its name; null when gone) **and, on merged events, `intoTitle`**: the title of the entry that the same
|
||||
connection's `intoKey` record maps to, else null. Titles come from a fixed number of batched queries
|
||||
(`packages/core/src/titles.ts`), not one per event.
|
||||
- **`GET plugins` adds `deviceAuth: boolean`** to each manifest, so the Sources screen knows when to offer the
|
||||
device-code sign-in (the manifest itself has no such capability).
|
||||
- **`listCopies` items carry `workTitle`** (override-aware, null while unmatched) so Owned needs no per-row work lookups.
|
||||
The export bundle is unchanged.
|
||||
|
||||
## 2026-10-30: Web app (Task 17): preferences, first run, optimistic edits, AA on orange, self-hosted fonts
|
||||
**Status:** accepted
|
||||
**Context:** Task 17 and its fix round 1 (controller rulings).
|
||||
**Decision:**
|
||||
- **Three small API routes for the web app.** `GET auth/status` (no auth) → `{ hasUsers }`: `/login` redirects a fresh
|
||||
instance to `/register`, which shows "Create the admin account", or invite mode when users exist (also on
|
||||
`invite_required`, and with `?invite=` prefilled). `GET attachments?entity=&entityId=` lists an owned record's
|
||||
attachments with their links (404 when the record is not the caller's). `GET/PATCH me/preferences` →
|
||||
`{ ratingScale: "5half"|"5"|"10"|"100", defaultVisibility }`, stored in `users.preferences` under the same keys
|
||||
`defaultVisibility()` already reads (`packages/core/src/preferences.ts`); unknown stored keys are kept.
|
||||
- **Rating scale** comes from preferences (default five stars with halves). Stored ratings stay integers 0–100; the web
|
||||
formats them with `formatRating(value, scale)`.
|
||||
- **Entry edits are optimistic and serial.** `useEntryPatch` updates the cached entry in `onMutate` (rollback on error),
|
||||
runs one entry's PATCHes in order through a TanStack mutation `scope`, and `edit(cur => patch)` builds from the latest
|
||||
cache, so two quick tag adds keep both. Only the last queued edit writes the server's answer back.
|
||||
- **Keyboard rating saves once:** arrow steps update locally and save 500 ms after the last key, on blur, or on unmount,
|
||||
so stepping does not write a history row per keypress.
|
||||
- **Errors in the product's voice:** every error display goes through `friendlyError()` (`apps/web/src/format.ts`), which maps
|
||||
`validation` field/reason pairs and 413 to plain copy; other API errors keep the server's message.
|
||||
- **AA contrast on the approved palette:** dark text on `--warn`, `--text-faint` group labels, `--text-muted` placeholders on
|
||||
`--surface-3` (recorded in DESIGN.md).
|
||||
- **Fonts are vendored** (Inter woff2 from fontsource; Gelasio as the official variable TTFs from github.com/google/fonts
|
||||
`ofl/gelasio`, unmodified, because no woff2 tooling was available offline) with their OFL texts.
|
||||
- **The web app mirrors wire types** in `apps/web/src/api.ts` instead of importing `@sttracker/core` (apps/web has no
|
||||
workspace dependency on it, and the browser bundle must not pull server code).
|
||||
|
||||
## 2026-10-30: Token actor in history, connection deleted_at, CSRF on every browser write
|
||||
**Status:** accepted. Supersedes the **Actor**, **CSRF** and **Deleted connections** bullets of
|
||||
"HTTP API: auth modes, CSRF header, error mapping, actor" below.
|
||||
**Context:** Task 16 fix round 1 (controller rulings).
|
||||
**Decision:**
|
||||
- **Actor vs. owner.** API writes pass actor `token:<name>` for Bearer requests and `"user"` for sessions, so history
|
||||
shows which script changed what (spec §6.9). `field_sources` stores `fieldOwner(actor)`: every actor that does not start
|
||||
with `plugin:` is stored as `"user"`. A token edit is the user's edit, and syncs file a pending change instead of
|
||||
overwriting it. List ownership, which is read from history, applies the same mapping.
|
||||
- **CSRF.** `X-Requested-With: sttracker` is required on every non-GET request that has no Bearer token, including the
|
||||
`auth: "none"` routes login and register (login CSRF). JSON-body routes also require `Content-Type: application/json`
|
||||
(415 `unsupported_media_type` otherwise), so a cross-site simple form post cannot pass as JSON.
|
||||
- **Deleted connections** carry `source_connections.deleted_at` (migration 0003). `deleteConnection` sets it and still pauses
|
||||
the connection and wipes its credentials. Listing, lookups, updates, the scheduler and the sync handler ignore deleted rows.
|
||||
A user-paused connection without credentials is a normal, visible, resumable connection.
|
||||
- **Dev mode** (`Bun.serve({ development })`) is on only with `NODE_ENV=development` or `bun --hot` (detected through
|
||||
`process.execArgv`; `Bun.argv` does not carry runtime flags). Everything else runs in production mode.
|
||||
- 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.
|
||||
It installs production dependencies only and copies `apps/web` too, because the server bundles it at startup.
|
||||
- `/data` is the volume (`DATA_DIR=/data`): SQLite file, attachments, backups, user plugins. It is created and owned by `bun` in the image.
|
||||
- `docker-compose.yml` has the `sttracker` service (`./data:/data`, `env_file: .env`) and a `postgres:17-alpine` service under
|
||||
the `postgres` profile with a named volume. SQLite stays the default.
|
||||
- CI is `.gitea/workflows/ci.yml` (GitHub-compatible syntax): typecheck, then `bun test` against a Postgres service, plus the `AGENTS.md` 150-line guard.
|
||||
**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.
|
||||
**Consequences:** Image start time includes bundling the web app. Agent permission files for the new Docker commands are pending the owner (`docs/TODO.md`).
|
||||
|
||||
## 2026-10-30: HTTP API: auth modes, CSRF header, error mapping, actor
|
||||
**Status:** accepted
|
||||
@@ -221,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
|
||||
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
|
||||
`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),
|
||||
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.
|
||||
@@ -270,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
|
||||
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
|
||||
**Status:** accepted
|
||||
**Context:** Kysely ships no driver for Bun. `Bun.SQL` speaks both `sqlite://` and `postgres://` behind one API.
|
||||
@@ -347,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
|
||||
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
@@ -11,16 +11,12 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
|
||||
## Next
|
||||
- [ ] API: response schemas in the OpenAPI document (requests only today).
|
||||
- [ ] Deployment docs (Task 19): the server runs `Bun.serve` in production mode unless `NODE_ENV=development` or `bun --hot`, so Docker needs no `NODE_ENV`. Document `NODE_ENV=development` for local debugging.
|
||||
- [ ] API: `restoreVersion`, `acceptSuggestion` and `resolveChange` take no actor, so history records `"user"` for them even when an app token calls them. Thread the actor through if script provenance matters there.
|
||||
- [ ] API: login/register rate limit is per process and keyed on the socket IP; behind a reverse proxy every client shares one key. Add a trusted-proxy setting for `X-Forwarded-For`.
|
||||
- [ ] API: file imports are base64 in the `jobs.payload` row (up to ~67 MB of text). Move uploads to `DATA_DIR/uploads/` and pass a path.
|
||||
- [ ] API: `PATCH list-items/:id` only moves items; editing an item's note needs a core function.
|
||||
- [ ] Connections: a user-paused connection that never had credentials looks deleted (`paused` + no credentials) and disappears from `GET sources`. Add an explicit `deleted_at`.
|
||||
- [ ] Phase 2 and Phase 3 plans (spec §12).
|
||||
- [ ] Generate image thumbnails for attachments (Phase 1 sizes images with CSS).
|
||||
- [ ] Web: convert the vendored Gelasio variable TTFs (~170 KB each) to latin-subset woff2 once fonttools/woff2 is available; Bun inlines fonts into the CSS bundle.
|
||||
- [ ] Web/API: "Linked sources" is inferred from `plugin:` actors in `fieldSources`, so a source that matched an entry but never wrote a field is not shown. Add a per-entry source-links route.
|
||||
- [ ] Web: the detail pane only lists attachments of the entry itself, not those linked to its reads or copies.
|
||||
|
||||
## Someday
|
||||
|
||||
@@ -35,7 +31,6 @@ Small, specific, independently actionable. Remove only when actually done.
|
||||
file, and compare steps and token use.
|
||||
|
||||
## Known issues
|
||||
- [ ] Web: in production the bundled HTML references `/../../chunk-*.css|js` (Bun bundles `../../web/index.html` from `apps/server`). Browsers normalise it to `/chunk-*` and it loads; check it behind a reverse proxy.
|
||||
- [ ] Plugins: reload re-imports entry files only; edits to a plugin's other modules need a server restart.
|
||||
- [ ] Plugins: rate limiting is per run (each run builds its own client), not per plugin (spec §11); share a limiter per plugin in Phase 2.
|
||||
- [ ] Plugins: `ctx.log` reaches `source_runs.log` only after the run ends, so a crash mid-run loses it.
|
||||
|
||||
@@ -111,11 +111,6 @@ export async function login(
|
||||
return { user: toUser(row), sessionToken };
|
||||
}
|
||||
|
||||
/** True once any account exists (the first one becomes admin). */
|
||||
export async function hasUsers(db: Db): Promise<boolean> {
|
||||
return (await db.k.selectFrom("users").select("id").limit(1).executeTakeFirst()) !== undefined;
|
||||
}
|
||||
|
||||
export async function getSessionUser(db: Db, token: string): Promise<User | null> {
|
||||
const row = await db.k
|
||||
.selectFrom("sessions")
|
||||
@@ -175,17 +170,11 @@ export async function createAppToken(
|
||||
}
|
||||
|
||||
export async function verifyAppToken(db: Db, token: string, scope: TokenScope): Promise<User | null> {
|
||||
return (await authenticateAppToken(db, token, scope))?.user ?? null;
|
||||
}
|
||||
|
||||
/** Like `verifyAppToken`, also returning the token's name (API writes record actor `token:<name>`). */
|
||||
export async function authenticateAppToken(db: Db, token: string, scope: TokenScope): Promise<{ user: User; tokenName: string } | null> {
|
||||
const row = await db.k
|
||||
.selectFrom("app_tokens")
|
||||
.innerJoin("users", "users.id", "app_tokens.user_id")
|
||||
.select([
|
||||
"app_tokens.id as token_id",
|
||||
"app_tokens.name as token_name",
|
||||
"app_tokens.scopes",
|
||||
"users.id",
|
||||
"users.username",
|
||||
@@ -197,19 +186,17 @@ export async function authenticateAppToken(db: Db, token: string, scope: TokenSc
|
||||
.executeTakeFirst();
|
||||
if (!row || !fromJson<string[]>(row.scopes, []).includes(scope)) return null;
|
||||
await db.k.updateTable("app_tokens").set({ last_used_at: nowIso() }).where("id", "=", row.token_id).execute();
|
||||
return { 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<boolean> {
|
||||
const r = await db.k
|
||||
export async function revokeAppToken(db: Db, userId: string, id: string): Promise<void> {
|
||||
await db.k
|
||||
.updateTable("app_tokens")
|
||||
.set({ revoked_at: nowIso() })
|
||||
.where("id", "=", id)
|
||||
.where("user_id", "=", userId)
|
||||
.where("revoked_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
return Number(r.numUpdatedRows) === 1;
|
||||
.execute();
|
||||
}
|
||||
|
||||
export interface AppTokenInfo {
|
||||
|
||||
@@ -77,7 +77,6 @@ export async function createConnection(db: Db, secret: string, userId: string, i
|
||||
status: "active",
|
||||
last_run_at: null,
|
||||
created_at: nowIso(),
|
||||
deleted_at: null,
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
@@ -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> {
|
||||
checkSchedule(patch.scheduleMinutes);
|
||||
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();
|
||||
const row = await q.executeTakeFirst();
|
||||
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> {
|
||||
const r = await db.k
|
||||
.updateTable("source_connections")
|
||||
.set({ status: "paused", credentials_enc: null, deleted_at: nowIso() })
|
||||
.set({ status: "paused", credentials_enc: null })
|
||||
.where("id", "=", id)
|
||||
.where("user_id", "=", userId)
|
||||
.where("deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
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[]> {
|
||||
const rows = await db.k
|
||||
.selectFrom("source_connections")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.where("deleted_at", "is", null)
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
const rows = await db.k.selectFrom("source_connections").selectAll().where("user_id", "=", userId).orderBy("created_at").execute();
|
||||
return rows.map((r) => ({
|
||||
id: r.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) {
|
||||
const r = await db.k
|
||||
.selectFrom("source_connections")
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.where("user_id", "=", userId)
|
||||
.where("deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("connection");
|
||||
const r = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).where("user_id", "=", userId).executeTakeFirst();
|
||||
if (!r || (r.status === "paused" && r.credentials_enc === null)) throw new NotFoundError("connection");
|
||||
return r;
|
||||
}
|
||||
|
||||
@@ -351,7 +342,6 @@ export function syncHandler(deps: SyncHandlerDeps): (payload: SyncPayload, jc?:
|
||||
const { db } = deps;
|
||||
const full = await db.k.selectFrom("source_connections").selectAll().where("id", "=", payload.connectionId).executeTakeFirst();
|
||||
if (!full) throw new DropJobError("connection not found");
|
||||
if (full.deleted_at !== null) throw new DropJobError("connection is deleted");
|
||||
if (full.status === "paused") throw new DropJobError("connection is paused");
|
||||
const plugin = deps.registry.get(full.plugin_id);
|
||||
if (!plugin) return failBeforeRun(db, full.id, payload.file ? "file" : (payload.mode ?? "full"), `Plugin '${full.plugin_id}' is not installed or failed to load.`);
|
||||
|
||||
@@ -7,7 +7,6 @@ import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
|
||||
import { checkKeys, checkText, fromStoredDate, resolveVisibleWork, toStoredDate } from "./entries.ts";
|
||||
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||
import { type Actor, type Executor, inTx, recordHistory } from "./history.ts";
|
||||
import { workTitles } from "./titles.ts";
|
||||
|
||||
/** An owned copy (spec §6.7). Always private. `workId` is null until matched; then `titleHint` names it. */
|
||||
export interface CopyPatch {
|
||||
@@ -225,16 +224,12 @@ export async function softDeleteCopy(
|
||||
await inTx(db, tx, async (k) => write(k, await load(k, userId, copyId), {}, actor, { deleted_at: nowIso() }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's live copies, optionally of one work, newest first. `workTitle` is the matched work's title as
|
||||
* the user sees it (their entry's title override wins); null while unmatched.
|
||||
*/
|
||||
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<(Copy & { workTitle: string | null })[]> {
|
||||
/** The user's live copies, optionally of one work, newest first. */
|
||||
export async function listCopies(db: Db, userId: string, filter: { workId?: string } = {}): Promise<Copy[]> {
|
||||
let q = db.k.selectFrom("copies").selectAll().where("user_id", "=", userId).where("deleted_at", "is", null);
|
||||
if (filter.workId) q = q.where("work_id", "=", filter.workId);
|
||||
const rows = await q.orderBy("created_at", "desc").orderBy("id", "desc").execute();
|
||||
const titles = await workTitles(db.k, userId, rows.map((r) => r.work_id));
|
||||
return rows.map((r) => ({ ...toCopy(r), workTitle: (r.work_id && titles.get(r.work_id)) || null }));
|
||||
return rows.map(toCopy);
|
||||
}
|
||||
|
||||
/** Called by `restoreVersion`: re-apply a history row's `before` values as actor "user". */
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
toStoredDate,
|
||||
} from "./entries.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. */
|
||||
export interface EngagementPatch {
|
||||
@@ -144,7 +144,7 @@ async function write(
|
||||
): Promise<Engagement> {
|
||||
const cols = await toColumns(k, userId, mediaType, patch);
|
||||
const fieldSources = { ...current.fieldSources };
|
||||
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
|
||||
for (const key of Object.keys(patch)) fieldSources[key] = actor;
|
||||
await k
|
||||
.updateTable("engagements")
|
||||
.set({ ...cols, ...extra, field_sources: toJson(fieldSources), updated_at: nowIso() })
|
||||
@@ -175,7 +175,7 @@ export async function addEngagement(
|
||||
const { mediaType } = await requireEntry(k, userId, entryId);
|
||||
const cols = await toColumns(k, userId, mediaType, patch);
|
||||
const fieldSources: FieldSources = {};
|
||||
for (const key of Object.keys(patch)) fieldSources[key] = fieldOwner(actor) as Actor;
|
||||
for (const key of Object.keys(patch)) fieldSources[key] = actor;
|
||||
const now = nowIso();
|
||||
const row: Row = {
|
||||
id: newId(),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parsePartialDate } from "@sttracker/plugin-sdk/partial-date";
|
||||
import type { MediaType, PartialDate } from "@sttracker/plugin-sdk/types";
|
||||
import { customFieldsFor, validateCustomValues } from "./custom-fields.ts";
|
||||
import { NotFoundError, ValidationError } from "./errors.ts";
|
||||
import { type Actor, type Executor, 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).
|
||||
@@ -320,7 +320,7 @@ async function writeEntry(
|
||||
): Promise<Entry> {
|
||||
const fieldSources: Record<string, Actor | undefined> = { ...current.fieldSources };
|
||||
for (const key of Object.keys(patch)) {
|
||||
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : (fieldOwner(actor) as Actor);
|
||||
fieldSources[key] = opts.sources && key in opts.sources ? opts.sources[key] : actor;
|
||||
if (fieldSources[key] === undefined) delete fieldSources[key];
|
||||
}
|
||||
const cols = { ...patchToColumns(patch), ...opts.extra, field_sources: toJson(fieldSources), updated_at: nowIso() };
|
||||
@@ -985,17 +985,14 @@ export interface MatchSuggestion {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** The user's pending match suggestions for a work, best first. Throws `NotFoundError` if the work is not visible. */
|
||||
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
|
||||
.selectFrom("match_suggestions")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.where("work_id", "=", work.id)
|
||||
.where("work_id", "=", workId)
|
||||
.where("status", "=", "pending")
|
||||
.orderBy("score", "desc")
|
||||
.orderBy("id")
|
||||
|
||||
@@ -12,15 +12,6 @@ import { restoreListFromHistory } from "./lists.ts";
|
||||
/** Who made a change. Plugin actors are `plugin:<pluginId>:<connectionId>`; API tokens are `token:<name>`. */
|
||||
export type Actor = "user" | `plugin:${string}:${string}` | `token:${string}`;
|
||||
|
||||
/**
|
||||
* Who owns a field in `field_sources` after `actor` writes it. Only plugin actors keep their name; the user,
|
||||
* through the UI or an API token, is always `"user"`, so syncs never overwrite a field a script edited.
|
||||
* History rows keep the full actor.
|
||||
*/
|
||||
export function fieldOwner(actor: string): string {
|
||||
return actor.startsWith("plugin:") ? actor : "user";
|
||||
}
|
||||
|
||||
/** User-layer entities that write `user_history`. */
|
||||
export type HistoryEntity = "entry" | "engagement" | "copy" | "list";
|
||||
|
||||
|
||||
@@ -198,7 +198,6 @@ export function startScheduler(db: Db, opts: { tickMs?: number } = {}): { stop()
|
||||
.selectFrom("source_connections")
|
||||
.select(["id", "schedule_minutes", "last_run_at"])
|
||||
.where("status", "!=", "paused")
|
||||
.where("deleted_at", "is", null)
|
||||
.where("schedule_minutes", "is not", null)
|
||||
.execute();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* The Log: one chronological feed of a user's engagements (reads, plays, watches) and listens, newest first.
|
||||
*
|
||||
* Each item has an `at` sort key: a listen's `listened_at`; an engagement's finish date, else its start
|
||||
* date, else when it was logged. Partial dates sort as strings ("2025-03" sits after "2025-03-31" and
|
||||
* before "2025-04"), never through `Date`. Pages use a keyset cursor over (at, kind, id), all descending,
|
||||
* so equal timestamps across the two kinds page without gaps or repeats.
|
||||
*/
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { fromJson } from "@sttracker/db/json";
|
||||
import type { PartialDate } from "@sttracker/plugin-sdk/types";
|
||||
import { sql } from "kysely";
|
||||
import { fromStoredDate } from "./entries.ts";
|
||||
import { ValidationError } from "./errors.ts";
|
||||
|
||||
export type LogItem =
|
||||
| {
|
||||
kind: "engagement";
|
||||
id: string;
|
||||
at: string;
|
||||
entryId: string;
|
||||
workId: string;
|
||||
title: string;
|
||||
mediaType: string;
|
||||
coverUrl: string;
|
||||
startedAt: PartialDate | null;
|
||||
finishedAt: PartialDate | null;
|
||||
platform: string | null;
|
||||
rating: number | null;
|
||||
}
|
||||
| {
|
||||
kind: "listen";
|
||||
id: string;
|
||||
at: string;
|
||||
workId: string | null;
|
||||
title: string;
|
||||
mediaType: "track";
|
||||
artist: string;
|
||||
track: string;
|
||||
album: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
type Kind = LogItem["kind"];
|
||||
interface Cursor {
|
||||
at: string;
|
||||
kind: Kind;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function encodeCursor(c: Cursor): string {
|
||||
return Buffer.from(JSON.stringify([c.at, c.kind, c.id])).toString("base64url");
|
||||
}
|
||||
|
||||
function decodeCursor(s: string): Cursor {
|
||||
try {
|
||||
const [at, kind, id] = JSON.parse(Buffer.from(s, "base64url").toString("utf8")) as unknown[];
|
||||
if (typeof at === "string" && (kind === "engagement" || kind === "listen") && typeof id === "string") return { at, kind, id };
|
||||
} catch {
|
||||
// Fall through to the validation error.
|
||||
}
|
||||
throw new ValidationError("cursor", "invalid");
|
||||
}
|
||||
|
||||
/** (at, kind, id) < cursor, with this query's fixed kind: kinds sort "listen" > "engagement". */
|
||||
function after(kind: Kind, c: Cursor): "lt" | "le" | "tie" {
|
||||
if (kind === c.kind) return "tie";
|
||||
return kind < c.kind ? "le" : "lt";
|
||||
}
|
||||
|
||||
export async function listLog(
|
||||
db: Db,
|
||||
userId: string,
|
||||
opts: { limit?: number; cursor?: string } = {},
|
||||
): Promise<{ items: LogItem[]; nextCursor: string | null }> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
const cursor = opts.cursor ? decodeCursor(opts.cursor) : null;
|
||||
|
||||
const at = sql<string>`coalesce(nullif(g.finished_at, ''), nullif(g.started_at, ''), g.created_at)`;
|
||||
let eq = db.k
|
||||
.selectFrom("engagements as g")
|
||||
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||
.innerJoin("works as w", "w.id", "e.work_id")
|
||||
.select([
|
||||
"g.id",
|
||||
"g.entry_id",
|
||||
"g.started_at",
|
||||
"g.started_precision",
|
||||
"g.finished_at",
|
||||
"g.finished_precision",
|
||||
"g.platform",
|
||||
"g.rating",
|
||||
"e.work_id",
|
||||
"e.overrides",
|
||||
"e.media_type_override",
|
||||
"w.media_type",
|
||||
"w.title",
|
||||
"w.cover_url",
|
||||
at.as("at"),
|
||||
])
|
||||
.where("e.user_id", "=", userId)
|
||||
.where("e.deleted_at", "is", null)
|
||||
.where("g.deleted_at", "is", null);
|
||||
let lq = db.k
|
||||
.selectFrom("listens")
|
||||
.select(["id", "listened_at", "artist", "track", "album", "work_id", "source"])
|
||||
.where("user_id", "=", userId);
|
||||
|
||||
if (cursor) {
|
||||
const ge = after("engagement", cursor);
|
||||
eq = eq.where((x) =>
|
||||
ge === "lt" ? x(at, "<", cursor.at) : ge === "le" ? x(at, "<=", cursor.at) : x.or([x(at, "<", cursor.at), x.and([x(at, "=", cursor.at), x("g.id", "<", cursor.id)])]),
|
||||
);
|
||||
const gl = after("listen", cursor);
|
||||
lq = lq.where((x) =>
|
||||
gl === "lt"
|
||||
? x("listened_at", "<", cursor.at)
|
||||
: gl === "le"
|
||||
? x("listened_at", "<=", cursor.at)
|
||||
: x.or([x("listened_at", "<", cursor.at), x.and([x("listened_at", "=", cursor.at), x("id", "<", cursor.id)])]),
|
||||
);
|
||||
}
|
||||
|
||||
const [engagements, listens] = await Promise.all([
|
||||
eq.orderBy(at, "desc").orderBy("g.id", "desc").limit(limit + 1).execute(),
|
||||
lq.orderBy("listened_at", "desc").orderBy("id", "desc").limit(limit + 1).execute(),
|
||||
]);
|
||||
|
||||
const merged: LogItem[] = [
|
||||
...engagements.map((r): LogItem => {
|
||||
const o = fromJson<{ title?: unknown; coverUrl?: unknown }>(r.overrides, {});
|
||||
return {
|
||||
kind: "engagement",
|
||||
id: r.id,
|
||||
at: r.at,
|
||||
entryId: r.entry_id,
|
||||
workId: r.work_id,
|
||||
title: typeof o.title === "string" && o.title ? o.title : r.title,
|
||||
mediaType: r.media_type_override ?? r.media_type,
|
||||
coverUrl: typeof o.coverUrl === "string" ? o.coverUrl : r.cover_url,
|
||||
startedAt: fromStoredDate(r.started_at, r.started_precision),
|
||||
finishedAt: fromStoredDate(r.finished_at, r.finished_precision),
|
||||
platform: r.platform,
|
||||
rating: r.rating,
|
||||
};
|
||||
}),
|
||||
...listens.map(
|
||||
(r): LogItem => ({
|
||||
kind: "listen",
|
||||
id: r.id,
|
||||
at: r.listened_at,
|
||||
workId: r.work_id,
|
||||
title: r.track,
|
||||
mediaType: "track",
|
||||
artist: r.artist,
|
||||
track: r.track,
|
||||
album: r.album,
|
||||
source: r.source,
|
||||
}),
|
||||
),
|
||||
];
|
||||
// Same order as the SQL: at, then kind, then id, all descending. Plain code-unit comparison on both sides.
|
||||
const cmp = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
|
||||
merged.sort((a, b) => cmp(a.at, b.at) || cmp(a.kind, b.kind) || cmp(a.id, b.id));
|
||||
const items = merged.slice(0, limit);
|
||||
const last = items[items.length - 1];
|
||||
return { items, nextCursor: merged.length > limit && last ? encodeCursor(last) : null };
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
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(db: Db, userId: string): Promise<Record<string, unknown>> {
|
||||
const u = await db.k.selectFrom("users").select("preferences").where("id", "=", userId).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> {
|
||||
const raw = await readRaw(db, userId);
|
||||
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");
|
||||
const raw = await readRaw(db, userId);
|
||||
const next = { ...raw, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) };
|
||||
await db.k.updateTable("users").set({ preferences: toJson(next), updated_at: nowIso() }).where("id", "=", userId).execute();
|
||||
return getPreferences(db, userId);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema";
|
||||
import type { EngagementFields, EntryFields, PartialDate, SourceRecord, WorkHint } from "@sttracker/plugin-sdk/types";
|
||||
import { addEngagement, type EngagementPatch, updateEngagement } from "../engagements.ts";
|
||||
import { type EntryPatch, fromStoredDate, resolveVisibleWork, upsertEntry } from "../entries.ts";
|
||||
import { type Actor, type Executor, fieldOwner } from "../history.ts";
|
||||
import type { Actor, Executor } from "../history.ts";
|
||||
import { createList, replaceListItems, updateList } from "../lists.ts";
|
||||
import { matchWork } from "../matching.ts";
|
||||
import { resolveAliasIn, updateWorkFromHintIn } from "../works.ts";
|
||||
@@ -146,8 +146,8 @@ export async function listFieldSources(k: Executor, listId: string, sourceActor:
|
||||
const fs: Record<string, string> = { items: sourceActor };
|
||||
for (const r of rows) {
|
||||
const after = fromJson<Fields>(r.after, {});
|
||||
for (const f of ["name", "description"]) if (f in after) fs[f] = fieldOwner(r.actor);
|
||||
if (fieldOwner(r.actor) === "user") fs.items = "user";
|
||||
for (const f of ["name", "description"]) if (f in after) fs[f] = r.actor;
|
||||
if (r.actor === "user") fs.items = "user";
|
||||
}
|
||||
return fs;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import { NotFoundError, ValidationError } from "../errors.ts";
|
||||
import type { Actor, Executor } from "../history.ts";
|
||||
import { softDeleteList } from "../lists.ts";
|
||||
import { matchWork } from "../matching.ts";
|
||||
import { entryTitles } from "../titles.ts";
|
||||
import { resolveAliasIn } from "../works.ts";
|
||||
import { applyPatch, engagementValues, entryValues } from "./apply.ts";
|
||||
import { decodeVersion } from "./snapshot.ts";
|
||||
@@ -28,14 +27,6 @@ export interface ChangeEvent {
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
/** An inbox item: the event plus the titles the Changes screen shows. */
|
||||
export interface ChangeItem extends ChangeEvent {
|
||||
/** The target's title: an entry's (override-aware), an engagement's entry's, a list's name. Null if gone. */
|
||||
title: string | null;
|
||||
/** Merged events only: the title of the entry the merge target record maps to, else null. */
|
||||
intoTitle?: string | null;
|
||||
}
|
||||
|
||||
function toChange(r: Database["change_events"]): ChangeEvent {
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -60,63 +51,15 @@ export async function listChanges(
|
||||
db: Db,
|
||||
userId: string,
|
||||
opts: { status?: "pending" | "all"; connectionId?: string; limit?: number; cursor?: string } = {},
|
||||
): Promise<{ items: ChangeItem[]; nextCursor: string | null }> {
|
||||
): Promise<{ items: ChangeEvent[]; nextCursor: string | null }> {
|
||||
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
||||
let q = db.k.selectFrom("change_events").selectAll().where("user_id", "=", userId);
|
||||
if ((opts.status ?? "pending") === "pending") q = q.where("status", "=", "pending");
|
||||
if (opts.connectionId) q = q.where("connection_id", "=", opts.connectionId);
|
||||
if (opts.cursor) q = q.where("id", "<", opts.cursor);
|
||||
const rows = await q.orderBy("id", "desc").limit(limit + 1).execute();
|
||||
const events = rows.slice(0, limit).map(toChange);
|
||||
return { items: await withTitles(db.k, userId, events), nextCursor: rows.length > limit ? events[events.length - 1]!.id : null };
|
||||
}
|
||||
|
||||
/** Attach titles with a fixed number of batched queries, whatever the page size. */
|
||||
async function withTitles(k: Executor, userId: string, events: ChangeEvent[]): Promise<ChangeItem[]> {
|
||||
const ids = (entity: string) => [...new Set(events.filter((e) => e.targetEntity === entity && e.targetId).map((e) => e.targetId!))];
|
||||
|
||||
// Merged events: the record their intoKey names (same connection) → its target.
|
||||
const merges = events.filter((e) => e.type === "merged" && e.connectionId && typeof e.payload.intoKey === "string");
|
||||
const intoRecords = merges.length
|
||||
? await k
|
||||
.selectFrom("source_records")
|
||||
.select(["connection_id", "key", "target_entity", "target_id"])
|
||||
.where("connection_id", "in", [...new Set(merges.map((e) => e.connectionId!))])
|
||||
.where("key", "in", [...new Set(merges.map((e) => String(e.payload.intoKey)))])
|
||||
.execute()
|
||||
: [];
|
||||
const intoTarget = new Map(intoRecords.map((r) => [`${r.connection_id}\u0000${r.key}`, r]));
|
||||
|
||||
const engagementIds = [...ids("engagement"), ...intoRecords.filter((r) => r.target_entity === "engagement").map((r) => r.target_id!)];
|
||||
const engagementEntry = new Map<string, string>();
|
||||
if (engagementIds.length) {
|
||||
const rows = await k.selectFrom("engagements").select(["id", "entry_id"]).where("id", "in", [...new Set(engagementIds)]).execute();
|
||||
for (const r of rows) engagementEntry.set(r.id, r.entry_id);
|
||||
}
|
||||
const entryOf = (entity: string | null, id: string | null): string | null =>
|
||||
!id ? null : entity === "entry" ? id : entity === "engagement" ? (engagementEntry.get(id) ?? null) : null;
|
||||
|
||||
const entryIds = [
|
||||
...ids("entry"),
|
||||
...engagementEntry.values(),
|
||||
...intoRecords.map((r) => entryOf(r.target_entity, r.target_id)),
|
||||
];
|
||||
const entryTitle = await entryTitles(k, userId, entryIds);
|
||||
const listIds = ids("list");
|
||||
const listName = new Map<string, string>();
|
||||
if (listIds.length) {
|
||||
const rows = await k.selectFrom("lists").select(["id", "name"]).where("user_id", "=", userId).where("id", "in", listIds).execute();
|
||||
for (const r of rows) listName.set(r.id, r.name);
|
||||
}
|
||||
|
||||
return events.map((e) => {
|
||||
const title =
|
||||
e.targetEntity === "list" ? (listName.get(e.targetId ?? "") ?? null) : (entryTitle.get(entryOf(e.targetEntity, e.targetId) ?? "") ?? null);
|
||||
if (e.type !== "merged") return { ...e, title };
|
||||
const into = intoTarget.get(`${e.connectionId}\u0000${String(e.payload.intoKey)}`);
|
||||
const intoEntry = into ? entryOf(into.target_entity, into.target_id) : null;
|
||||
return { ...e, title, intoTitle: (intoEntry && entryTitle.get(intoEntry)) || null };
|
||||
});
|
||||
const items = rows.slice(0, limit).map(toChange);
|
||||
return { items, nextCursor: rows.length > limit ? items[items.length - 1]!.id : null };
|
||||
}
|
||||
|
||||
const STATUS_FOR = { accept: "accepted", ignore: "ignored", keep_mine: "kept_mine" } as const;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Batched title lookups for views that list many things at once (Changes, Log, Owned). Titles are what
|
||||
* the user sees: their entry's `overrides.title` when set, else the work's title.
|
||||
*/
|
||||
import { fromJson } from "@sttracker/db/json";
|
||||
import type { Executor } from "./history.ts";
|
||||
|
||||
const unique = (ids: (string | null | undefined)[]) => [...new Set(ids.filter((x): x is string => !!x))];
|
||||
|
||||
function overrideTitle(overrides: string): string | undefined {
|
||||
const t = fromJson<{ title?: unknown }>(overrides, {}).title;
|
||||
return typeof t === "string" && t ? t : undefined;
|
||||
}
|
||||
|
||||
/** Work id → title as this user sees it (their live entry's title override, else the work's title). */
|
||||
export async function workTitles(k: Executor, userId: string, workIds: (string | null | undefined)[]): Promise<Map<string, string>> {
|
||||
const ids = unique(workIds);
|
||||
const out = new Map<string, string>();
|
||||
if (ids.length === 0) return out;
|
||||
const works = await k.selectFrom("works").select(["id", "title"]).where("id", "in", ids).execute();
|
||||
for (const w of works) out.set(w.id, w.title);
|
||||
const entries = await k
|
||||
.selectFrom("entries")
|
||||
.select(["work_id", "overrides"])
|
||||
.where("user_id", "=", userId)
|
||||
.where("work_id", "in", ids)
|
||||
.where("deleted_at", "is", null)
|
||||
.execute();
|
||||
for (const e of entries) {
|
||||
const t = overrideTitle(e.overrides);
|
||||
if (t) out.set(e.work_id, t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Entry id → title (override-aware). Deleted entries keep their title; other users' entries are absent. */
|
||||
export async function entryTitles(k: Executor, userId: string, entryIds: (string | null | undefined)[]): Promise<Map<string, string>> {
|
||||
const ids = unique(entryIds);
|
||||
const out = new Map<string, string>();
|
||||
if (ids.length === 0) return out;
|
||||
const rows = await k
|
||||
.selectFrom("entries as e")
|
||||
.innerJoin("works as w", "w.id", "e.work_id")
|
||||
.select(["e.id", "e.overrides", "w.title"])
|
||||
.where("e.user_id", "=", userId)
|
||||
.where("e.id", "in", ids)
|
||||
.execute();
|
||||
for (const r of rows) out.set(r.id, overrideTitle(r.overrides) ?? r.title);
|
||||
return out;
|
||||
}
|
||||
@@ -6,14 +6,8 @@ import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { definePlugin, type SourcePlugin } from "@sttracker/plugin-sdk/types";
|
||||
import { listAttachmentLinks, readAttachment, requireLinkTarget, storeAttachment } from "../src/attachments.ts";
|
||||
import { authenticateAppToken, createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
|
||||
import { getHistory } from "../src/history.ts";
|
||||
import { fieldOwner } from "../src/history.ts";
|
||||
import { getEntry, upsertEntry } from "../src/entries.ts";
|
||||
import { listEngagements } from "../src/engagements.ts";
|
||||
import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listConnections, listRuns, syncHandler, updateConnection } from "../src/connections.ts";
|
||||
import { startScheduler } from "../src/jobs.ts";
|
||||
import { DropJobError } from "../src/jobs.ts";
|
||||
import { createAppToken, listAppTokens, revokeAppToken } from "../src/auth.ts";
|
||||
import { createConnection, deleteConnection, devicePoll, deviceStart, getConnection, listRuns, syncHandler } from "../src/connections.ts";
|
||||
import { listSuggestions, quickAdd } from "../src/entries.ts";
|
||||
import { addEngagement } from "../src/engagements.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"),
|
||||
},
|
||||
});
|
||||
let flipStatus: "planned" | "completed" = "planned";
|
||||
const flip: SourcePlugin = definePlugin({
|
||||
manifest: { id: "flip", name: "Flip", version: "1", sdkVersion: 1, mediaTypes: ["book"], capabilities: ["pull"], config: [] },
|
||||
async *pull() {
|
||||
yield {
|
||||
kind: "entry",
|
||||
key: "flip-1",
|
||||
raw: flipStatus,
|
||||
work: { title: "Flip", mediaType: "book", externalIds: { isbn13: "9780000000010" } },
|
||||
entry: { status: flipStatus, rating: flipStatus === "planned" ? 40 : 90 },
|
||||
};
|
||||
},
|
||||
});
|
||||
const extra: Record<string, SourcePlugin> = { fileish, flip };
|
||||
const reg2: PluginRegistry = { get: (id) => extra[id] ?? reg.get(id), list: () => [...reg.list(), fileish.manifest, flip.manifest], reload: async () => {} };
|
||||
const reg2: PluginRegistry = { get: (id) => (id === "fileish" ? fileish : reg.get(id)), list: () => [...reg.list(), fileish.manifest], reload: async () => {} };
|
||||
|
||||
forEachDialect("api support", (getDb) => {
|
||||
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 t = await createAppToken(db, u, "script", ["read"]);
|
||||
await createAppToken(db, other, "x", ["write"]);
|
||||
expect(await revokeAppToken(db, other, t.id)).toBe(false);
|
||||
expect(await revokeAppToken(db, u, t.id)).toBe(true);
|
||||
expect(await revokeAppToken(db, u, t.id)).toBe(false);
|
||||
await revokeAppToken(db, u, t.id);
|
||||
const list = await listAppTokens(db, u);
|
||||
expect(list).toHaveLength(1);
|
||||
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");
|
||||
});
|
||||
|
||||
test("fieldOwner: only plugin actors keep their name in field_sources", () => {
|
||||
expect(fieldOwner("user")).toBe("user");
|
||||
expect(fieldOwner("token:script")).toBe("user");
|
||||
expect(fieldOwner("plugin:echo:c1")).toBe("plugin:echo:c1");
|
||||
});
|
||||
|
||||
test("authenticateAppToken returns the user and the token name", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
const t = await createAppToken(db, u, "script", ["write"]);
|
||||
expect(await authenticateAppToken(db, t.token, "write")).toMatchObject({ user: { id: u }, tokenName: "script" });
|
||||
expect(await authenticateAppToken(db, t.token, "read")).toBeNull();
|
||||
});
|
||||
|
||||
test("a token write keeps its actor in history, is owned by the user, and a later sync files a pending change", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
flipStatus = "planned";
|
||||
const id = await createConnection(db, SECRET, u, { pluginId: "flip", name: "F", config: {} }, flip.manifest);
|
||||
const sync = syncHandler({ db, secret: SECRET, registry: reg2 });
|
||||
await sync({ connectionId: id, mode: "full" });
|
||||
const entry = await db.k.selectFrom("entries").selectAll().where("user_id", "=", u).executeTakeFirstOrThrow();
|
||||
await upsertEntry(db, u, entry.work_id, { status: "dropped" }, "token:script");
|
||||
const view = await getEntry(db, u, entry.id);
|
||||
expect(view.fieldSources.status).toBe("user");
|
||||
expect(view.fieldSources.rating).toBe(`plugin:flip:${id}`);
|
||||
const hist = await getHistory(db, u, "entry", entry.id);
|
||||
expect(hist[0]!.actor).toBe("token:script");
|
||||
|
||||
flipStatus = "completed";
|
||||
await sync({ connectionId: id, mode: "full" });
|
||||
const after = await getEntry(db, u, entry.id);
|
||||
expect(after.status).toBe("dropped");
|
||||
expect(after.rating).toBe(90);
|
||||
const pending = await db.k.selectFrom("change_events").selectAll().where("user_id", "=", u).where("status", "=", "pending").execute();
|
||||
expect(pending.map((p) => [p.type, JSON.parse(p.payload).field])).toEqual([["changed", "status"]]);
|
||||
});
|
||||
|
||||
test("token-authored engagement fields are owned by the user", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
const e = await quickAdd(db, u, { title: "Dune" });
|
||||
await addEngagement(db, u, e.id, { platform: "kindle" }, "token:script");
|
||||
const [g] = await listEngagements(db, u, e.id);
|
||||
expect(g!.fieldSources).toEqual({ platform: "user" });
|
||||
});
|
||||
|
||||
test("listSuggestions lists pending suggestions for a visible work", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
@@ -137,11 +68,6 @@ forEachDialect("api support", (getDb) => {
|
||||
expect(s[0]).toMatchObject({ workId: draft.workId, candidateWorkId: good, score: 0.75, status: "pending" });
|
||||
const other = await makeUser(db, "bob");
|
||||
await expect(listSuggestions(db, other, draft.workId)).rejects.toBeInstanceOf(NotFoundError);
|
||||
|
||||
// Asking by an id the user aliased away lists the canonical work's suggestions.
|
||||
const old = await makeWork(db, { title: "Old Dune", ownerUserId: u });
|
||||
await db.k.insertInto("work_aliases").values({ id: newId(), user_id: u, from_work_id: old, to_work_id: draft.workId }).execute();
|
||||
expect((await listSuggestions(db, u, old)).map((x) => x.workId)).toEqual([draft.workId]);
|
||||
});
|
||||
|
||||
test("requireLinkTarget checks ownership per entity; links and filename are readable", async () => {
|
||||
@@ -188,41 +114,6 @@ forEachDialect("api support", (getDb) => {
|
||||
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
test("a paused connection without credentials stays listed and can be unpaused; a deleted one is gone", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {} }, echo.manifest);
|
||||
await updateConnection(db, SECRET, u, id, { status: "paused" }, echo.manifest);
|
||||
expect((await listConnections(db, u)).map((c) => c.id)).toEqual([id]);
|
||||
expect((await getConnection(db, u, id)).status).toBe("paused");
|
||||
await updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest);
|
||||
expect((await getConnection(db, u, id)).status).toBe("active");
|
||||
|
||||
await deleteConnection(db, u, id);
|
||||
const row = await db.k.selectFrom("source_connections").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
expect(row).toMatchObject({ status: "paused", credentials_enc: null });
|
||||
expect(row.deleted_at).not.toBeNull();
|
||||
expect(await listConnections(db, u)).toEqual([]);
|
||||
await expect(getConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
|
||||
await expect(updateConnection(db, SECRET, u, id, { status: "active" }, echo.manifest)).rejects.toBeInstanceOf(NotFoundError);
|
||||
await expect(deleteConnection(db, u, id)).rejects.toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
test("deleted connections are never scheduled and their queued syncs are dropped", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
const id = await createConnection(db, SECRET, u, { pluginId: "echo", name: "E", config: {}, scheduleMinutes: 1 }, echo.manifest);
|
||||
// Simulate a delete that left status "error" (e.g. a row edited by hand): deleted_at alone must exclude it.
|
||||
await deleteConnection(db, u, id);
|
||||
await db.k.updateTable("source_connections").set({ status: "error" }).where("id", "=", id).execute();
|
||||
const sched = startScheduler(db, { tickMs: 3_600_000 });
|
||||
await sched.tickNow();
|
||||
sched.stop();
|
||||
expect(await db.k.selectFrom("jobs").select("id").execute()).toEqual([]);
|
||||
const sync = syncHandler({ db, secret: SECRET, registry: reg });
|
||||
await expect(sync({ connectionId: id })).rejects.toBeInstanceOf(DropJobError);
|
||||
});
|
||||
|
||||
test("syncHandler runs file mode from a base64 payload", async () => {
|
||||
const db = getDb();
|
||||
const u = await makeUser(db);
|
||||
|
||||
@@ -55,8 +55,7 @@ forEachDialect("connections", (getDb) => {
|
||||
r = await raw(id);
|
||||
expect(r.status).toBe("paused");
|
||||
expect(r.credentials_enc).toBeNull();
|
||||
expect(r.deleted_at).not.toBeNull();
|
||||
expect(await listConnections(db, u)).toEqual([]);
|
||||
expect((await listConnections(db, u))[0]!.hasCredentials).toBe(false);
|
||||
});
|
||||
|
||||
test("cursor persists on set; http uses manifest rate limit", async () => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect, test } from "bun:test";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { createCopy, listCopies, softDeleteCopy, updateCopy } from "../src/copies.ts";
|
||||
import { defineCustomField } from "../src/custom-fields.ts";
|
||||
import { upsertEntry } from "../src/entries.ts";
|
||||
import { NotFoundError, ValidationError } from "../src/errors.ts";
|
||||
import { getHistory, restoreVersion } from "../src/history.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
@@ -74,23 +73,4 @@ forEachDialect("copies", (getDb) => {
|
||||
NotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
test("listCopies carries the work title, with the user's title override", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const dune = await makeWork(db, { title: "Dune" });
|
||||
const hyperion = await makeWork(db, { title: "Hyperion" });
|
||||
await upsertEntry(db, userId, dune, { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const a = await createCopy(db, userId, { workId: dune });
|
||||
const b = await createCopy(db, userId, { workId: hyperion });
|
||||
const c = await createCopy(db, userId, { titleHint: "HALF-LIFE 2" });
|
||||
const byId = new Map((await listCopies(db, userId)).map((x) => [x.id, x]));
|
||||
expect(byId.get(a.id)!.workTitle).toBe("Dune (1965)");
|
||||
expect(byId.get(b.id)!.workTitle).toBe("Hyperion");
|
||||
expect(byId.get(c.id)!.workTitle).toBeNull();
|
||||
// Another user's override does not leak.
|
||||
const bob = await makeUser(db, "bob");
|
||||
await createCopy(db, bob, { workId: dune });
|
||||
expect((await listCopies(db, bob))[0]!.workTitle).toBe("Dune");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { addEngagement, softDeleteEngagement } from "../src/engagements.ts";
|
||||
import { softDeleteEntry, upsertEntry } from "../src/entries.ts";
|
||||
import { ValidationError } from "../src/errors.ts";
|
||||
import { listLog } from "../src/log.ts";
|
||||
import { makeUser, makeWork } from "./user-layer-fixtures.ts";
|
||||
|
||||
async function listen(db: Db, userId: string, listenedAt: string, track: string, workId: string | null = null): Promise<string> {
|
||||
const id = newId();
|
||||
await db.k
|
||||
.insertInto("listens")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
listened_at: listenedAt,
|
||||
artist: "Low",
|
||||
track,
|
||||
album: "Hey What",
|
||||
duration_ms: 200_000,
|
||||
mbids: "{}",
|
||||
work_id: workId,
|
||||
source: "listenbrainz",
|
||||
source_key: `${listenedAt}:${track}`,
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function setup(db: Db) {
|
||||
const userId = await makeUser(db);
|
||||
const dune = await makeWork(db, { title: "Dune" });
|
||||
const entry = await upsertEntry(db, userId, dune, { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const finished = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-10-01", precision: "day" } }, "user");
|
||||
const started = await addEngagement(db, userId, entry.id, { startedAt: { value: "2025-03", precision: "month" } }, "user");
|
||||
const undated = await addEngagement(db, userId, entry.id, {}, "user");
|
||||
const gone = await addEngagement(db, userId, entry.id, { finishedAt: { value: "2026-01-01", precision: "day" } }, "user");
|
||||
await softDeleteEngagement(db, userId, gone.id, "user");
|
||||
|
||||
// An engagement on a deleted entry, and another user's data, never show.
|
||||
const game = await makeWork(db, { title: "Outer Wilds", mediaType: "game" });
|
||||
const deletedEntry = await upsertEntry(db, userId, game, {}, "user");
|
||||
await addEngagement(db, userId, deletedEntry.id, { finishedAt: { value: "2026-05-01", precision: "day" } }, "user");
|
||||
await softDeleteEntry(db, userId, deletedEntry.id, "user");
|
||||
const other = await makeUser(db, "bob");
|
||||
const otherEntry = await upsertEntry(db, other, dune, {}, "user");
|
||||
await addEngagement(db, other, otherEntry.id, { finishedAt: { value: "2026-06-01", precision: "day" } }, "user");
|
||||
await listen(db, other, "2026-06-01T10:00:00.000Z", "Not mine");
|
||||
|
||||
// Two listens tie with the finished read on `at`, so pages must break ties by kind, then id.
|
||||
const l1 = await listen(db, userId, "2026-10-01", "Days Like These");
|
||||
const l2 = await listen(db, userId, "2026-10-01", "White Horses");
|
||||
return { userId, entry, finished, started, undated, l1, l2 };
|
||||
}
|
||||
|
||||
forEachDialect("log", (getDb) => {
|
||||
test("newest first: engagements of live entries and listens, with titles", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
const { items, nextCursor } = await listLog(db, s.userId);
|
||||
expect(nextCursor).toBeNull();
|
||||
expect(items.map((i) => i.id)).toEqual([s.undated.id, s.l2, s.l1, s.finished.id, s.started.id]);
|
||||
|
||||
const read = items.find((i) => i.id === s.finished.id)!;
|
||||
expect(read).toMatchObject({
|
||||
kind: "engagement",
|
||||
at: "2026-10-01",
|
||||
entryId: s.entry.id,
|
||||
title: "Dune (1965)",
|
||||
mediaType: "book",
|
||||
finishedAt: { value: "2026-10-01", precision: "day" },
|
||||
startedAt: null,
|
||||
});
|
||||
const heard = items.find((i) => i.id === s.l1)!;
|
||||
expect(heard).toMatchObject({ kind: "listen", at: "2026-10-01", title: "Days Like These", artist: "Low", track: "Days Like These", album: "Hey What", mediaType: "track" });
|
||||
expect(items.find((i) => i.id === s.started.id)!.at).toBe("2025-03");
|
||||
});
|
||||
|
||||
test("keyset pages cross the kind boundary without gaps or repeats", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
const all = (await listLog(db, s.userId)).items.map((i) => i.id);
|
||||
for (const limit of [1, 2, 3]) {
|
||||
const seen: string[] = [];
|
||||
let cursor: string | undefined;
|
||||
for (let guard = 0; guard < 10; guard++) {
|
||||
const page = await listLog(db, s.userId, { limit, cursor });
|
||||
expect(page.items.length).toBeLessThanOrEqual(limit);
|
||||
seen.push(...page.items.map((i) => i.id));
|
||||
if (!page.nextCursor) break;
|
||||
cursor = page.nextCursor;
|
||||
}
|
||||
expect(seen).toEqual(all);
|
||||
}
|
||||
});
|
||||
|
||||
test("limit is clamped and a bad cursor is a validation error", async () => {
|
||||
const db = getDb();
|
||||
const s = await setup(db);
|
||||
expect((await listLog(db, s.userId, { limit: 0 })).items).toHaveLength(1);
|
||||
expect((await listLog(db, s.userId, { limit: 1000 })).items).toHaveLength(5);
|
||||
await expect(listLog(db, s.userId, { cursor: "nonsense" })).rejects.toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { newId, nowIso } from "@sttracker/db/ids";
|
||||
import { toJson } from "@sttracker/db/json";
|
||||
import { forEachDialect } from "@sttracker/db/testing";
|
||||
import { addEngagement } from "../../src/engagements.ts";
|
||||
import { upsertEntry } from "../../src/entries.ts";
|
||||
import { createList } from "../../src/lists.ts";
|
||||
import { listChanges } from "../../src/sync/changes.ts";
|
||||
import { makeConnection, makeUser, makeWork } from "./helpers.ts";
|
||||
|
||||
async function event(
|
||||
db: Db,
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
e: { type: "changed" | "removed" | "merged"; targetEntity: string; targetId: string; payload?: Record<string, unknown> },
|
||||
): Promise<string> {
|
||||
const id = newId();
|
||||
await db.k
|
||||
.insertInto("change_events")
|
||||
.values({
|
||||
id,
|
||||
user_id: userId,
|
||||
connection_id: connectionId,
|
||||
run_id: null,
|
||||
record_id: null,
|
||||
type: e.type,
|
||||
target_entity: e.targetEntity,
|
||||
target_id: e.targetId,
|
||||
payload: toJson(e.payload ?? {}),
|
||||
status: "pending",
|
||||
created_at: nowIso(),
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function record(db: Db, connectionId: string, key: string, targetEntity: string, targetId: string): Promise<void> {
|
||||
const now = nowIso();
|
||||
await db.k
|
||||
.insertInto("source_records")
|
||||
.values({
|
||||
id: newId(),
|
||||
connection_id: connectionId,
|
||||
key,
|
||||
kind: targetEntity,
|
||||
current_hash: "h",
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
deleted_at: null,
|
||||
merged_into_key: null,
|
||||
target_entity: targetEntity,
|
||||
target_id: targetId,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
forEachDialect("change titles", (getDb) => {
|
||||
test("events carry their target's title, and merged events the merge target's", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const conn = await makeConnection(db, userId, "hardcover");
|
||||
const dune = await upsertEntry(db, userId, await makeWork(db, { title: "Dune" }), { overrides: { title: "Dune (1965)" } }, "user");
|
||||
const piranesi = await upsertEntry(db, userId, await makeWork(db, { title: "Piranesi" }), {}, "user");
|
||||
const deluxe = await upsertEntry(db, userId, await makeWork(db, { title: "Piranesi (Deluxe)" }), {}, "user");
|
||||
const read = await addEngagement(db, userId, piranesi.id, {}, "user");
|
||||
const list = await createList(db, userId, { name: "Want to read" });
|
||||
await record(db, conn, "b:deluxe", "entry", deluxe.id);
|
||||
|
||||
const onEntry = await event(db, userId, conn, { type: "changed", targetEntity: "entry", targetId: dune.id, payload: { field: "rating", from: 80, to: 60 } });
|
||||
const onRead = await event(db, userId, conn, { type: "removed", targetEntity: "engagement", targetId: read.id });
|
||||
const onList = await event(db, userId, conn, { type: "removed", targetEntity: "list", targetId: list.id });
|
||||
const merged = await event(db, userId, conn, { type: "merged", targetEntity: "entry", targetId: piranesi.id, payload: { key: "b:p", intoKey: "b:deluxe" } });
|
||||
const unknownMerge = await event(db, userId, conn, { type: "merged", targetEntity: "entry", targetId: piranesi.id, payload: { key: "b:p", intoKey: "b:nope" } });
|
||||
|
||||
const byId = new Map((await listChanges(db, userId)).items.map((c) => [c.id, c]));
|
||||
expect(byId.get(onEntry)!.title).toBe("Dune (1965)");
|
||||
expect(byId.get(onRead)!.title).toBe("Piranesi");
|
||||
expect(byId.get(onList)!.title).toBe("Want to read");
|
||||
expect(byId.get(merged)!.title).toBe("Piranesi");
|
||||
expect(byId.get(merged)!.intoTitle).toBe("Piranesi (Deluxe)");
|
||||
expect(byId.get(unknownMerge)!.intoTitle).toBeNull();
|
||||
expect(byId.get(onEntry)!.intoTitle).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a missing target gives a null title", async () => {
|
||||
const db = getDb();
|
||||
const userId = await makeUser(db);
|
||||
const conn = await makeConnection(db, userId, "hardcover");
|
||||
const id = await event(db, userId, conn, { type: "removed", targetEntity: "entry", targetId: newId() });
|
||||
const [c] = (await listChanges(db, userId)).items;
|
||||
expect(c!.id).toBe(id);
|
||||
expect(c!.title).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { Migration } from "kysely/migration";
|
||||
import { initial } from "./0001_initial.ts";
|
||||
import { worksPositionUnique } from "./0002_works_position_unique.ts";
|
||||
import { sourceConnectionsDeletedAt } from "./0003_source_connections_deleted_at.ts";
|
||||
|
||||
/** All migrations, keyed by sortable name. Imported statically so bundles need no filesystem. */
|
||||
export const migrations: Record<string, Migration> = {
|
||||
"0001_initial": initial,
|
||||
"0002_works_position_unique": worksPositionUnique,
|
||||
"0003_source_connections_deleted_at": sourceConnectionsDeletedAt,
|
||||
};
|
||||
|
||||
@@ -269,8 +269,6 @@ export interface SourceConnectionsTable {
|
||||
status: ConnectionStatus;
|
||||
last_run_at: string | null;
|
||||
created_at: string;
|
||||
/** Set by `deleteConnection`; deleted connections are hidden, never scheduled and never synced. */
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
export interface SourceRunsTable {
|
||||
|
||||
Reference in New Issue
Block a user