All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 42s
Posting a dump was a single form where the important choices were the easiest to miss. It is now three panels: link or file, why & where, playlists. Composition: - No more URL/File toggle. An empty panel offers both ways in at once and the kind follows what you actually did; a file dropped anywhere in the modal is accepted, not just on the zone. - Categories and visibility get their own panel instead of a disclosure that read as optional, and the primary button stays "Next" until they've been seen. Visibility carries a real label now. - The draft (link, title, why, categories, visibility) is mirrored to localStorage on every change and restored on reopen, so Escape or a stray backdrop click costs nothing. Only an attached file can't be restored, so that is the one case that asks before closing. - URL dumps can carry a poster-supplied title instead of being stuck with whatever the page scraped, editable right under the preview. - Multipart uploads go through XHR so there is a real progress bar and a percentage on the button, rather than 50 MB of silence. Duplicates: - New dumps.url_canonical column (+ index, backfilled by 0013) holding a lossy key that ignores scheme, www., trailing slashes, tracking parameters and YouTube share shapes. GET /api/dumps/by-url reads it, and the create form warns "already dumped by X" while it fetches the preview. Never blocking. Fixes: - /api/preview now reports whether the page was actually reached: a failed fetch still yields a hostname-only stub, so a dead link and a page without metadata used to render identically. - The Web Share Target never worked. The manifest posts to "/", but the index redirect dropped the query string, so every Android share landed on the feed with nothing pre-filled. - File dumps no longer take the extension into their title. - The link field no longer autofocuses on touch, where it raised a keyboard over the modal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiAPtJZeCYYk8rKehtLUQU
71 lines
3.0 KiB
TypeScript
71 lines
3.0 KiB
TypeScript
import type { DatabaseSync } from "node:sqlite";
|
|
import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts";
|
|
import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts";
|
|
import { up as up0003DumpBacklinks } from "./migrations/0003_dump_backlinks.ts";
|
|
import { up as up0004UserRoles } from "./migrations/0004_user_roles.ts";
|
|
import { up as up0005DropIsAdmin } from "./migrations/0005_drop_is_admin.ts";
|
|
import { up as up0006Categories } from "./migrations/0006_categories.ts";
|
|
import { up as up0007PasswordResetTokens } from "./migrations/0007_password_reset_tokens.ts";
|
|
import { up as up0008ChatMessages } from "./migrations/0008_chat_messages.ts";
|
|
import { up as up0009ChatReply } from "./migrations/0009_chat_reply.ts";
|
|
import { up as up0010YoutubeEmbedStart } from "./migrations/0010_youtube_embed_start.ts";
|
|
import { up as up0011SplitFaviconThumbnail } from "./migrations/0011_split_favicon_thumbnail.ts";
|
|
import { up as up0012FixFaviconReclassification } from "./migrations/0012_fix_favicon_reclassification.ts";
|
|
import { up as up0013DumpUrlCanonical } from "./migrations/0013_dump_url_canonical.ts";
|
|
|
|
interface Migration {
|
|
name: string;
|
|
up: (db: DatabaseSync) => void;
|
|
}
|
|
|
|
// Append-only — never edit or reorder a migration once it has shipped.
|
|
// Each `up` must be idempotent: it also runs against fresh databases created
|
|
// straight from schema.sql, where the change it makes already exists.
|
|
const MIGRATIONS: Migration[] = [
|
|
{ name: "0001_comment_likes", up: up0001CommentLikes },
|
|
{ name: "0002_dump_thumbnail", up: up0002DumpThumbnail },
|
|
{ name: "0003_dump_backlinks", up: up0003DumpBacklinks },
|
|
{ name: "0004_user_roles", up: up0004UserRoles },
|
|
{ name: "0005_drop_is_admin", up: up0005DropIsAdmin },
|
|
{ name: "0006_categories", up: up0006Categories },
|
|
{ name: "0007_password_reset_tokens", up: up0007PasswordResetTokens },
|
|
{ name: "0008_chat_messages", up: up0008ChatMessages },
|
|
{ name: "0009_chat_reply", up: up0009ChatReply },
|
|
{ name: "0010_youtube_embed_start", up: up0010YoutubeEmbedStart },
|
|
{ name: "0011_split_favicon_thumbnail", up: up0011SplitFaviconThumbnail },
|
|
{
|
|
name: "0012_fix_favicon_reclassification",
|
|
up: up0012FixFaviconReclassification,
|
|
},
|
|
{ name: "0013_dump_url_canonical", up: up0013DumpUrlCanonical },
|
|
];
|
|
|
|
export function runMigrations(db: DatabaseSync): void {
|
|
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
name TEXT PRIMARY KEY,
|
|
applied_at TEXT NOT NULL
|
|
);`);
|
|
|
|
const applied = new Set(
|
|
(db.prepare(`SELECT name FROM schema_migrations;`).all() as {
|
|
name: string;
|
|
}[]).map((r) => r.name),
|
|
);
|
|
|
|
for (const { name, up } of MIGRATIONS) {
|
|
if (applied.has(name)) continue;
|
|
db.exec("BEGIN;");
|
|
try {
|
|
up(db);
|
|
db.prepare(
|
|
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?);`,
|
|
).run(name, new Date().toISOString());
|
|
db.exec("COMMIT;");
|
|
console.log(`[migrate] applied ${name}`);
|
|
} catch (err) {
|
|
db.exec("ROLLBACK;");
|
|
throw err;
|
|
}
|
|
}
|
|
}
|