v1 review pass: fixed some minor bugs
This commit is contained in:
@@ -3,7 +3,7 @@ import { jwtVerify, SignJWT } from "@panva/jose";
|
||||
|
||||
import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts";
|
||||
|
||||
const JWT_SECRET = "tp-M1-SOR-2026";
|
||||
const JWT_SECRET = "FIXME-gerbeur-dev-env";
|
||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
export async function createJWT(
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||
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;");
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,7 +39,11 @@ 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");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"Expected multipart/form-data",
|
||||
);
|
||||
}
|
||||
|
||||
const body = await ctx.request.body.formData();
|
||||
@@ -58,13 +62,21 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||
}
|
||||
|
||||
if (file.size > MAX_AVATAR_SIZE) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)");
|
||||
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");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File content does not match declared type",
|
||||
);
|
||||
}
|
||||
|
||||
await Deno.mkdir(AVATARS_DIR, { recursive: true });
|
||||
|
||||
@@ -87,7 +87,11 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
|
||||
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 formData = await ctx.request.body.formData();
|
||||
@@ -95,7 +99,11 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||
const comment = formData.get("comment");
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "A file is required");
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
400,
|
||||
"A file is required",
|
||||
);
|
||||
}
|
||||
|
||||
const updatedDump = await replaceFileDump(
|
||||
@@ -113,13 +121,21 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const body = await ctx.request.body.json();
|
||||
|
||||
if (!isUpdateDumpRequest(body)) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 422, "Erroneous user input");
|
||||
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 = await updateDump(dumpId, body);
|
||||
@@ -133,7 +149,11 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (userId !== dump.userId) {
|
||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete dump");
|
||||
throw new APIException(
|
||||
APIErrorCode.UNAUTHORIZED,
|
||||
401,
|
||||
"Not authorized to delete dump",
|
||||
);
|
||||
}
|
||||
|
||||
await deleteDump(dumpId);
|
||||
|
||||
@@ -15,19 +15,61 @@ router.get("/:dumpId", async (ctx) => {
|
||||
const dump = getDump(dumpId);
|
||||
|
||||
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No file for this dump");
|
||||
throw new APIException(
|
||||
APIErrorCode.NOT_FOUND,
|
||||
404,
|
||||
"No file for this dump",
|
||||
);
|
||||
}
|
||||
|
||||
const path = `api/uploads/${dumpId}`;
|
||||
|
||||
let data: Uint8Array;
|
||||
try {
|
||||
const data = await Deno.readFile(`api/uploads/${dumpId}`);
|
||||
data = await Deno.readFile(path);
|
||||
} catch {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
||||
}
|
||||
|
||||
const total = data.byteLength;
|
||||
ctx.response.headers.set("Content-Type", dump.fileMime);
|
||||
ctx.response.headers.set(
|
||||
"Content-Disposition",
|
||||
`inline; filename="${dump.fileName}"`,
|
||||
);
|
||||
ctx.response.headers.set("Accept-Ranges", "bytes");
|
||||
|
||||
const rangeHeader = ctx.request.headers.get("Range");
|
||||
|
||||
if (rangeHeader) {
|
||||
const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/);
|
||||
if (!match) {
|
||||
ctx.response.status = 416;
|
||||
ctx.response.headers.set("Content-Range", `bytes */${total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = match[1]
|
||||
? parseInt(match[1], 10)
|
||||
: total - parseInt(match[2], 10);
|
||||
const end = match[2]
|
||||
? Math.min(parseInt(match[2], 10), total - 1)
|
||||
: total - 1;
|
||||
|
||||
if (start > end || start >= total) {
|
||||
ctx.response.status = 416;
|
||||
ctx.response.headers.set("Content-Range", `bytes */${total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunk = data.subarray(start, end + 1);
|
||||
ctx.response.status = 206;
|
||||
ctx.response.headers.set("Content-Range", `bytes ${start}-${end}/${total}`);
|
||||
ctx.response.headers.set("Content-Length", String(chunk.byteLength));
|
||||
ctx.response.body = chunk;
|
||||
} else {
|
||||
ctx.response.headers.set("Content-Length", String(total));
|
||||
ctx.response.body = data;
|
||||
} catch {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Router } from "@oak/oak";
|
||||
import { fetchRichContent, isValidHttpUrl } from "../services/rich-content-service.ts";
|
||||
import {
|
||||
fetchRichContent,
|
||||
isValidHttpUrl,
|
||||
} from "../services/rich-content-service.ts";
|
||||
|
||||
const previewRouter = new Router();
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
} from "../services/user-service.ts";
|
||||
import { getDumpsByUser, getVotedDumpsByUser } from "../services/dump-service.ts";
|
||||
import {
|
||||
getDumpsByUser,
|
||||
getVotedDumpsByUser,
|
||||
} from "../services/dump-service.ts";
|
||||
|
||||
// Users router
|
||||
const router = new Router({ prefix: "/api/users" });
|
||||
|
||||
@@ -5,10 +5,14 @@ import {
|
||||
broadcastVoteUpdate,
|
||||
getOnlineUsers,
|
||||
register,
|
||||
type WsClient,
|
||||
unregister,
|
||||
type WsClient,
|
||||
} from "../services/ws-service.ts";
|
||||
import { castVote, getUserVotes, removeVote } from "../services/vote-service.ts";
|
||||
import {
|
||||
castVote,
|
||||
getUserVotes,
|
||||
removeVote,
|
||||
} from "../services/vote-service.ts";
|
||||
import { getUserById } from "../services/user-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
|
||||
@@ -38,7 +42,9 @@ router.get("/ws", async (ctx) => {
|
||||
|
||||
let avatarMime: string | undefined;
|
||||
if (authPayload) {
|
||||
try { avatarMime = getUserById(authPayload.userId).avatarMime; } catch { /* user not found */ }
|
||||
try {
|
||||
avatarMime = getUserById(authPayload.userId).avatarMime;
|
||||
} catch { /* user not found */ }
|
||||
}
|
||||
|
||||
const client: WsClient = {
|
||||
@@ -100,7 +106,9 @@ function handleVote(
|
||||
const { socket } = client;
|
||||
|
||||
if (!client.userId) {
|
||||
socket.send(JSON.stringify({ type: "error", message: "Authentication required" }));
|
||||
socket.send(
|
||||
JSON.stringify({ type: "error", message: "Authentication required" }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -122,7 +130,7 @@ function handleVote(
|
||||
voteCount: newCount,
|
||||
}));
|
||||
|
||||
broadcastVoteUpdate(dumpId, newCount);
|
||||
broadcastVoteUpdate(dumpId, newCount, client.userId, action);
|
||||
} catch (err) {
|
||||
const message = err instanceof APIException ? err.message : "Vote failed";
|
||||
socket.send(JSON.stringify({ type: "error", message }));
|
||||
|
||||
@@ -69,7 +69,17 @@ export async function createUrlDump(
|
||||
richContent ? JSON.stringify(richContent) : null,
|
||||
);
|
||||
|
||||
const dump: Dump = { id: dumpId, kind: "url", title, comment: request.comment, userId, createdAt, url: request.url, richContent, voteCount: 0 };
|
||||
const dump: Dump = {
|
||||
id: dumpId,
|
||||
kind: "url",
|
||||
title,
|
||||
comment: request.comment,
|
||||
userId,
|
||||
createdAt,
|
||||
url: request.url,
|
||||
richContent,
|
||||
voteCount: 0,
|
||||
};
|
||||
broadcastNewDump(dump);
|
||||
return dump;
|
||||
}
|
||||
@@ -87,7 +97,11 @@ export async function createFileDump(
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File too large (max 50 MB)",
|
||||
);
|
||||
}
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
@@ -153,7 +167,11 @@ export function listDumps(): Dump[] {
|
||||
).all();
|
||||
|
||||
if (!rows || !rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
|
||||
return rows.map(dumpRowToApi);
|
||||
@@ -167,7 +185,12 @@ export async function updateDump(
|
||||
|
||||
// File dumps: only comment is editable
|
||||
if (dump.kind === "file") {
|
||||
const updatedDump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment };
|
||||
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;
|
||||
@@ -190,7 +213,9 @@ export async function updateDump(
|
||||
const updatedDump: Dump = {
|
||||
...dump,
|
||||
title,
|
||||
comment: "comment" in request ? (request.comment ?? undefined) : dump.comment,
|
||||
comment: "comment" in request
|
||||
? (request.comment ?? undefined)
|
||||
: dump.comment,
|
||||
url: newUrl,
|
||||
richContent,
|
||||
};
|
||||
@@ -213,10 +238,18 @@ export async function replaceFileDump(
|
||||
comment: string | undefined,
|
||||
): Promise<Dump> {
|
||||
if (!isAllowedMime(file.type)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, `File type '${file.type}' is not allowed`);
|
||||
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)");
|
||||
throw new APIException(
|
||||
APIErrorCode.BAD_REQUEST,
|
||||
400,
|
||||
"File too large (max 50 MB)",
|
||||
);
|
||||
}
|
||||
|
||||
const dump = getDump(dumpId);
|
||||
@@ -231,7 +264,14 @@ export async function replaceFileDump(
|
||||
`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 };
|
||||
return {
|
||||
...dump,
|
||||
title: file.name,
|
||||
fileName: file.name,
|
||||
fileMime: file.type,
|
||||
fileSize: file.size,
|
||||
comment,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDumpsByUser(userId: string): Dump[] {
|
||||
@@ -239,7 +279,11 @@ export function getDumpsByUser(userId: string): Dump[] {
|
||||
`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");
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
}
|
||||
@@ -253,7 +297,11 @@ export function getVotedDumpsByUser(userId: string): Dump[] {
|
||||
ORDER BY v.created_at DESC;`,
|
||||
).all(userId);
|
||||
if (!rows.every(isDumpRow)) {
|
||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||
throw new APIException(
|
||||
APIErrorCode.SERVER_ERROR,
|
||||
500,
|
||||
"Malformed dump data",
|
||||
);
|
||||
}
|
||||
return rows.map(dumpRowToApi);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ export const youtubeProvider: RichContentProvider = {
|
||||
// oembed failed — thumbnail still works
|
||||
}
|
||||
|
||||
return { type: "youtube", siteName: "YouTube", url, videoId, title, thumbnailUrl };
|
||||
return {
|
||||
type: "youtube",
|
||||
siteName: "YouTube",
|
||||
url,
|
||||
videoId,
|
||||
title,
|
||||
thumbnailUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,8 +33,10 @@ export async function fetchWithTimeout(
|
||||
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",
|
||||
"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",
|
||||
},
|
||||
});
|
||||
@@ -51,7 +53,10 @@ function decodeHtmlEntities(str: string): string {
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)));
|
||||
.replace(
|
||||
/&#x([0-9a-f]+);/gi,
|
||||
(_, hex) => String.fromCodePoint(parseInt(hex, 16)),
|
||||
);
|
||||
}
|
||||
|
||||
export function extractOgTag(
|
||||
|
||||
@@ -18,7 +18,11 @@ export function castVote(dumpId: string, userId: string): number {
|
||||
} catch (err) {
|
||||
db.exec("ROLLBACK;");
|
||||
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
||||
throw new APIException(APIErrorCode.VALIDATION_ERROR, 409, "Already voted");
|
||||
throw new APIException(
|
||||
APIErrorCode.VALIDATION_ERROR,
|
||||
409,
|
||||
"Already voted",
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -65,15 +65,26 @@ export function broadcastDumpDeleted(dumpId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastVoteUpdate(dumpId: string, voteCount: number): void {
|
||||
export function broadcastVoteUpdate(
|
||||
dumpId: string,
|
||||
voteCount: number,
|
||||
voterId: string,
|
||||
action: "cast" | "remove",
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
send(client.socket, { type: "votes_update", dumpId, voteCount });
|
||||
send(client.socket, {
|
||||
type: "votes_update",
|
||||
dumpId,
|
||||
voteCount,
|
||||
voterId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
||||
const PING_INTERVAL = 30_000;
|
||||
const PONG_TIMEOUT = 5_000;
|
||||
const _PONG_TIMEOUT = 5_000;
|
||||
|
||||
setInterval(() => {
|
||||
for (const client of clients) {
|
||||
|
||||
217
src/App.css
217
src/App.css
@@ -89,7 +89,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
|
||||
/* Forms */
|
||||
.auth-form {
|
||||
display: flex;
|
||||
@@ -139,7 +138,11 @@
|
||||
.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%);
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 45%,
|
||||
var(--color-border) 55%
|
||||
);
|
||||
}
|
||||
|
||||
.dump-form input:focus,
|
||||
@@ -147,8 +150,13 @@
|
||||
.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);
|
||||
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 ── */
|
||||
@@ -212,7 +220,7 @@
|
||||
|
||||
.dump-mode-toggle button.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.dump-mode-toggle button:not(.active):hover {
|
||||
@@ -309,7 +317,7 @@
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.38));
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
@@ -320,14 +328,13 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
.video-player-controls .audio-player-time {
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.video-player-controls .audio-player-vol-btn {
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.video-player-controls .audio-player-track {
|
||||
@@ -350,7 +357,11 @@
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.6rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface) 92%);
|
||||
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%;
|
||||
@@ -366,7 +377,7 @@
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -398,7 +409,11 @@
|
||||
min-width: 48px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(in srgb, var(--color-accent) 20%, var(--color-border) 80%);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--color-accent) 20%,
|
||||
var(--color-border) 80%
|
||||
);
|
||||
}
|
||||
|
||||
.audio-player-track--volume {
|
||||
@@ -480,7 +495,7 @@
|
||||
|
||||
.file-download-link:hover {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.dump-file-notice {
|
||||
@@ -489,7 +504,6 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
.rich-content-card {
|
||||
display: flex;
|
||||
border: 2px solid var(--color-border);
|
||||
@@ -505,33 +519,33 @@
|
||||
}
|
||||
|
||||
.rich-content-card--youtube {
|
||||
border-color: #c00;
|
||||
border-color: var(--color-youtube);
|
||||
}
|
||||
.rich-content-card--youtube:hover {
|
||||
border-color: #f00;
|
||||
border-color: var(--color-youtube-hover);
|
||||
}
|
||||
.rich-content-card--youtube .rich-content-badge {
|
||||
background: #c00;
|
||||
background: var(--color-youtube);
|
||||
}
|
||||
|
||||
.rich-content-card--bandcamp {
|
||||
border-color: #1da0c3;
|
||||
border-color: var(--color-bandcamp);
|
||||
}
|
||||
.rich-content-card--bandcamp:hover {
|
||||
border-color: #25c8f0;
|
||||
border-color: var(--color-bandcamp-hover);
|
||||
}
|
||||
.rich-content-card--bandcamp .rich-content-badge {
|
||||
background: #1da0c3;
|
||||
background: var(--color-bandcamp);
|
||||
}
|
||||
|
||||
.rich-content-card--soundcloud {
|
||||
border-color: #f50;
|
||||
border-color: var(--color-soundcloud);
|
||||
}
|
||||
.rich-content-card--soundcloud:hover {
|
||||
border-color: #f73;
|
||||
border-color: var(--color-soundcloud-hover);
|
||||
}
|
||||
.rich-content-card--soundcloud .rich-content-badge {
|
||||
background: #f50;
|
||||
background: var(--color-soundcloud);
|
||||
}
|
||||
|
||||
.rich-content-thumbnail {
|
||||
@@ -552,8 +566,8 @@
|
||||
|
||||
.rich-content-badge {
|
||||
display: inline-block;
|
||||
background: #c00;
|
||||
color: #fff;
|
||||
background: var(--color-youtube);
|
||||
color: var(--color-on-accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
@@ -588,7 +602,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.dump-card--fading {
|
||||
opacity: 0.28;
|
||||
}
|
||||
@@ -648,7 +661,7 @@
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
@@ -682,13 +695,13 @@
|
||||
.vote-btn--active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.vote-btn--active:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
border-color: var(--color-accent-hover);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.vote-btn:focus {
|
||||
@@ -696,7 +709,8 @@
|
||||
}
|
||||
|
||||
.vote-btn:focus-visible {
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
box-shadow: 0 0 0 3px
|
||||
color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
}
|
||||
|
||||
.vote-btn:disabled {
|
||||
@@ -704,7 +718,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
/* Dump OP line */
|
||||
.dump-op {
|
||||
display: flex;
|
||||
@@ -734,8 +747,8 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
background: var(--color-overlay);
|
||||
color: var(--color-on-accent);
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -813,6 +826,26 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-header .logout-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
padding: 0.3rem 0.9rem;
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.avatar-upload-label {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 1rem;
|
||||
@@ -827,16 +860,15 @@
|
||||
|
||||
.avatar-upload-label:hover {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #e55;
|
||||
color: var(--color-danger);
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
/* ── Shared layout ── */
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
@@ -874,9 +906,15 @@
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.page-error-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: #a02b2b;
|
||||
color: #fff;
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-on-accent);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
@@ -884,9 +922,6 @@
|
||||
|
||||
/* ── Shared header ── */
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
@@ -896,6 +931,45 @@
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ── Floating "+ New" button ── */
|
||||
.fab-new {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
right: max(1.5rem, calc((100vw - 860px) / 2 + 1.5rem));
|
||||
z-index: 20;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 0.6rem 1.4rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 20px
|
||||
color-mix(in srgb, var(--color-accent) 50%, transparent);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(10px) scale(0.94);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
background 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.fab-new--visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.fab-new:hover {
|
||||
background: var(--color-accent-hover);
|
||||
box-shadow: 0 6px 24px
|
||||
color-mix(in srgb, var(--color-accent) 60%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (min-width: 740px) {
|
||||
.app-header--has-center {
|
||||
display: grid;
|
||||
@@ -920,7 +994,9 @@
|
||||
}
|
||||
|
||||
@media (min-width: 740px) {
|
||||
.app-header-center { display: flex; }
|
||||
.app-header-center {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.header-center-slot {
|
||||
@@ -951,12 +1027,12 @@
|
||||
font-size: 0.95rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: var(--color-header-user-bg);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.app-header-user:hover {
|
||||
background: rgba(0, 0, 0, 0.32);
|
||||
background: var(--color-header-user-bg-hover);
|
||||
}
|
||||
|
||||
/* ── Auth card ── */
|
||||
@@ -988,8 +1064,14 @@
|
||||
|
||||
/* ── Form pages (DumpCreate / DumpEdit) ── */
|
||||
@keyframes page-enter {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-page {
|
||||
@@ -1004,9 +1086,15 @@
|
||||
.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--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 {
|
||||
@@ -1055,7 +1143,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
margin-top: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@@ -1081,15 +1169,15 @@
|
||||
|
||||
/* ── Delete button ── */
|
||||
.btn-danger {
|
||||
background: #a02b2b;
|
||||
color: #fff;
|
||||
background: var(--color-danger-bg);
|
||||
color: var(--color-on-accent);
|
||||
border-color: transparent;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4em 0.9em;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c03030;
|
||||
background: var(--color-danger-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
@@ -1115,20 +1203,22 @@
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
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);
|
||||
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);
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--color-accent) 50%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -1162,7 +1252,6 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
||||
.index-status {
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
@@ -1170,7 +1259,7 @@
|
||||
}
|
||||
|
||||
.index-status--error {
|
||||
color: #e55;
|
||||
color: var(--color-danger);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -1185,7 +1274,9 @@
|
||||
}
|
||||
|
||||
@media (min-width: 740px) {
|
||||
.index-below-header { display: none; }
|
||||
.index-below-header {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Feed sort buttons (shared between header center and below-header) ── */
|
||||
@@ -1216,7 +1307,7 @@
|
||||
.feed-sort-btn.active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
color: var(--color-on-accent);
|
||||
}
|
||||
|
||||
/* ── Dump feed ── */
|
||||
@@ -1239,7 +1330,10 @@
|
||||
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;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
grid-template-rows 0.32s ease,
|
||||
opacity 0.25s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1299,7 +1393,6 @@
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
|
||||
.dump-card-inner:hover .dump-card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,82 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
|
||||
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const headerRef = useRef<HTMLElement>(null);
|
||||
const [showFab, setShowFab] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = headerRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => setShowFab(!entry.isIntersecting),
|
||||
{ threshold: 0 },
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className={`app-header${centerSlot ? " app-header--has-center" : ""}`}>
|
||||
<>
|
||||
<header
|
||||
ref={headerRef}
|
||||
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
||||
>
|
||||
<Link to="/" className="app-header-brand">🚚 gerbeur</Link>
|
||||
|
||||
{centerSlot && <div className="app-header-center">{centerSlot}</div>}
|
||||
|
||||
<nav className="app-header-nav">
|
||||
{user ? (
|
||||
{user
|
||||
? (
|
||||
<>
|
||||
<Link to={`/users/${user.username}`} className="app-header-user">
|
||||
<Link
|
||||
to={`/users/${user.username}`}
|
||||
className="app-header-user"
|
||||
>
|
||||
{user.username}
|
||||
</Link>
|
||||
<button className="btn-primary" onClick={() => navigate("/dumps/new")}>+ New</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => navigate("/dumps/new")}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<button onClick={() => navigate("/login")}>Log in</button>
|
||||
<button className="btn-primary" onClick={() => navigate("/register")}>Register</button>
|
||||
<button type="button" onClick={() => navigate("/login")}>
|
||||
Log in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={() => navigate("/register")}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{user && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
||||
onClick={() => navigate("/dumps/new")}
|
||||
aria-label="New dump"
|
||||
>
|
||||
+ New
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ interface AvatarProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export function Avatar({ userId, username, hasAvatar, size = 36 }: AvatarProps) {
|
||||
export function Avatar(
|
||||
{ userId, username, hasAvatar, size = 36 }: AvatarProps,
|
||||
) {
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const sizeStyle = { width: size, height: size };
|
||||
|
||||
|
||||
@@ -15,12 +15,18 @@ interface DumpCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote, className }: DumpCardProps) {
|
||||
export function DumpCard(
|
||||
{ dump, voteCount, voted, canVote, castVote, removeVote, className }:
|
||||
DumpCardProps,
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<li className={`dump-card${className ? ` ${className}` : ""}`}>
|
||||
<div className="dump-card-inner" onClick={() => navigate(`/dumps/${dump.id}`)}>
|
||||
<div
|
||||
className="dump-card-inner"
|
||||
onClick={() => navigate(`/dumps/${dump.id}`)}
|
||||
>
|
||||
<div
|
||||
className="dump-card-preview"
|
||||
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
|
||||
@@ -33,11 +39,19 @@ export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote
|
||||
</div>
|
||||
|
||||
<div className="dump-card-body">
|
||||
<Link to={`/dumps/${dump.id}`} className="dump-card-title" onClick={(e) => e.stopPropagation()}>
|
||||
<Link
|
||||
to={`/dumps/${dump.id}`}
|
||||
className="dump-card-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{dump.title}
|
||||
</Link>
|
||||
{dump.comment && <p className="dump-card-comment">{dump.comment}</p>}
|
||||
<time className="dump-card-date" dateTime={dump.createdAt} title={new Date(dump.createdAt).toLocaleString()}>
|
||||
<time
|
||||
className="dump-card-date"
|
||||
dateTime={dump.createdAt.toISOString()}
|
||||
title={dump.createdAt.toLocaleString()}
|
||||
>
|
||||
{relativeTime(dump.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,9 @@ function mimeIcon(mime: string): string {
|
||||
return "📁";
|
||||
}
|
||||
|
||||
export default function FilePreview({ dump, compact = false }: FilePreviewProps) {
|
||||
export default function FilePreview(
|
||||
{ dump, compact = false }: FilePreviewProps,
|
||||
) {
|
||||
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
||||
const mime = dump.fileMime ?? "";
|
||||
|
||||
|
||||
@@ -23,8 +23,12 @@ const IconPause = () => (
|
||||
const IconVolume = ({ muted }: { muted: boolean }) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
{muted
|
||||
? <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
||||
: <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />}
|
||||
? (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z M19 12l2.5 2.5M21.5 12 19 14.5" />
|
||||
)
|
||||
: (
|
||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM19 12c0 2.76-1.67 5.12-4 6.19V5.81C17.33 6.88 19 9.24 19 12z" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -55,7 +59,9 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current!;
|
||||
const onTime = () => { if (!dragging) setCurrent(a.currentTime); };
|
||||
const onTime = () => {
|
||||
if (!dragging) setCurrent(a.currentTime);
|
||||
};
|
||||
const onDuration = () => setDuration(a.duration);
|
||||
const onEnded = () => setPlaying(false);
|
||||
a.addEventListener("timeupdate", onTime);
|
||||
@@ -68,31 +74,52 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
// Show controls when paused; schedule hide when playing
|
||||
// Stop any in-flight load on unmount.
|
||||
useEffect(() => {
|
||||
const a = mediaRef.current!;
|
||||
return () => {
|
||||
a.pause();
|
||||
a.removeAttribute("src");
|
||||
a.load();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Schedule controls hide when playing; controls are always visible when paused (derived below)
|
||||
useEffect(() => {
|
||||
if (kind !== "video") return;
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||
} else {
|
||||
setControlsVisible(true);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||
return () => {
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [playing, kind]);
|
||||
|
||||
// Controls are always visible when paused or for audio; otherwise follow controlsVisible state
|
||||
const showingControls = kind !== "video" || !playing || controlsVisible;
|
||||
|
||||
const showControlsTemporarily = () => {
|
||||
if (kind !== "video") return;
|
||||
setControlsVisible(true);
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
if (playing) {
|
||||
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||
hideTimer.current = setTimeout(
|
||||
() => setControlsVisible(false),
|
||||
HIDE_DELAY,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
const a = mediaRef.current!;
|
||||
if (playing) { a.pause(); setPlaying(false); }
|
||||
else { a.play(); setPlaying(true); }
|
||||
if (playing) {
|
||||
a.pause();
|
||||
setPlaying(false);
|
||||
} else a.play().then(() => setPlaying(true)).catch(() => {});
|
||||
};
|
||||
|
||||
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -105,7 +132,10 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
const v = Number(e.target.value);
|
||||
setVolume(v);
|
||||
mediaRef.current!.volume = v;
|
||||
if (v > 0 && muted) { setMuted(false); mediaRef.current!.muted = false; }
|
||||
if (v > 0 && muted) {
|
||||
setMuted(false);
|
||||
mediaRef.current!.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
@@ -122,18 +152,29 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
|
||||
const controls = (
|
||||
<>
|
||||
<button type="button" className="audio-player-btn" onClick={toggle} aria-label={playing ? "Pause" : "Play"}>
|
||||
<button
|
||||
type="button"
|
||||
className="audio-player-btn"
|
||||
onClick={toggle}
|
||||
aria-label={playing ? "Pause" : "Play"}
|
||||
>
|
||||
{playing ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
|
||||
<span className="audio-player-time">{fmt(current)}</span>
|
||||
|
||||
<div className="audio-player-track">
|
||||
<div className="audio-player-fill" style={{ width: `${progress * 100}%` }} />
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0} max={duration || 1} step={0.01} value={current}
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
step={0.01}
|
||||
value={current}
|
||||
onMouseDown={() => setDragging(true)}
|
||||
onMouseUp={() => setDragging(false)}
|
||||
onChange={seek}
|
||||
@@ -144,15 +185,26 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
<span className="audio-player-time">{fmt(duration)}</span>
|
||||
|
||||
<div className="audio-player-volume">
|
||||
<button type="button" className="audio-player-vol-btn" onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"}>
|
||||
<button
|
||||
type="button"
|
||||
className="audio-player-vol-btn"
|
||||
onClick={toggleMute}
|
||||
aria-label={muted ? "Unmute" : "Mute"}
|
||||
>
|
||||
<IconVolume muted={muted} />
|
||||
</button>
|
||||
<div className="audio-player-track audio-player-track--volume">
|
||||
<div className="audio-player-fill" style={{ width: `${(muted ? 0 : volume) * 100}%` }} />
|
||||
<div
|
||||
className="audio-player-fill"
|
||||
style={{ width: `${(muted ? 0 : volume) * 100}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
className="audio-player-range"
|
||||
min={0} max={1} step={0.01} value={muted ? 0 : volume}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={muted ? 0 : volume}
|
||||
onChange={changeVolume}
|
||||
aria-label="Volume"
|
||||
/>
|
||||
@@ -160,7 +212,12 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
</div>
|
||||
|
||||
{kind === "video" && (
|
||||
<button type="button" className="audio-player-vol-btn" onClick={goFullscreen} aria-label="Fullscreen">
|
||||
<button
|
||||
type="button"
|
||||
className="audio-player-vol-btn"
|
||||
onClick={goFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
<IconFullscreen />
|
||||
</button>
|
||||
)}
|
||||
@@ -170,11 +227,12 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
if (kind === "video") {
|
||||
return (
|
||||
<div
|
||||
className={`video-player${controlsVisible ? " video-player--controls-visible" : ""}`}
|
||||
className={`video-player${
|
||||
showingControls ? " video-player--controls-visible" : ""
|
||||
}`}
|
||||
onMouseMove={showControlsTemporarily}
|
||||
onMouseLeave={() => playing && setControlsVisible(false)}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
ref={mediaRef as React.RefObject<HTMLVideoElement>}
|
||||
src={src}
|
||||
@@ -193,7 +251,13 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||
|
||||
return (
|
||||
<div className="audio-player">
|
||||
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} src={src} preload="metadata" />
|
||||
<audio
|
||||
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
||||
src={src}
|
||||
preload="metadata"
|
||||
>
|
||||
{mime && <source src={src} type={mime} />}
|
||||
</audio>
|
||||
{controls}
|
||||
</div>
|
||||
);
|
||||
|
||||
17
src/components/PageError.tsx
Normal file
17
src/components/PageError.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PageShell } from "./PageShell.tsx";
|
||||
|
||||
export function PageError({ message, actions }: {
|
||||
message: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{message}</p>
|
||||
{actions && <div className="page-error-actions">{actions}</div>}
|
||||
</div>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,9 @@ export function PageShell({ children, centered = false }: PageShellProps) {
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<AppHeader />
|
||||
<main className={`page-content${centered ? " page-content--centered" : ""}`}>
|
||||
<main
|
||||
className={`page-content${centered ? " page-content--centered" : ""}`}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RichContent } from "../model";
|
||||
import type { RichContent } from "../model.ts";
|
||||
|
||||
interface RichContentCardProps {
|
||||
richContent: RichContent;
|
||||
|
||||
@@ -7,9 +7,12 @@ interface VoteButtonProps {
|
||||
onRemove: (dumpId: string) => void;
|
||||
}
|
||||
|
||||
export function VoteButton({ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps) {
|
||||
export function VoteButton(
|
||||
{ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
|
||||
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
|
||||
|
||||
import { type AuthResponse } from "../model.ts";
|
||||
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => {
|
||||
const stored = localStorage.getItem("authResponse");
|
||||
|
||||
return stored ? JSON.parse(stored) : null;
|
||||
return stored ? deserializeAuthResponse(JSON.parse(stored)) : null;
|
||||
});
|
||||
|
||||
const value: AuthContextValue = { authResponse, setAuthResponse };
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { createContext } from "react";
|
||||
import type { Dump, OnlineUser } from "../model.ts";
|
||||
|
||||
export interface VoteEvent {
|
||||
dumpId: string;
|
||||
voterId: string;
|
||||
action: "cast" | "remove";
|
||||
}
|
||||
|
||||
export interface WSContextValue {
|
||||
onlineUsers: OnlineUser[];
|
||||
voteCounts: Record<string, number>;
|
||||
myVotes: Set<string>;
|
||||
recentDumps: Dump[];
|
||||
deletedDumpIds: Set<string>;
|
||||
lastVoteEvent: VoteEvent | null;
|
||||
castVote: (dumpId: string) => void;
|
||||
removeVote: (dumpId: string) => void;
|
||||
}
|
||||
@@ -17,6 +24,7 @@ export const WSContext = createContext<WSContextValue>({
|
||||
myVotes: new Set(),
|
||||
recentDumps: [],
|
||||
deletedDumpIds: new Set(),
|
||||
lastVoteEvent: null,
|
||||
castVote: () => {},
|
||||
removeVote: () => {},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { WSContext, type WSContextValue } from "./WSContext.ts";
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type VoteEvent, WSContext, type WSContextValue } from "./WSContext.ts";
|
||||
import { WS_URL } from "../config/api.ts";
|
||||
import type { Dump, OnlineUser } from "../model.ts";
|
||||
import type { Dump, OnlineUser, RawDump } from "../model.ts";
|
||||
import { deserializeDump } from "../model.ts";
|
||||
|
||||
interface WSProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -17,16 +25,21 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
||||
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
||||
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
||||
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
|
||||
|
||||
// Refs to avoid stale closures in event handlers
|
||||
const voteCountsRef = useRef(voteCounts);
|
||||
const myVotesRef = useRef(myVotes);
|
||||
useLayoutEffect(() => {
|
||||
voteCountsRef.current = voteCounts;
|
||||
myVotesRef.current = myVotes;
|
||||
});
|
||||
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
// Tracks pending optimistic votes: dumpId → revert timeout ID
|
||||
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let closed = false;
|
||||
@@ -36,7 +49,9 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
function connect() {
|
||||
if (closed) return;
|
||||
|
||||
const url = `${WS_URL}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||
const url = `${WS_URL}/ws${
|
||||
token ? `?token=${encodeURIComponent(token)}` : ""
|
||||
}`;
|
||||
const ws = new WebSocket(url);
|
||||
socketRef.current = ws;
|
||||
|
||||
@@ -67,13 +82,21 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
break;
|
||||
|
||||
case "votes_update": {
|
||||
const { dumpId, voteCount } = msg as { dumpId: string; voteCount: number };
|
||||
const { dumpId, voteCount, voterId, action } = msg as {
|
||||
dumpId: string;
|
||||
voteCount: number;
|
||||
voterId: string;
|
||||
action: "cast" | "remove";
|
||||
};
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||
if (voterId && action) {
|
||||
setLastVoteEvent({ dumpId, voterId, action });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "dump_created": {
|
||||
const dump = msg.dump as Dump;
|
||||
const dump = deserializeDump(msg.dump as RawDump);
|
||||
setRecentDumps((prev) => [dump, ...prev]);
|
||||
break;
|
||||
}
|
||||
@@ -131,14 +154,14 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
|
||||
connect();
|
||||
|
||||
const pending = pendingRef.current;
|
||||
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();
|
||||
for (const t of pending.values()) clearTimeout(t);
|
||||
pending.clear();
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
@@ -148,13 +171,21 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
const prevVoted = myVotesRef.current.has(dumpId);
|
||||
if (prevVoted) return; // already voted
|
||||
|
||||
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
|
||||
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; });
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
@@ -168,13 +199,24 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
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) }));
|
||||
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; });
|
||||
setMyVotes((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(dumpId);
|
||||
return n;
|
||||
});
|
||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||
}, ACK_TIMEOUT);
|
||||
pendingRef.current.set(dumpId, timeout);
|
||||
@@ -188,6 +230,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
||||
myVotes,
|
||||
recentDumps,
|
||||
deletedDumpIds,
|
||||
lastVoteEvent,
|
||||
castVote,
|
||||
removeVote,
|
||||
};
|
||||
|
||||
@@ -7,28 +7,64 @@
|
||||
|
||||
color-scheme: light dark;
|
||||
|
||||
/* Text */
|
||||
--color-text: #f9fafb;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-on-accent: #fff;
|
||||
|
||||
/* Surfaces */
|
||||
--color-bg: #111827;
|
||||
--color-link: #646cff;
|
||||
--color-link-hover: #535bf2;
|
||||
--color-surface: #1f2937;
|
||||
|
||||
/* Borders */
|
||||
--color-border: transparent;
|
||||
--color-border-subtle: rgba(128, 128, 128, 0.18);
|
||||
|
||||
/* Accent */
|
||||
--color-accent: #7c83ff;
|
||||
--color-accent-hover: #4a50e0;
|
||||
|
||||
/* Links */
|
||||
--color-link: #646cff;
|
||||
--color-link-hover: #535bf2;
|
||||
|
||||
/* Danger */
|
||||
--color-danger: #e55;
|
||||
--color-danger-bg: #a02b2b;
|
||||
--color-danger-hover: #c03030;
|
||||
|
||||
/* Overlays */
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-header-user-bg: rgba(0, 0, 0, 0.2);
|
||||
--color-header-user-bg-hover: rgba(0, 0, 0, 0.32);
|
||||
|
||||
/* Misc */
|
||||
--color-option-bg: #37366e;
|
||||
--color-option-border: #111827;
|
||||
--color-notice-bg: #a02b2b;
|
||||
|
||||
/* Service brand colors (fixed, not theme-dependent) */
|
||||
--color-youtube: #c00;
|
||||
--color-youtube-hover: #f00;
|
||||
--color-bandcamp: #1da0c3;
|
||||
--color-bandcamp-hover: #25c8f0;
|
||||
--color-soundcloud: #f50;
|
||||
--color-soundcloud-hover: #f73;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--color-text: #213547;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-bg: #ffffff;
|
||||
--color-link-hover: #747bff;
|
||||
--color-surface: #f9f9f9;
|
||||
--color-border: #646cff;
|
||||
--color-link-hover: #747bff;
|
||||
--color-option-bg: #f5f5f5;
|
||||
--color-option-border: #cccccc;
|
||||
--color-border: #646cff;
|
||||
--color-header-user-bg: rgba(0, 0, 0, 0.08);
|
||||
--color-header-user-bg-hover: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
|
||||
import App from "./App.tsx";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
26
src/model.ts
26
src/model.ts
@@ -48,6 +48,32 @@ export interface PublicUser {
|
||||
avatarMime?: string;
|
||||
}
|
||||
|
||||
// Wire types — createdAt arrives as an ISO string from API/WS/localStorage
|
||||
type WithStringDate<T extends { createdAt: Date }> = Omit<T, "createdAt"> & {
|
||||
createdAt: string;
|
||||
};
|
||||
export type RawDump = WithStringDate<Dump>;
|
||||
export type RawUser = WithStringDate<User>;
|
||||
export type RawPublicUser = WithStringDate<PublicUser>;
|
||||
export type RawAuthResponse = Omit<AuthResponse, "user"> & { user: RawUser };
|
||||
|
||||
// Deserializers — convert wire types to domain types at API/WS/localStorage boundaries
|
||||
export function deserializeDump(raw: RawDump): Dump {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
|
||||
export function deserializeUser(raw: RawUser): User {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
|
||||
export function deserializePublicUser(raw: RawPublicUser): PublicUser {
|
||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||
}
|
||||
|
||||
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
|
||||
return { ...raw, user: deserializeUser(raw.user) };
|
||||
}
|
||||
|
||||
export interface LoginUserRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useParams } from "react-router";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import type { Dump, PublicUser } from "../model.ts";
|
||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
@@ -13,6 +14,7 @@ import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
import { VoteButton } from "../components/VoteButton.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
|
||||
type DumpState =
|
||||
| { status: "loading" }
|
||||
@@ -22,6 +24,7 @@ type DumpState =
|
||||
export function Dump() {
|
||||
const { selectedDump } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null;
|
||||
|
||||
const [dumpState, setDumpState] = useState<DumpState>(
|
||||
@@ -38,7 +41,7 @@ export function Dump() {
|
||||
if (preloaded) {
|
||||
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`)
|
||||
.then((r) => r.json())
|
||||
.then((r) => r.success && setOp(r.data))
|
||||
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
@@ -48,16 +51,18 @@ export function Dump() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
|
||||
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();
|
||||
const dump: Dump = apiResponse.data;
|
||||
const dump: Dump = deserializeDump(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))
|
||||
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
||||
.catch(() => {});
|
||||
} catch (err) {
|
||||
setDumpState({
|
||||
@@ -66,22 +71,39 @@ export function Dump() {
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [selectedDump]);
|
||||
}, [selectedDump, preloaded]);
|
||||
|
||||
if (dumpState.status === "loading") {
|
||||
return <PageShell><p className="page-loading">Loading dump…</p></PageShell>;
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading dump…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (dumpState.status === "error") {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{dumpState.error}</p>
|
||||
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
</div>
|
||||
</PageShell>
|
||||
<PageError
|
||||
message={dumpState.error}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="logout-btn"
|
||||
type="button"
|
||||
onClick={() => globalThis.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
className="logout-btn"
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
← Back to all dumps
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,9 +134,17 @@ export function Dump() {
|
||||
size={22}
|
||||
/>
|
||||
{op
|
||||
? <Link to={`/users/${op.username}`} className="dump-op-link">{op.username}</Link>
|
||||
? (
|
||||
<Link to={`/users/${op.username}`} className="dump-op-link">
|
||||
{op.username}
|
||||
</Link>
|
||||
)
|
||||
: <span className="dump-op-link">…</span>}
|
||||
<time className="dump-card-date" dateTime={dump.createdAt} title={new Date(dump.createdAt).toLocaleString()}>
|
||||
<time
|
||||
className="dump-card-date"
|
||||
dateTime={dump.createdAt.toISOString()}
|
||||
title={dump.createdAt.toLocaleString()}
|
||||
>
|
||||
{relativeTime(dump.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
@@ -133,7 +163,12 @@ export function Dump() {
|
||||
: dump.richContent
|
||||
? <RichContentCard richContent={dump.richContent} />
|
||||
: (
|
||||
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
|
||||
<a
|
||||
href={dump.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="dump-url-link"
|
||||
>
|
||||
{dump.url}
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -24,19 +24,25 @@ type UrlPreview =
|
||||
| { status: "done"; richContent: RichContent | null };
|
||||
|
||||
function LocalFilePreview({ file }: { file: File }) {
|
||||
const src = URL.createObjectURL(file);
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const mime = file.type;
|
||||
|
||||
useEffect(() => () => URL.revokeObjectURL(src), [src]);
|
||||
useEffect(() => {
|
||||
const url = URL.createObjectURL(file);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
if (!src) return null;
|
||||
|
||||
if (mime.startsWith("image/")) {
|
||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||
}
|
||||
if (mime.startsWith("video/")) {
|
||||
return <MediaPlayer src={src} kind="video" mime={mime} />;
|
||||
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||
}
|
||||
if (mime.startsWith("audio/")) {
|
||||
return <MediaPlayer src={src} kind="audio" />;
|
||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||
}
|
||||
return (
|
||||
<div className="local-preview-generic">
|
||||
@@ -78,15 +84,22 @@ export function DumpCreate() {
|
||||
setUrlPreview({ status: "loading" });
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`);
|
||||
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 });
|
||||
setUrlPreview({
|
||||
status: "done",
|
||||
richContent: body.success ? body.data : null,
|
||||
});
|
||||
} catch {
|
||||
setUrlPreview({ status: "done", richContent: null });
|
||||
}
|
||||
}, 600);
|
||||
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||
@@ -173,8 +186,8 @@ export function DumpCreate() {
|
||||
} catch { /* not a URL */ }
|
||||
};
|
||||
|
||||
window.addEventListener("paste", handler);
|
||||
return () => window.removeEventListener("paste", handler);
|
||||
globalThis.addEventListener("paste", handler);
|
||||
return () => globalThis.removeEventListener("paste", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -186,7 +199,11 @@ export function DumpCreate() {
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "url" ? "active" : ""}
|
||||
onClick={() => { setMode("url"); setFile(null); setState({ status: "idle" }); }}
|
||||
onClick={() => {
|
||||
setMode("url");
|
||||
setFile(null);
|
||||
setState({ status: "idle" });
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
🔗 URL
|
||||
@@ -194,7 +211,12 @@ export function DumpCreate() {
|
||||
<button
|
||||
type="button"
|
||||
className={mode === "file" ? "active" : ""}
|
||||
onClick={() => { setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); setState({ status: "idle" }); }}
|
||||
onClick={() => {
|
||||
setMode("file");
|
||||
setUrl("");
|
||||
setUrlPreview({ status: "idle" });
|
||||
setState({ status: "idle" });
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
📎 File
|
||||
@@ -210,7 +232,7 @@ export function DumpCreate() {
|
||||
{mode === "url"
|
||||
? (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div key="url-field" className="form-group">
|
||||
<label htmlFor="url">URL</label>
|
||||
<input
|
||||
id="url"
|
||||
@@ -244,7 +266,7 @@ export function DumpCreate() {
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="form-group">
|
||||
<div key="file-field" className="form-group">
|
||||
<label htmlFor="file">File</label>
|
||||
<input
|
||||
id="file"
|
||||
@@ -273,8 +295,14 @@ export function DumpCreate() {
|
||||
<div className="form-actions">
|
||||
<div className="form-actions-right">
|
||||
<Link to="/" className="form-cancel">Cancel</Link>
|
||||
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||
{submitting ? (mode === "url" ? "Fetching…" : "Uploading…") : "Dump it"}
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting
|
||||
? (mode === "url" ? "Fetching…" : "Uploading…")
|
||||
: "Dump it"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Link, useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { Dump, UpdateDumpRequest } from "../model.ts";
|
||||
import { deserializeDump } from "../model.ts";
|
||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
|
||||
@@ -31,13 +33,15 @@ export function DumpEdit() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
|
||||
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();
|
||||
|
||||
if (apiResponse.success) {
|
||||
const dump: Dump = apiResponse.data;
|
||||
const dump: Dump = deserializeDump(apiResponse.data);
|
||||
setUrl(dump.url ?? "");
|
||||
setComment(dump.comment ?? "");
|
||||
setState({ status: "loaded", dump });
|
||||
@@ -83,11 +87,14 @@ export function DumpEdit() {
|
||||
|
||||
const apiResponse = await res.json();
|
||||
if (!apiResponse.success) {
|
||||
setState({ status: "error", error: apiResponse.error?.message ?? "Update failed" });
|
||||
setState({
|
||||
status: "error",
|
||||
error: apiResponse.error?.message ?? "Update failed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedDump: Dump = apiResponse.data;
|
||||
const updatedDump: Dump = deserializeDump(apiResponse.data);
|
||||
setState({ status: "loaded", dump: updatedDump });
|
||||
setNewFile(null);
|
||||
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
|
||||
@@ -109,19 +116,36 @@ export function DumpEdit() {
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <PageShell><p className="page-loading">Loading dump…</p></PageShell>;
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading dump…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{state.error}</p>
|
||||
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
</div>
|
||||
</PageShell>
|
||||
<PageError
|
||||
message={state.error}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="logout-btn"
|
||||
type="button"
|
||||
onClick={() => globalThis.location.reload()}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
className="logout-btn"
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
← Back to all dumps
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +165,12 @@ export function DumpEdit() {
|
||||
: dump.richContent
|
||||
? <RichContentCard richContent={dump.richContent} />
|
||||
: dump.url && (
|
||||
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
|
||||
<a
|
||||
href={dump.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="dump-url-link"
|
||||
>
|
||||
{dump.url}
|
||||
</a>
|
||||
)}
|
||||
@@ -149,7 +178,10 @@ export function DumpEdit() {
|
||||
|
||||
<form
|
||||
className="dump-form"
|
||||
onSubmit={(e) => { e.preventDefault(); handleSave(); }}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}}
|
||||
>
|
||||
{dump.kind === "url"
|
||||
? (
|
||||
@@ -178,7 +210,9 @@ export function DumpEdit() {
|
||||
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{newFile && (
|
||||
<p className="file-input-info">{newFile.name} — {formatBytes(newFile.size)}</p>
|
||||
<p className="file-input-info">
|
||||
{newFile.name} — {formatBytes(newFile.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -199,7 +233,9 @@ export function DumpEdit() {
|
||||
Delete dump
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
<Link to={`/dumps/${dump.id}`} className="form-cancel">Cancel</Link>
|
||||
<Link to={`/dumps/${dump.id}`} className="form-cancel">
|
||||
Cancel
|
||||
</Link>
|
||||
<button type="submit" className="btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { type Dump } from "../model.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { AppHeader } from "../components/AppHeader.tsx";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
import { deserializeDump, type Dump } from "../model.ts";
|
||||
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
|
||||
type DumpsState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
@@ -17,18 +20,29 @@ type DumpsState =
|
||||
type SortMode = "new" | "hot";
|
||||
|
||||
function hotScore(dump: Dump): number {
|
||||
const ageHours = (Date.now() - new Date(dump.createdAt).getTime()) / 3_600_000;
|
||||
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
||||
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
||||
}
|
||||
|
||||
export function Index() {
|
||||
const location = useLocation();
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)?.deletedDumpId;
|
||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||
?.deletedDumpId;
|
||||
|
||||
const { user } = useAuth();
|
||||
const { onlineUsers, voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote } = useWS();
|
||||
const {
|
||||
onlineUsers,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
recentDumps,
|
||||
deletedDumpIds,
|
||||
castVote,
|
||||
removeVote,
|
||||
} = useWS();
|
||||
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>({ status: "loading" });
|
||||
const [dumpsState, setDumpsState] = useState<DumpsState>({
|
||||
status: "loading",
|
||||
});
|
||||
const [sort, setSort] = useState<SortMode>("hot");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,9 +51,15 @@ export function Index() {
|
||||
const res = await fetch(`${API_URL}/api/dumps/`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
setDumpsState({ status: "loaded", dumps: body.data });
|
||||
setDumpsState({
|
||||
status: "loaded",
|
||||
dumps: body.data.map(deserializeDump),
|
||||
});
|
||||
} catch (err) {
|
||||
setDumpsState({ status: "error", error: err instanceof Error ? err.message : "Failed to load" });
|
||||
setDumpsState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
@@ -54,14 +74,24 @@ export function Index() {
|
||||
const sortedDumps = [...combined].sort(
|
||||
sort === "hot"
|
||||
? (a, b) => hotScore(b) - hotScore(a)
|
||||
: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
: (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
||||
);
|
||||
|
||||
const presenceRow = (
|
||||
<div className="index-presence">
|
||||
{onlineUsers.map((u) => (
|
||||
<Link key={u.userId} to={`/users/${u.username}`} title={u.username} className="index-presence-avatar">
|
||||
<Avatar userId={u.userId} username={u.username} hasAvatar={u.hasAvatar} size={32} />
|
||||
<Link
|
||||
key={u.userId}
|
||||
to={`/users/${u.username}`}
|
||||
title={u.username}
|
||||
className="index-presence-avatar"
|
||||
>
|
||||
<Avatar
|
||||
userId={u.userId}
|
||||
username={u.username}
|
||||
hasAvatar={u.hasAvatar}
|
||||
size={32}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -69,19 +99,33 @@ export function Index() {
|
||||
|
||||
const sortButtons = !loading && !error && combined.length > 0 && (
|
||||
<div className="feed-sort">
|
||||
<button className={`feed-sort-btn${sort === "hot" ? " active" : ""}`} onClick={() => setSort("hot")}>Hot</button>
|
||||
<button className={`feed-sort-btn${sort === "new" ? " active" : ""}`} onClick={() => setSort("new")}>New</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${sort === "hot" ? " active" : ""}`}
|
||||
onClick={() => setSort("hot")}
|
||||
>
|
||||
Hot
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`feed-sort-btn${sort === "new" ? " active" : ""}`}
|
||||
onClick={() => setSort("new")}
|
||||
>
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="index-page">
|
||||
<AppHeader centerSlot={
|
||||
<AppHeader
|
||||
centerSlot={
|
||||
<div className="header-center-slot">
|
||||
{presenceRow}
|
||||
{sortButtons}
|
||||
</div>
|
||||
} />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Shown only on narrow viewports */}
|
||||
<div className="index-below-header">
|
||||
@@ -98,7 +142,6 @@ export function Index() {
|
||||
|
||||
{!loading && !error && combined.length > 0 && (
|
||||
<>
|
||||
|
||||
<ul className="dump-feed">
|
||||
{sortedDumps.map((dump) => (
|
||||
<DumpCard
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { deserializeAuthResponse } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
@@ -38,7 +39,7 @@ export function UserLogin() {
|
||||
const apiResponse = await res.json();
|
||||
|
||||
if (apiResponse.success) {
|
||||
login(apiResponse.data);
|
||||
login(deserializeAuthResponse(apiResponse.data));
|
||||
navigate("/");
|
||||
} else {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
@@ -76,7 +77,11 @@ export function UserLogin() {
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={state.status === "submitting"}
|
||||
>
|
||||
{state.status === "submitting" ? "Logging in…" : "Log in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { AuthResponse, Dump, PublicUser } from "../model.ts";
|
||||
import type { Dump, PublicUser } from "../model.ts";
|
||||
import {
|
||||
deserializeAuthResponse,
|
||||
deserializeDump,
|
||||
deserializePublicUser,
|
||||
deserializeUser,
|
||||
type RawUser,
|
||||
} from "../model.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
|
||||
@@ -16,12 +24,18 @@ type ProfileState =
|
||||
|
||||
export function UserPublicProfile() {
|
||||
const { username } = useParams();
|
||||
const { user: me, authFetch, login } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
const navigate = useNavigate();
|
||||
const { user: me, authFetch, login, logout } = useAuth();
|
||||
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
||||
|
||||
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||
// Tracks which dumps the profile user currently has voted on (real-time).
|
||||
// For own profile this mirrors myVotes; for others it's maintained separately.
|
||||
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||
|
||||
@@ -38,7 +52,11 @@ export function UserPublicProfile() {
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
throw new Error(userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`);
|
||||
throw new Error(
|
||||
userRes.status === 404
|
||||
? "User not found"
|
||||
: `HTTP ${userRes.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const [userBody, dumpsBody, votesBody] = await Promise.all([
|
||||
@@ -47,37 +65,88 @@ export function UserPublicProfile() {
|
||||
votesRes.json(),
|
||||
]);
|
||||
|
||||
const votes: Dump[] = votesBody.success
|
||||
? votesBody.data.map(deserializeDump)
|
||||
: [];
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: userBody.data,
|
||||
dumps: dumpsBody.success ? dumpsBody.data : [],
|
||||
votes: votesBody.success ? votesBody.data : [],
|
||||
user: deserializePublicUser(userBody.data),
|
||||
dumps: dumpsBody.success ? dumpsBody.data.map(deserializeDump) : [],
|
||||
votes,
|
||||
});
|
||||
setProfileVotedIds(new Set(votes.map((d: Dump) => d.id)));
|
||||
} catch (err) {
|
||||
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" });
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load profile",
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [username]);
|
||||
|
||||
// Add newly-voted own dumps to the Upvoted list.
|
||||
// Removals are handled inside UpvotedDumpList (with fade animation).
|
||||
// Stable primitive derived from state — only changes when navigating to a different profile.
|
||||
// Using this instead of `state` directly avoids re-running effects on every vote update.
|
||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||
|
||||
// Own profile: keep profileVotedIds in sync with myVotes, and add newly-voted
|
||||
// dumps (that belong to this user) to the votes list without a fetch.
|
||||
useEffect(() => {
|
||||
if (!profileUserId || me?.id !== profileUserId) return;
|
||||
|
||||
setProfileVotedIds(new Set(myVotes));
|
||||
|
||||
if (prevMyVotesRef.current === null) {
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
return;
|
||||
}
|
||||
const prev = prevMyVotesRef.current;
|
||||
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const voteIds = new Set(s.votes.map((d) => d.id));
|
||||
const toAdd = s.dumps.filter((d) => myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id));
|
||||
const toAdd = s.dumps.filter((d) =>
|
||||
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
|
||||
);
|
||||
if (toAdd.length === 0) return s;
|
||||
return { ...s, votes: [...toAdd, ...s.votes] };
|
||||
});
|
||||
|
||||
prevMyVotesRef.current = new Set(myVotes);
|
||||
}, [myVotes]);
|
||||
}, [myVotes, me, profileUserId]);
|
||||
|
||||
// Real-time upvoted list sync for any profile via WS vote events.
|
||||
useEffect(() => {
|
||||
if (!lastVoteEvent || !profileUserId) return;
|
||||
const { dumpId, voterId, action } = lastVoteEvent;
|
||||
if (voterId !== profileUserId) return;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
|
||||
if (action === "remove") {
|
||||
if (!isOwnProfile) {
|
||||
setProfileVotedIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (!isOwnProfile) {
|
||||
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
||||
}
|
||||
// Always fetch on cast; the setState callback below deduplicates.
|
||||
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) return;
|
||||
const dump = deserializeDump(body.data);
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
|
||||
return s;
|
||||
}
|
||||
return { ...s, votes: [dump, ...s.votes] };
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [lastVoteEvent, me, profileUserId]);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -90,8 +159,15 @@ export function UserPublicProfile() {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const res = await authFetch(`${API_URL}/api/avatars/me`, { method: "POST", body: formData });
|
||||
const body = await res.json() as { success: boolean; data?: AuthResponse["user"]; error?: { message: string } };
|
||||
const res = await authFetch(`${API_URL}/api/avatars/me`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const body = await res.json() as {
|
||||
success: boolean;
|
||||
data?: RawUser;
|
||||
error?: { message: string };
|
||||
};
|
||||
|
||||
if (!res.ok || !body.success) {
|
||||
setAvatarError(body.error?.message ?? "Upload failed");
|
||||
@@ -100,12 +176,20 @@ export function UserPublicProfile() {
|
||||
|
||||
const storedRaw = localStorage.getItem("authResponse");
|
||||
if (storedRaw && body.data) {
|
||||
login({ ...(JSON.parse(storedRaw) as AuthResponse), user: body.data });
|
||||
login({
|
||||
...deserializeAuthResponse(JSON.parse(storedRaw)),
|
||||
user: deserializeUser(body.data),
|
||||
});
|
||||
}
|
||||
|
||||
setState((prev) => prev.status === "loaded"
|
||||
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
|
||||
: prev);
|
||||
setState((prev) =>
|
||||
prev.status === "loaded"
|
||||
? {
|
||||
...prev,
|
||||
user: { ...prev.user, avatarMime: body.data?.avatarMime },
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} catch {
|
||||
setAvatarError("Upload failed");
|
||||
} finally {
|
||||
@@ -115,18 +199,34 @@ export function UserPublicProfile() {
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return <PageShell><p className="page-loading">Loading profile…</p></PageShell>;
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading profile…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="page-error">
|
||||
<h2>Error</h2>
|
||||
<p>{state.error}</p>
|
||||
<Link to="/">← Back</Link>
|
||||
</div>
|
||||
</PageShell>
|
||||
<PageError
|
||||
message={state.error}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="logout-btn"
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
{me && (
|
||||
<button className="logout-btn" type="button" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,6 +260,11 @@ export function UserPublicProfile() {
|
||||
<div>
|
||||
<h1 className="profile-username">{profileUser.username}</h1>
|
||||
{avatarError && <p className="form-error">{avatarError}</p>}
|
||||
{isOwnProfile && (
|
||||
<button type="button" className="logout-btn" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -175,8 +280,9 @@ export function UserPublicProfile() {
|
||||
/>
|
||||
|
||||
<UpvotedDumpList
|
||||
title={`Upvoted (${votes.filter((d) => myVotes.has(d.id)).length})`}
|
||||
title={`Upvoted (${profileVotedIds.size})`}
|
||||
dumps={votes}
|
||||
votedIds={profileVotedIds}
|
||||
voteCounts={voteCounts}
|
||||
myVotes={myVotes}
|
||||
canVote={!!me}
|
||||
@@ -190,7 +296,8 @@ export function UserPublicProfile() {
|
||||
|
||||
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
|
||||
|
||||
function DumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
function DumpList(
|
||||
{ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
voteCounts: Record<string, number>;
|
||||
@@ -198,7 +305,8 @@ function DumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, remove
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
}) {
|
||||
},
|
||||
) {
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<h2>{title}</h2>
|
||||
@@ -225,44 +333,65 @@ function DumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, remove
|
||||
|
||||
// ── Upvoted list: fades items out when votes are removed ────────────────────
|
||||
|
||||
function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
function UpvotedDumpList(
|
||||
{
|
||||
title,
|
||||
dumps,
|
||||
votedIds,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
canVote,
|
||||
castVote,
|
||||
removeVote,
|
||||
}: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
/** Which dumps the profile user currently has voted on. Drives visibility and animation. */
|
||||
votedIds: Set<string>;
|
||||
voteCounts: Record<string, number>;
|
||||
/** Logged-in user's votes — used only for the vote button state on each card. */
|
||||
myVotes: Set<string>;
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
}) {
|
||||
},
|
||||
) {
|
||||
// fading: items whose vote was just removed — dimmed during cooldown, then animating out
|
||||
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
|
||||
// cancels: id → function that aborts the pending removal sequence
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
// prevVotes: null on first render (skip initial diff), then previous myVotes snapshot
|
||||
const prevVotes = useRef<Set<string> | null>(null);
|
||||
// prevVotedIds: null on first render (skip initial diff), then previous votedIds snapshot
|
||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||
|
||||
useEffect(() => () => { cancels.current.forEach((c) => c()); }, []);
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// First run: capture baseline without triggering any fades
|
||||
if (prevVotes.current === null) {
|
||||
prevVotes.current = new Set(myVotes);
|
||||
if (prevVotedIds.current === null) {
|
||||
prevVotedIds.current = new Set(votedIds);
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = prevVotes.current;
|
||||
const prev = prevVotedIds.current;
|
||||
|
||||
// Newly unvoted → start fade (idempotent: skip if already running)
|
||||
for (const id of prev) {
|
||||
if (!myVotes.has(id) && !cancels.current.has(id)) {
|
||||
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
||||
let dead = false;
|
||||
// We update `kill` in-place so the cancel ref always points to the right cleanup
|
||||
let kill = () => {};
|
||||
kill = () => {
|
||||
dead = true;
|
||||
setFading((f) => { const n = { ...f }; delete n[id]; return n; });
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
@@ -271,30 +400,49 @@ function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote,
|
||||
const t1 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
||||
const t2 = setTimeout(() => { if (!dead) kill(); }, 350);
|
||||
kill = () => { dead = true; clearTimeout(t2); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); };
|
||||
const t2 = setTimeout(() => {
|
||||
if (!dead) kill();
|
||||
}, 350);
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t2);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
}, 2000);
|
||||
|
||||
// Override kill so cancelling before t1 fires clears t1
|
||||
const killT1 = kill;
|
||||
void killT1; // used below
|
||||
kill = () => { dead = true; clearTimeout(t1); setFading((f) => { const n = { ...f }; delete n[id]; return n; }); cancels.current.delete(id); };
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t1);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
}
|
||||
|
||||
// Newly re-voted while fading → cancel removal
|
||||
for (const id of myVotes) {
|
||||
for (const id of votedIds) {
|
||||
if (!prev.has(id) && cancels.current.has(id)) {
|
||||
cancels.current.get(id)!();
|
||||
}
|
||||
}
|
||||
|
||||
prevVotes.current = new Set(myVotes);
|
||||
}, [myVotes]);
|
||||
prevVotedIds.current = new Set(votedIds);
|
||||
}, [votedIds]);
|
||||
|
||||
// Visible = currently voted OR within the fade-out animation window
|
||||
const visibleDumps = dumps.filter((d) => myVotes.has(d.id) || d.id in fading);
|
||||
const visibleDumps = dumps.filter((d) =>
|
||||
votedIds.has(d.id) || d.id in fading
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="profile-section">
|
||||
@@ -305,8 +453,10 @@ function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote,
|
||||
<ul className="dump-feed">
|
||||
{visibleDumps.map((dump) => {
|
||||
const phase = fading[dump.id];
|
||||
const extraCls = phase === "cooldown" ? "dump-card--fading"
|
||||
: phase === "dismissing" ? "dump-card--dismissing"
|
||||
const extraCls = phase === "cooldown"
|
||||
? "dump-card--fading"
|
||||
: phase === "dismissing"
|
||||
? "dump-card--dismissing"
|
||||
: undefined;
|
||||
return (
|
||||
<DumpCard
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SubmitEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import { deserializeAuthResponse } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
@@ -38,7 +39,7 @@ export function UserRegister() {
|
||||
const apiResponse = await res.json();
|
||||
|
||||
if (apiResponse.success) {
|
||||
login(apiResponse.data);
|
||||
login(deserializeAuthResponse(apiResponse.data));
|
||||
navigate("/");
|
||||
} else {
|
||||
setState({ status: "error", error: apiResponse.error.message });
|
||||
@@ -76,7 +77,11 @@ export function UserRegister() {
|
||||
required
|
||||
disabled={state.status === "submitting"}
|
||||
/>
|
||||
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={state.status === "submitting"}
|
||||
>
|
||||
{state.status === "submitting" ? "Registering…" : "Register"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
|
||||
export function relativeTime(dateStr: string): string {
|
||||
const diff = new Date(dateStr).getTime() - Date.now(); // negative = past
|
||||
export function relativeTime(date: Date): string {
|
||||
const diff = date.getTime() - Date.now(); // negative = past
|
||||
const abs = Math.abs(diff) / 1000;
|
||||
|
||||
if (abs < 60) return rtf.format(-Math.round(abs), "second");
|
||||
@@ -9,6 +9,8 @@ export function relativeTime(dateStr: string): string {
|
||||
if (abs < 86400) return rtf.format(-Math.round(abs / 3600), "hour");
|
||||
if (abs < 7 * 86400) return rtf.format(-Math.round(abs / 86400), "day");
|
||||
if (abs < 30 * 86400) return rtf.format(-Math.round(abs / 7 / 86400), "week");
|
||||
if (abs < 365 * 86400) return rtf.format(-Math.round(abs / 30 / 86400), "month");
|
||||
if (abs < 365 * 86400) {
|
||||
return rtf.format(-Math.round(abs / 30 / 86400), "month");
|
||||
}
|
||||
return rtf.format(-Math.round(abs / 365 / 86400), "year");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user