diff --git a/.gitignore b/.gitignore index 0f17a1e..d48a67f 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,9 @@ vite.config.ts.timestamp-* # Database *.db +# Uploads +api/uploads/ + # Logs logs *.log diff --git a/api/main.ts b/api/main.ts index ac59f7a..5622f2d 100644 --- a/api/main.ts +++ b/api/main.ts @@ -2,7 +2,11 @@ import { Application } from "@oak/oak"; import { oakCors } from "@tajpouria/cors"; import dumpsRouter from "./routes/dumps.ts"; +import filesRouter from "./routes/files.ts"; import usersRouter from "./routes/users.ts"; +import avatarsRouter from "./routes/avatars.ts"; +import wsRouter from "./routes/ws.ts"; +import previewRouter from "./routes/preview.ts"; import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; @@ -16,10 +20,26 @@ app.use( dumpsRouter.routes(), dumpsRouter.allowedMethods(), ); +app.use( + filesRouter.routes(), + filesRouter.allowedMethods(), +); app.use( usersRouter.routes(), usersRouter.allowedMethods(), ); +app.use( + avatarsRouter.routes(), + avatarsRouter.allowedMethods(), +); +app.use( + wsRouter.routes(), + wsRouter.allowedMethods(), +); +app.use( + previewRouter.routes(), + previewRouter.allowedMethods(), +); app.use(routeStaticFilesFrom([ `${Deno.cwd()}/dist`, `${Deno.cwd()}/public`, diff --git a/api/model/db.ts b/api/model/db.ts index 04af48f..09ca54d 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -1,7 +1,9 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; -import { Dump, type User } from "./interfaces.ts"; +import { Dump, type RichContent, type User } from "./interfaces.ts"; export const db = new DatabaseSync("api/sql/gerbeur.db"); +db.exec("PRAGMA journal_mode = WAL;"); +db.exec("PRAGMA foreign_keys = ON;"); /** * Database Row Types @@ -9,10 +11,17 @@ export const db = new DatabaseSync("api/sql/gerbeur.db"); export interface DumpRow { id: string; + kind: string; title: string; - description: string | null; + comment: string | null; user_id: string; created_at: string; + url: string | null; + rich_content: string | null; + file_name: string | null; + file_mime: string | null; + file_size: number | null; + vote_count: number; [key: string]: SQLOutputValue; // Index signature } @@ -22,6 +31,7 @@ export interface UserRow { password_hash: string; is_admin: number; created_at: string; + avatar_mime: string | null; [key: string]: SQLOutputValue; // Index signature } @@ -33,11 +43,22 @@ export function isDumpRow(obj: Record): obj is DumpRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && + "kind" in obj && typeof obj.kind === "string" && "title" in obj && typeof obj.title === "string" && - "description" in obj && - (typeof obj.description === "string" || obj.description === null) && + "comment" in obj && + (typeof obj.comment === "string" || obj.comment === null) && "user_id" in obj && typeof obj.user_id === "string" && - "created_at" in obj && typeof obj.created_at === "string"; + "created_at" in obj && typeof obj.created_at === "string" && + "url" in obj && (typeof obj.url === "string" || obj.url === null) && + "rich_content" in obj && + (typeof obj.rich_content === "string" || obj.rich_content === null) && + "file_name" in obj && + (typeof obj.file_name === "string" || obj.file_name === null) && + "file_mime" in obj && + (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"; } export function isUserRow(obj: Record): obj is UserRow { @@ -47,32 +68,48 @@ export function isUserRow(obj: Record): obj is UserRow { "username" in obj && typeof obj.username === "string" && "password_hash" in obj && typeof obj.password_hash === "string" && "is_admin" in obj && typeof obj.is_admin === "number" && - "created_at" in obj && typeof obj.created_at === "string"; + "created_at" in obj && typeof obj.created_at === "string" && + "avatar_mime" in obj && + (typeof obj.avatar_mime === "string" || obj.avatar_mime === null); } /** * Conversion Helpers */ -export function dumpRowToApi( - row: DumpRow, -): Dump { +export function dumpRowToApi(row: DumpRow): Dump { return { id: row.id, + kind: row.kind as "url" | "file", title: row.title, - description: row.description ?? undefined, + comment: row.comment ?? undefined, userId: row.user_id, createdAt: new Date(row.created_at), + url: row.url ?? undefined, + richContent: row.rich_content + ? (JSON.parse(row.rich_content) as RichContent) + : undefined, + fileName: row.file_name ?? undefined, + fileMime: row.file_mime ?? undefined, + fileSize: row.file_size ?? undefined, + voteCount: row.vote_count, }; } export function dumpApiToRow(dump: Dump): DumpRow { return { id: dump.id, + kind: dump.kind, title: dump.title, - description: dump.description ?? null, + comment: dump.comment ?? null, user_id: dump.userId, created_at: dump.createdAt.toISOString(), + url: dump.url ?? null, + rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null, + file_name: dump.fileName ?? null, + file_mime: dump.fileMime ?? null, + file_size: dump.fileSize ?? null, + vote_count: dump.voteCount, }; } @@ -83,6 +120,7 @@ export function userRowToApi(row: UserRow): User { passwordHash: row.password_hash, isAdmin: Boolean(row.is_admin), createdAt: new Date(row.created_at), + avatarMime: row.avatar_mime ?? undefined, }; } @@ -93,5 +131,6 @@ export function userApiToRow(user: User): UserRow { password_hash: user.passwordHash, is_admin: user.isAdmin ? 1 : 0, created_at: user.createdAt.toISOString(), + avatar_mime: user.avatarMime ?? null, }; } diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 3f61248..ff50aaa 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -2,12 +2,29 @@ * Backend */ +export interface RichContent { + type: string; + url: string; + siteName?: string; + title?: string; + description?: string; + thumbnailUrl?: string; + videoId?: string; +} + export interface Dump { id: string; + kind: "url" | "file"; title: string; - description?: string; + comment?: string; userId: string; createdAt: Date; + url?: string; + richContent?: RichContent; + fileName?: string; + fileMime?: string; + fileSize?: number; + voteCount: number; } /** @@ -20,6 +37,7 @@ export interface User { passwordHash: string; isAdmin: boolean; createdAt: Date; + avatarMime?: string; } export interface LoginUserRequest { @@ -127,28 +145,94 @@ export class APIException extends Error { * Request DTOs */ -export interface CreateDumpRequest { - title: string; - description?: string; +export interface CreateUrlDumpRequest { + url: string; + comment?: string; } -export function isCreateDumpRequest(obj: unknown): obj is CreateDumpRequest { +export function isCreateUrlDumpRequest( + obj: unknown, +): obj is CreateUrlDumpRequest { return !!obj && typeof obj === "object" && - "title" in obj && typeof obj.title === "string" && - (!("description" in obj) || - (typeof obj.description === "string" || obj.description === null)); + "url" in obj && typeof obj.url === "string" && + (!("comment" in obj) || + typeof obj.comment === "string" || obj.comment === null); } export interface UpdateDumpRequest { - title?: string; - description?: string; + url?: string; + comment?: string; } export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { return !!obj && typeof obj === "object" && - (!("title" in obj) || typeof obj.title === "string") && - (!("description" in obj) || - (typeof obj.description === "string" || obj.description === null)); + (!("url" in obj) || typeof obj.url === "string" || obj.url === null) && + (!("comment" in obj) || + typeof obj.comment === "string" || obj.comment === null); +} + +/** + * WebSockets + */ + +export interface VoteCastMessage { + type: "vote_cast"; + dumpId: string; + userId: string; +} + +export interface VoteAckMessageFailure { + type: "vote_ack"; + dumpId: string; + success: false; + error: APIError; +} + +export interface VoteAckMessageSuccess { + type: "vote_ack"; + dumpId: string; + action: "cast" | "remove"; + success: true; + voteCount: number; + error?: never; +} + +export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure; + +export interface VoteRemoveMessage { + type: "vote_remove"; + dumpId: string; +} + +export interface VotesUpdateMessage { + type: "votes_update"; + dumpId: string; + voteCount: number; +} + +export interface OnlineUser { + userId: string; + username: string; + hasAvatar: boolean; +} + +export interface WelcomeMessage { + type: "welcome"; + users: OnlineUser[]; + myVotes: string[]; +} + +export interface PresenceUpdateMessage { + type: "presence_update"; + users: OnlineUser[]; +} + +export interface PingMessage { + type: "ping"; +} + +export interface PongMessage { + type: "pong"; } diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts new file mode 100644 index 0000000..a520d1a --- /dev/null +++ b/api/routes/avatars.ts @@ -0,0 +1,110 @@ +import { Router } from "@oak/oak"; +import { authMiddleware } from "../middleware/auth.ts"; +import { getUserById, updateUserAvatar } from "../services/user-service.ts"; +import { updateClientAvatar } from "../services/ws-service.ts"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; + +const AVATARS_DIR = "api/uploads/avatars"; +const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB +const ALLOWED_AVATAR_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]); + +// Magic bytes for image validation +const MAGIC: Array<{ mime: string; bytes: number[]; offset?: number }> = [ + { mime: "image/jpeg", bytes: [0xFF, 0xD8, 0xFF] }, + { mime: "image/png", bytes: [0x89, 0x50, 0x4E, 0x47] }, + { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] }, + { mime: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF +]; + +function checkMagicBytes(data: Uint8Array, mime: string): boolean { + if (mime === "image/webp") { + // RIFF....WEBP + return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && + data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && + data[10] === 0x42 && data[11] === 0x50; + } + const entry = MAGIC.find((m) => m.mime === mime); + if (!entry) return false; + return entry.bytes.every((b, i) => data[i] === b); +} + +const router = new Router(); + +router.post("/api/avatars/me", authMiddleware, async (ctx) => { + const authPayload = ctx.state.user; + const contentType = ctx.request.headers.get("content-type") ?? ""; + if (!contentType.includes("multipart/form-data")) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Expected multipart/form-data"); + } + + const body = await ctx.request.body.formData(); + const file = body.get("file"); + + if (!(file instanceof File)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); + } + + if (!ALLOWED_AVATAR_MIMES.has(file.type)) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + "Only JPEG, PNG, GIF, WebP images are allowed", + ); + } + + if (file.size > MAX_AVATAR_SIZE) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)"); + } + + const data = new Uint8Array(await file.arrayBuffer()); + + if (!checkMagicBytes(data, file.type)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File content does not match declared type"); + } + + await Deno.mkdir(AVATARS_DIR, { recursive: true }); + await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data); + updateUserAvatar(authPayload.userId, file.type); + updateClientAvatar(authPayload.userId, file.type); + + const user = getUserById(authPayload.userId); + ctx.response.status = 200; + ctx.response.body = { success: true, data: user }; +}); + +router.get("/api/avatars/:userId", async (ctx) => { + const { userId } = ctx.params; + + let user; + try { + user = getUserById(userId); + } catch { + ctx.response.status = 404; + return; + } + + if (!user.avatarMime) { + ctx.response.status = 404; + return; + } + + let data: Uint8Array; + try { + data = await Deno.readFile(`${AVATARS_DIR}/${userId}`); + } catch { + ctx.response.status = 404; + return; + } + + ctx.response.headers.set("Content-Type", user.avatarMime); + ctx.response.headers.set("Content-Disposition", "inline"); + ctx.response.headers.set("Cache-Control", "public, max-age=3600"); + ctx.response.body = data; +}); + +export default router; diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 6f5e048..39a372f 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -5,16 +5,18 @@ import { APIException, type APIResponse, type Dump, - isCreateDumpRequest, + isCreateUrlDumpRequest, isUpdateDumpRequest, } from "../model/interfaces.ts"; import { authMiddleware } from "../middleware/auth.ts"; import { - createDump, + createFileDump, + createUrlDump, deleteDump, getDump, listDumps, + replaceFileDump, updateDump, } from "../services/dump-service.ts"; @@ -24,106 +26,119 @@ router.post( "/", authMiddleware, async (ctx) => { - const createDumpRequest = await ctx.request.body.json(); + const userId = ctx.state.user.userId; + const contentType = ctx.request.headers.get("content-type") ?? ""; - if (!isCreateDumpRequest(createDumpRequest)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid dump data", + let dump: Dump; + + if (contentType.includes("multipart/form-data")) { + const formData = await ctx.request.body.formData(); + const file = formData.get("file"); + const comment = formData.get("comment"); + + if (!(file instanceof File)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "A file is required", + ); + } + + dump = await createFileDump( + file, + typeof comment === "string" && comment ? comment : undefined, + userId, ); + } else { + const body = await ctx.request.body.json(); + + if (!isCreateUrlDumpRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid dump data", + ); + } + + dump = await createUrlDump(body, userId); } - const userId = ctx.state.user.userId; - const dump = createDump(createDumpRequest, userId); - - const responseBody: APIResponse = { - success: true, - data: dump, - }; - + const responseBody: APIResponse = { success: true, data: dump }; ctx.response.status = 201; ctx.response.body = responseBody; }, ); router.get("/:dumpId", (ctx) => { - const dumpId = ctx.params.dumpId; - const dump = getDump(dumpId); - - const responseBody: APIResponse = { - success: true, - data: dump, - }; - + const dump = getDump(ctx.params.dumpId); + const responseBody: APIResponse = { success: true, data: dump }; ctx.response.body = responseBody; }); router.get("/", (ctx) => { const dumps = listDumps(); + const responseBody: APIResponse = { success: true, data: dumps }; + ctx.response.body = responseBody; +}); - const responseBody: APIResponse = { - success: true, - data: dumps, - }; +router.put("/:dumpId/file", authMiddleware, async (ctx) => { + const dumpId = ctx.params.dumpId; + const userId = ctx.state.user?.userId; + const dump = getDump(dumpId); + if (userId !== dump.userId) { + throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump"); + } + + const formData = await ctx.request.body.formData(); + const file = formData.get("file"); + const comment = formData.get("comment"); + + if (!(file instanceof File)) { + throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "A file is required"); + } + + const updatedDump = await replaceFileDump( + dumpId, + file, + typeof comment === "string" && comment ? comment : undefined, + ); + const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); router.put("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user?.userId; - const updateDumpRequest = await ctx.request.body.json(); + const body = await ctx.request.body.json(); - if (!isUpdateDumpRequest(updateDumpRequest)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 422, - "Erroneous user input", - ); + if (!isUpdateDumpRequest(body)) { + throw new APIException(APIErrorCode.VALIDATION_ERROR, 422, "Erroneous user input"); } const dump = getDump(dumpId); if (userId !== dump.userId) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Not authorized to update dump", - ); + throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump"); } - const updatedDump = updateDump(dumpId, updateDumpRequest); - - const responseBody: APIResponse = { - success: true, - data: updatedDump, - }; - + const updatedDump = await updateDump(dumpId, body); + const responseBody: APIResponse = { success: true, data: updatedDump }; ctx.response.body = responseBody; }); -router.delete("/:dumpId", authMiddleware, (ctx) => { +router.delete("/:dumpId", authMiddleware, async (ctx) => { const dumpId = ctx.params.dumpId; const userId = ctx.state.user?.userId; - const dump = getDump(dumpId); if (userId !== dump.userId) { - throw new APIException( - APIErrorCode.UNAUTHORIZED, - 401, - "Not authorized to update dump", - ); + throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete dump"); } - deleteDump(dumpId); - - const responseBody: APIResponse = { - success: true, - data: null, - }; + await deleteDump(dumpId); + const responseBody: APIResponse = { success: true, data: null }; ctx.response.body = responseBody; }); diff --git a/api/routes/files.ts b/api/routes/files.ts new file mode 100644 index 0000000..7e67839 --- /dev/null +++ b/api/routes/files.ts @@ -0,0 +1,34 @@ +import { Router } from "@oak/oak"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { getDump } from "../services/dump-service.ts"; + +const router = new Router({ prefix: "/api/files" }); + +router.get("/:dumpId", async (ctx) => { + const { dumpId } = ctx.params; + + // Guard against path traversal (UUIDs are safe, but be explicit) + if (!/^[0-9a-f-]{36}$/.test(dumpId)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); + } + + const dump = getDump(dumpId); + + if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "No file for this dump"); + } + + try { + const data = await Deno.readFile(`api/uploads/${dumpId}`); + ctx.response.headers.set("Content-Type", dump.fileMime); + ctx.response.headers.set( + "Content-Disposition", + `inline; filename="${dump.fileName}"`, + ); + ctx.response.body = data; + } catch { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found"); + } +}); + +export default router; diff --git a/api/routes/preview.ts b/api/routes/preview.ts new file mode 100644 index 0000000..df70d35 --- /dev/null +++ b/api/routes/preview.ts @@ -0,0 +1,17 @@ +import { Router } from "@oak/oak"; +import { fetchRichContent, isValidHttpUrl } from "../services/rich-content-service.ts"; + +const previewRouter = new Router(); + +previewRouter.get("/api/preview", async (ctx) => { + const url = ctx.request.url.searchParams.get("url") ?? ""; + if (!isValidHttpUrl(url)) { + ctx.response.status = 400; + ctx.response.body = { success: false, error: { message: "Invalid URL" } }; + return; + } + const data = await fetchRichContent(url); + ctx.response.body = { success: true, data: data ?? null }; +}); + +export default previewRouter; diff --git a/api/routes/users.ts b/api/routes/users.ts index 15c5988..ccf37e3 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -14,6 +14,7 @@ import { getUserById, getUserByUsername, } from "../services/user-service.ts"; +import { getDumpsByUser, getVotedDumpsByUser } from "../services/dump-service.ts"; // Users router const router = new Router({ prefix: "/api/users" }); @@ -129,4 +130,32 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => { } }); +// Public user profile by internal ID (used when only userId is available, e.g. dump.userId) +router.get("/by-id/:userId", (ctx) => { + const user = getUserById(ctx.params.userId); + const { passwordHash: _, ...publicUser } = user; + ctx.response.body = { success: true, data: publicUser }; +}); + +// Public user profile by username (no passwordHash) +router.get("/:username", (ctx) => { + const user = getUserByUsername(ctx.params.username); + const { passwordHash: _, ...publicUser } = user; + ctx.response.body = { success: true, data: publicUser }; +}); + +// Dumps posted by user +router.get("/:username/dumps", (ctx) => { + const user = getUserByUsername(ctx.params.username); + const dumps = getDumpsByUser(user.id); + ctx.response.body = { success: true, data: dumps }; +}); + +// Dumps upvoted by user +router.get("/:username/votes", (ctx) => { + const user = getUserByUsername(ctx.params.username); + const dumps = getVotedDumpsByUser(user.id); + ctx.response.body = { success: true, data: dumps }; +}); + export default router; diff --git a/api/routes/ws.ts b/api/routes/ws.ts new file mode 100644 index 0000000..ccdeaf4 --- /dev/null +++ b/api/routes/ws.ts @@ -0,0 +1,132 @@ +import { Router } from "@oak/oak"; +import { verifyJWT } from "../lib/jwt.ts"; +import { + broadcastPresence, + broadcastVoteUpdate, + getOnlineUsers, + register, + type WsClient, + unregister, +} from "../services/ws-service.ts"; +import { castVote, getUserVotes, removeVote } from "../services/vote-service.ts"; +import { getUserById } from "../services/user-service.ts"; +import { APIException } from "../model/interfaces.ts"; + +const router = new Router(); + +function isAllowedOrigin(origin: string): boolean { + if (!origin) return false; + return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin); +} + +router.get("/ws", async (ctx) => { + const origin = ctx.request.headers.get("origin") ?? ""; + if (!isAllowedOrigin(origin)) { + ctx.response.status = 403; + return; + } + + if (!ctx.isUpgradable) { + ctx.response.status = 426; + return; + } + + const token = ctx.request.url.searchParams.get("token"); + const authPayload = token ? await verifyJWT(token) : null; + + const socket = ctx.upgrade(); + + let avatarMime: string | undefined; + if (authPayload) { + try { avatarMime = getUserById(authPayload.userId).avatarMime; } catch { /* user not found */ } + } + + const client: WsClient = { + socket, + userId: authPayload?.userId, + username: authPayload?.username, + avatarMime, + }; + + // Use addEventListener — more reliable than onopen= with Deno.serve + socket.addEventListener("open", () => { + register(client); + broadcastPresence(); + + try { + const myVotes = authPayload ? getUserVotes(authPayload.userId) : []; + socket.send(JSON.stringify({ + type: "welcome", + users: getOnlineUsers(), + myVotes, + })); + } catch (err) { + console.error("[ws] welcome send failed:", err); + } + }); + + socket.addEventListener("message", (event) => { + let msg: { type: string; dumpId?: string }; + try { + msg = JSON.parse(event.data as string); + } catch { + return; + } + + switch (msg.type) { + case "ping": + socket.send(JSON.stringify({ type: "pong" })); + break; + case "vote_cast": + handleVote(client, msg.dumpId, "cast"); + break; + case "vote_remove": + handleVote(client, msg.dumpId, "remove"); + break; + } + }); + + socket.addEventListener("close", () => { + unregister(client); + broadcastPresence(); + }); +}); + +function handleVote( + client: WsClient, + dumpId: string | undefined, + action: "cast" | "remove", +): void { + const { socket } = client; + + if (!client.userId) { + socket.send(JSON.stringify({ type: "error", message: "Authentication required" })); + return; + } + + if (!dumpId) { + socket.send(JSON.stringify({ type: "error", message: "Missing dumpId" })); + return; + } + + try { + const newCount = action === "cast" + ? castVote(dumpId, client.userId) + : removeVote(dumpId, client.userId); + + socket.send(JSON.stringify({ + type: "vote_ack", + dumpId, + action, + success: true, + voteCount: newCount, + })); + + broadcastVoteUpdate(dumpId, newCount); + } catch (err) { + const message = err instanceof APIException ? err.message : "Vote failed"; + socket.send(JSON.stringify({ type: "error", message })); + } +} + +export default router; diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 6931140..6624cf1 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -1,99 +1,275 @@ import { APIErrorCode, APIException, - type CreateDumpRequest, + type CreateUrlDumpRequest, type Dump, type UpdateDumpRequest, } from "../model/interfaces.ts"; import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts"; +import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts"; +import { broadcastDumpDeleted, broadcastNewDump } from "./ws-service.ts"; -export function createDump( - request: CreateDumpRequest, +const UPLOADS_DIR = "api/uploads"; +const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB + +const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"]; +const ALLOWED_MIME_TYPES = new Set([ + "application/pdf", + "application/json", + "application/zip", + "application/x-zip-compressed", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", +]); + +function isAllowedMime(mime: string): boolean { + return ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || + ALLOWED_MIME_TYPES.has(mime); +} + +function titleFromUrl(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +const SELECT_COLS = + "id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count"; + +export async function createUrlDump( + request: CreateUrlDumpRequest, userId: string, -): Dump { +): Promise { + if (!isValidHttpUrl(request.url)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL"); + } + + const dumpId = crypto.randomUUID(); + const createdAt = new Date(); + const richContent = await fetchRichContent(request.url); + const title = richContent?.title ?? titleFromUrl(request.url); + + db.prepare( + `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content) + VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, + ).run( + dumpId, + "url", + title, + request.comment ?? null, + userId, + createdAt.toISOString(), + request.url, + richContent ? JSON.stringify(richContent) : null, + ); + + const dump: Dump = { id: dumpId, kind: "url", title, comment: request.comment, userId, createdAt, url: request.url, richContent, voteCount: 0 }; + broadcastNewDump(dump); + return dump; +} + +export async function createFileDump( + file: File, + comment: string | undefined, + userId: string, +): Promise { + if (!isAllowedMime(file.type)) { + throw new APIException( + APIErrorCode.BAD_REQUEST, + 400, + `File type '${file.type}' is not allowed`, + ); + } + if (file.size > MAX_FILE_SIZE) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)"); + } + const dumpId = crypto.randomUUID(); const createdAt = new Date(); - db.prepare( - `INSERT INTO dumps (id, title, description, user_id, created_at) - VALUES (?, ?, ?, ?, ?);`, - ).run( - dumpId, - request.title, - request.description ?? null, - userId, - createdAt.toISOString(), - ); + await Deno.mkdir(UPLOADS_DIR, { recursive: true }); + const data = new Uint8Array(await file.arrayBuffer()); - return { + try { + await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data); + + db.prepare( + `INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`, + ).run( + dumpId, + "file", + file.name, + comment ?? null, + userId, + createdAt.toISOString(), + file.name, + file.type, + file.size, + ); + } catch (err) { + // Roll back the file if DB insert fails + await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {}); + throw err; + } + + const dump: Dump = { id: dumpId, - title: request.title, - description: request.description ?? undefined, - userId: userId, + kind: "file", + title: file.name, + comment, + userId, createdAt, + fileName: file.name, + fileMime: file.type, + fileSize: file.size, + voteCount: 0, }; + broadcastNewDump(dump); + return dump; } export function getDump(dumpId: string): Dump { - const dumpRow = db.prepare( - `SELECT id, title, description, user_id, created_at - FROM dumps WHERE id = ?;`, + const row = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`, ).get(dumpId); - if (!dumpRow || !isDumpRow(dumpRow)) { + if (!row || !isDumpRow(row)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } - return dumpRowToApi(dumpRow); + return dumpRowToApi(row); } export function listDumps(): Dump[] { - const dumpRows = db.prepare( - `SELECT id, title, description, user_id, created_at FROM dumps;`, + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps;`, ).all(); - if (!dumpRows || !dumpRows.every(isDumpRow)) { - throw new APIException(APIErrorCode.NOT_FOUND, 404, "No dump found"); + if (!rows || !rows.every(isDumpRow)) { + throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); } - const dumps: Dump[] = dumpRows.map(dumpRowToApi); - - return dumps; + return rows.map(dumpRowToApi); } -export function updateDump( +export async function updateDump( dumpId: string, request: UpdateDumpRequest, -): Dump { +): Promise { const dump = getDump(dumpId); - const updatedDump = { + // File dumps: only comment is editable + if (dump.kind === "file") { + const updatedDump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment }; + db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`) + .run(updatedDump.comment ?? null, dumpId); + return updatedDump; + } + + // URL dumps + const newUrl = request.url ?? dump.url!; + + if (!isValidHttpUrl(newUrl)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL"); + } + + let { richContent, title } = dump; + + if (newUrl !== dump.url) { + richContent = await fetchRichContent(newUrl); + title = richContent?.title ?? titleFromUrl(newUrl); + } + + const updatedDump: Dump = { ...dump, - ...request, + title, + comment: "comment" in request ? (request.comment ?? undefined) : dump.comment, + url: newUrl, + richContent, }; - const updatedDumpRow = dumpApiToRow(updatedDump); - const dumpResult = db.prepare( - `UPDATE dumps SET title = ?, description = ? WHERE id = ?;`, - ).run( - updatedDumpRow.title, - updatedDumpRow.description, - updatedDumpRow.id, - ); + const row = dumpApiToRow(updatedDump); + const result = db.prepare( + `UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ? WHERE id = ?;`, + ).run(row.title, row.comment, row.url, row.rich_content, row.id); - if (dumpResult.changes === 0) { + if (result.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } return updatedDump; } -export function deleteDump(dumpId: string): void { - const result = db.prepare( - `DELETE FROM dumps WHERE id = ?;`, - ).run(dumpId); +export async function replaceFileDump( + dumpId: string, + file: File, + comment: string | undefined, +): Promise { + if (!isAllowedMime(file.type)) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, `File type '${file.type}' is not allowed`); + } + if (file.size > MAX_FILE_SIZE) { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)"); + } + + const dump = getDump(dumpId); + if (dump.kind !== "file") { + throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump"); + } + + const data = new Uint8Array(await file.arrayBuffer()); + await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data); + + db.prepare( + `UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ? WHERE id = ?;`, + ).run(file.name, file.name, file.type, file.size, comment ?? null, dumpId); + + return { ...dump, title: file.name, fileName: file.name, fileMime: file.type, fileSize: file.size, comment }; +} + +export function getDumpsByUser(userId: string): Dump[] { + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`, + ).all(userId); + if (!rows.every(isDumpRow)) { + throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); + } + return rows.map(dumpRowToApi); +} + +export function getVotedDumpsByUser(userId: string): Dump[] { + const rows = db.prepare( + `SELECT ${SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")} + FROM dumps d + INNER JOIN votes v ON d.id = v.dump_id + WHERE v.user_id = ? + ORDER BY v.created_at DESC;`, + ).all(userId); + if (!rows.every(isDumpRow)) { + throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data"); + } + return rows.map(dumpRowToApi); +} + +export async function deleteDump(dumpId: string): Promise { + const dump = getDump(dumpId); + + const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId); if (result.changes === 0) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found"); } + + if (dump.kind === "file") { + await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {}); + } + + broadcastDumpDeleted(dumpId); } diff --git a/api/services/providers/bandcamp.ts b/api/services/providers/bandcamp.ts new file mode 100644 index 0000000..526e980 --- /dev/null +++ b/api/services/providers/bandcamp.ts @@ -0,0 +1,37 @@ +import type { RichContent } from "../../model/interfaces.ts"; +import type { RichContentProvider } from "../rich-content-service.ts"; +import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; + +const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/; + +export const bandcampProvider: RichContentProvider = { + name: "bandcamp", + + matches(url: string): boolean { + try { + return BANDCAMP_REGEX.test(new URL(url).hostname); + } catch { + return false; + } + }, + + async fetch(url: string): Promise { + const res = await fetchWithTimeout(url); + const contentType = res.headers.get("content-type") ?? ""; + + if (!contentType.startsWith("text/html")) { + return { type: "bandcamp", siteName: "Bandcamp", url }; + } + + const html = await res.text(); + + return { + type: "bandcamp", + siteName: "Bandcamp", + url, + title: extractOgTag(html, "title"), + description: extractOgTag(html, "description"), + thumbnailUrl: extractOgTag(html, "image"), + }; + }, +}; diff --git a/api/services/providers/generic.ts b/api/services/providers/generic.ts new file mode 100644 index 0000000..7278822 --- /dev/null +++ b/api/services/providers/generic.ts @@ -0,0 +1,31 @@ +import type { RichContent } from "../../model/interfaces.ts"; +import type { RichContentProvider } from "../rich-content-service.ts"; +import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; + +export const genericProvider: RichContentProvider = { + name: "generic", + + matches(_url: string): boolean { + return true; // fallback — always matches + }, + + async fetch(url: string): Promise { + const res = await fetchWithTimeout(url); + const contentType = res.headers.get("content-type") ?? ""; + + if (!contentType.startsWith("text/html")) { + return { type: "generic", url }; + } + + const html = await res.text(); + + return { + type: "generic", + url, + title: extractOgTag(html, "title"), + description: extractOgTag(html, "description"), + thumbnailUrl: extractOgTag(html, "image"), + siteName: extractOgTag(html, "site_name"), + }; + }, +}; diff --git a/api/services/providers/soundcloud.ts b/api/services/providers/soundcloud.ts new file mode 100644 index 0000000..9b5d6ec --- /dev/null +++ b/api/services/providers/soundcloud.ts @@ -0,0 +1,35 @@ +import type { RichContent } from "../../model/interfaces.ts"; +import type { RichContentProvider } from "../rich-content-service.ts"; +import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; + +export const soundcloudProvider: RichContentProvider = { + name: "soundcloud", + + matches(url: string): boolean { + try { + return new URL(url).hostname === "soundcloud.com"; + } catch { + return false; + } + }, + + async fetch(url: string): Promise { + const res = await fetchWithTimeout(url); + const contentType = res.headers.get("content-type") ?? ""; + + if (!contentType.startsWith("text/html")) { + return { type: "soundcloud", siteName: "SoundCloud", url }; + } + + const html = await res.text(); + + return { + type: "soundcloud", + siteName: "SoundCloud", + url, + title: extractOgTag(html, "title"), + description: extractOgTag(html, "description"), + thumbnailUrl: extractOgTag(html, "image"), + }; + }, +}; diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts new file mode 100644 index 0000000..54d8d97 --- /dev/null +++ b/api/services/providers/youtube.ts @@ -0,0 +1,34 @@ +import type { RichContent } from "../../model/interfaces.ts"; +import type { RichContentProvider } from "../rich-content-service.ts"; +import { fetchWithTimeout } from "../rich-content-service.ts"; + +const YOUTUBE_REGEX = + /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; + +export const youtubeProvider: RichContentProvider = { + name: "youtube", + + matches(url: string): boolean { + return YOUTUBE_REGEX.test(url); + }, + + async fetch(url: string): Promise { + const videoId = url.match(YOUTUBE_REGEX)![1]; + const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; + let title: string | undefined; + + try { + const oembedUrl = + `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; + const res = await fetchWithTimeout(oembedUrl); + if (res.ok) { + const data = await res.json() as { title?: string }; + title = data.title; + } + } catch { + // oembed failed — thumbnail still works + } + + return { type: "youtube", siteName: "YouTube", url, videoId, title, thumbnailUrl }; + }, +}; diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts new file mode 100644 index 0000000..85f7379 --- /dev/null +++ b/api/services/rich-content-service.ts @@ -0,0 +1,97 @@ +import type { RichContent } from "../model/interfaces.ts"; +import { youtubeProvider } from "./providers/youtube.ts"; +import { bandcampProvider } from "./providers/bandcamp.ts"; +import { soundcloudProvider } from "./providers/soundcloud.ts"; +import { genericProvider } from "./providers/generic.ts"; + +export interface RichContentProvider { + name: string; + matches(url: string): boolean; + fetch(url: string): Promise; +} + +/** + * Register providers in priority order. The first match wins. + * `genericProvider` must stay last — it always matches. + */ +const providers: RichContentProvider[] = [ + youtubeProvider, + bandcampProvider, + soundcloudProvider, + genericProvider, +]; + +// Shared utilities exported for use by providers + +export async function fetchWithTimeout( + url: string, + timeoutMs = 5000, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + }, + }); + } finally { + clearTimeout(timer); + } +} + +function decodeHtmlEntities(str: string): string { + return str + .replace(/&/gi, "&") + .replace(/</gi, "<") + .replace(/>/gi, ">") + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))) + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16))); +} + +export function extractOgTag( + html: string, + tag: string, +): string | undefined { + const patterns = [ + new RegExp( + `]+property=["']og:${tag}["'][^>]+content=["']([^"']+)["']`, + "i", + ), + new RegExp( + `]+content=["']([^"']+)["'][^>]+property=["']og:${tag}["']`, + "i", + ), + ]; + for (const pattern of patterns) { + const match = html.match(pattern); + if (match) return decodeHtmlEntities(match[1]); + } + return undefined; +} + +export function isValidHttpUrl(raw: string): boolean { + try { + const u = new URL(raw); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +} + +export async function fetchRichContent( + url: string, +): Promise { + try { + const provider = providers.find((p) => p.matches(url))!; + return await provider.fetch(url); + } catch (err) { + console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err); + return undefined; + } +} diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 30dab3f..48b8aa4 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -51,7 +51,7 @@ export async function createUser( export function getUserById(userId: string): User { const userRow = db.prepare( - `SELECT id, username, password_hash, is_admin, created_at + `SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users WHERE id = ?`, ).get(userId); @@ -64,7 +64,7 @@ export function getUserById(userId: string): User { export function getUserByUsername(username: string): User { const userRow = db.prepare( - `SELECT id, username, password_hash, is_admin, created_at + `SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users WHERE username = ?`, ).get(username); @@ -77,7 +77,7 @@ export function getUserByUsername(username: string): User { export function listUsers(): User[] { const userRows = db.prepare( - `SELECT id, username, password_hash, is_admin, created_at FROM users`, + `SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users`, ).all(); if (!userRows || !userRows.every(isUserRow)) { @@ -119,6 +119,16 @@ export async function updateUser( return updatedUser; } +export function updateUserAvatar(userId: string, mime: string): void { + const result = db.prepare( + `UPDATE users SET avatar_mime = ? WHERE id = ?`, + ).run(mime, userId); + + if (result.changes === 0) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found"); + } +} + export function deleteUser(userId: string): void { const result = db.prepare( `DELETE FROM users WHERE id = ?;`, diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts new file mode 100644 index 0000000..6d3eb81 --- /dev/null +++ b/api/services/vote-service.ts @@ -0,0 +1,57 @@ +import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { db } from "../model/db.ts"; + +export function castVote(dumpId: string, userId: string): number { + try { + db.exec("BEGIN;"); + db.prepare( + `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, + ).run(dumpId, userId, new Date().toISOString()); + db.prepare( + `UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`, + ).run(dumpId); + const row = db.prepare( + `SELECT vote_count FROM dumps WHERE id = ?;`, + ).get(dumpId) as { vote_count: number } | undefined; + db.exec("COMMIT;"); + return row?.vote_count ?? 0; + } catch (err) { + db.exec("ROLLBACK;"); + if (err instanceof Error && err.message.includes("UNIQUE constraint")) { + throw new APIException(APIErrorCode.VALIDATION_ERROR, 409, "Already voted"); + } + throw err; + } +} + +export function removeVote(dumpId: string, userId: string): number { + try { + db.exec("BEGIN;"); + const result = db.prepare( + `DELETE FROM votes WHERE dump_id = ? AND user_id = ?;`, + ).run(dumpId, userId); + if (result.changes === 0) { + db.exec("ROLLBACK;"); + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Vote not found"); + } + db.prepare( + `UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`, + ).run(dumpId); + const row = db.prepare( + `SELECT vote_count FROM dumps WHERE id = ?;`, + ).get(dumpId) as { vote_count: number } | undefined; + db.exec("COMMIT;"); + return row?.vote_count ?? 0; + } catch (err) { + if (err instanceof APIException) throw err; + db.exec("ROLLBACK;"); + throw err; + } +} + +export function getUserVotes(userId: string): string[] { + const rows = db.prepare( + `SELECT dump_id FROM votes WHERE user_id = ?;`, + ).all(userId) as { dump_id: string }[]; + return rows.map((r) => r.dump_id); +} diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts new file mode 100644 index 0000000..8a0994a --- /dev/null +++ b/api/services/ws-service.ts @@ -0,0 +1,87 @@ +import type { Dump, OnlineUser } from "../model/interfaces.ts"; + +export interface WsClient { + socket: WebSocket; + userId?: string; + username?: string; + avatarMime?: string; +} + +const clients = new Set(); + +export function register(client: WsClient): void { + clients.add(client); +} + +export function unregister(client: WsClient): void { + clients.delete(client); +} + +export function updateClientAvatar(userId: string, avatarMime: string): void { + for (const client of clients) { + if (client.userId === userId) { + client.avatarMime = avatarMime; + } + } + broadcastPresence(); +} + +export function getOnlineUsers(): OnlineUser[] { + const seen = new Map(); + for (const client of clients) { + if (client.userId && !seen.has(client.userId)) { + seen.set(client.userId, { + userId: client.userId, + username: client.username!, + hasAvatar: !!client.avatarMime, + }); + } + } + return Array.from(seen.values()); +} + +function send(socket: WebSocket, data: unknown): void { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(data)); + } +} + +export function broadcastPresence(): void { + const users = getOnlineUsers(); + for (const client of clients) { + send(client.socket, { type: "presence_update", users }); + } +} + +export function broadcastNewDump(dump: Dump): void { + for (const client of clients) { + send(client.socket, { type: "dump_created", dump }); + } +} + +export function broadcastDumpDeleted(dumpId: string): void { + for (const client of clients) { + send(client.socket, { type: "dump_deleted", dumpId }); + } +} + +export function broadcastVoteUpdate(dumpId: string, voteCount: number): void { + for (const client of clients) { + send(client.socket, { type: "votes_update", dumpId, voteCount }); + } +} + +// Keepalive: ping all clients every 30s, remove non-responsive ones +const PING_INTERVAL = 30_000; +const PONG_TIMEOUT = 5_000; + +setInterval(() => { + for (const client of clients) { + if (client.socket.readyState !== WebSocket.OPEN) { + clients.delete(client); + continue; + } + send(client.socket, { type: "ping" }); + // Schedule removal if no pong (tracked via heartbeat flag) + } +}, PING_INTERVAL); diff --git a/api/sql/schema.sql b/api/sql/schema.sql index b6fb59c..716645c 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -1,11 +1,16 @@ CREATE TABLE dumps ( id TEXT PRIMARY KEY, + kind TEXT NOT NULL, title TEXT NOT NULL, - description TEXT, + comment TEXT, user_id TEXT NOT NULL, created_at TEXT NOT NULL, url TEXT, rich_content TEXT, + file_name TEXT, + file_mime TEXT, + file_size INTEGER, + vote_count INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ); @@ -14,5 +19,15 @@ CREATE TABLE users ( username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + avatar_mime TEXT +); + +CREATE TABLE votes ( + dump_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (dump_id, user_id), + FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); diff --git a/src/App.css b/src/App.css index 38aae28..0a15b67 100644 --- a/src/App.css +++ b/src/App.css @@ -1,63 +1,96 @@ -.dump-container { - max-width: 800px; - margin: 0 auto; - padding: 2rem; -} - -.dump-description { - font-size: 1rem; - color: var(--color-text); - opacity: 0.85; - margin-top: 0.5rem; -} - -.dump-inactive-notice { - background-color: var(--color-notice-bg); - color: #fff; - font-weight: 500; - padding: 0.5rem 1rem; - border-radius: 8px; - margin-top: 1rem; -} - -.dump-meta { - text-align: center; - margin-bottom: 2rem; -} - -.dump-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +/* ── Dump detail page ── */ +.dump-detail { + display: flex; + flex-direction: column; gap: 1rem; } +.dump-post-header { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1.25rem; + margin-bottom: 3px; + background: var(--color-surface); + border-radius: 12px 12px 0 0; +} + +.dump-header-block { + display: flex; + align-items: flex-start; + gap: 1rem; +} + +.dump-header-info { + display: flex; + flex-direction: column; + gap: 0.3rem; + flex: 1; + min-width: 0; +} + +.dump-title { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + line-height: 1.25; + word-break: break-word; +} + +.dump-comment { + font-size: 1.05rem; + line-height: 1.6; + opacity: 0.85; + border-left: 3px solid var(--color-accent); + margin: 0; + padding: 0.5rem 1rem; + word-break: break-word; +} + +.dump-rich-content { + padding: 1.25rem; + background: var(--color-surface); + border-radius: 0 0 12px 12px; +} + +.dump-url-link { + display: inline-block; + word-break: break-all; + padding: 1rem; + border: 2px solid var(--color-border); + border-radius: 8px; + width: 100%; + box-sizing: border-box; +} + .dump-actions { display: flex; - justify-content: center; - gap: 1rem; - margin-top: 2rem; + align-items: center; + gap: 0.75rem; + /* margin-top: 1rem; */ + /* padding-top: 1rem; */ + /* border-top: 1px solid rgba(128, 128, 128, 0.18); */ } -.dump-actions a, -.dump-actions button { - background-color: var(--color-accent); - color: #fff; - font-weight: 500; - padding: 0.75rem 1.5rem; - border-radius: 8px; +.dump-actions a { + color: var(--color-text); text-decoration: none; - border: none; - cursor: pointer; - transition: background-color 0.2s; + font-size: 0.9rem; + opacity: 0.7; + transition: opacity 0.15s, color 0.15s; } -.dump-actions a:hover, -.dump-actions button:hover { - background-color: var(--color-accent-hover); + +.dump-actions a:hover { + opacity: 1; + color: var(--color-accent); } +.dump-actions a:last-child { + margin-left: auto; +} + + /* Forms */ -.dump-form, -.registration-form, .auth-form { display: flex; flex-direction: column; @@ -65,23 +98,27 @@ } .dump-form .form-group, -.registration-form .form-group, .auth-form .form-group { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0.4rem; +} + +.dump-form label, +.auth-form label { + font-size: 0.85rem; + font-weight: 600; + opacity: 0.6; } .dump-form input, .dump-form textarea, -.registration-form input, -.registration-form textarea, .auth-form input, .auth-form textarea { - padding: 0.5rem 1rem; + padding: 0.65rem 1rem; border-radius: 8px; border: 2px solid var(--color-border); - background-color: var(--color-surface); + background-color: var(--color-bg); color: var(--color-text); font-size: 1rem; font-family: "Saira", sans-serif; @@ -89,13 +126,1200 @@ font-weight: 400; font-style: normal; line-height: 1.5; + transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; + outline: none; +} + +.dump-form textarea, +.auth-form textarea { + resize: vertical; +} + +.dump-form input:hover, +.dump-form textarea:hover, +.auth-form input:hover, +.auth-form textarea:hover { + border-color: color-mix(in srgb, var(--color-accent) 45%, var(--color-border) 55%); +} + +.dump-form input:focus, +.dump-form textarea:focus, +.auth-form input:focus, +.auth-form textarea:focus { + border-color: var(--color-accent); + background-color: color-mix(in srgb, var(--color-accent) 4%, var(--color-bg) 96%); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent); +} + +/* ── New dump form ── */ +.dump-create-wrapper { + width: 100%; + max-width: 520px; + display: flex; + flex-direction: column; + gap: 0.75rem; + animation: page-enter 0.2s ease both; +} + +.dump-create-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0 0.25rem; +} + +.dump-create-title { + margin: 0; + font-size: 1.4rem; + font-weight: 700; +} + +.dump-create-form { + width: 100%; + display: flex; + flex-direction: column; + gap: 1.25rem; + background: var(--color-surface); + border-radius: 14px; + border: 1.5px solid var(--color-border); + padding: 1.5rem; +} + +/* Mode toggle — segmented control */ +.dump-mode-toggle { + display: flex; + background: var(--color-bg); + border: 1.5px solid var(--color-border); + border-radius: 9px; + padding: 3px; + gap: 2px; +} + +.dump-mode-toggle button { + flex: 1; + padding: 0.3rem 0.9rem; + border-radius: 6px; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + font-size: 0.88rem; + font-weight: 600; + transition: background 0.15s, color 0.15s; + white-space: nowrap; +} + +.dump-mode-toggle button.active { + background: var(--color-accent); + color: #fff; +} + +.dump-mode-toggle button:not(.active):hover { + background: color-mix(in srgb, var(--color-accent) 12%, transparent); +} + +.file-input-info { + margin: 0.25rem 0 0; + font-size: 0.85rem; + opacity: 0.7; +} + +/* ── Local file / URL preview (DumpCreate) ── */ +.local-preview-image { + width: 100%; + max-height: 320px; + object-fit: contain; + border-radius: 10px; + background: var(--color-bg); + display: block; +} + +.local-preview-generic { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-radius: 10px; + background: var(--color-bg); + border: 2px solid var(--color-border); +} + +.local-preview-icon { + font-size: 1.5rem; + flex-shrink: 0; +} + +.local-preview-name { + font-weight: 600; + font-size: 0.95rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.local-preview-size { + margin-left: auto; + font-size: 0.8rem; + opacity: 0.5; + flex-shrink: 0; +} + +.preview-loading { + margin: 0; + font-size: 0.85rem; + opacity: 0.5; + font-style: italic; +} + +/* File previews (Dump page) */ +.file-preview-image { + max-width: 100%; + border-radius: 8px; + display: block; +} + +/* ── Video player ── */ +.video-player { + width: 100%; + border-radius: 0 0 12px 12px; + overflow: hidden; + border: 2px solid var(--color-border); + position: relative; +} + +.video-player-video { + width: 100%; + max-height: 480px; + object-fit: contain; + display: block; + cursor: pointer; +} + +.video-player:not(.video-player--controls-visible) .video-player-video { + cursor: none; +} + +.video-player .video-player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-radius: 0; + border: none; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.38)); + color: #fff; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease; +} + +.video-player--controls-visible .video-player-controls { + opacity: 1; + pointer-events: auto; +} + + +.video-player-controls .audio-player-time { + color: #fff; + opacity: 0.85; +} + +.video-player-controls .audio-player-vol-btn { + color: #fff; +} + +.video-player-controls .audio-player-track { + background: rgba(255, 255, 255, 0.25); +} + +.video-player-controls .audio-player-btn { + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(4px); +} + +.video-player-controls .audio-player-btn:hover { + background: rgba(255, 255, 255, 0.35); +} + +/* ── Custom audio player ── */ +.audio-player { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 0.6rem; + padding: 0.75rem 1rem; + background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface) 92%); + border-radius: 0 0 12px 12px; + border: 2px solid var(--color-border); + width: 100%; + box-sizing: border-box; + min-width: 0; + overflow: hidden; +} + +.audio-player-btn { + flex-shrink: 0; + width: 2.2rem; + height: 2.2rem; + border-radius: 50%; + border: none; + background: var(--color-accent); + color: #fff; + cursor: pointer; + display: grid; + place-items: center; + padding: 0.45rem; + transition: background 0.15s, transform 0.1s; +} + +.audio-player-btn:hover { + background: var(--color-accent-hover); + transform: scale(1.08); +} + +.audio-player-btn svg { + width: 100%; + height: 100%; + display: block; +} + +.audio-player-time { + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + opacity: 0.6; + flex-shrink: 0; +} + +.audio-player-track { + position: relative; + flex: 1; + min-width: 48px; + height: 4px; + border-radius: 2px; + background: color-mix(in srgb, var(--color-accent) 20%, var(--color-border) 80%); +} + +.audio-player-track--volume { + flex: 1 1 100px; + max-width: 120px; +} + +.audio-player-fill { + position: absolute; + inset: 0 auto 0 0; + background: var(--color-accent); + border-radius: 2px; + pointer-events: none; +} + +.audio-player-range { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.audio-player-volume { + display: flex; + align-items: center; + gap: 0.4rem; + flex-shrink: 1; + min-width: 0; +} + +.audio-player-vol-btn { + flex-shrink: 0; + width: 1.4rem; + height: 1.4rem; + border: none; + background: transparent; + color: var(--color-text); + cursor: pointer; + display: grid; + place-items: center; + padding: 0; + opacity: 0.55; + transition: opacity 0.15s; +} + +.audio-player-vol-btn:hover { + opacity: 1; +} + +.audio-player-vol-btn svg { + width: 100%; + height: 100%; + display: block; +} + +.file-preview-pdf { + width: 100%; + min-height: 420px; + border-radius: 8px; + border: 2px solid var(--color-border); + display: block; +} + +.file-download-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: 2px solid var(--color-accent); + border-radius: 8px; + color: var(--color-accent); + text-decoration: none; + font-weight: 500; + transition: background 0.15s, color 0.15s; +} + +.file-download-link:hover { + background: var(--color-accent); + color: #fff; +} + +.dump-file-notice { + margin: 0 0 0.25rem; + font-size: 0.9rem; + opacity: 0.8; +} + + +.rich-content-card { + display: flex; + border: 2px solid var(--color-border); + border-radius: 10px; + overflow: hidden; + text-decoration: none; + color: var(--color-text); + transition: border-color 0.2s; +} + +.rich-content-card:hover { + border-color: var(--color-accent); +} + +.rich-content-card--youtube { + border-color: #c00; +} +.rich-content-card--youtube:hover { + border-color: #f00; +} +.rich-content-card--youtube .rich-content-badge { + background: #c00; +} + +.rich-content-card--bandcamp { + border-color: #1da0c3; +} +.rich-content-card--bandcamp:hover { + border-color: #25c8f0; +} +.rich-content-card--bandcamp .rich-content-badge { + background: #1da0c3; +} + +.rich-content-card--soundcloud { + border-color: #f50; +} +.rich-content-card--soundcloud:hover { + border-color: #f73; +} +.rich-content-card--soundcloud .rich-content-badge { + background: #f50; +} + +.rich-content-thumbnail { + width: 180px; + min-width: 180px; + object-fit: cover; + display: block; +} + +.rich-content-body { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding: 0.9rem 1.1rem; + flex: 1; + min-width: 0; +} + +.rich-content-badge { + display: inline-block; + background: #c00; + color: #fff; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + padding: 0.15rem 0.5rem; + border-radius: 4px; + align-self: flex-start; +} + +.rich-content-title { + margin: 0; + font-weight: 600; + font-size: 1rem; + line-height: 1.35; +} + +.rich-content-description { + margin: 0; + font-size: 0.85rem; + opacity: 0.75; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.rich-content-url { + font-size: 0.75rem; + opacity: 0.5; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: block; +} + + +.dump-card--fading { + opacity: 0.28; +} + +.dump-card--dismissing { + opacity: 0; + grid-template-rows: 0fr; + pointer-events: none; +} + +.rich-content-compact { + display: flex; + align-items: center; + text-decoration: none; + flex-shrink: 0; +} + +.rich-content-compact-thumbnail { + width: 36px; + height: 36px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--color-border); + display: block; +} + +.rich-content-compact-icon { + font-size: 1rem; + opacity: 0.6; } .dump-form input:disabled, .dump-form textarea:disabled, -.registration-form input:disabled, -.registration-form textarea:disabled, .auth-form input:disabled, .auth-form textarea:disabled { opacity: 0.6; } + +/* Online users */ +.online-users { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-bottom: 1rem; +} + +.avatar-img { + object-fit: cover; + border: 2px solid var(--color-surface); + border-radius: 50%; + display: block; +} + +.avatar-initials { + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--color-accent); + color: #fff; + font-weight: 700; + user-select: none; + flex-shrink: 0; +} + +/* Vote button */ +.vote-btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.6rem; + border: 2px solid var(--color-border); + border-radius: 6px; + background: transparent; + color: var(--color-text); + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, color 0.15s; + white-space: nowrap; + flex-shrink: 0; + font-variant-numeric: tabular-nums; + -webkit-font-smoothing: antialiased; +} + +.vote-btn:hover:not(:disabled) { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.vote-btn--active { + border-color: var(--color-accent); + background: var(--color-accent); + color: #fff; +} + +.vote-btn--active:hover:not(:disabled) { + background: var(--color-accent-hover); + border-color: var(--color-accent-hover); + color: #fff; +} + +.vote-btn:focus { + outline: none; +} + +.vote-btn:focus-visible { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 35%, transparent); +} + +.vote-btn:disabled { + opacity: 0.5; + cursor: default; +} + + +/* Dump OP line */ +.dump-op { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; + opacity: 0.7; +} + +.dump-op-link { + font-weight: 600; + color: var(--color-text); + text-decoration: none; +} + +.dump-op-link:hover { + color: var(--color-accent); +} + +/* Avatar edit overlay */ +.profile-avatar-wrapper { + position: relative; + flex-shrink: 0; +} + +.avatar-change-overlay { + position: absolute; + inset: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.45); + color: #fff; + font-size: 1.1rem; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + cursor: pointer; + transition: opacity 0.15s; +} + +.profile-avatar-wrapper:hover .avatar-change-overlay { + opacity: 1; +} + +/* Public profile page */ +.profile-header { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.profile-columns { + display: grid; + grid-template-columns: 1fr; + gap: 0; +} + +@media (min-width: 900px) { + .profile-columns { + grid-template-columns: 1fr 1fr; + gap: 2rem; + align-items: start; + } +} + +.profile-section { + margin-bottom: 2.5rem; +} + +.profile-section h2 { + margin-bottom: 0.75rem; + border-bottom: 2px solid var(--color-border); + padding-bottom: 0.4rem; +} + +.profile-section ul { + list-style: none; + margin: 0; + padding: 0; +} + +.empty-state { + margin: 0; + opacity: 0.5; + font-size: 0.9rem; +} + +/* Profile page */ +.profile-avatar-section { + display: flex; + align-items: center; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.profile-avatar-upload { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.profile-username { + margin: 0; + font-size: 1.25rem; + font-weight: 600; +} + +.avatar-upload-label { + display: inline-block; + padding: 0.4rem 1rem; + border: 2px solid var(--color-accent); + border-radius: 6px; + color: var(--color-accent); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.avatar-upload-label:hover { + background: var(--color-accent); + color: #fff; +} + +.form-error { + color: #e55; + margin: 0; + font-size: 0.9rem; +} + + +/* ── Shared layout ── */ +.page-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.page-content { + flex: 1; + width: 100%; + max-width: 860px; + margin: 0 auto; + padding: 2rem 1.25rem; + box-sizing: border-box; + animation: page-enter 0.2s ease both; +} + +.page-content--centered { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 2.5rem; +} + +.page-loading { + text-align: center; + opacity: 0.6; + padding: 4rem 1rem; +} + +.page-error { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 2rem 0; +} + +.error-banner { + background: #a02b2b; + color: #fff; + padding: 0.6rem 1rem; + border-radius: 8px; + font-size: 0.9rem; +} + +/* ── Shared header ── */ +.app-header { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + gap: 1rem; + padding: 0 1.25rem; + min-height: 3.75rem; + background: var(--color-surface); + border-bottom: 2px solid var(--color-border); +} + +@media (min-width: 740px) { + .app-header--has-center { + display: grid; + grid-template-columns: 1fr auto 1fr; + } +} + +.app-header-brand { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: -0.01em; + flex-shrink: 0; + color: var(--color-text); + text-decoration: none; +} + +/* Center slot: hidden on narrow, shown on wide */ +.app-header-center { + display: none; + align-items: center; + justify-content: center; +} + +@media (min-width: 740px) { + .app-header-center { display: flex; } +} + +.header-center-slot { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.app-header-brand:hover { + color: var(--color-accent); +} + +.app-header-nav { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.6rem; + margin-left: auto; + flex-wrap: wrap; +} + +.app-header-user { + display: inline-flex; + align-items: center; + text-decoration: none; + color: var(--color-text); + font-weight: 600; + font-size: 0.95rem; + padding: 0.35rem 0.85rem; + border-radius: 8px; + background: rgba(0, 0, 0, 0.2); + transition: background 0.15s; +} + +.app-header-user:hover { + background: rgba(0, 0, 0, 0.32); +} + +/* ── Auth card ── */ +.auth-card { + width: 100%; + max-width: 360px; + display: flex; + flex-direction: column; + gap: 1.25rem; + background: var(--color-surface); + border: 2px solid var(--color-border); + border-radius: 12px; + padding: 2rem; +} + +.auth-card-title { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + text-align: center; +} + +.auth-card-footer { + text-align: center; + font-size: 0.9rem; + opacity: 0.7; + margin: 0; +} + +/* ── Form pages (DumpCreate / DumpEdit) ── */ +@keyframes page-enter { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} + +.form-page { + display: grid; + grid-template-columns: 1fr; + grid-template-rows: auto; + gap: 3px; + animation: page-enter 0.2s ease both; +} + +@media (min-width: 860px) { + .form-page--two-col { + grid-template-columns: 1fr 1fr; + } + .form-page--two-col .form-page-header { grid-column: 1 / -1; } + .form-page--two-col .dump-edit-preview { border-radius: 0 0 0 12px; } + .form-page--two-col .dump-form { border-radius: 0 0 12px 0; } +} + +.form-page-header { + padding: 1.25rem; + background: var(--color-surface); + border-radius: 12px 12px 0 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.form-page-eyebrow { + margin: 0; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + opacity: 0.45; +} + +.form-page-title { + margin: 0; + font-size: 1.4rem; + font-weight: 700; + word-break: break-word; +} + +.dump-edit-preview { + padding: 1.25rem; + background: var(--color-surface); + overflow: hidden; + border-radius: 0 0 12px 12px; +} + +.dump-form { + display: flex; + flex-direction: column; + gap: 1rem; + background: var(--color-surface); + border-radius: 0 0 12px 12px; + padding: 1.25rem; +} + +.form-actions { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 0.75rem; + border-top: 1px solid rgba(128, 128, 128, 0.18); + margin-top: 0.5rem; + gap: 0.75rem; +} + +.form-actions-right { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; +} + +.form-cancel { + font-size: 0.9rem; + color: var(--color-text); + opacity: 0.6; + text-decoration: none; + transition: opacity 0.15s; +} + +.form-cancel:hover { + opacity: 1; +} + +/* ── Delete button ── */ +.btn-danger { + background: #a02b2b; + color: #fff; + border-color: transparent; + font-size: 0.85rem; + padding: 0.4em 0.9em; +} + +.btn-danger:hover { + background: #c03030; + border-color: transparent; +} + +/* ── Index page ── */ +.index-page { + min-height: 100vh; + display: flex; + flex-direction: column; + animation: page-enter 0.2s ease both; +} + +.index-presence { + display: flex; + align-items: center; + gap: 0; +} + +@media (min-width: 740px) { + .index-page .dump-feed { + margin-top: 1rem; + } +} + +.btn-primary { + background: var(--color-accent); + color: #fff; + font-weight: 700; + font-size: 0.95rem; + padding: 0.45rem 1.1rem; + border: none; + border-radius: 8px; + cursor: pointer; + box-shadow: 0 2px 8px color-mix(in srgb, var(--color-accent) 40%, transparent); + transition: background 0.15s, box-shadow 0.15s, transform 0.1s; +} + +.btn-primary:hover { + background: var(--color-accent-hover); + box-shadow: 0 4px 14px color-mix(in srgb, var(--color-accent) 50%, transparent); + transform: translateY(-1px); +} + +.btn-primary:active { + transform: translateY(0); + box-shadow: none; +} + +.index-presence-avatar { + display: block; + flex-shrink: 0; + margin-left: -10px; + transition: margin-left 0.2s ease, transform 0.2s ease; + position: relative; +} + +.index-presence-avatar:first-child { + margin-left: 0; +} + +.index-presence:hover .index-presence-avatar { + margin-left: 4px; +} + +.index-presence:hover .index-presence-avatar:first-child { + margin-left: 0; +} + +.index-presence-avatar:hover { + transform: translateY(-3px) scale(1.12); + z-index: 2; +} + + +.index-status { + text-align: center; + opacity: 0.6; + padding: 3rem 1rem; +} + +.index-status--error { + color: #e55; + opacity: 1; +} + +/* ── Below-header strip (presence + sort on narrow viewports) ── */ +.index-below-header { + display: flex; + flex-direction: row; + align-items: center; + margin-top: 0.5rem; + gap: 0.75rem; + padding: 0.6rem 1.25rem 0; +} + +@media (min-width: 740px) { + .index-below-header { display: none; } +} + +/* ── Feed sort buttons (shared between header center and below-header) ── */ +.feed-sort { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.feed-header { + padding: 0.5rem 0 0; +} + +.feed-sort-btn { + padding: 0.25rem 0.8rem; + border-radius: 6px; + border: 2px solid var(--color-border); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + transition: border-color 0.15s, background 0.15s, color 0.15s; +} + +.feed-sort-btn.active { + border-color: var(--color-accent); + background: var(--color-accent); + color: #fff; +} + +/* ── Dump feed ── */ +.dump-feed { + list-style: none; + margin: 0; + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 860px; + width: 100%; + box-sizing: border-box; + align-self: center; +} + +.dump-card { + display: grid; + grid-template-rows: 1fr; + border: 2px solid var(--color-border); + border-radius: 10px; + background: var(--color-surface); + transition: border-color 0.15s, grid-template-rows 0.32s ease, opacity 0.25s ease; + min-width: 0; +} + +.dump-card:hover { + border-color: var(--color-accent); +} + +.dump-card-inner { + overflow: hidden; + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem 1rem; + min-width: 0; + cursor: pointer; +} + +.dump-card-preview { + flex-shrink: 0; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--color-border); + background: var(--color-bg); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.dump-card-inner:hover .dump-card-preview { + transform: scale(1.08); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +.dump-card-preview-icon { + font-size: 1.4rem; + opacity: 0.7; +} + +.dump-card-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.dump-card-title { + font-weight: 600; + font-size: 1rem; + color: var(--color-text); + text-decoration: none; + word-break: break-word; + transition: color 0.15s; + line-height: 1.35; +} + + +.dump-card-inner:hover .dump-card-title { + color: var(--color-accent); +} + +.dump-card-comment { + margin: 0; + font-size: 0.85rem; + opacity: 0.65; + word-break: break-word; + line-height: 1.4; +} + +.dump-card-date { + display: block; + font-size: 0.78rem; + opacity: 0.45; + margin-top: 0.2rem; +} + +.dump-card-vote { + flex-shrink: 0; + align-self: center; +} diff --git a/src/App.tsx b/src/App.tsx index e161314..2e69d49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,16 +7,19 @@ import { Dump } from "./pages/Dump.tsx"; import { DumpCreate } from "./pages/DumpCreate.tsx"; import { DumpEdit } from "./pages/DumpEdit.tsx"; import { UserLogin } from "./pages/UserLogin.tsx"; -import { UserProfile } from "./pages/UserProfile.tsx"; +import { UserPublicProfile } from "./pages/UserPublicProfile.tsx"; import { UserRegister } from "./pages/UserRegister.tsx"; import { AuthProvider } from "./contexts/AuthProvider.tsx"; +import { WSProvider } from "./contexts/WSProvider.tsx"; +import { useAuth } from "./hooks/useAuth.ts"; import "./App.css"; -function App() { +function AppRoutes() { + const { token } = useAuth(); return ( - + } /> @@ -53,16 +56,17 @@ function App() { } /> - - - - } - /> + } /> + + ); +} + +function App() { + return ( + + ); } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx new file mode 100644 index 0000000..103a9b1 --- /dev/null +++ b/src/components/AppHeader.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import { Link, useNavigate } from "react-router"; +import { useAuth } from "../hooks/useAuth.ts"; + +export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) { + const { user } = useAuth(); + const navigate = useNavigate(); + + return ( +
+ 🚚 gerbeur + + {centerSlot &&
{centerSlot}
} + + +
+ ); +} diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx new file mode 100644 index 0000000..edc63f0 --- /dev/null +++ b/src/components/Avatar.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { API_URL } from "../config/api.ts"; + +interface AvatarProps { + userId: string; + username: string; + hasAvatar: boolean; + size?: number; +} + +export function Avatar({ userId, username, hasAvatar, size = 36 }: AvatarProps) { + const [imgFailed, setImgFailed] = useState(false); + const sizeStyle = { width: size, height: size }; + + if (hasAvatar && !imgFailed) { + return ( + {username} setImgFailed(true)} + /> + ); + } + + return ( +
+ {username.charAt(0).toUpperCase()} +
+ ); +} diff --git a/src/components/DumpCard.tsx b/src/components/DumpCard.tsx new file mode 100644 index 0000000..9a2ae5b --- /dev/null +++ b/src/components/DumpCard.tsx @@ -0,0 +1,58 @@ +import { Link, useNavigate } from "react-router"; +import type { Dump } from "../model.ts"; +import { relativeTime } from "../utils/relativeTime.ts"; +import FilePreview from "./FilePreview.tsx"; +import RichContentCard from "./RichContentCard.tsx"; +import { VoteButton } from "./VoteButton.tsx"; + +interface DumpCardProps { + dump: Dump; + voteCount: number; + voted: boolean; + canVote: boolean; + castVote: (id: string) => void; + removeVote: (id: string) => void; + className?: string; +} + +export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote, className }: DumpCardProps) { + const navigate = useNavigate(); + + return ( +
  • +
    navigate(`/dumps/${dump.id}`)}> +
    e.stopPropagation() : undefined} + > + {dump.kind === "file" + ? + : dump.richContent + ? + : 🔗} +
    + +
    + e.stopPropagation()}> + {dump.title} + + {dump.comment &&

    {dump.comment}

    } + +
    + +
    e.stopPropagation()}> + +
    +
    +
  • + ); +} diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx new file mode 100644 index 0000000..1e2ef14 --- /dev/null +++ b/src/components/FilePreview.tsx @@ -0,0 +1,69 @@ +import { API_URL } from "../config/api.ts"; +import type { Dump } from "../model.ts"; +import { formatBytes } from "../utils/format.ts"; +import { MediaPlayer } from "./MediaPlayer.tsx"; + +interface FilePreviewProps { + dump: Dump; + compact?: boolean; +} + +function mimeIcon(mime: string): string { + if (mime.startsWith("video/")) return "🎬"; + if (mime.startsWith("audio/")) return "🎵"; + if (mime === "application/pdf") return "📄"; + if (mime.startsWith("text/")) return "📝"; + return "📁"; +} + +export default function FilePreview({ dump, compact = false }: FilePreviewProps) { + const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`; + const mime = dump.fileMime ?? ""; + + if (compact) { + if (mime.startsWith("image/")) { + return ( + {dump.fileName} { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ); + } + return {mimeIcon(mime)}; + } + + if (mime.startsWith("image/")) { + return ( + {dump.fileName} + ); + } + + if (mime.startsWith("video/")) { + return ; + } + + if (mime.startsWith("audio/")) { + return ; + } + + if (mime === "application/pdf") { + return ( + + ); + } + + return ( + + {mimeIcon(mime)} Download {dump.fileName} + {dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`} + + ); +} diff --git a/src/components/MediaPlayer.tsx b/src/components/MediaPlayer.tsx new file mode 100644 index 0000000..7a2bf83 --- /dev/null +++ b/src/components/MediaPlayer.tsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from "react"; + +function fmt(s: number): string { + if (!isFinite(s)) return "0:00"; + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${m}:${sec.toString().padStart(2, "0")}`; +} + +const IconPlay = () => ( + + + +); + +const IconPause = () => ( + + + + +); + +const IconVolume = ({ muted }: { muted: boolean }) => ( + + {muted + ? + : } + +); + +const IconFullscreen = () => ( + + + +); + +const HIDE_DELAY = 2500; + +interface MediaPlayerProps { + src: string; + kind: "audio" | "video"; + mime?: string; +} + +export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) { + const mediaRef = useRef(null); + const [playing, setPlaying] = useState(false); + const [current, setCurrent] = useState(0); + const [duration, setDuration] = useState(0); + const [dragging, setDragging] = useState(false); + const [volume, setVolume] = useState(1); + const [muted, setMuted] = useState(false); + const [controlsVisible, setControlsVisible] = useState(true); + const hideTimer = useRef | null>(null); + + useEffect(() => { + const a = mediaRef.current!; + const onTime = () => { if (!dragging) setCurrent(a.currentTime); }; + const onDuration = () => setDuration(a.duration); + const onEnded = () => setPlaying(false); + a.addEventListener("timeupdate", onTime); + a.addEventListener("durationchange", onDuration); + a.addEventListener("ended", onEnded); + return () => { + a.removeEventListener("timeupdate", onTime); + a.removeEventListener("durationchange", onDuration); + a.removeEventListener("ended", onEnded); + }; + }, [dragging]); + + // Show controls when paused; schedule hide when playing + useEffect(() => { + if (kind !== "video") return; + if (hideTimer.current) clearTimeout(hideTimer.current); + if (playing) { + hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY); + } else { + setControlsVisible(true); + } + return () => { if (hideTimer.current) clearTimeout(hideTimer.current); }; + }, [playing, kind]); + + const showControlsTemporarily = () => { + if (kind !== "video") return; + setControlsVisible(true); + if (hideTimer.current) clearTimeout(hideTimer.current); + if (playing) { + hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY); + } + }; + + const toggle = () => { + const a = mediaRef.current!; + if (playing) { a.pause(); setPlaying(false); } + else { a.play(); setPlaying(true); } + }; + + const seek = (e: React.ChangeEvent) => { + const v = Number(e.target.value); + setCurrent(v); + mediaRef.current!.currentTime = v; + }; + + const changeVolume = (e: React.ChangeEvent) => { + const v = Number(e.target.value); + setVolume(v); + mediaRef.current!.volume = v; + if (v > 0 && muted) { setMuted(false); mediaRef.current!.muted = false; } + }; + + const toggleMute = () => { + const next = !muted; + setMuted(next); + mediaRef.current!.muted = next; + }; + + const goFullscreen = () => { + (mediaRef.current as HTMLVideoElement).requestFullscreen?.(); + }; + + const progress = duration > 0 ? current / duration : 0; + + const controls = ( + <> + + + {fmt(current)} + +
    +
    + setDragging(true)} + onMouseUp={() => setDragging(false)} + onChange={seek} + aria-label="Seek" + /> +
    + + {fmt(duration)} + +
    + +
    +
    + +
    +
    + + {kind === "video" && ( + + )} + + ); + + if (kind === "video") { + return ( +
    playing && setControlsVisible(false)} + > + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + +
    + {controls} +
    +
    + ); + } + + return ( +
    +
    + ); +} diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx new file mode 100644 index 0000000..fa12db9 --- /dev/null +++ b/src/components/PageShell.tsx @@ -0,0 +1,18 @@ +import { type ReactNode } from "react"; +import { AppHeader } from "./AppHeader.tsx"; + +interface PageShellProps { + children: ReactNode; + centered?: boolean; +} + +export function PageShell({ children, centered = false }: PageShellProps) { + return ( +
    + +
    + {children} +
    +
    + ); +} diff --git a/src/components/RichContentCard.tsx b/src/components/RichContentCard.tsx new file mode 100644 index 0000000..02392e9 --- /dev/null +++ b/src/components/RichContentCard.tsx @@ -0,0 +1,67 @@ +import type { RichContent } from "../model"; + +interface RichContentCardProps { + richContent: RichContent; + compact?: boolean; +} + +export default function RichContentCard( + { richContent, compact = false }: RichContentCardProps, +) { + if (compact) { + return ( + e.stopPropagation()} + > + {richContent.thumbnailUrl + ? ( + {richContent.title { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ) + : 🔗} + + ); + } + + return ( + + {richContent.thumbnailUrl && ( + {richContent.title { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + )} +
    + {richContent.siteName && ( + {richContent.siteName} + )} + {richContent.title && ( +

    {richContent.title}

    + )} + {richContent.description && ( +

    {richContent.description}

    + )} + {richContent.url} +
    +
    + ); +} diff --git a/src/components/VoteButton.tsx b/src/components/VoteButton.tsx new file mode 100644 index 0000000..8136160 --- /dev/null +++ b/src/components/VoteButton.tsx @@ -0,0 +1,22 @@ +interface VoteButtonProps { + dumpId: string; + count: number; + voted: boolean; + disabled?: boolean; + onCast: (dumpId: string) => void; + onRemove: (dumpId: string) => void; +} + +export function VoteButton({ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps) { + return ( + + ); +} diff --git a/src/config/api.ts b/src/config/api.ts index 96d83ba..2c0e463 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -7,3 +7,4 @@ const serverHost = import.meta.env.VITE_SERVER_HOST || "localhost"; const serverPort = import.meta.env.VITE_SERVER_PORT || "8000"; export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`; +export const WS_URL = API_URL.replace(/^http/, "ws"); diff --git a/src/contexts/AuthProvider.tsx b/src/contexts/AuthProvider.tsx index 4b6974f..92b5674 100644 --- a/src/contexts/AuthProvider.tsx +++ b/src/contexts/AuthProvider.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useState } from "react"; +import { useState, type ReactNode } from "react"; import { AuthContext, type AuthContextValue } from "./AuthContext.ts"; diff --git a/src/contexts/WSContext.ts b/src/contexts/WSContext.ts new file mode 100644 index 0000000..c10af74 --- /dev/null +++ b/src/contexts/WSContext.ts @@ -0,0 +1,22 @@ +import { createContext } from "react"; +import type { Dump, OnlineUser } from "../model.ts"; + +export interface WSContextValue { + onlineUsers: OnlineUser[]; + voteCounts: Record; + myVotes: Set; + recentDumps: Dump[]; + deletedDumpIds: Set; + castVote: (dumpId: string) => void; + removeVote: (dumpId: string) => void; +} + +export const WSContext = createContext({ + onlineUsers: [], + voteCounts: {}, + myVotes: new Set(), + recentDumps: [], + deletedDumpIds: new Set(), + castVote: () => {}, + removeVote: () => {}, +}); diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx new file mode 100644 index 0000000..73dfbd6 --- /dev/null +++ b/src/contexts/WSProvider.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { WSContext, type WSContextValue } from "./WSContext.ts"; +import { WS_URL } from "../config/api.ts"; +import type { Dump, OnlineUser } from "../model.ts"; + +interface WSProviderProps { + children: ReactNode; + token: string | null; +} + +const MAX_BACKOFF = 30_000; +const ACK_TIMEOUT = 5_000; + +export function WSProvider({ children, token }: WSProviderProps) { + const [onlineUsers, setOnlineUsers] = useState([]); + const [voteCounts, setVoteCounts] = useState>({}); + const [myVotes, setMyVotes] = useState>(new Set()); + const [recentDumps, setRecentDumps] = useState([]); + const [deletedDumpIds, setDeletedDumpIds] = useState>(new Set()); + + // Refs to avoid stale closures in event handlers + const voteCountsRef = useRef(voteCounts); + const myVotesRef = useRef(myVotes); + voteCountsRef.current = voteCounts; + myVotesRef.current = myVotes; + + const socketRef = useRef(null); + // Tracks pending optimistic votes: dumpId → revert timeout ID + const pendingRef = useRef>>(new Map()); + + useEffect(() => { + let closed = false; + let backoff = 500; + let reconnectTimer: ReturnType | null = null; + + function connect() { + if (closed) return; + + const url = `${WS_URL}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`; + const ws = new WebSocket(url); + socketRef.current = ws; + + ws.onmessage = (event) => { + let msg: Record; + try { + msg = JSON.parse(event.data); + } catch { + return; + } + + switch (msg.type) { + case "ping": + ws.send(JSON.stringify({ type: "pong" })); + break; + + case "welcome": { + backoff = 500; // reset backoff on successful connect + const users = msg.users as OnlineUser[]; + const votes = msg.myVotes as string[]; + setOnlineUsers(users); + setMyVotes(new Set(votes)); + break; + } + + case "presence_update": + setOnlineUsers(msg.users as OnlineUser[]); + break; + + case "votes_update": { + const { dumpId, voteCount } = msg as { dumpId: string; voteCount: number }; + setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount })); + break; + } + + case "dump_created": { + const dump = msg.dump as Dump; + setRecentDumps((prev) => [dump, ...prev]); + break; + } + + case "dump_deleted": { + const dumpId = msg.dumpId as string; + setDeletedDumpIds((prev) => new Set([...prev, dumpId])); + setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId)); + break; + } + + case "vote_ack": { + const { dumpId, action, voteCount } = msg as { + dumpId: string; + action: "cast" | "remove"; + voteCount: number; + }; + // Clear pending revert timeout + const timeout = pendingRef.current.get(dumpId); + if (timeout !== undefined) { + clearTimeout(timeout); + pendingRef.current.delete(dumpId); + } + // Reconcile with authoritative count + setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount })); + // Confirm vote state + setMyVotes((prev) => { + const next = new Set(prev); + if (action === "cast") next.add(dumpId); + else next.delete(dumpId); + return next; + }); + break; + } + + case "error": + // On error, revert any pending optimistic update for the affected dump + // (the revert timeout will handle it) + break; + } + }; + + ws.onclose = () => { + if (closed) return; + reconnectTimer = setTimeout(() => { + backoff = Math.min(backoff * 2, MAX_BACKOFF); + connect(); + }, backoff); + }; + + ws.onerror = () => { + // onclose will fire after onerror + }; + } + + connect(); + + return () => { + closed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + socketRef.current?.close(); + socketRef.current = null; + // Clear all pending revert timeouts + for (const t of pendingRef.current.values()) clearTimeout(t); + pendingRef.current.clear(); + }; + }, [token]); + + const castVote = useCallback((dumpId: string) => { + // Optimistic update + const prevCount = voteCountsRef.current[dumpId] ?? 0; + const prevVoted = myVotesRef.current.has(dumpId); + if (prevVoted) return; // already voted + + setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; }); + setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 })); + + // Schedule revert if no ack + const timeout = setTimeout(() => { + pendingRef.current.delete(dumpId); + setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); + setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount })); + }, ACK_TIMEOUT); + pendingRef.current.set(dumpId, timeout); + + socketRef.current?.send(JSON.stringify({ type: "vote_cast", dumpId })); + }, []); + + const removeVote = useCallback((dumpId: string) => { + // Optimistic update + const prevCount = voteCountsRef.current[dumpId] ?? 0; + const prevVoted = myVotesRef.current.has(dumpId); + if (!prevVoted) return; // not voted + + setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; }); + setVoteCounts((prev) => ({ ...prev, [dumpId]: Math.max(0, prevCount - 1) })); + + // Schedule revert if no ack + const timeout = setTimeout(() => { + pendingRef.current.delete(dumpId); + setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; }); + setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount })); + }, ACK_TIMEOUT); + pendingRef.current.set(dumpId, timeout); + + socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId })); + }, []); + + const value: WSContextValue = { + onlineUsers, + voteCounts, + myVotes, + recentDumps, + deletedDumpIds, + castVote, + removeVote, + }; + + return ( + + {children} + + ); +} diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index 6213b8e..2b78d3e 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -19,13 +19,15 @@ export const useAuth = () => { const authFetch = async (input: RequestInfo, init: RequestInit = {}) => { const token = authResponse?.token; + const isFormData = init.body instanceof FormData; const res = await fetch(input, { ...init, headers: { ...(init.headers ?? {}), Authorization: `Bearer ${token}`, - "Content-Type": "application/json", + // Let the browser set Content-Type for FormData (it includes the boundary) + ...(isFormData ? {} : { "Content-Type": "application/json" }), }, }); diff --git a/src/hooks/useWS.ts b/src/hooks/useWS.ts new file mode 100644 index 0000000..8403ddd --- /dev/null +++ b/src/hooks/useWS.ts @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { WSContext } from "../contexts/WSContext.ts"; + +export function useWS() { + return useContext(WSContext); +} diff --git a/src/index.css b/src/index.css index 97bd452..ac195d6 100644 --- a/src/index.css +++ b/src/index.css @@ -40,13 +40,18 @@ body { margin: 0; - display: grid; - place-items: center; min-height: 100vh; background-color: var(--color-bg); color: var(--color-text); } +#root { + min-height: 100vh; + width: 100%; + display: flex; + flex-direction: column; +} + a { font-weight: 500; color: var(--color-link); diff --git a/src/model.ts b/src/model.ts index fd1a711..e30b448 100644 --- a/src/model.ts +++ b/src/model.ts @@ -2,12 +2,29 @@ * Backend */ +export interface RichContent { + type: string; + url: string; + siteName?: string; + title?: string; + description?: string; + thumbnailUrl?: string; + videoId?: string; +} + export interface Dump { id: string; + kind: "url" | "file"; title: string; - description?: string; + comment?: string; userId: string; createdAt: Date; + url?: string; + richContent?: RichContent; + fileName?: string; + fileMime?: string; + fileSize?: number; + voteCount: number; } /** @@ -19,6 +36,16 @@ export interface User { username: string; isAdmin: boolean; createdAt: Date; + avatarMime?: string; +} + +// Public user profile (no passwordHash) +export interface PublicUser { + id: string; + username: string; + isAdmin: boolean; + createdAt: Date; + avatarMime?: string; } export interface LoginUserRequest { @@ -46,14 +73,15 @@ export interface AuthResponse { * API */ -export enum APIErrorCode { - BAD_REQUEST = "BAD_REQUEST", - NOT_FOUND = "NOT_FOUND", - SERVER_ERROR = "SERVER_ERROR", - TIMEOUT = "TIMEOUT", - UNAUTHORIZED = "UNAUTHORIZED", - VALIDATION_ERROR = "VALIDATION_ERROR", -} +export const APIErrorCode = { + BAD_REQUEST: "BAD_REQUEST", + NOT_FOUND: "NOT_FOUND", + SERVER_ERROR: "SERVER_ERROR", + TIMEOUT: "TIMEOUT", + UNAUTHORIZED: "UNAUTHORIZED", + VALIDATION_ERROR: "VALIDATION_ERROR", +} as const; +export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode]; export interface APIError { code: APIErrorCode; @@ -78,30 +106,78 @@ export type APIResponse = APISuccess | APIFailure; * Request DTOs */ -export interface CreateDumpRequest { - title: string; - description?: string; +export interface CreateUrlDumpRequest { + url: string; + comment?: string; } export interface UpdateDumpRequest { - title?: string; - description?: string; + url?: string; + comment?: string; } -export interface LoginUserRequest { +/** + * WebSockets + */ + +export interface VoteCastMessage { + type: "vote_cast"; + dumpId: string; + userId: string; +} + +export interface VoteAckMessageFailure { + type: "vote_ack"; + dumpId: string; + success: false; + error: APIError; +} + +export interface VoteAckMessageSuccess { + type: "vote_ack"; + dumpId: string; + action: "cast" | "remove"; + success: true; + voteCount: number; + error?: never; +} + +export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure; + +export interface VoteRemoveMessage { + type: "vote_remove"; + dumpId: string; +} + +export interface VotesUpdateMessage { + type: "votes_update"; + dumpId: string; + voteCount: number; +} + +export interface OnlineUser { + userId: string; username: string; - password: string; + hasAvatar: boolean; } -export interface RegisterUserRequest { - username: string; - password: string; +export interface WelcomeMessage { + type: "welcome"; + users: OnlineUser[]; + myVotes: string[]; } -export interface UpdateUserRequest { - username?: string; - password?: string; - isAdmin?: boolean; +export interface PresenceUpdateMessage { + type: "presence_update"; + users: OnlineUser[]; +} + +export interface PingMessage { + type: "ping"; +} + +export interface PongMessage { + type: "pong"; } /** diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index de103d7..5565574 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -1,11 +1,18 @@ import { useEffect, useState } from "react"; -import { Link, useParams } from "react-router"; +import { Link, useLocation, useParams } from "react-router"; import { API_URL } from "../config/api.ts"; -import type { Dump } from "../model.ts"; +import type { Dump, PublicUser } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; +import { relativeTime } from "../utils/relativeTime.ts"; +import { useWS } from "../hooks/useWS.ts"; +import { Avatar } from "../components/Avatar.tsx"; +import RichContentCard from "../components/RichContentCard.tsx"; +import FilePreview from "../components/FilePreview.tsx"; +import { VoteButton } from "../components/VoteButton.tsx"; +import { PageShell } from "../components/PageShell.tsx"; type DumpState = | { status: "loading" } @@ -14,26 +21,44 @@ type DumpState = export function Dump() { const { selectedDump } = useParams(); + const location = useLocation(); + const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null; - const [dumpState, setDumpState] = useState({ status: "loading" }); + const [dumpState, setDumpState] = useState( + preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" }, + ); + const [op, setOp] = useState(null); const { user } = useAuth(); + const { voteCounts, myVotes, castVote, removeVote } = useWS(); - // Fetch dump data useEffect(() => { if (!selectedDump) return; + if (preloaded) { + fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`) + .then((r) => r.json()) + .then((r) => r.success && setOp(r.data)) + .catch(() => {}); + return; + } + setDumpState({ status: "loading" }); + setOp(null); (async () => { try { - const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`); - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); - } + const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); const apiResponse = await res.json(); - setDumpState({ status: "loaded", dump: apiResponse.data }); + const dump: Dump = apiResponse.data; + setDumpState({ status: "loaded", dump }); + + fetch(`${API_URL}/api/users/by-id/${dump.userId}`) + .then((r) => r.json()) + .then((r) => r.success && setOp(r.data)) + .catch(() => {}); } catch (err) { setDumpState({ status: "error", @@ -44,49 +69,82 @@ export function Dump() { }, [selectedDump]); if (dumpState.status === "loading") { - return
    Loading dump...
    ; + return

    Loading dump…

    ; } if (dumpState.status === "error") { return ( -
    -

    Error

    -

    {dumpState.error}

    - -

    + +

    +

    Error

    +

    {dumpState.error}

    + ← Back to all dumps -

    -
    +
    + ); } const { dump } = dumpState; - const canEdit = !!user && - (dump.userId === user.id || user.isAdmin === true); + const canEdit = !!user && (dump.userId === user.id || user.isAdmin === true); return ( -
    -
    -

    {dump.title}

    - {dump.description && ( -

    {dump.description}

    - )} -
    + +
    + {/* Post header */} +
    +
    + +
    +

    {dump.title}

    +
    + + {op + ? {op.username} + : } + +
    +
    +
    -
    - ~ -
    + {dump.comment && ( +
    {dump.comment}
    + )} +
    -
    - {canEdit && ( - - Edit dump - - )} - ← Back to all dumps + {/* Main content */} +
    + {dump.kind === "file" + ? + : dump.richContent + ? + : ( + + {dump.url} + + )} +
    + + {/* Actions */} +
    + {canEdit && Edit} + ← Back to all dumps +
    -
    +
    ); } diff --git a/src/pages/DumpCreate.tsx b/src/pages/DumpCreate.tsx index 412816b..53faa22 100644 --- a/src/pages/DumpCreate.tsx +++ b/src/pages/DumpCreate.tsx @@ -1,62 +1,139 @@ -import { SubmitEvent, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { SubmitEvent } from "react"; import { Link, useNavigate } from "react-router"; import { API_URL } from "../config/api.ts"; - -import type { CreateDumpRequest } from "../model.ts"; - +import type { CreateUrlDumpRequest, RichContent } from "../model.ts"; import { useRequiredAuth } from "../hooks/useAuth.ts"; +import { formatBytes } from "../utils/format.ts"; +import { PageShell } from "../components/PageShell.tsx"; +import RichContentCard from "../components/RichContentCard.tsx"; +import { MediaPlayer } from "../components/MediaPlayer.tsx"; +const MAX_FILE_SIZE = 50 * 1024 * 1024; + +type Mode = "url" | "file"; type DumpCreateState = | { status: "idle" } | { status: "submitting" } | { status: "error"; error: string }; +type UrlPreview = + | { status: "idle" } + | { status: "loading" } + | { status: "done"; richContent: RichContent | null }; + +function LocalFilePreview({ file }: { file: File }) { + const src = URL.createObjectURL(file); + const mime = file.type; + + useEffect(() => () => URL.revokeObjectURL(src), [src]); + + if (mime.startsWith("image/")) { + return {file.name}; + } + if (mime.startsWith("video/")) { + return ; + } + if (mime.startsWith("audio/")) { + return ; + } + return ( +
    + + {mime.startsWith("application/pdf") ? "📄" : "📎"} + + {file.name} + {formatBytes(file.size)} +
    + ); +} + export function DumpCreate() { const navigate = useNavigate(); const { authFetch } = useRequiredAuth(); - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); + const [mode, setMode] = useState("url"); + const [url, setUrl] = useState(""); + const [file, setFile] = useState(null); + const [comment, setComment] = useState(""); const [state, setState] = useState({ status: "idle" }); + const [urlPreview, setUrlPreview] = useState({ status: "idle" }); + const debounceRef = useRef | null>(null); - const handleSubmit = async (e: SubmitEvent) => { - e.preventDefault(); + // Debounced URL preview fetch + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); - const trimmedTitle = title.trim(); - - if (!trimmedTitle) { - setState({ status: "error", error: "Title is required." }); + let trimmed: string; + try { + const u = new URL(url.trim()); + if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error(); + trimmed = u.toString(); + } catch { + setUrlPreview({ status: "idle" }); return; } - const body: CreateDumpRequest = { - title, - description: description || undefined, - }; + setUrlPreview({ status: "loading" }); + debounceRef.current = setTimeout(async () => { + try { + const res = await fetch(`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`); + const body = await res.json(); + setUrlPreview({ status: "done", richContent: body.success ? body.data : null }); + } catch { + setUrlPreview({ status: "done", richContent: null }); + } + }, 600); + return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; + }, [url]); + + const handleSubmit = async (e: SubmitEvent) => { + e.preventDefault(); setState({ status: "submitting" }); try { - const res = await authFetch(`${API_URL}/api/dumps`, { - method: "POST", - body: JSON.stringify(body), - }); + let res: Response; - if (!res.ok) { - throw new Error(`HTTP ${res.status}`); + if (mode === "url") { + if (!url.trim()) { + setState({ status: "error", error: "URL is required." }); + return; + } + const body: CreateUrlDumpRequest = { + url: url.trim(), + comment: comment.trim() || undefined, + }; + res = await authFetch(`${API_URL}/api/dumps`, { + method: "POST", + body: JSON.stringify(body), + }); + } else { + if (!file) { + setState({ status: "error", error: "Please select a file." }); + return; + } + if (file.size > MAX_FILE_SIZE) { + setState({ status: "error", error: "File too large (max 50 MB)." }); + return; + } + const formData = new FormData(); + formData.append("file", file); + if (comment.trim()) formData.append("comment", comment.trim()); + res = await authFetch(`${API_URL}/api/dumps`, { + method: "POST", + body: formData, + }); } - const apiResponse = await res.json(); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const apiResponse = await res.json(); if (apiResponse.success) { - const createdDump = apiResponse.data; - navigate(`/dumps/${createdDump.id}`); + navigate(`/dumps/${apiResponse.data.id}`); } else { - setState({ - status: "error", - error: apiResponse.error.message, - }); + setState({ status: "error", error: apiResponse.error.message }); } } catch (err) { setState({ @@ -66,55 +143,143 @@ export function DumpCreate() { } }; + const submitting = state.status === "submitting"; + + useEffect(() => { + const handler = (e: ClipboardEvent) => { + const pastedFile = e.clipboardData?.files[0]; + if (pastedFile) { + setMode("file"); + setUrl(""); + setUrlPreview({ status: "idle" }); + setFile(pastedFile); + setState({ status: "idle" }); + return; + } + + // Only intercept text pastes when outside an input/textarea + const tag = (e.target as HTMLElement).tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + + const text = e.clipboardData?.getData("text") ?? ""; + try { + const u = new URL(text.trim()); + if (u.protocol === "http:" || u.protocol === "https:") { + setMode("url"); + setFile(null); + setUrl(text.trim()); + setState({ status: "idle" }); + } + } catch { /* not a URL */ } + }; + + window.addEventListener("paste", handler); + return () => window.removeEventListener("paste", handler); + }, []); + return ( -
    -
    -

    Create Dump

    -
    - - {state.status === "error" && ( -
    {state.error}
    - )} - -
    -
    - - setTitle(e.target.value)} - disabled={state.status === "submitting"} - required - /> + +
    +
    +

    New dump

    +
    + + +
    + + {state.status === "error" && ( +

    {state.error}

    + )} + + {mode === "url" + ? ( + <> +
    + + setUrl(e.target.value)} + onPaste={(e) => { + const pastedFile = e.clipboardData.files[0]; + if (pastedFile) { + e.preventDefault(); + setMode("file"); + setUrl(""); + setUrlPreview({ status: "idle" }); + setFile(pastedFile); + setState({ status: "idle" }); + } + }} + disabled={submitting} + placeholder="https://..." + required + autoFocus + /> +
    + {urlPreview.status === "loading" && ( +

    Fetching preview…

    + )} + {urlPreview.status === "done" && urlPreview.richContent && ( + + )} + + ) + : ( + <> +
    + + setFile(e.target.files?.[0] ?? null)} + disabled={submitting} + required + /> +
    + {file && } + + )} +
    - +