v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -1,5 +1,6 @@
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
import {
type Comment,
Dump,
type Playlist,
type RichContent,
@@ -9,6 +10,32 @@ import {
export const db = new DatabaseSync("api/sql/gerbeur.db");
db.exec("PRAGMA foreign_keys = ON;");
// Migration: add is_private column if it doesn't exist yet
try {
db.exec(`ALTER TABLE dumps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;`);
} catch { /* column already exists */ }
// Migration: create comments table if it doesn't exist yet
try {
db.exec(`CREATE TABLE IF NOT EXISTS comments (
id TEXT PRIMARY KEY,
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0
);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_dump ON comments(dump_id, created_at);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_dumps_dump ON playlist_dumps(dump_id);`);
} catch { /* already exists */ }
// Migration: add deleted column to comments if it doesn't exist yet
try {
db.exec(`ALTER TABLE comments ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;`);
} catch { /* column already exists */ }
/**
* Database Row Types
*/
@@ -26,6 +53,8 @@ export interface DumpRow {
file_mime: string | null;
file_size: number | null;
vote_count: number;
comment_count?: number;
is_private: number;
[key: string]: SQLOutputValue; // Index signature
}
@@ -62,7 +91,8 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
"file_size" in obj &&
(typeof obj.file_size === "number" || obj.file_size === null) &&
"vote_count" in obj && typeof obj.vote_count === "number";
"vote_count" in obj && typeof obj.vote_count === "number" &&
"is_private" in obj && typeof obj.is_private === "number";
}
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
@@ -97,6 +127,8 @@ export function dumpRowToApi(row: DumpRow): Dump {
fileMime: row.file_mime ?? undefined,
fileSize: row.file_size ?? undefined,
voteCount: row.vote_count,
commentCount: row.comment_count ?? 0,
isPrivate: Boolean(row.is_private),
};
}
@@ -114,6 +146,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
file_mime: dump.fileMime ?? null,
file_size: dump.fileSize ?? null,
vote_count: dump.voteCount,
is_private: dump.isPrivate ? 1 : 0,
};
}
@@ -139,6 +172,46 @@ export function userApiToRow(user: User): UserRow {
};
}
export interface CommentRow {
id: string;
dump_id: string;
user_id: string;
parent_id: string | null;
body: string;
created_at: string;
deleted: number;
author_username: string;
author_avatar_mime: string | null;
[key: string]: SQLOutputValue;
}
export function isCommentRow(obj: Record<string, SQLOutputValue>): obj is CommentRow {
return !!obj && typeof obj === "object" &&
typeof obj.id === "string" &&
typeof obj.dump_id === "string" &&
typeof obj.user_id === "string" &&
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
typeof obj.body === "string" &&
typeof obj.created_at === "string" &&
typeof obj.deleted === "number" &&
typeof obj.author_username === "string" &&
(typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null);
}
export function commentRowToApi(row: CommentRow): Comment {
return {
id: row.id,
dumpId: row.dump_id,
userId: row.user_id,
parentId: row.parent_id ?? undefined,
body: row.body,
createdAt: new Date(row.created_at),
deleted: Boolean(row.deleted),
authorUsername: row.author_username,
authorAvatarMime: row.author_avatar_mime ?? undefined,
};
}
export interface PlaylistRow {
id: string;
user_id: string;

View File

@@ -10,6 +10,7 @@ export interface RichContent {
description?: string;
thumbnailUrl?: string;
videoId?: string;
embedUrl?: string;
}
export interface Dump {
@@ -25,6 +26,8 @@ export interface Dump {
fileMime?: string;
fileSize?: number;
voteCount: number;
commentCount: number;
isPrivate: boolean;
}
/**
@@ -130,6 +133,12 @@ export interface APIFailure {
export type APIResponse<T> = APISuccess<T> | APIFailure;
export interface PaginatedData<T> {
items: T[];
total: number;
hasMore: boolean;
}
export class APIException extends Error {
readonly code: APIErrorCode;
readonly status: number;
@@ -141,6 +150,34 @@ export class APIException extends Error {
}
}
/**
* Comments
*/
export interface Comment {
id: string;
dumpId: string;
userId: string;
parentId?: string;
body: string;
createdAt: Date;
deleted: boolean;
authorUsername: string;
authorAvatarMime?: string;
}
export interface CreateCommentRequest {
body: string;
parentId?: string;
}
export function isCreateCommentRequest(obj: unknown): obj is CreateCommentRequest {
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 &&
(!("parentId" in o) || typeof o.parentId === "string" || o.parentId === null);
}
/**
* Playlists
*/
@@ -216,6 +253,7 @@ export function isReorderPlaylistRequest(
export interface CreateUrlDumpRequest {
url: string;
comment?: string;
isPrivate?: boolean;
}
export function isCreateUrlDumpRequest(
@@ -225,12 +263,14 @@ export function isCreateUrlDumpRequest(
typeof obj === "object" &&
"url" in obj && typeof obj.url === "string" &&
(!("comment" in obj) ||
typeof obj.comment === "string" || obj.comment === null);
typeof obj.comment === "string" || obj.comment === null) &&
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
}
export interface UpdateDumpRequest {
url?: string;
comment?: string;
isPrivate?: boolean;
}
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
@@ -238,7 +278,8 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
typeof obj === "object" &&
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) &&
(!("comment" in obj) ||
typeof obj.comment === "string" || obj.comment === null);
typeof obj.comment === "string" || obj.comment === null) &&
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
}
/**