v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone

This commit is contained in:
khannurien
2026-03-22 16:06:26 +00:00
parent 39a0cc397e
commit 34e908d1bc
42 changed files with 2170 additions and 628 deletions

View File

@@ -8,10 +8,41 @@ import {
type RichContent,
type User,
} from "./interfaces.ts";
import { makeSlug } from "../lib/slugify.ts";
export const db = new DatabaseSync("api/sql/gerbeur.db");
db.exec("PRAGMA foreign_keys = ON;");
// Add columns to existing tables if missing (idempotent migrations)
for (
const [table, col, def] of [
["dumps", "updated_at", "TEXT"],
["users", "updated_at", "TEXT"],
["playlists", "updated_at", "TEXT"],
["comments", "updated_at", "TEXT"],
["dumps", "slug", "TEXT"],
["playlists", "slug", "TEXT"],
] as [string, string, string][]
) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as {
name: string;
}[];
if (!cols.some((c) => c.name === col)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def};`);
}
}
// Backfill slugs for any records created before this migration
for (const table of ["dumps", "playlists"] as const) {
const rows = db.prepare(
`SELECT id, title FROM ${table} WHERE slug IS NULL;`,
).all() as { id: string; title: string }[];
const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`);
for (const row of rows) {
update.run(makeSlug(row.title, row.id), row.id);
}
}
// Purge expired unused invites on startup
db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
@@ -28,6 +59,8 @@ export interface DumpRow {
comment: string | null;
user_id: string;
created_at: string;
updated_at: string | null;
slug: string | null;
url: string | null;
rich_content: string | null;
file_name: string | null;
@@ -45,6 +78,7 @@ export interface UserRow {
password_hash: string;
is_admin: number;
created_at: string;
updated_at: string | null;
avatar_mime: string | null;
invited_by: string | null;
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
@@ -100,9 +134,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
id: row.id,
kind: row.kind as "url" | "file",
title: row.title,
slug: row.slug ?? undefined,
comment: row.comment ?? undefined,
userId: row.user_id,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
url: row.url ?? undefined,
richContent: row.rich_content
? (JSON.parse(row.rich_content) as RichContent)
@@ -121,9 +157,11 @@ export function dumpApiToRow(dump: Dump): DumpRow {
id: dump.id,
kind: dump.kind,
title: dump.title,
slug: dump.slug ?? null,
comment: dump.comment ?? null,
user_id: dump.userId,
created_at: dump.createdAt.toISOString(),
updated_at: dump.updatedAt?.toISOString() ?? null,
url: dump.url ?? null,
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
file_name: dump.fileName ?? null,
@@ -142,6 +180,7 @@ export function userRowToApi(row: UserRow): User {
passwordHash: row.password_hash,
isAdmin: Boolean(row.is_admin),
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
avatarMime: row.avatar_mime ?? undefined,
invitedByUsername: typeof row.invited_by_username === "string"
? row.invited_by_username
@@ -156,6 +195,7 @@ export function userApiToRow(user: User): UserRow {
password_hash: user.passwordHash,
is_admin: user.isAdmin ? 1 : 0,
created_at: user.createdAt.toISOString(),
updated_at: user.updatedAt?.toISOString() ?? null,
avatar_mime: user.avatarMime ?? null,
invited_by: null,
invited_by_username: null,
@@ -169,6 +209,7 @@ export interface CommentRow {
parent_id: string | null;
body: string;
created_at: string;
updated_at: string | null;
deleted: number;
author_username: string;
author_avatar_mime: string | null;
@@ -199,6 +240,7 @@ export function commentRowToApi(row: CommentRow): Comment {
parentId: row.parent_id ?? undefined,
body: row.body,
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
deleted: Boolean(row.deleted),
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
@@ -209,9 +251,11 @@ export interface PlaylistRow {
id: string;
user_id: string;
title: string;
slug: string | null;
description: string | null;
is_public: number;
created_at: string;
updated_at: string | null;
image_mime: string | null;
[key: string]: SQLOutputValue;
}
@@ -231,9 +275,11 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
id: row.id,
userId: row.user_id,
title: row.title,
slug: row.slug ?? undefined,
description: row.description ?? undefined,
isPublic: Boolean(row.is_public),
createdAt: new Date(row.created_at),
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
imageMime: row.image_mime ?? undefined,
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
ownerUsername: typeof row.owner_username === "string"

View File

@@ -17,9 +17,11 @@ export interface Dump {
id: string;
kind: "url" | "file";
title: string;
slug?: string;
comment?: string;
userId: string;
createdAt: Date;
updatedAt?: Date;
url?: string;
richContent?: RichContent;
fileName?: string;
@@ -40,6 +42,7 @@ export interface User {
passwordHash: string;
isAdmin: boolean;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
invitedByUsername?: string;
}
@@ -177,6 +180,7 @@ export interface Comment {
parentId?: string;
body: string;
createdAt: Date;
updatedAt?: Date;
deleted: boolean;
authorUsername: string;
authorAvatarMime?: string;
@@ -197,6 +201,18 @@ export function isCreateCommentRequest(
o.parentId === null);
}
export interface UpdateCommentRequest {
body: string;
}
export function isUpdateCommentRequest(
obj: unknown,
): obj is UpdateCommentRequest {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.body === "string" && (o.body as string).trim().length > 0;
}
/**
* Playlists
*/
@@ -205,9 +221,11 @@ export interface Playlist {
id: string;
userId: string;
title: string;
slug?: string;
description?: string;
isPublic: boolean;
createdAt: Date;
updatedAt?: Date;
imageMime?: string;
dumpCount?: number;
ownerUsername?: string;
@@ -384,7 +402,8 @@ export type NotificationType =
| "user_followed"
| "user_dump_posted"
| "playlist_dump_added"
| "dump_upvoted";
| "dump_upvoted"
| "user_mentioned";
export interface PlaylistFollowedData {
followerId: string;
@@ -419,12 +438,22 @@ export interface DumpUpvotedData {
dumpTitle: string;
}
export interface UserMentionedData {
mentionerId: string;
mentionerUsername: string;
contextType: "comment" | "dump" | "playlist";
contextId: string;
contextTitle: string;
dumpId?: string;
}
export type NotificationData =
| PlaylistFollowedData
| UserFollowedData
| UserDumpPostedData
| PlaylistDumpAddedData
| DumpUpvotedData;
| DumpUpvotedData
| UserMentionedData;
export interface Notification {
id: string;