vibe coded v1
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -143,6 +143,9 @@ vite.config.ts.timestamp-*
|
|||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
|
# Uploads
|
||||||
|
api/uploads/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
20
api/main.ts
20
api/main.ts
@@ -2,7 +2,11 @@ import { Application } from "@oak/oak";
|
|||||||
import { oakCors } from "@tajpouria/cors";
|
import { oakCors } from "@tajpouria/cors";
|
||||||
|
|
||||||
import dumpsRouter from "./routes/dumps.ts";
|
import dumpsRouter from "./routes/dumps.ts";
|
||||||
|
import filesRouter from "./routes/files.ts";
|
||||||
import usersRouter from "./routes/users.ts";
|
import usersRouter from "./routes/users.ts";
|
||||||
|
import avatarsRouter from "./routes/avatars.ts";
|
||||||
|
import wsRouter from "./routes/ws.ts";
|
||||||
|
import previewRouter from "./routes/preview.ts";
|
||||||
|
|
||||||
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
||||||
import { errorMiddleware } from "./middleware/error.ts";
|
import { errorMiddleware } from "./middleware/error.ts";
|
||||||
@@ -16,10 +20,26 @@ app.use(
|
|||||||
dumpsRouter.routes(),
|
dumpsRouter.routes(),
|
||||||
dumpsRouter.allowedMethods(),
|
dumpsRouter.allowedMethods(),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
filesRouter.routes(),
|
||||||
|
filesRouter.allowedMethods(),
|
||||||
|
);
|
||||||
app.use(
|
app.use(
|
||||||
usersRouter.routes(),
|
usersRouter.routes(),
|
||||||
usersRouter.allowedMethods(),
|
usersRouter.allowedMethods(),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
avatarsRouter.routes(),
|
||||||
|
avatarsRouter.allowedMethods(),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
wsRouter.routes(),
|
||||||
|
wsRouter.allowedMethods(),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
previewRouter.routes(),
|
||||||
|
previewRouter.allowedMethods(),
|
||||||
|
);
|
||||||
app.use(routeStaticFilesFrom([
|
app.use(routeStaticFilesFrom([
|
||||||
`${Deno.cwd()}/dist`,
|
`${Deno.cwd()}/dist`,
|
||||||
`${Deno.cwd()}/public`,
|
`${Deno.cwd()}/public`,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||||
import { Dump, type User } from "./interfaces.ts";
|
import { Dump, type RichContent, type User } from "./interfaces.ts";
|
||||||
|
|
||||||
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
||||||
|
db.exec("PRAGMA journal_mode = WAL;");
|
||||||
|
db.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database Row Types
|
* Database Row Types
|
||||||
@@ -9,10 +11,17 @@ export const db = new DatabaseSync("api/sql/gerbeur.db");
|
|||||||
|
|
||||||
export interface DumpRow {
|
export interface DumpRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
comment: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
url: string | null;
|
||||||
|
rich_content: string | null;
|
||||||
|
file_name: string | null;
|
||||||
|
file_mime: string | null;
|
||||||
|
file_size: number | null;
|
||||||
|
vote_count: number;
|
||||||
[key: string]: SQLOutputValue; // Index signature
|
[key: string]: SQLOutputValue; // Index signature
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,6 +31,7 @@ export interface UserRow {
|
|||||||
password_hash: string;
|
password_hash: string;
|
||||||
is_admin: number;
|
is_admin: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
avatar_mime: string | null;
|
||||||
[key: string]: SQLOutputValue; // Index signature
|
[key: string]: SQLOutputValue; // Index signature
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,11 +43,22 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
|||||||
return !!obj &&
|
return !!obj &&
|
||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
"id" in obj && typeof obj.id === "string" &&
|
"id" in obj && typeof obj.id === "string" &&
|
||||||
|
"kind" in obj && typeof obj.kind === "string" &&
|
||||||
"title" in obj && typeof obj.title === "string" &&
|
"title" in obj && typeof obj.title === "string" &&
|
||||||
"description" in obj &&
|
"comment" in obj &&
|
||||||
(typeof obj.description === "string" || obj.description === null) &&
|
(typeof obj.comment === "string" || obj.comment === null) &&
|
||||||
"user_id" in obj && typeof obj.user_id === "string" &&
|
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||||
"created_at" in obj && typeof obj.created_at === "string";
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||||
|
"url" in obj && (typeof obj.url === "string" || obj.url === null) &&
|
||||||
|
"rich_content" in obj &&
|
||||||
|
(typeof obj.rich_content === "string" || obj.rich_content === null) &&
|
||||||
|
"file_name" in obj &&
|
||||||
|
(typeof obj.file_name === "string" || obj.file_name === null) &&
|
||||||
|
"file_mime" in obj &&
|
||||||
|
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
|
||||||
|
"file_size" in obj &&
|
||||||
|
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
||||||
|
"vote_count" in obj && typeof obj.vote_count === "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
||||||
@@ -47,32 +68,48 @@ export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
|||||||
"username" in obj && typeof obj.username === "string" &&
|
"username" in obj && typeof obj.username === "string" &&
|
||||||
"password_hash" in obj && typeof obj.password_hash === "string" &&
|
"password_hash" in obj && typeof obj.password_hash === "string" &&
|
||||||
"is_admin" in obj && typeof obj.is_admin === "number" &&
|
"is_admin" in obj && typeof obj.is_admin === "number" &&
|
||||||
"created_at" in obj && typeof obj.created_at === "string";
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||||
|
"avatar_mime" in obj &&
|
||||||
|
(typeof obj.avatar_mime === "string" || obj.avatar_mime === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conversion Helpers
|
* Conversion Helpers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function dumpRowToApi(
|
export function dumpRowToApi(row: DumpRow): Dump {
|
||||||
row: DumpRow,
|
|
||||||
): Dump {
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
kind: row.kind as "url" | "file",
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description ?? undefined,
|
comment: row.comment ?? undefined,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
|
url: row.url ?? undefined,
|
||||||
|
richContent: row.rich_content
|
||||||
|
? (JSON.parse(row.rich_content) as RichContent)
|
||||||
|
: undefined,
|
||||||
|
fileName: row.file_name ?? undefined,
|
||||||
|
fileMime: row.file_mime ?? undefined,
|
||||||
|
fileSize: row.file_size ?? undefined,
|
||||||
|
voteCount: row.vote_count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dumpApiToRow(dump: Dump): DumpRow {
|
export function dumpApiToRow(dump: Dump): DumpRow {
|
||||||
return {
|
return {
|
||||||
id: dump.id,
|
id: dump.id,
|
||||||
|
kind: dump.kind,
|
||||||
title: dump.title,
|
title: dump.title,
|
||||||
description: dump.description ?? null,
|
comment: dump.comment ?? null,
|
||||||
user_id: dump.userId,
|
user_id: dump.userId,
|
||||||
created_at: dump.createdAt.toISOString(),
|
created_at: dump.createdAt.toISOString(),
|
||||||
|
url: dump.url ?? null,
|
||||||
|
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
|
||||||
|
file_name: dump.fileName ?? null,
|
||||||
|
file_mime: dump.fileMime ?? null,
|
||||||
|
file_size: dump.fileSize ?? null,
|
||||||
|
vote_count: dump.voteCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +120,7 @@ export function userRowToApi(row: UserRow): User {
|
|||||||
passwordHash: row.password_hash,
|
passwordHash: row.password_hash,
|
||||||
isAdmin: Boolean(row.is_admin),
|
isAdmin: Boolean(row.is_admin),
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
|
avatarMime: row.avatar_mime ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,5 +131,6 @@ export function userApiToRow(user: User): UserRow {
|
|||||||
password_hash: user.passwordHash,
|
password_hash: user.passwordHash,
|
||||||
is_admin: user.isAdmin ? 1 : 0,
|
is_admin: user.isAdmin ? 1 : 0,
|
||||||
created_at: user.createdAt.toISOString(),
|
created_at: user.createdAt.toISOString(),
|
||||||
|
avatar_mime: user.avatarMime ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,29 @@
|
|||||||
* Backend
|
* Backend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface RichContent {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
siteName?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
videoId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Dump {
|
export interface Dump {
|
||||||
id: string;
|
id: string;
|
||||||
|
kind: "url" | "file";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
comment?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
url?: string;
|
||||||
|
richContent?: RichContent;
|
||||||
|
fileName?: string;
|
||||||
|
fileMime?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
voteCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +37,7 @@ export interface User {
|
|||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
avatarMime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginUserRequest {
|
export interface LoginUserRequest {
|
||||||
@@ -127,28 +145,94 @@ export class APIException extends Error {
|
|||||||
* Request DTOs
|
* Request DTOs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CreateDumpRequest {
|
export interface CreateUrlDumpRequest {
|
||||||
title: string;
|
url: string;
|
||||||
description?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCreateDumpRequest(obj: unknown): obj is CreateDumpRequest {
|
export function isCreateUrlDumpRequest(
|
||||||
|
obj: unknown,
|
||||||
|
): obj is CreateUrlDumpRequest {
|
||||||
return !!obj &&
|
return !!obj &&
|
||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
"title" in obj && typeof obj.title === "string" &&
|
"url" in obj && typeof obj.url === "string" &&
|
||||||
(!("description" in obj) ||
|
(!("comment" in obj) ||
|
||||||
(typeof obj.description === "string" || obj.description === null));
|
typeof obj.comment === "string" || obj.comment === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDumpRequest {
|
export interface UpdateDumpRequest {
|
||||||
title?: string;
|
url?: string;
|
||||||
description?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
||||||
return !!obj &&
|
return !!obj &&
|
||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
(!("title" in obj) || typeof obj.title === "string") &&
|
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) &&
|
||||||
(!("description" in obj) ||
|
(!("comment" in obj) ||
|
||||||
(typeof obj.description === "string" || obj.description === null));
|
typeof obj.comment === "string" || obj.comment === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSockets
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VoteCastMessage {
|
||||||
|
type: "vote_cast";
|
||||||
|
dumpId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteAckMessageFailure {
|
||||||
|
type: "vote_ack";
|
||||||
|
dumpId: string;
|
||||||
|
success: false;
|
||||||
|
error: APIError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteAckMessageSuccess {
|
||||||
|
type: "vote_ack";
|
||||||
|
dumpId: string;
|
||||||
|
action: "cast" | "remove";
|
||||||
|
success: true;
|
||||||
|
voteCount: number;
|
||||||
|
error?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure;
|
||||||
|
|
||||||
|
export interface VoteRemoveMessage {
|
||||||
|
type: "vote_remove";
|
||||||
|
dumpId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VotesUpdateMessage {
|
||||||
|
type: "votes_update";
|
||||||
|
dumpId: string;
|
||||||
|
voteCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineUser {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WelcomeMessage {
|
||||||
|
type: "welcome";
|
||||||
|
users: OnlineUser[];
|
||||||
|
myVotes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceUpdateMessage {
|
||||||
|
type: "presence_update";
|
||||||
|
users: OnlineUser[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingMessage {
|
||||||
|
type: "ping";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PongMessage {
|
||||||
|
type: "pong";
|
||||||
}
|
}
|
||||||
|
|||||||
110
api/routes/avatars.ts
Normal file
110
api/routes/avatars.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import { getUserById, updateUserAvatar } from "../services/user-service.ts";
|
||||||
|
import { updateClientAvatar } from "../services/ws-service.ts";
|
||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
|
||||||
|
const AVATARS_DIR = "api/uploads/avatars";
|
||||||
|
const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB
|
||||||
|
const ALLOWED_AVATAR_MIMES = new Set([
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/webp",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Magic bytes for image validation
|
||||||
|
const MAGIC: Array<{ mime: string; bytes: number[]; offset?: number }> = [
|
||||||
|
{ mime: "image/jpeg", bytes: [0xFF, 0xD8, 0xFF] },
|
||||||
|
{ mime: "image/png", bytes: [0x89, 0x50, 0x4E, 0x47] },
|
||||||
|
{ mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] },
|
||||||
|
{ mime: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF
|
||||||
|
];
|
||||||
|
|
||||||
|
function checkMagicBytes(data: Uint8Array, mime: string): boolean {
|
||||||
|
if (mime === "image/webp") {
|
||||||
|
// RIFF....WEBP
|
||||||
|
return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 &&
|
||||||
|
data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 &&
|
||||||
|
data[10] === 0x42 && data[11] === 0x50;
|
||||||
|
}
|
||||||
|
const entry = MAGIC.find((m) => m.mime === mime);
|
||||||
|
if (!entry) return false;
|
||||||
|
return entry.bytes.every((b, i) => data[i] === b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
||||||
|
const authPayload = ctx.state.user;
|
||||||
|
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("multipart/form-data")) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Expected multipart/form-data");
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await ctx.request.body.formData();
|
||||||
|
const file = body.get("file");
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_AVATAR_MIMES.has(file.type)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.BAD_REQUEST,
|
||||||
|
400,
|
||||||
|
"Only JPEG, PNG, GIF, WebP images are allowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_AVATAR_SIZE) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 5 MB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
|
||||||
|
if (!checkMagicBytes(data, file.type)) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File content does not match declared type");
|
||||||
|
}
|
||||||
|
|
||||||
|
await Deno.mkdir(AVATARS_DIR, { recursive: true });
|
||||||
|
await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data);
|
||||||
|
updateUserAvatar(authPayload.userId, file.type);
|
||||||
|
updateClientAvatar(authPayload.userId, file.type);
|
||||||
|
|
||||||
|
const user = getUserById(authPayload.userId);
|
||||||
|
ctx.response.status = 200;
|
||||||
|
ctx.response.body = { success: true, data: user };
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/api/avatars/:userId", async (ctx) => {
|
||||||
|
const { userId } = ctx.params;
|
||||||
|
|
||||||
|
let user;
|
||||||
|
try {
|
||||||
|
user = getUserById(userId);
|
||||||
|
} catch {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.avatarMime) {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Uint8Array;
|
||||||
|
try {
|
||||||
|
data = await Deno.readFile(`${AVATARS_DIR}/${userId}`);
|
||||||
|
} catch {
|
||||||
|
ctx.response.status = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.headers.set("Content-Type", user.avatarMime);
|
||||||
|
ctx.response.headers.set("Content-Disposition", "inline");
|
||||||
|
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
|
||||||
|
ctx.response.body = data;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -5,16 +5,18 @@ import {
|
|||||||
APIException,
|
APIException,
|
||||||
type APIResponse,
|
type APIResponse,
|
||||||
type Dump,
|
type Dump,
|
||||||
isCreateDumpRequest,
|
isCreateUrlDumpRequest,
|
||||||
isUpdateDumpRequest,
|
isUpdateDumpRequest,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import {
|
import {
|
||||||
createDump,
|
createFileDump,
|
||||||
|
createUrlDump,
|
||||||
deleteDump,
|
deleteDump,
|
||||||
getDump,
|
getDump,
|
||||||
listDumps,
|
listDumps,
|
||||||
|
replaceFileDump,
|
||||||
updateDump,
|
updateDump,
|
||||||
} from "../services/dump-service.ts";
|
} from "../services/dump-service.ts";
|
||||||
|
|
||||||
@@ -24,9 +26,33 @@ router.post(
|
|||||||
"/",
|
"/",
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const createDumpRequest = await ctx.request.body.json();
|
const userId = ctx.state.user.userId;
|
||||||
|
const contentType = ctx.request.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
if (!isCreateDumpRequest(createDumpRequest)) {
|
let dump: Dump;
|
||||||
|
|
||||||
|
if (contentType.includes("multipart/form-data")) {
|
||||||
|
const formData = await ctx.request.body.formData();
|
||||||
|
const file = formData.get("file");
|
||||||
|
const comment = formData.get("comment");
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"A file is required",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dump = await createFileDump(
|
||||||
|
file,
|
||||||
|
typeof comment === "string" && comment ? comment : undefined,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
if (!isCreateUrlDumpRequest(body)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.VALIDATION_ERROR,
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
400,
|
400,
|
||||||
@@ -34,96 +60,85 @@ router.post(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = ctx.state.user.userId;
|
dump = await createUrlDump(body, userId);
|
||||||
const dump = createDump(createDumpRequest, userId);
|
}
|
||||||
|
|
||||||
const responseBody: APIResponse<Dump> = {
|
|
||||||
success: true,
|
|
||||||
data: dump,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const responseBody: APIResponse<Dump> = { success: true, data: dump };
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get("/:dumpId", (ctx) => {
|
router.get("/:dumpId", (ctx) => {
|
||||||
const dumpId = ctx.params.dumpId;
|
const dump = getDump(ctx.params.dumpId);
|
||||||
const dump = getDump(dumpId);
|
const responseBody: APIResponse<Dump> = { success: true, data: dump };
|
||||||
|
|
||||||
const responseBody: APIResponse<Dump> = {
|
|
||||||
success: true,
|
|
||||||
data: dump,
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/", (ctx) => {
|
router.get("/", (ctx) => {
|
||||||
const dumps = listDumps();
|
const dumps = listDumps();
|
||||||
|
const responseBody: APIResponse<Dump[]> = { success: true, data: dumps };
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
const responseBody: APIResponse<Dump[]> = {
|
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
||||||
success: true,
|
const dumpId = ctx.params.dumpId;
|
||||||
data: dumps,
|
const userId = ctx.state.user?.userId;
|
||||||
};
|
|
||||||
|
|
||||||
|
const dump = getDump(dumpId);
|
||||||
|
if (userId !== dump.userId) {
|
||||||
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await ctx.request.body.formData();
|
||||||
|
const file = formData.get("file");
|
||||||
|
const comment = formData.get("comment");
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new APIException(APIErrorCode.VALIDATION_ERROR, 400, "A file is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDump = await replaceFileDump(
|
||||||
|
dumpId,
|
||||||
|
file,
|
||||||
|
typeof comment === "string" && comment ? comment : undefined,
|
||||||
|
);
|
||||||
|
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
router.put("/:dumpId", authMiddleware, async (ctx) => {
|
||||||
const dumpId = ctx.params.dumpId;
|
const dumpId = ctx.params.dumpId;
|
||||||
const userId = ctx.state.user?.userId;
|
const userId = ctx.state.user?.userId;
|
||||||
const updateDumpRequest = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
if (!isUpdateDumpRequest(updateDumpRequest)) {
|
if (!isUpdateDumpRequest(body)) {
|
||||||
throw new APIException(
|
throw new APIException(APIErrorCode.VALIDATION_ERROR, 422, "Erroneous user input");
|
||||||
APIErrorCode.VALIDATION_ERROR,
|
|
||||||
422,
|
|
||||||
"Erroneous user input",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dump = getDump(dumpId);
|
const dump = getDump(dumpId);
|
||||||
|
|
||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to update dump");
|
||||||
APIErrorCode.UNAUTHORIZED,
|
|
||||||
401,
|
|
||||||
"Not authorized to update dump",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDump = updateDump(dumpId, updateDumpRequest);
|
const updatedDump = await updateDump(dumpId, body);
|
||||||
|
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||||
const responseBody: APIResponse<Dump> = {
|
|
||||||
success: true,
|
|
||||||
data: updatedDump,
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete("/:dumpId", authMiddleware, (ctx) => {
|
router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||||
const dumpId = ctx.params.dumpId;
|
const dumpId = ctx.params.dumpId;
|
||||||
const userId = ctx.state.user?.userId;
|
const userId = ctx.state.user?.userId;
|
||||||
|
|
||||||
const dump = getDump(dumpId);
|
const dump = getDump(dumpId);
|
||||||
|
|
||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authorized to delete dump");
|
||||||
APIErrorCode.UNAUTHORIZED,
|
|
||||||
401,
|
|
||||||
"Not authorized to update dump",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteDump(dumpId);
|
await deleteDump(dumpId);
|
||||||
|
|
||||||
const responseBody: APIResponse<null> = {
|
|
||||||
success: true,
|
|
||||||
data: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
34
api/routes/files.ts
Normal file
34
api/routes/files.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { getDump } from "../services/dump-service.ts";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/files" });
|
||||||
|
|
||||||
|
router.get("/:dumpId", async (ctx) => {
|
||||||
|
const { dumpId } = ctx.params;
|
||||||
|
|
||||||
|
// Guard against path traversal (UUIDs are safe, but be explicit)
|
||||||
|
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dump = getDump(dumpId);
|
||||||
|
|
||||||
|
if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No file for this dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await Deno.readFile(`api/uploads/${dumpId}`);
|
||||||
|
ctx.response.headers.set("Content-Type", dump.fileMime);
|
||||||
|
ctx.response.headers.set(
|
||||||
|
"Content-Disposition",
|
||||||
|
`inline; filename="${dump.fileName}"`,
|
||||||
|
);
|
||||||
|
ctx.response.body = data;
|
||||||
|
} catch {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
17
api/routes/preview.ts
Normal file
17
api/routes/preview.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { fetchRichContent, isValidHttpUrl } from "../services/rich-content-service.ts";
|
||||||
|
|
||||||
|
const previewRouter = new Router();
|
||||||
|
|
||||||
|
previewRouter.get("/api/preview", async (ctx) => {
|
||||||
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||||
|
if (!isValidHttpUrl(url)) {
|
||||||
|
ctx.response.status = 400;
|
||||||
|
ctx.response.body = { success: false, error: { message: "Invalid URL" } };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await fetchRichContent(url);
|
||||||
|
ctx.response.body = { success: true, data: data ?? null };
|
||||||
|
});
|
||||||
|
|
||||||
|
export default previewRouter;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getUserById,
|
getUserById,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
} from "../services/user-service.ts";
|
} from "../services/user-service.ts";
|
||||||
|
import { getDumpsByUser, getVotedDumpsByUser } from "../services/dump-service.ts";
|
||||||
|
|
||||||
// Users router
|
// Users router
|
||||||
const router = new Router({ prefix: "/api/users" });
|
const router = new Router({ prefix: "/api/users" });
|
||||||
@@ -129,4 +130,32 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
|
||||||
|
router.get("/by-id/:userId", (ctx) => {
|
||||||
|
const user = getUserById(ctx.params.userId);
|
||||||
|
const { passwordHash: _, ...publicUser } = user;
|
||||||
|
ctx.response.body = { success: true, data: publicUser };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public user profile by username (no passwordHash)
|
||||||
|
router.get("/:username", (ctx) => {
|
||||||
|
const user = getUserByUsername(ctx.params.username);
|
||||||
|
const { passwordHash: _, ...publicUser } = user;
|
||||||
|
ctx.response.body = { success: true, data: publicUser };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dumps posted by user
|
||||||
|
router.get("/:username/dumps", (ctx) => {
|
||||||
|
const user = getUserByUsername(ctx.params.username);
|
||||||
|
const dumps = getDumpsByUser(user.id);
|
||||||
|
ctx.response.body = { success: true, data: dumps };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dumps upvoted by user
|
||||||
|
router.get("/:username/votes", (ctx) => {
|
||||||
|
const user = getUserByUsername(ctx.params.username);
|
||||||
|
const dumps = getVotedDumpsByUser(user.id);
|
||||||
|
ctx.response.body = { success: true, data: dumps };
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
132
api/routes/ws.ts
Normal file
132
api/routes/ws.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { verifyJWT } from "../lib/jwt.ts";
|
||||||
|
import {
|
||||||
|
broadcastPresence,
|
||||||
|
broadcastVoteUpdate,
|
||||||
|
getOnlineUsers,
|
||||||
|
register,
|
||||||
|
type WsClient,
|
||||||
|
unregister,
|
||||||
|
} from "../services/ws-service.ts";
|
||||||
|
import { castVote, getUserVotes, removeVote } from "../services/vote-service.ts";
|
||||||
|
import { getUserById } from "../services/user-service.ts";
|
||||||
|
import { APIException } from "../model/interfaces.ts";
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
|
||||||
|
function isAllowedOrigin(origin: string): boolean {
|
||||||
|
if (!origin) return false;
|
||||||
|
return /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/ws", async (ctx) => {
|
||||||
|
const origin = ctx.request.headers.get("origin") ?? "";
|
||||||
|
if (!isAllowedOrigin(origin)) {
|
||||||
|
ctx.response.status = 403;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.isUpgradable) {
|
||||||
|
ctx.response.status = 426;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = ctx.request.url.searchParams.get("token");
|
||||||
|
const authPayload = token ? await verifyJWT(token) : null;
|
||||||
|
|
||||||
|
const socket = ctx.upgrade();
|
||||||
|
|
||||||
|
let avatarMime: string | undefined;
|
||||||
|
if (authPayload) {
|
||||||
|
try { avatarMime = getUserById(authPayload.userId).avatarMime; } catch { /* user not found */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const client: WsClient = {
|
||||||
|
socket,
|
||||||
|
userId: authPayload?.userId,
|
||||||
|
username: authPayload?.username,
|
||||||
|
avatarMime,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use addEventListener — more reliable than onopen= with Deno.serve
|
||||||
|
socket.addEventListener("open", () => {
|
||||||
|
register(client);
|
||||||
|
broadcastPresence();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: "welcome",
|
||||||
|
users: getOnlineUsers(),
|
||||||
|
myVotes,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[ws] welcome send failed:", err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
let msg: { type: string; dumpId?: string };
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(event.data as string);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case "ping":
|
||||||
|
socket.send(JSON.stringify({ type: "pong" }));
|
||||||
|
break;
|
||||||
|
case "vote_cast":
|
||||||
|
handleVote(client, msg.dumpId, "cast");
|
||||||
|
break;
|
||||||
|
case "vote_remove":
|
||||||
|
handleVote(client, msg.dumpId, "remove");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
unregister(client);
|
||||||
|
broadcastPresence();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleVote(
|
||||||
|
client: WsClient,
|
||||||
|
dumpId: string | undefined,
|
||||||
|
action: "cast" | "remove",
|
||||||
|
): void {
|
||||||
|
const { socket } = client;
|
||||||
|
|
||||||
|
if (!client.userId) {
|
||||||
|
socket.send(JSON.stringify({ type: "error", message: "Authentication required" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dumpId) {
|
||||||
|
socket.send(JSON.stringify({ type: "error", message: "Missing dumpId" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newCount = action === "cast"
|
||||||
|
? castVote(dumpId, client.userId)
|
||||||
|
: removeVote(dumpId, client.userId);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: "vote_ack",
|
||||||
|
dumpId,
|
||||||
|
action,
|
||||||
|
success: true,
|
||||||
|
voteCount: newCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
broadcastVoteUpdate(dumpId, newCount);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof APIException ? err.message : "Vote failed";
|
||||||
|
socket.send(JSON.stringify({ type: "error", message }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,99 +1,275 @@
|
|||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
APIException,
|
||||||
type CreateDumpRequest,
|
type CreateUrlDumpRequest,
|
||||||
type Dump,
|
type Dump,
|
||||||
type UpdateDumpRequest,
|
type UpdateDumpRequest,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts";
|
import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts";
|
||||||
|
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
|
||||||
|
import { broadcastDumpDeleted, broadcastNewDump } from "./ws-service.ts";
|
||||||
|
|
||||||
export function createDump(
|
const UPLOADS_DIR = "api/uploads";
|
||||||
request: CreateDumpRequest,
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
|
const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"];
|
||||||
|
const ALLOWED_MIME_TYPES = new Set([
|
||||||
|
"application/pdf",
|
||||||
|
"application/json",
|
||||||
|
"application/zip",
|
||||||
|
"application/x-zip-compressed",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isAllowedMime(mime: string): boolean {
|
||||||
|
return ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||||
|
ALLOWED_MIME_TYPES.has(mime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.replace(/^www\./, "");
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SELECT_COLS =
|
||||||
|
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count";
|
||||||
|
|
||||||
|
export async function createUrlDump(
|
||||||
|
request: CreateUrlDumpRequest,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Dump {
|
): Promise<Dump> {
|
||||||
|
if (!isValidHttpUrl(request.url)) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dumpId = crypto.randomUUID();
|
||||||
|
const createdAt = new Date();
|
||||||
|
const richContent = await fetchRichContent(request.url);
|
||||||
|
const title = richContent?.title ?? titleFromUrl(request.url);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
|
).run(
|
||||||
|
dumpId,
|
||||||
|
"url",
|
||||||
|
title,
|
||||||
|
request.comment ?? null,
|
||||||
|
userId,
|
||||||
|
createdAt.toISOString(),
|
||||||
|
request.url,
|
||||||
|
richContent ? JSON.stringify(richContent) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dump: Dump = { id: dumpId, kind: "url", title, comment: request.comment, userId, createdAt, url: request.url, richContent, voteCount: 0 };
|
||||||
|
broadcastNewDump(dump);
|
||||||
|
return dump;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFileDump(
|
||||||
|
file: File,
|
||||||
|
comment: string | undefined,
|
||||||
|
userId: string,
|
||||||
|
): Promise<Dump> {
|
||||||
|
if (!isAllowedMime(file.type)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.BAD_REQUEST,
|
||||||
|
400,
|
||||||
|
`File type '${file.type}' is not allowed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)");
|
||||||
|
}
|
||||||
|
|
||||||
const dumpId = crypto.randomUUID();
|
const dumpId = crypto.randomUUID();
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
|
|
||||||
|
await Deno.mkdir(UPLOADS_DIR, { recursive: true });
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, title, description, user_id, created_at)
|
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size)
|
||||||
VALUES (?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
dumpId,
|
dumpId,
|
||||||
request.title,
|
"file",
|
||||||
request.description ?? null,
|
file.name,
|
||||||
|
comment ?? null,
|
||||||
userId,
|
userId,
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
|
file.name,
|
||||||
|
file.type,
|
||||||
|
file.size,
|
||||||
);
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Roll back the file if DB insert fails
|
||||||
|
await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const dump: Dump = {
|
||||||
id: dumpId,
|
id: dumpId,
|
||||||
title: request.title,
|
kind: "file",
|
||||||
description: request.description ?? undefined,
|
title: file.name,
|
||||||
userId: userId,
|
comment,
|
||||||
|
userId,
|
||||||
createdAt,
|
createdAt,
|
||||||
|
fileName: file.name,
|
||||||
|
fileMime: file.type,
|
||||||
|
fileSize: file.size,
|
||||||
|
voteCount: 0,
|
||||||
};
|
};
|
||||||
|
broadcastNewDump(dump);
|
||||||
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDump(dumpId: string): Dump {
|
export function getDump(dumpId: string): Dump {
|
||||||
const dumpRow = db.prepare(
|
const row = db.prepare(
|
||||||
`SELECT id, title, description, user_id, created_at
|
`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`,
|
||||||
FROM dumps WHERE id = ?;`,
|
|
||||||
).get(dumpId);
|
).get(dumpId);
|
||||||
|
|
||||||
if (!dumpRow || !isDumpRow(dumpRow)) {
|
if (!row || !isDumpRow(row)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return dumpRowToApi(dumpRow);
|
return dumpRowToApi(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listDumps(): Dump[] {
|
export function listDumps(): Dump[] {
|
||||||
const dumpRows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT id, title, description, user_id, created_at FROM dumps;`,
|
`SELECT ${SELECT_COLS} FROM dumps;`,
|
||||||
).all();
|
).all();
|
||||||
|
|
||||||
if (!dumpRows || !dumpRows.every(isDumpRow)) {
|
if (!rows || !rows.every(isDumpRow)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "No dump found");
|
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||||
}
|
}
|
||||||
|
|
||||||
const dumps: Dump[] = dumpRows.map(dumpRowToApi);
|
return rows.map(dumpRowToApi);
|
||||||
|
|
||||||
return dumps;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateDump(
|
export async function updateDump(
|
||||||
dumpId: string,
|
dumpId: string,
|
||||||
request: UpdateDumpRequest,
|
request: UpdateDumpRequest,
|
||||||
): Dump {
|
): Promise<Dump> {
|
||||||
const dump = getDump(dumpId);
|
const dump = getDump(dumpId);
|
||||||
|
|
||||||
const updatedDump = {
|
// File dumps: only comment is editable
|
||||||
|
if (dump.kind === "file") {
|
||||||
|
const updatedDump = { ...dump, comment: "comment" in request ? (request.comment ?? undefined) : dump.comment };
|
||||||
|
db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`)
|
||||||
|
.run(updatedDump.comment ?? null, dumpId);
|
||||||
|
return updatedDump;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL dumps
|
||||||
|
const newUrl = request.url ?? dump.url!;
|
||||||
|
|
||||||
|
if (!isValidHttpUrl(newUrl)) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
let { richContent, title } = dump;
|
||||||
|
|
||||||
|
if (newUrl !== dump.url) {
|
||||||
|
richContent = await fetchRichContent(newUrl);
|
||||||
|
title = richContent?.title ?? titleFromUrl(newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDump: Dump = {
|
||||||
...dump,
|
...dump,
|
||||||
...request,
|
title,
|
||||||
|
comment: "comment" in request ? (request.comment ?? undefined) : dump.comment,
|
||||||
|
url: newUrl,
|
||||||
|
richContent,
|
||||||
};
|
};
|
||||||
const updatedDumpRow = dumpApiToRow(updatedDump);
|
|
||||||
|
|
||||||
const dumpResult = db.prepare(
|
const row = dumpApiToRow(updatedDump);
|
||||||
`UPDATE dumps SET title = ?, description = ? WHERE id = ?;`,
|
const result = db.prepare(
|
||||||
).run(
|
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ? WHERE id = ?;`,
|
||||||
updatedDumpRow.title,
|
).run(row.title, row.comment, row.url, row.rich_content, row.id);
|
||||||
updatedDumpRow.description,
|
|
||||||
updatedDumpRow.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dumpResult.changes === 0) {
|
if (result.changes === 0) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteDump(dumpId: string): void {
|
export async function replaceFileDump(
|
||||||
const result = db.prepare(
|
dumpId: string,
|
||||||
`DELETE FROM dumps WHERE id = ?;`,
|
file: File,
|
||||||
).run(dumpId);
|
comment: string | undefined,
|
||||||
|
): Promise<Dump> {
|
||||||
|
if (!isAllowedMime(file.type)) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, `File type '${file.type}' is not allowed`);
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "File too large (max 50 MB)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dump = getDump(dumpId);
|
||||||
|
if (dump.kind !== "file") {
|
||||||
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ? WHERE id = ?;`,
|
||||||
|
).run(file.name, file.name, file.type, file.size, comment ?? null, dumpId);
|
||||||
|
|
||||||
|
return { ...dump, title: file.name, fileName: file.name, fileMime: file.type, fileSize: file.size, comment };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDumpsByUser(userId: string): Dump[] {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`,
|
||||||
|
).all(userId);
|
||||||
|
if (!rows.every(isDumpRow)) {
|
||||||
|
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||||
|
}
|
||||||
|
return rows.map(dumpRowToApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVotedDumpsByUser(userId: string): Dump[] {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")}
|
||||||
|
FROM dumps d
|
||||||
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
|
WHERE v.user_id = ?
|
||||||
|
ORDER BY v.created_at DESC;`,
|
||||||
|
).all(userId);
|
||||||
|
if (!rows.every(isDumpRow)) {
|
||||||
|
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||||
|
}
|
||||||
|
return rows.map(dumpRowToApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDump(dumpId: string): Promise<void> {
|
||||||
|
const dump = getDump(dumpId);
|
||||||
|
|
||||||
|
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dump.kind === "file") {
|
||||||
|
await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastDumpDeleted(dumpId);
|
||||||
}
|
}
|
||||||
|
|||||||
37
api/services/providers/bandcamp.ts
Normal file
37
api/services/providers/bandcamp.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { RichContent } from "../../model/interfaces.ts";
|
||||||
|
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||||
|
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
|
||||||
|
|
||||||
|
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
|
||||||
|
|
||||||
|
export const bandcampProvider: RichContentProvider = {
|
||||||
|
name: "bandcamp",
|
||||||
|
|
||||||
|
matches(url: string): boolean {
|
||||||
|
try {
|
||||||
|
return BANDCAMP_REGEX.test(new URL(url).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch(url: string): Promise<RichContent> {
|
||||||
|
const res = await fetchWithTimeout(url);
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
|
if (!contentType.startsWith("text/html")) {
|
||||||
|
return { type: "bandcamp", siteName: "Bandcamp", url };
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "bandcamp",
|
||||||
|
siteName: "Bandcamp",
|
||||||
|
url,
|
||||||
|
title: extractOgTag(html, "title"),
|
||||||
|
description: extractOgTag(html, "description"),
|
||||||
|
thumbnailUrl: extractOgTag(html, "image"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
31
api/services/providers/generic.ts
Normal file
31
api/services/providers/generic.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { RichContent } from "../../model/interfaces.ts";
|
||||||
|
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||||
|
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
|
||||||
|
|
||||||
|
export const genericProvider: RichContentProvider = {
|
||||||
|
name: "generic",
|
||||||
|
|
||||||
|
matches(_url: string): boolean {
|
||||||
|
return true; // fallback — always matches
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch(url: string): Promise<RichContent> {
|
||||||
|
const res = await fetchWithTimeout(url);
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
|
if (!contentType.startsWith("text/html")) {
|
||||||
|
return { type: "generic", url };
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "generic",
|
||||||
|
url,
|
||||||
|
title: extractOgTag(html, "title"),
|
||||||
|
description: extractOgTag(html, "description"),
|
||||||
|
thumbnailUrl: extractOgTag(html, "image"),
|
||||||
|
siteName: extractOgTag(html, "site_name"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
35
api/services/providers/soundcloud.ts
Normal file
35
api/services/providers/soundcloud.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { RichContent } from "../../model/interfaces.ts";
|
||||||
|
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||||
|
import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts";
|
||||||
|
|
||||||
|
export const soundcloudProvider: RichContentProvider = {
|
||||||
|
name: "soundcloud",
|
||||||
|
|
||||||
|
matches(url: string): boolean {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname === "soundcloud.com";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch(url: string): Promise<RichContent> {
|
||||||
|
const res = await fetchWithTimeout(url);
|
||||||
|
const contentType = res.headers.get("content-type") ?? "";
|
||||||
|
|
||||||
|
if (!contentType.startsWith("text/html")) {
|
||||||
|
return { type: "soundcloud", siteName: "SoundCloud", url };
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await res.text();
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "soundcloud",
|
||||||
|
siteName: "SoundCloud",
|
||||||
|
url,
|
||||||
|
title: extractOgTag(html, "title"),
|
||||||
|
description: extractOgTag(html, "description"),
|
||||||
|
thumbnailUrl: extractOgTag(html, "image"),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
34
api/services/providers/youtube.ts
Normal file
34
api/services/providers/youtube.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { RichContent } from "../../model/interfaces.ts";
|
||||||
|
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||||
|
import { fetchWithTimeout } from "../rich-content-service.ts";
|
||||||
|
|
||||||
|
const YOUTUBE_REGEX =
|
||||||
|
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||||
|
|
||||||
|
export const youtubeProvider: RichContentProvider = {
|
||||||
|
name: "youtube",
|
||||||
|
|
||||||
|
matches(url: string): boolean {
|
||||||
|
return YOUTUBE_REGEX.test(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetch(url: string): Promise<RichContent> {
|
||||||
|
const videoId = url.match(YOUTUBE_REGEX)![1];
|
||||||
|
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||||
|
let title: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const oembedUrl =
|
||||||
|
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`;
|
||||||
|
const res = await fetchWithTimeout(oembedUrl);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json() as { title?: string };
|
||||||
|
title = data.title;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// oembed failed — thumbnail still works
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "youtube", siteName: "YouTube", url, videoId, title, thumbnailUrl };
|
||||||
|
},
|
||||||
|
};
|
||||||
97
api/services/rich-content-service.ts
Normal file
97
api/services/rich-content-service.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { RichContent } from "../model/interfaces.ts";
|
||||||
|
import { youtubeProvider } from "./providers/youtube.ts";
|
||||||
|
import { bandcampProvider } from "./providers/bandcamp.ts";
|
||||||
|
import { soundcloudProvider } from "./providers/soundcloud.ts";
|
||||||
|
import { genericProvider } from "./providers/generic.ts";
|
||||||
|
|
||||||
|
export interface RichContentProvider {
|
||||||
|
name: string;
|
||||||
|
matches(url: string): boolean;
|
||||||
|
fetch(url: string): Promise<RichContent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register providers in priority order. The first match wins.
|
||||||
|
* `genericProvider` must stay last — it always matches.
|
||||||
|
*/
|
||||||
|
const providers: RichContentProvider[] = [
|
||||||
|
youtubeProvider,
|
||||||
|
bandcampProvider,
|
||||||
|
soundcloudProvider,
|
||||||
|
genericProvider,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Shared utilities exported for use by providers
|
||||||
|
|
||||||
|
export async function fetchWithTimeout(
|
||||||
|
url: string,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
return await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
|
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHtmlEntities(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'/gi, "'")
|
||||||
|
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec)))
|
||||||
|
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractOgTag(
|
||||||
|
html: string,
|
||||||
|
tag: string,
|
||||||
|
): string | undefined {
|
||||||
|
const patterns = [
|
||||||
|
new RegExp(
|
||||||
|
`<meta[^>]+property=["']og:${tag}["'][^>]+content=["']([^"']+)["']`,
|
||||||
|
"i",
|
||||||
|
),
|
||||||
|
new RegExp(
|
||||||
|
`<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:${tag}["']`,
|
||||||
|
"i",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = html.match(pattern);
|
||||||
|
if (match) return decodeHtmlEntities(match[1]);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidHttpUrl(raw: string): boolean {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
return u.protocol === "http:" || u.protocol === "https:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRichContent(
|
||||||
|
url: string,
|
||||||
|
): Promise<RichContent | undefined> {
|
||||||
|
try {
|
||||||
|
const provider = providers.find((p) => p.matches(url))!;
|
||||||
|
return await provider.fetch(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ export async function createUser(
|
|||||||
|
|
||||||
export function getUserById(userId: string): User {
|
export function getUserById(userId: string): User {
|
||||||
const userRow = db.prepare(
|
const userRow = db.prepare(
|
||||||
`SELECT id, username, password_hash, is_admin, created_at
|
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
|
||||||
FROM users WHERE id = ?`,
|
FROM users WHERE id = ?`,
|
||||||
).get(userId);
|
).get(userId);
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ export function getUserById(userId: string): User {
|
|||||||
|
|
||||||
export function getUserByUsername(username: string): User {
|
export function getUserByUsername(username: string): User {
|
||||||
const userRow = db.prepare(
|
const userRow = db.prepare(
|
||||||
`SELECT id, username, password_hash, is_admin, created_at
|
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
|
||||||
FROM users WHERE username = ?`,
|
FROM users WHERE username = ?`,
|
||||||
).get(username);
|
).get(username);
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export function getUserByUsername(username: string): User {
|
|||||||
|
|
||||||
export function listUsers(): User[] {
|
export function listUsers(): User[] {
|
||||||
const userRows = db.prepare(
|
const userRows = db.prepare(
|
||||||
`SELECT id, username, password_hash, is_admin, created_at FROM users`,
|
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users`,
|
||||||
).all();
|
).all();
|
||||||
|
|
||||||
if (!userRows || !userRows.every(isUserRow)) {
|
if (!userRows || !userRows.every(isUserRow)) {
|
||||||
@@ -119,6 +119,16 @@ export async function updateUser(
|
|||||||
return updatedUser;
|
return updatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateUserAvatar(userId: string, mime: string): void {
|
||||||
|
const result = db.prepare(
|
||||||
|
`UPDATE users SET avatar_mime = ? WHERE id = ?`,
|
||||||
|
).run(mime, userId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteUser(userId: string): void {
|
export function deleteUser(userId: string): void {
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
`DELETE FROM users WHERE id = ?;`,
|
`DELETE FROM users WHERE id = ?;`,
|
||||||
|
|||||||
57
api/services/vote-service.ts
Normal file
57
api/services/vote-service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { db } from "../model/db.ts";
|
||||||
|
|
||||||
|
export function castVote(dumpId: string, userId: string): number {
|
||||||
|
try {
|
||||||
|
db.exec("BEGIN;");
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||||
|
).run(dumpId, userId, new Date().toISOString());
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE dumps SET vote_count = vote_count + 1 WHERE id = ?;`,
|
||||||
|
).run(dumpId);
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||||
|
).get(dumpId) as { vote_count: number } | undefined;
|
||||||
|
db.exec("COMMIT;");
|
||||||
|
return row?.vote_count ?? 0;
|
||||||
|
} catch (err) {
|
||||||
|
db.exec("ROLLBACK;");
|
||||||
|
if (err instanceof Error && err.message.includes("UNIQUE constraint")) {
|
||||||
|
throw new APIException(APIErrorCode.VALIDATION_ERROR, 409, "Already voted");
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeVote(dumpId: string, userId: string): number {
|
||||||
|
try {
|
||||||
|
db.exec("BEGIN;");
|
||||||
|
const result = db.prepare(
|
||||||
|
`DELETE FROM votes WHERE dump_id = ? AND user_id = ?;`,
|
||||||
|
).run(dumpId, userId);
|
||||||
|
if (result.changes === 0) {
|
||||||
|
db.exec("ROLLBACK;");
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Vote not found");
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE dumps SET vote_count = vote_count - 1 WHERE id = ?;`,
|
||||||
|
).run(dumpId);
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||||
|
).get(dumpId) as { vote_count: number } | undefined;
|
||||||
|
db.exec("COMMIT;");
|
||||||
|
return row?.vote_count ?? 0;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof APIException) throw err;
|
||||||
|
db.exec("ROLLBACK;");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserVotes(userId: string): string[] {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT dump_id FROM votes WHERE user_id = ?;`,
|
||||||
|
).all(userId) as { dump_id: string }[];
|
||||||
|
return rows.map((r) => r.dump_id);
|
||||||
|
}
|
||||||
87
api/services/ws-service.ts
Normal file
87
api/services/ws-service.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Dump, OnlineUser } from "../model/interfaces.ts";
|
||||||
|
|
||||||
|
export interface WsClient {
|
||||||
|
socket: WebSocket;
|
||||||
|
userId?: string;
|
||||||
|
username?: string;
|
||||||
|
avatarMime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clients = new Set<WsClient>();
|
||||||
|
|
||||||
|
export function register(client: WsClient): void {
|
||||||
|
clients.add(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister(client: WsClient): void {
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateClientAvatar(userId: string, avatarMime: string): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.userId === userId) {
|
||||||
|
client.avatarMime = avatarMime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
broadcastPresence();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOnlineUsers(): OnlineUser[] {
|
||||||
|
const seen = new Map<string, OnlineUser>();
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.userId && !seen.has(client.userId)) {
|
||||||
|
seen.set(client.userId, {
|
||||||
|
userId: client.userId,
|
||||||
|
username: client.username!,
|
||||||
|
hasAvatar: !!client.avatarMime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(socket: WebSocket, data: unknown): void {
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastPresence(): void {
|
||||||
|
const users = getOnlineUsers();
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "presence_update", users });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastNewDump(dump: Dump): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "dump_created", dump });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastDumpDeleted(dumpId: string): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "dump_deleted", dumpId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastVoteUpdate(dumpId: string, voteCount: number): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "votes_update", dumpId, voteCount });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
||||||
|
const PING_INTERVAL = 30_000;
|
||||||
|
const PONG_TIMEOUT = 5_000;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.socket.readyState !== WebSocket.OPEN) {
|
||||||
|
clients.delete(client);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
send(client.socket, { type: "ping" });
|
||||||
|
// Schedule removal if no pong (tracked via heartbeat flag)
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL);
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
CREATE TABLE dumps (
|
CREATE TABLE dumps (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
comment TEXT,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
rich_content TEXT,
|
rich_content TEXT,
|
||||||
|
file_name TEXT,
|
||||||
|
file_mime TEXT,
|
||||||
|
file_size INTEGER,
|
||||||
|
vote_count INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -14,5 +19,15 @@ CREATE TABLE users (
|
|||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL,
|
||||||
|
avatar_mime TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE votes (
|
||||||
|
dump_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (dump_id, user_id),
|
||||||
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|||||||
1336
src/App.css
1336
src/App.css
File diff suppressed because it is too large
Load Diff
26
src/App.tsx
26
src/App.tsx
@@ -7,16 +7,19 @@ import { Dump } from "./pages/Dump.tsx";
|
|||||||
import { DumpCreate } from "./pages/DumpCreate.tsx";
|
import { DumpCreate } from "./pages/DumpCreate.tsx";
|
||||||
import { DumpEdit } from "./pages/DumpEdit.tsx";
|
import { DumpEdit } from "./pages/DumpEdit.tsx";
|
||||||
import { UserLogin } from "./pages/UserLogin.tsx";
|
import { UserLogin } from "./pages/UserLogin.tsx";
|
||||||
import { UserProfile } from "./pages/UserProfile.tsx";
|
import { UserPublicProfile } from "./pages/UserPublicProfile.tsx";
|
||||||
import { UserRegister } from "./pages/UserRegister.tsx";
|
import { UserRegister } from "./pages/UserRegister.tsx";
|
||||||
|
|
||||||
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
||||||
|
import { WSProvider } from "./contexts/WSProvider.tsx";
|
||||||
|
import { useAuth } from "./hooks/useAuth.ts";
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function App() {
|
function AppRoutes() {
|
||||||
|
const { token } = useAuth();
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<WSProvider token={token}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
@@ -53,16 +56,17 @@ function App() {
|
|||||||
</RestrictedGuest>
|
</RestrictedGuest>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route path="/users/:username" element={<UserPublicProfile />} />
|
||||||
path="/profile"
|
|
||||||
element={
|
|
||||||
<RestrictedLoggedIn>
|
|
||||||
<UserProfile />
|
|
||||||
</RestrictedLoggedIn>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</WSProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/components/AppHeader.tsx
Normal file
32
src/components/AppHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
|
||||||
|
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header 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 ? (
|
||||||
|
<>
|
||||||
|
<Link to={`/users/${user.username}`} className="app-header-user">
|
||||||
|
{user.username}
|
||||||
|
</Link>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/Avatar.tsx
Normal file
37
src/components/Avatar.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
hasAvatar: boolean;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({ userId, username, hasAvatar, size = 36 }: AvatarProps) {
|
||||||
|
const [imgFailed, setImgFailed] = useState(false);
|
||||||
|
const sizeStyle = { width: size, height: size };
|
||||||
|
|
||||||
|
if (hasAvatar && !imgFailed) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={`${API_URL}/api/avatars/${userId}`}
|
||||||
|
alt={username}
|
||||||
|
title={username}
|
||||||
|
style={sizeStyle}
|
||||||
|
className="avatar-img"
|
||||||
|
onError={() => setImgFailed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="avatar-initials"
|
||||||
|
title={username}
|
||||||
|
style={{ ...sizeStyle, fontSize: size * 0.45 }}
|
||||||
|
>
|
||||||
|
{username.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/DumpCard.tsx
Normal file
58
src/components/DumpCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
|
import type { Dump } from "../model.ts";
|
||||||
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
|
import FilePreview from "./FilePreview.tsx";
|
||||||
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
|
import { VoteButton } from "./VoteButton.tsx";
|
||||||
|
|
||||||
|
interface DumpCardProps {
|
||||||
|
dump: Dump;
|
||||||
|
voteCount: number;
|
||||||
|
voted: boolean;
|
||||||
|
canVote: boolean;
|
||||||
|
castVote: (id: string) => void;
|
||||||
|
removeVote: (id: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DumpCard({ dump, voteCount, voted, canVote, castVote, removeVote, className }: DumpCardProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className={`dump-card${className ? ` ${className}` : ""}`}>
|
||||||
|
<div className="dump-card-inner" onClick={() => navigate(`/dumps/${dump.id}`)}>
|
||||||
|
<div
|
||||||
|
className="dump-card-preview"
|
||||||
|
onClick={dump.richContent ? (e) => e.stopPropagation() : undefined}
|
||||||
|
>
|
||||||
|
{dump.kind === "file"
|
||||||
|
? <FilePreview dump={dump} compact />
|
||||||
|
: dump.richContent
|
||||||
|
? <RichContentCard richContent={dump.richContent} compact />
|
||||||
|
: <span className="dump-card-preview-icon">🔗</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dump-card-body">
|
||||||
|
<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()}>
|
||||||
|
{relativeTime(dump.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<VoteButton
|
||||||
|
dumpId={dump.id}
|
||||||
|
count={voteCount}
|
||||||
|
voted={voted}
|
||||||
|
disabled={!canVote}
|
||||||
|
onCast={castVote}
|
||||||
|
onRemove={removeVote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
src/components/FilePreview.tsx
Normal file
69
src/components/FilePreview.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type { Dump } from "../model.ts";
|
||||||
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
|
|
||||||
|
interface FilePreviewProps {
|
||||||
|
dump: Dump;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mimeIcon(mime: string): string {
|
||||||
|
if (mime.startsWith("video/")) return "🎬";
|
||||||
|
if (mime.startsWith("audio/")) return "🎵";
|
||||||
|
if (mime === "application/pdf") return "📄";
|
||||||
|
if (mime.startsWith("text/")) return "📝";
|
||||||
|
return "📁";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilePreview({ dump, compact = false }: FilePreviewProps) {
|
||||||
|
const fileUrl = `${API_URL}/api/files/${dump.id}?v=${dump.fileSize ?? 0}`;
|
||||||
|
const mime = dump.fileMime ?? "";
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
if (mime.startsWith("image/")) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={fileUrl}
|
||||||
|
alt={dump.fileName}
|
||||||
|
className="rich-content-compact-thumbnail"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="rich-content-compact-icon">{mimeIcon(mime)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mime.startsWith("image/")) {
|
||||||
|
return (
|
||||||
|
<img src={fileUrl} alt={dump.fileName} className="file-preview-image" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mime.startsWith("video/")) {
|
||||||
|
return <MediaPlayer src={fileUrl} kind="video" mime={mime} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mime.startsWith("audio/")) {
|
||||||
|
return <MediaPlayer src={fileUrl} kind="audio" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mime === "application/pdf") {
|
||||||
|
return (
|
||||||
|
<embed
|
||||||
|
src={fileUrl}
|
||||||
|
type="application/pdf"
|
||||||
|
className="file-preview-pdf"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={fileUrl} download={dump.fileName} className="file-download-link">
|
||||||
|
{mimeIcon(mime)} Download {dump.fileName}
|
||||||
|
{dump.fileSize != null && ` (${formatBytes(dump.fileSize)})`}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/components/MediaPlayer.tsx
Normal file
200
src/components/MediaPlayer.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function fmt(s: number): string {
|
||||||
|
if (!isFinite(s)) return "0:00";
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = Math.floor(s % 60);
|
||||||
|
return `${m}:${sec.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IconPlay = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: "2px" }}>
|
||||||
|
<polygon points="6,3 20,12 6,21" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconPause = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" style={{ padding: "1px" }}>
|
||||||
|
<rect x="5" y="3" width="4" height="18" rx="1" />
|
||||||
|
<rect x="15" y="3" width="4" height="18" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
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" />}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IconFullscreen = () => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HIDE_DELAY = 2500;
|
||||||
|
|
||||||
|
interface MediaPlayerProps {
|
||||||
|
src: string;
|
||||||
|
kind: "audio" | "video";
|
||||||
|
mime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
|
||||||
|
const mediaRef = useRef<HTMLMediaElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [current, setCurrent] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [controlsVisible, setControlsVisible] = useState(true);
|
||||||
|
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const a = mediaRef.current!;
|
||||||
|
const onTime = () => { if (!dragging) setCurrent(a.currentTime); };
|
||||||
|
const onDuration = () => setDuration(a.duration);
|
||||||
|
const onEnded = () => setPlaying(false);
|
||||||
|
a.addEventListener("timeupdate", onTime);
|
||||||
|
a.addEventListener("durationchange", onDuration);
|
||||||
|
a.addEventListener("ended", onEnded);
|
||||||
|
return () => {
|
||||||
|
a.removeEventListener("timeupdate", onTime);
|
||||||
|
a.removeEventListener("durationchange", onDuration);
|
||||||
|
a.removeEventListener("ended", onEnded);
|
||||||
|
};
|
||||||
|
}, [dragging]);
|
||||||
|
|
||||||
|
// Show controls when paused; schedule hide when playing
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind !== "video") return;
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
if (playing) {
|
||||||
|
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||||
|
} else {
|
||||||
|
setControlsVisible(true);
|
||||||
|
}
|
||||||
|
return () => { if (hideTimer.current) clearTimeout(hideTimer.current); };
|
||||||
|
}, [playing, kind]);
|
||||||
|
|
||||||
|
const showControlsTemporarily = () => {
|
||||||
|
if (kind !== "video") return;
|
||||||
|
setControlsVisible(true);
|
||||||
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
|
if (playing) {
|
||||||
|
hideTimer.current = setTimeout(() => setControlsVisible(false), HIDE_DELAY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const a = mediaRef.current!;
|
||||||
|
if (playing) { a.pause(); setPlaying(false); }
|
||||||
|
else { a.play(); setPlaying(true); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setCurrent(v);
|
||||||
|
mediaRef.current!.currentTime = v;
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setVolume(v);
|
||||||
|
mediaRef.current!.volume = v;
|
||||||
|
if (v > 0 && muted) { setMuted(false); mediaRef.current!.muted = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const next = !muted;
|
||||||
|
setMuted(next);
|
||||||
|
mediaRef.current!.muted = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goFullscreen = () => {
|
||||||
|
(mediaRef.current as HTMLVideoElement).requestFullscreen?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = duration > 0 ? current / duration : 0;
|
||||||
|
|
||||||
|
const controls = (
|
||||||
|
<>
|
||||||
|
<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}%` }} />
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="audio-player-range"
|
||||||
|
min={0} max={duration || 1} step={0.01} value={current}
|
||||||
|
onMouseDown={() => setDragging(true)}
|
||||||
|
onMouseUp={() => setDragging(false)}
|
||||||
|
onChange={seek}
|
||||||
|
aria-label="Seek"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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"}>
|
||||||
|
<IconVolume muted={muted} />
|
||||||
|
</button>
|
||||||
|
<div className="audio-player-track audio-player-track--volume">
|
||||||
|
<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}
|
||||||
|
onChange={changeVolume}
|
||||||
|
aria-label="Volume"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kind === "video" && (
|
||||||
|
<button type="button" className="audio-player-vol-btn" onClick={goFullscreen} aria-label="Fullscreen">
|
||||||
|
<IconFullscreen />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (kind === "video") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`video-player${controlsVisible ? " 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}
|
||||||
|
preload="metadata"
|
||||||
|
className="video-player-video"
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{mime && <source src={src} type={mime} />}
|
||||||
|
</video>
|
||||||
|
<div className="video-player-controls audio-player">
|
||||||
|
{controls}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="audio-player">
|
||||||
|
<audio ref={mediaRef as React.RefObject<HTMLAudioElement>} src={src} preload="metadata" />
|
||||||
|
{controls}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/PageShell.tsx
Normal file
18
src/components/PageShell.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { type ReactNode } from "react";
|
||||||
|
import { AppHeader } from "./AppHeader.tsx";
|
||||||
|
|
||||||
|
interface PageShellProps {
|
||||||
|
children: ReactNode;
|
||||||
|
centered?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageShell({ children, centered = false }: PageShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="page-shell">
|
||||||
|
<AppHeader />
|
||||||
|
<main className={`page-content${centered ? " page-content--centered" : ""}`}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
src/components/RichContentCard.tsx
Normal file
67
src/components/RichContentCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { RichContent } from "../model";
|
||||||
|
|
||||||
|
interface RichContentCardProps {
|
||||||
|
richContent: RichContent;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RichContentCard(
|
||||||
|
{ richContent, compact = false }: RichContentCardProps,
|
||||||
|
) {
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={richContent.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rich-content-compact"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{richContent.thumbnailUrl
|
||||||
|
? (
|
||||||
|
<img
|
||||||
|
src={richContent.thumbnailUrl}
|
||||||
|
alt={richContent.title ?? ""}
|
||||||
|
className="rich-content-compact-thumbnail"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: <span className="rich-content-compact-icon">🔗</span>}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={richContent.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`rich-content-card rich-content-card--${richContent.type}`}
|
||||||
|
>
|
||||||
|
{richContent.thumbnailUrl && (
|
||||||
|
<img
|
||||||
|
src={richContent.thumbnailUrl}
|
||||||
|
alt={richContent.title ?? ""}
|
||||||
|
className="rich-content-thumbnail"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="rich-content-body">
|
||||||
|
{richContent.siteName && (
|
||||||
|
<span className="rich-content-badge">{richContent.siteName}</span>
|
||||||
|
)}
|
||||||
|
{richContent.title && (
|
||||||
|
<p className="rich-content-title">{richContent.title}</p>
|
||||||
|
)}
|
||||||
|
{richContent.description && (
|
||||||
|
<p className="rich-content-description">{richContent.description}</p>
|
||||||
|
)}
|
||||||
|
<span className="rich-content-url">{richContent.url}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/VoteButton.tsx
Normal file
22
src/components/VoteButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
interface VoteButtonProps {
|
||||||
|
dumpId: string;
|
||||||
|
count: number;
|
||||||
|
voted: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onCast: (dumpId: string) => void;
|
||||||
|
onRemove: (dumpId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoteButton({ dumpId, count, voted, disabled, onCast, onRemove }: VoteButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`vote-btn${voted ? " vote-btn--active" : ""}`}
|
||||||
|
onClick={() => voted ? onRemove(dumpId) : onCast(dumpId)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={voted ? "Remove vote" : "Upvote"}
|
||||||
|
title={disabled ? "Log in to vote" : undefined}
|
||||||
|
>
|
||||||
|
▲ {count}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,3 +7,4 @@ const serverHost = import.meta.env.VITE_SERVER_HOST || "localhost";
|
|||||||
const serverPort = import.meta.env.VITE_SERVER_PORT || "8000";
|
const serverPort = import.meta.env.VITE_SERVER_PORT || "8000";
|
||||||
|
|
||||||
export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`;
|
export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`;
|
||||||
|
export const WS_URL = API_URL.replace(/^http/, "ws");
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ReactNode, useState } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
|
|
||||||
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
|
import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
|
||||||
|
|
||||||
|
|||||||
22
src/contexts/WSContext.ts
Normal file
22
src/contexts/WSContext.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
import type { Dump, OnlineUser } from "../model.ts";
|
||||||
|
|
||||||
|
export interface WSContextValue {
|
||||||
|
onlineUsers: OnlineUser[];
|
||||||
|
voteCounts: Record<string, number>;
|
||||||
|
myVotes: Set<string>;
|
||||||
|
recentDumps: Dump[];
|
||||||
|
deletedDumpIds: Set<string>;
|
||||||
|
castVote: (dumpId: string) => void;
|
||||||
|
removeVote: (dumpId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WSContext = createContext<WSContextValue>({
|
||||||
|
onlineUsers: [],
|
||||||
|
voteCounts: {},
|
||||||
|
myVotes: new Set(),
|
||||||
|
recentDumps: [],
|
||||||
|
deletedDumpIds: new Set(),
|
||||||
|
castVote: () => {},
|
||||||
|
removeVote: () => {},
|
||||||
|
});
|
||||||
200
src/contexts/WSProvider.tsx
Normal file
200
src/contexts/WSProvider.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { WSContext, type WSContextValue } from "./WSContext.ts";
|
||||||
|
import { WS_URL } from "../config/api.ts";
|
||||||
|
import type { Dump, OnlineUser } from "../model.ts";
|
||||||
|
|
||||||
|
interface WSProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BACKOFF = 30_000;
|
||||||
|
const ACK_TIMEOUT = 5_000;
|
||||||
|
|
||||||
|
export function WSProvider({ children, token }: WSProviderProps) {
|
||||||
|
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||||
|
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||||
|
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
||||||
|
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
||||||
|
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Refs to avoid stale closures in event handlers
|
||||||
|
const voteCountsRef = useRef(voteCounts);
|
||||||
|
const myVotesRef = useRef(myVotes);
|
||||||
|
voteCountsRef.current = voteCounts;
|
||||||
|
myVotesRef.current = myVotes;
|
||||||
|
|
||||||
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
// Tracks pending optimistic votes: dumpId → revert timeout ID
|
||||||
|
const pendingRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let closed = false;
|
||||||
|
let backoff = 500;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (closed) return;
|
||||||
|
|
||||||
|
const url = `${WS_URL}/ws${token ? `?token=${encodeURIComponent(token)}` : ""}`;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
socketRef.current = ws;
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case "ping":
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "welcome": {
|
||||||
|
backoff = 500; // reset backoff on successful connect
|
||||||
|
const users = msg.users as OnlineUser[];
|
||||||
|
const votes = msg.myVotes as string[];
|
||||||
|
setOnlineUsers(users);
|
||||||
|
setMyVotes(new Set(votes));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "presence_update":
|
||||||
|
setOnlineUsers(msg.users as OnlineUser[]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "votes_update": {
|
||||||
|
const { dumpId, voteCount } = msg as { dumpId: string; voteCount: number };
|
||||||
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "dump_created": {
|
||||||
|
const dump = msg.dump as Dump;
|
||||||
|
setRecentDumps((prev) => [dump, ...prev]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "dump_deleted": {
|
||||||
|
const dumpId = msg.dumpId as string;
|
||||||
|
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||||
|
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "vote_ack": {
|
||||||
|
const { dumpId, action, voteCount } = msg as {
|
||||||
|
dumpId: string;
|
||||||
|
action: "cast" | "remove";
|
||||||
|
voteCount: number;
|
||||||
|
};
|
||||||
|
// Clear pending revert timeout
|
||||||
|
const timeout = pendingRef.current.get(dumpId);
|
||||||
|
if (timeout !== undefined) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
pendingRef.current.delete(dumpId);
|
||||||
|
}
|
||||||
|
// Reconcile with authoritative count
|
||||||
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||||
|
// Confirm vote state
|
||||||
|
setMyVotes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (action === "cast") next.add(dumpId);
|
||||||
|
else next.delete(dumpId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
// On error, revert any pending optimistic update for the affected dump
|
||||||
|
// (the revert timeout will handle it)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (closed) return;
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
backoff = Math.min(backoff * 2, MAX_BACKOFF);
|
||||||
|
connect();
|
||||||
|
}, backoff);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// onclose will fire after onerror
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
closed = true;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
socketRef.current?.close();
|
||||||
|
socketRef.current = null;
|
||||||
|
// Clear all pending revert timeouts
|
||||||
|
for (const t of pendingRef.current.values()) clearTimeout(t);
|
||||||
|
pendingRef.current.clear();
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const castVote = useCallback((dumpId: string) => {
|
||||||
|
// Optimistic update
|
||||||
|
const prevCount = voteCountsRef.current[dumpId] ?? 0;
|
||||||
|
const prevVoted = myVotesRef.current.has(dumpId);
|
||||||
|
if (prevVoted) return; // already voted
|
||||||
|
|
||||||
|
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
|
||||||
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount + 1 }));
|
||||||
|
|
||||||
|
// Schedule revert if no ack
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingRef.current.delete(dumpId);
|
||||||
|
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
|
||||||
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||||
|
}, ACK_TIMEOUT);
|
||||||
|
pendingRef.current.set(dumpId, timeout);
|
||||||
|
|
||||||
|
socketRef.current?.send(JSON.stringify({ type: "vote_cast", dumpId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeVote = useCallback((dumpId: string) => {
|
||||||
|
// Optimistic update
|
||||||
|
const prevCount = voteCountsRef.current[dumpId] ?? 0;
|
||||||
|
const prevVoted = myVotesRef.current.has(dumpId);
|
||||||
|
if (!prevVoted) return; // not voted
|
||||||
|
|
||||||
|
setMyVotes((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
|
||||||
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: Math.max(0, prevCount - 1) }));
|
||||||
|
|
||||||
|
// Schedule revert if no ack
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
pendingRef.current.delete(dumpId);
|
||||||
|
setMyVotes((prev) => { const n = new Set(prev); n.add(dumpId); return n; });
|
||||||
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: prevCount }));
|
||||||
|
}, ACK_TIMEOUT);
|
||||||
|
pendingRef.current.set(dumpId, timeout);
|
||||||
|
|
||||||
|
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: WSContextValue = {
|
||||||
|
onlineUsers,
|
||||||
|
voteCounts,
|
||||||
|
myVotes,
|
||||||
|
recentDumps,
|
||||||
|
deletedDumpIds,
|
||||||
|
castVote,
|
||||||
|
removeVote,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WSContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</WSContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,13 +19,15 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
|
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
|
||||||
const token = authResponse?.token;
|
const token = authResponse?.token;
|
||||||
|
const isFormData = init.body instanceof FormData;
|
||||||
|
|
||||||
const res = await fetch(input, {
|
const res = await fetch(input, {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
"Content-Type": "application/json",
|
// Let the browser set Content-Type for FormData (it includes the boundary)
|
||||||
|
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
6
src/hooks/useWS.ts
Normal file
6
src/hooks/useWS.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { WSContext } from "../contexts/WSContext.ts";
|
||||||
|
|
||||||
|
export function useWS() {
|
||||||
|
return useContext(WSContext);
|
||||||
|
}
|
||||||
@@ -40,13 +40,18 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-link);
|
color: var(--color-link);
|
||||||
|
|||||||
122
src/model.ts
122
src/model.ts
@@ -2,12 +2,29 @@
|
|||||||
* Backend
|
* Backend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface RichContent {
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
siteName?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
videoId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Dump {
|
export interface Dump {
|
||||||
id: string;
|
id: string;
|
||||||
|
kind: "url" | "file";
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
comment?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
url?: string;
|
||||||
|
richContent?: RichContent;
|
||||||
|
fileName?: string;
|
||||||
|
fileMime?: string;
|
||||||
|
fileSize?: number;
|
||||||
|
voteCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +36,16 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
avatarMime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public user profile (no passwordHash)
|
||||||
|
export interface PublicUser {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
avatarMime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginUserRequest {
|
export interface LoginUserRequest {
|
||||||
@@ -46,14 +73,15 @@ export interface AuthResponse {
|
|||||||
* API
|
* API
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export enum APIErrorCode {
|
export const APIErrorCode = {
|
||||||
BAD_REQUEST = "BAD_REQUEST",
|
BAD_REQUEST: "BAD_REQUEST",
|
||||||
NOT_FOUND = "NOT_FOUND",
|
NOT_FOUND: "NOT_FOUND",
|
||||||
SERVER_ERROR = "SERVER_ERROR",
|
SERVER_ERROR: "SERVER_ERROR",
|
||||||
TIMEOUT = "TIMEOUT",
|
TIMEOUT: "TIMEOUT",
|
||||||
UNAUTHORIZED = "UNAUTHORIZED",
|
UNAUTHORIZED: "UNAUTHORIZED",
|
||||||
VALIDATION_ERROR = "VALIDATION_ERROR",
|
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||||
}
|
} as const;
|
||||||
|
export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode];
|
||||||
|
|
||||||
export interface APIError {
|
export interface APIError {
|
||||||
code: APIErrorCode;
|
code: APIErrorCode;
|
||||||
@@ -78,30 +106,78 @@ export type APIResponse<T> = APISuccess<T> | APIFailure;
|
|||||||
* Request DTOs
|
* Request DTOs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface CreateDumpRequest {
|
export interface CreateUrlDumpRequest {
|
||||||
title: string;
|
url: string;
|
||||||
description?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDumpRequest {
|
export interface UpdateDumpRequest {
|
||||||
title?: string;
|
url?: string;
|
||||||
description?: string;
|
comment?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginUserRequest {
|
/**
|
||||||
|
* WebSockets
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface VoteCastMessage {
|
||||||
|
type: "vote_cast";
|
||||||
|
dumpId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteAckMessageFailure {
|
||||||
|
type: "vote_ack";
|
||||||
|
dumpId: string;
|
||||||
|
success: false;
|
||||||
|
error: APIError;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoteAckMessageSuccess {
|
||||||
|
type: "vote_ack";
|
||||||
|
dumpId: string;
|
||||||
|
action: "cast" | "remove";
|
||||||
|
success: true;
|
||||||
|
voteCount: number;
|
||||||
|
error?: never;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure;
|
||||||
|
|
||||||
|
export interface VoteRemoveMessage {
|
||||||
|
type: "vote_remove";
|
||||||
|
dumpId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VotesUpdateMessage {
|
||||||
|
type: "votes_update";
|
||||||
|
dumpId: string;
|
||||||
|
voteCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineUser {
|
||||||
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
hasAvatar: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterUserRequest {
|
export interface WelcomeMessage {
|
||||||
username: string;
|
type: "welcome";
|
||||||
password: string;
|
users: OnlineUser[];
|
||||||
|
myVotes: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
export interface PresenceUpdateMessage {
|
||||||
username?: string;
|
type: "presence_update";
|
||||||
password?: string;
|
users: OnlineUser[];
|
||||||
isAdmin?: boolean;
|
}
|
||||||
|
|
||||||
|
export interface PingMessage {
|
||||||
|
type: "ping";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PongMessage {
|
||||||
|
type: "pong";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useLocation, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump, PublicUser } from "../model.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
|
import RichContentCard from "../components/RichContentCard.tsx";
|
||||||
|
import FilePreview from "../components/FilePreview.tsx";
|
||||||
|
import { VoteButton } from "../components/VoteButton.tsx";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
|
||||||
type DumpState =
|
type DumpState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -14,26 +21,44 @@ type DumpState =
|
|||||||
|
|
||||||
export function Dump() {
|
export function Dump() {
|
||||||
const { selectedDump } = useParams();
|
const { selectedDump } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null;
|
||||||
|
|
||||||
const [dumpState, setDumpState] = useState<DumpState>({ status: "loading" });
|
const [dumpState, setDumpState] = useState<DumpState>(
|
||||||
|
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
|
||||||
|
);
|
||||||
|
const [op, setOp] = useState<PublicUser | null>(null);
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||||
|
|
||||||
// Fetch dump data
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDump) return;
|
if (!selectedDump) return;
|
||||||
|
|
||||||
|
if (preloaded) {
|
||||||
|
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((r) => r.success && setOp(r.data))
|
||||||
|
.catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDumpState({ status: "loading" });
|
setDumpState({ status: "loading" });
|
||||||
|
setOp(null);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`);
|
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
setDumpState({ status: "loaded", dump: apiResponse.data });
|
const dump: Dump = apiResponse.data;
|
||||||
|
setDumpState({ status: "loaded", dump });
|
||||||
|
|
||||||
|
fetch(`${API_URL}/api/users/by-id/${dump.userId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((r) => r.success && setOp(r.data))
|
||||||
|
.catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDumpState({
|
setDumpState({
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -44,49 +69,82 @@ export function Dump() {
|
|||||||
}, [selectedDump]);
|
}, [selectedDump]);
|
||||||
|
|
||||||
if (dumpState.status === "loading") {
|
if (dumpState.status === "loading") {
|
||||||
return <div className="loading">Loading dump...</div>;
|
return <PageShell><p className="page-loading">Loading dump…</p></PageShell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dumpState.status === "error") {
|
if (dumpState.status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="error-container">
|
<PageShell>
|
||||||
|
<div className="page-error">
|
||||||
<h2>Error</h2>
|
<h2>Error</h2>
|
||||||
<p>{dumpState.error}</p>
|
<p>{dumpState.error}</p>
|
||||||
<button type="button" onClick={() => globalThis.location.reload()}>
|
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
<Link to="/">← Back to all dumps</Link>
|
<Link to="/">← Back to all dumps</Link>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { dump } = dumpState;
|
const { dump } = dumpState;
|
||||||
const canEdit = !!user &&
|
const canEdit = !!user && (dump.userId === user.id || user.isAdmin === true);
|
||||||
(dump.userId === user.id || user.isAdmin === true);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dump-container">
|
<PageShell>
|
||||||
<div className="dump-meta">
|
<div className="dump-detail">
|
||||||
<h1>{dump.title}</h1>
|
{/* Post header */}
|
||||||
{dump.description && (
|
<div className="dump-post-header">
|
||||||
<p className="dump-description">{dump.description}</p>
|
<div className="dump-header-block">
|
||||||
|
<VoteButton
|
||||||
|
dumpId={dump.id}
|
||||||
|
count={voteCounts[dump.id] ?? dump.voteCount}
|
||||||
|
voted={myVotes.has(dump.id)}
|
||||||
|
disabled={!user}
|
||||||
|
onCast={castVote}
|
||||||
|
onRemove={removeVote}
|
||||||
|
/>
|
||||||
|
<div className="dump-header-info">
|
||||||
|
<h1 className="dump-title">{dump.title}</h1>
|
||||||
|
<div className="dump-op">
|
||||||
|
<Avatar
|
||||||
|
userId={dump.userId}
|
||||||
|
username={op?.username ?? "?"}
|
||||||
|
hasAvatar={!!op?.avatarMime}
|
||||||
|
size={22}
|
||||||
|
/>
|
||||||
|
{op
|
||||||
|
? <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()}>
|
||||||
|
{relativeTime(dump.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dump.comment && (
|
||||||
|
<blockquote className="dump-comment">{dump.comment}</blockquote>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dump-grid">
|
{/* Main content */}
|
||||||
~
|
<div className="dump-rich-content">
|
||||||
|
{dump.kind === "file"
|
||||||
|
? <FilePreview dump={dump} />
|
||||||
|
: dump.richContent
|
||||||
|
? <RichContentCard richContent={dump.richContent} />
|
||||||
|
: (
|
||||||
|
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
|
||||||
|
{dump.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
<div className="dump-actions">
|
<div className="dump-actions">
|
||||||
{canEdit && (
|
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
|
||||||
<Link to={`/dumps/${dump.id}/edit`}>
|
|
||||||
Edit dump
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link to="/">← Back to all dumps</Link>
|
<Link to="/">← Back to all dumps</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,139 @@
|
|||||||
import { SubmitEvent, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { SubmitEvent } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type { CreateUrlDumpRequest, RichContent } from "../model.ts";
|
||||||
import type { CreateDumpRequest } from "../model.ts";
|
|
||||||
|
|
||||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
import RichContentCard from "../components/RichContentCard.tsx";
|
||||||
|
import { MediaPlayer } from "../components/MediaPlayer.tsx";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
type Mode = "url" | "file";
|
||||||
type DumpCreateState =
|
type DumpCreateState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
| { status: "submitting" }
|
| { status: "submitting" }
|
||||||
| { status: "error"; error: string };
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
|
type UrlPreview =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "done"; richContent: RichContent | null };
|
||||||
|
|
||||||
|
function LocalFilePreview({ file }: { file: File }) {
|
||||||
|
const src = URL.createObjectURL(file);
|
||||||
|
const mime = file.type;
|
||||||
|
|
||||||
|
useEffect(() => () => URL.revokeObjectURL(src), [src]);
|
||||||
|
|
||||||
|
if (mime.startsWith("image/")) {
|
||||||
|
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("video/")) {
|
||||||
|
return <MediaPlayer src={src} kind="video" mime={mime} />;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("audio/")) {
|
||||||
|
return <MediaPlayer src={src} kind="audio" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="local-preview-generic">
|
||||||
|
<span className="local-preview-icon">
|
||||||
|
{mime.startsWith("application/pdf") ? "📄" : "📎"}
|
||||||
|
</span>
|
||||||
|
<span className="local-preview-name">{file.name}</span>
|
||||||
|
<span className="local-preview-size">{formatBytes(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function DumpCreate() {
|
export function DumpCreate() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { authFetch } = useRequiredAuth();
|
const { authFetch } = useRequiredAuth();
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
const [mode, setMode] = useState<Mode>("url");
|
||||||
const [description, setDescription] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
const [state, setState] = useState<DumpCreateState>({ status: "idle" });
|
const [state, setState] = useState<DumpCreateState>({ status: "idle" });
|
||||||
|
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
// Debounced URL preview fetch
|
||||||
e.preventDefault();
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
const trimmedTitle = title.trim();
|
let trimmed: string;
|
||||||
|
try {
|
||||||
if (!trimmedTitle) {
|
const u = new URL(url.trim());
|
||||||
setState({ status: "error", error: "Title is required." });
|
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
|
||||||
|
trimmed = u.toString();
|
||||||
|
} catch {
|
||||||
|
setUrlPreview({ status: "idle" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: CreateDumpRequest = {
|
setUrlPreview({ status: "loading" });
|
||||||
title,
|
debounceRef.current = setTimeout(async () => {
|
||||||
description: description || undefined,
|
try {
|
||||||
};
|
const res = await fetch(`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`);
|
||||||
|
const body = await res.json();
|
||||||
|
setUrlPreview({ status: "done", richContent: body.success ? body.data : null });
|
||||||
|
} catch {
|
||||||
|
setUrlPreview({ status: "done", richContent: null });
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
setState({ status: "submitting" });
|
setState({ status: "submitting" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`${API_URL}/api/dumps`, {
|
let res: Response;
|
||||||
|
|
||||||
|
if (mode === "url") {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setState({ status: "error", error: "URL is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: CreateUrlDumpRequest = {
|
||||||
|
url: url.trim(),
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
};
|
||||||
|
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
if (!res.ok) {
|
if (!file) {
|
||||||
throw new Error(`HTTP ${res.status}`);
|
setState({ status: "error", error: "Please select a file." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
setState({ status: "error", error: "File too large (max 50 MB)." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
if (comment.trim()) formData.append("comment", comment.trim());
|
||||||
|
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const apiResponse = await res.json();
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
const createdDump = apiResponse.data;
|
navigate(`/dumps/${apiResponse.data.id}`);
|
||||||
navigate(`/dumps/${createdDump.id}`);
|
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({
|
setState({
|
||||||
@@ -66,55 +143,143 @@ export function DumpCreate() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitting = state.status === "submitting";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: ClipboardEvent) => {
|
||||||
|
const pastedFile = e.clipboardData?.files[0];
|
||||||
|
if (pastedFile) {
|
||||||
|
setMode("file");
|
||||||
|
setUrl("");
|
||||||
|
setUrlPreview({ status: "idle" });
|
||||||
|
setFile(pastedFile);
|
||||||
|
setState({ status: "idle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only intercept text pastes when outside an input/textarea
|
||||||
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||||
|
|
||||||
|
const text = e.clipboardData?.getData("text") ?? "";
|
||||||
|
try {
|
||||||
|
const u = new URL(text.trim());
|
||||||
|
if (u.protocol === "http:" || u.protocol === "https:") {
|
||||||
|
setMode("url");
|
||||||
|
setFile(null);
|
||||||
|
setUrl(text.trim());
|
||||||
|
setState({ status: "idle" });
|
||||||
|
}
|
||||||
|
} catch { /* not a URL */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("paste", handler);
|
||||||
|
return () => window.removeEventListener("paste", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dump-container">
|
<PageShell centered>
|
||||||
<div className="dump-meta">
|
<div className="dump-create-wrapper">
|
||||||
<h1>Create Dump</h1>
|
<div className="dump-create-header">
|
||||||
|
<h1 className="dump-create-title">New dump</h1>
|
||||||
|
<div className="dump-mode-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "url" ? "active" : ""}
|
||||||
|
onClick={() => { setMode("url"); setFile(null); setState({ status: "idle" }); }}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
🔗 URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "file" ? "active" : ""}
|
||||||
|
onClick={() => { setMode("file"); setUrl(""); setUrlPreview({ status: "idle" }); setState({ status: "idle" }); }}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
📎 File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="dump-create-form dump-form">
|
||||||
{state.status === "error" && (
|
{state.status === "error" && (
|
||||||
<div className="error-banner">{state.error}</div>
|
<p className="form-error">{state.error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="dump-form">
|
{mode === "url"
|
||||||
|
? (
|
||||||
|
<>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="title">
|
<label htmlFor="url">URL</label>
|
||||||
<strong>Title</strong>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="url"
|
||||||
type="text"
|
type="url"
|
||||||
value={title}
|
value={url}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
disabled={state.status === "submitting"}
|
onPaste={(e) => {
|
||||||
|
const pastedFile = e.clipboardData.files[0];
|
||||||
|
if (pastedFile) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMode("file");
|
||||||
|
setUrl("");
|
||||||
|
setUrlPreview({ status: "idle" });
|
||||||
|
setFile(pastedFile);
|
||||||
|
setState({ status: "idle" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="https://..."
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{urlPreview.status === "loading" && (
|
||||||
|
<p className="preview-loading">Fetching preview…</p>
|
||||||
|
)}
|
||||||
|
{urlPreview.status === "done" && urlPreview.richContent && (
|
||||||
|
<RichContentCard richContent={urlPreview.richContent} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="file">File</label>
|
||||||
|
<input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
disabled={submitting}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{file && <LocalFilePreview file={file} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="description">
|
<label htmlFor="comment">Why are you dumping this?</label>
|
||||||
<strong>Description (optional)</strong>
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="comment"
|
||||||
value={description}
|
value={comment}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
disabled={state.status === "submitting"}
|
disabled={submitting}
|
||||||
|
placeholder="Tell the community what makes this worth their time..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dump-actions">
|
<div className="form-actions">
|
||||||
<button
|
<div className="form-actions-right">
|
||||||
type="submit"
|
<Link to="/" className="form-cancel">Cancel</Link>
|
||||||
disabled={state.status === "submitting"}
|
<button type="submit" className="btn-primary" disabled={submitting}>
|
||||||
>
|
{submitting ? (mode === "url" ? "Fetching…" : "Uploading…") : "Dump it"}
|
||||||
{state.status === "submitting" ? "Creating..." : "Create dump"}
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<Link to="/">Cancel</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import type { Dump, UpdateDumpRequest } from "../model.ts";
|
import type { Dump, UpdateDumpRequest } from "../model.ts";
|
||||||
|
|
||||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
import RichContentCard from "../components/RichContentCard.tsx";
|
||||||
|
import FilePreview from "../components/FilePreview.tsx";
|
||||||
|
|
||||||
type DumpEditState =
|
type DumpEditState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -18,8 +20,9 @@ export function DumpEdit() {
|
|||||||
const { authFetch } = useRequiredAuth();
|
const { authFetch } = useRequiredAuth();
|
||||||
|
|
||||||
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
||||||
const [title, setTitle] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
|
const [newFile, setNewFile] = useState<File | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDump) return;
|
if (!selectedDump) return;
|
||||||
@@ -28,21 +31,18 @@ export function DumpEdit() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`);
|
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store" });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
|
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
const dump: Dump = apiResponse.data;
|
const dump: Dump = apiResponse.data;
|
||||||
setTitle(dump.title);
|
setUrl(dump.url ?? "");
|
||||||
setDescription(dump.description ?? "");
|
setComment(dump.comment ?? "");
|
||||||
setState({ status: "loaded", dump });
|
setState({ status: "loaded", dump });
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({
|
setState({
|
||||||
@@ -56,25 +56,41 @@ export function DumpEdit() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (state.status !== "loaded") return;
|
if (state.status !== "loaded") return;
|
||||||
|
|
||||||
const body: UpdateDumpRequest = {
|
let res: Response;
|
||||||
title,
|
|
||||||
description: description || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
if (state.dump.kind === "file" && newFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", newFile);
|
||||||
|
if (comment.trim()) formData.append("comment", comment.trim());
|
||||||
|
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}/file`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const body: UpdateDumpRequest = state.dump.kind === "url"
|
||||||
|
? { url: url.trim() || undefined, comment: comment.trim() || undefined }
|
||||||
|
: { comment: comment.trim() || undefined };
|
||||||
|
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setState({
|
setState({ status: "error", error: `Update failed (${res.status})` });
|
||||||
status: "error",
|
|
||||||
error: `Update failed (${res.status})`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(`/dumps/${state.dump.id}`);
|
const apiResponse = await res.json();
|
||||||
|
if (!apiResponse.success) {
|
||||||
|
setState({ status: "error", error: apiResponse.error?.message ?? "Update failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDump: Dump = apiResponse.data;
|
||||||
|
setState({ status: "loaded", dump: updatedDump });
|
||||||
|
setNewFile(null);
|
||||||
|
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
@@ -85,85 +101,110 @@ export function DumpEdit() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setState({
|
setState({ status: "error", error: `Delete failed (${res.status})` });
|
||||||
status: "error",
|
|
||||||
error: `Delete failed (${res.status})`,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate("/");
|
navigate("/", { state: { deletedDumpId: state.dump.id } });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return <div className="loading">Loading dump...</div>;
|
return <PageShell><p className="page-loading">Loading dump…</p></PageShell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.status === "error") {
|
if (state.status === "error") {
|
||||||
return (
|
return (
|
||||||
<div className="error-container">
|
<PageShell>
|
||||||
|
<div className="page-error">
|
||||||
<h2>Error</h2>
|
<h2>Error</h2>
|
||||||
<p>{state.error}</p>
|
<p>{state.error}</p>
|
||||||
<button type="button" onClick={() => globalThis.location.reload()}>
|
<button type="button" onClick={() => globalThis.location.reload()}>Retry</button>
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
<Link to="/">← Back to all dumps</Link>
|
<Link to="/">← Back to all dumps</Link>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { dump } = state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dump-container">
|
<PageShell>
|
||||||
<div className="dump-meta">
|
<div className="form-page form-page--two-col">
|
||||||
<h1>Edit Dump</h1>
|
<div className="form-page-header">
|
||||||
|
<p className="form-page-eyebrow">Editing</p>
|
||||||
|
<h1 className="form-page-title">{dump.title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dump-edit-preview">
|
||||||
|
{dump.kind === "file"
|
||||||
|
? <FilePreview dump={dump} />
|
||||||
|
: dump.richContent
|
||||||
|
? <RichContentCard richContent={dump.richContent} />
|
||||||
|
: dump.url && (
|
||||||
|
<a href={dump.url} target="_blank" rel="noopener noreferrer" className="dump-url-link">
|
||||||
|
{dump.url}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="dump-form"
|
className="dump-form"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => { e.preventDefault(); handleSave(); }}
|
||||||
e.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{dump.kind === "url"
|
||||||
|
? (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="title">
|
<label htmlFor="url">URL</label>
|
||||||
<strong>Title</strong>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
id="title"
|
id="url"
|
||||||
type="text"
|
type="url"
|
||||||
value={title}
|
value={url}
|
||||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
onChange={(e) => setUrl(e.currentTarget.value)}
|
||||||
|
placeholder="https://..."
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="form-group">
|
||||||
|
<p className="dump-file-notice">
|
||||||
|
<strong>{dump.fileName}</strong>
|
||||||
|
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
||||||
|
</p>
|
||||||
|
<label htmlFor="replace-file">Replace file</label>
|
||||||
|
<input
|
||||||
|
id="replace-file"
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
{newFile && (
|
||||||
|
<p className="file-input-info">{newFile.name} — {formatBytes(newFile.size)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="description">
|
<label htmlFor="comment">Why are you dumping this?</label>
|
||||||
<strong>Description (optional)</strong>
|
|
||||||
</label>
|
|
||||||
<textarea
|
<textarea
|
||||||
id="description"
|
id="comment"
|
||||||
value={description}
|
value={comment}
|
||||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
onChange={(e) => setComment(e.currentTarget.value)}
|
||||||
|
placeholder="Tell the community what makes this worth their time..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dump-actions">
|
<div className="form-actions">
|
||||||
<button type="submit">Save</button>
|
<button type="button" onClick={handleDelete} className="btn-danger">
|
||||||
<button
|
Delete dump
|
||||||
type="button"
|
|
||||||
onClick={handleDelete}
|
|
||||||
style={{ backgroundColor: "#a02b2b" }}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
</button>
|
||||||
<Link to={`/dumps/${state.dump.id}`}>Cancel</Link>
|
<div className="form-actions-right">
|
||||||
|
<Link to={`/dumps/${dump.id}`} className="form-cancel">Cancel</Link>
|
||||||
|
<button type="submit" className="btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,122 +1,119 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { type Dump } from "../model.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";
|
||||||
|
|
||||||
type DumpsState =
|
type DumpsState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
| { status: "loaded"; dumps: Dump[] };
|
| { status: "loaded"; dumps: Dump[] };
|
||||||
|
|
||||||
|
type SortMode = "new" | "hot";
|
||||||
|
|
||||||
|
function hotScore(dump: Dump): number {
|
||||||
|
const ageHours = (Date.now() - new Date(dump.createdAt).getTime()) / 3_600_000;
|
||||||
|
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
export function Index() {
|
export function Index() {
|
||||||
const { user, logout } = useAuth();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const justDeletedId = (location.state as { deletedDumpId?: string } | null)?.deletedDumpId;
|
||||||
|
|
||||||
const handleCreateDump = () => {
|
const { user } = useAuth();
|
||||||
navigate("/dumps/new");
|
const { onlineUsers, voteCounts, myVotes, recentDumps, deletedDumpIds, castVote, removeVote } = useWS();
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegister = () => {
|
const [dumpsState, setDumpsState] = useState<DumpsState>({ status: "loading" });
|
||||||
navigate("/register");
|
const [sort, setSort] = useState<SortMode>("hot");
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogin = () => {
|
|
||||||
navigate("/login");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
navigate("/", { replace: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const [dumpsState, setDumpsState] = useState<DumpsState>({
|
|
||||||
status: "loading",
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_URL}/api/dumps/`);
|
const res = await fetch(`${API_URL}/api/dumps/`);
|
||||||
if (!response.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
throw new Error(`HTTP ${response.status}`);
|
const body = await res.json();
|
||||||
}
|
setDumpsState({ status: "loaded", dumps: body.data });
|
||||||
|
|
||||||
const apiResponse = await response.json();
|
|
||||||
setDumpsState({ status: "loaded", dumps: apiResponse.data });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDumpsState({
|
setDumpsState({ status: "error", error: err instanceof Error ? err.message : "Failed to load" });
|
||||||
status: "error",
|
|
||||||
error: err instanceof Error ? err.message : "Failed to load dumps",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (dumpsState.status === "loading") {
|
const loading = dumpsState.status === "loading";
|
||||||
return (
|
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||||
<main id="content">
|
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
|
||||||
<div className="loading">Loading dumps...</div>
|
const restIds = new Set(dumps.map((d) => d.id));
|
||||||
</main>
|
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
|
||||||
|
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||||
|
|
||||||
|
const sortedDumps = [...combined].sort(
|
||||||
|
sort === "hot"
|
||||||
|
? (a, b) => hotScore(b) - hotScore(a)
|
||||||
|
: (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
if (dumpsState.status === "error") {
|
const presenceRow = (
|
||||||
return (
|
<div className="index-presence">
|
||||||
<main id="content">
|
{onlineUsers.map((u) => (
|
||||||
<div className="error-container">
|
<Link key={u.userId} to={`/users/${u.username}`} title={u.username} className="index-presence-avatar">
|
||||||
<h2>Error</h2>
|
<Avatar userId={u.userId} username={u.username} hasAvatar={u.hasAvatar} size={32} />
|
||||||
<p>{dumpsState.error}</p>
|
|
||||||
<button type="button" onClick={() => globalThis.location.reload()}>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { dumps } = dumpsState;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main id="content">
|
|
||||||
<h1>🚚 Dumps</h1>
|
|
||||||
|
|
||||||
<p>Welcome, {user?.username ?? "guest"}!</p>
|
|
||||||
|
|
||||||
{user &&
|
|
||||||
<button type="button" onClick={handleCreateDump}>New dump</button>}
|
|
||||||
|
|
||||||
<p>Click on a dump below to participate.</p>
|
|
||||||
|
|
||||||
{dumps.length === 0
|
|
||||||
? <p className="empty-state">No dumps available yet.</p>
|
|
||||||
: (
|
|
||||||
<ul>
|
|
||||||
{dumps.map((dump) => (
|
|
||||||
<li key={dump.id}>
|
|
||||||
<Link to={`/dumps/${dump.id}`} className="dump">
|
|
||||||
{dump.title}
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="index-page">
|
||||||
|
<AppHeader centerSlot={
|
||||||
|
<div className="header-center-slot">
|
||||||
|
{presenceRow}
|
||||||
|
{sortButtons}
|
||||||
|
</div>
|
||||||
|
} />
|
||||||
|
|
||||||
|
{/* Shown only on narrow viewports */}
|
||||||
|
<div className="index-below-header">
|
||||||
|
{sortButtons}
|
||||||
|
{presenceRow}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="index-status">Loading…</p>}
|
||||||
|
{error && <p className="index-status index-status--error">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && combined.length === 0 && (
|
||||||
|
<p className="index-status">No dumps yet. Be the first!</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && combined.length > 0 && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{sortedDumps.map((dump) => (
|
||||||
|
<DumpCard
|
||||||
|
key={dump.id}
|
||||||
|
dump={dump}
|
||||||
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||||
|
voted={myVotes.has(dump.id)}
|
||||||
|
canVote={!!user}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{user
|
|
||||||
? (
|
|
||||||
<form>
|
|
||||||
<button type="button" onClick={handleLogout}>Logout</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<form>
|
|
||||||
<button type="button" onClick={handleRegister}>Register</button>
|
|
||||||
<button type="button" onClick={handleLogin}>Log in</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { SubmitEvent, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import type { SubmitEvent } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
|
||||||
type UserLoginState =
|
type UserLoginState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
@@ -32,9 +33,7 @@ export function UserLogin() {
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
|
|
||||||
@@ -42,10 +41,7 @@ export function UserLogin() {
|
|||||||
login(apiResponse.data);
|
login(apiResponse.data);
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({
|
setState({
|
||||||
@@ -56,7 +52,10 @@ export function UserLogin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-container">
|
<PageShell centered>
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-card-title">Log in</h1>
|
||||||
|
|
||||||
{state.status === "error" && (
|
{state.status === "error" && (
|
||||||
<div className="error-banner">{state.error}</div>
|
<div className="error-banner">{state.error}</div>
|
||||||
)}
|
)}
|
||||||
@@ -68,6 +67,7 @@ export function UserLogin() {
|
|||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={state.status === "submitting"}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
@@ -76,13 +76,15 @@ export function UserLogin() {
|
|||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={state.status === "submitting"}
|
||||||
/>
|
/>
|
||||||
<button
|
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
|
||||||
type="submit"
|
{state.status === "submitting" ? "Logging in…" : "Log in"}
|
||||||
disabled={state.status === "submitting"}
|
|
||||||
>
|
|
||||||
{state.status === "submitting" ? "Logging in..." : "Login"}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-card-footer">
|
||||||
|
No account? <Link to="/register">Register</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
|
||||||
|
|
||||||
export function UserProfile() {
|
|
||||||
const { user } = useRequiredAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
`Hello, ${user.username}!`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
328
src/pages/UserPublicProfile.tsx
Normal file
328
src/pages/UserPublicProfile.tsx
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type { AuthResponse, Dump, PublicUser } from "../model.ts";
|
||||||
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
|
||||||
|
type ProfileState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "error"; error: string }
|
||||||
|
| { status: "loaded"; user: PublicUser; dumps: Dump[]; votes: Dump[] };
|
||||||
|
|
||||||
|
export function UserPublicProfile() {
|
||||||
|
const { username } = useParams();
|
||||||
|
const { user: me, authFetch, login } = useAuth();
|
||||||
|
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||||
|
|
||||||
|
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [userRes, dumpsRes, votesRes] = await Promise.all([
|
||||||
|
fetch(`${API_URL}/api/users/${username}`),
|
||||||
|
fetch(`${API_URL}/api/users/${username}/dumps`),
|
||||||
|
fetch(`${API_URL}/api/users/${username}/votes`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!userRes.ok) {
|
||||||
|
throw new Error(userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userBody, dumpsBody, votesBody] = await Promise.all([
|
||||||
|
userRes.json(),
|
||||||
|
dumpsRes.json(),
|
||||||
|
votesRes.json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
user: userBody.data,
|
||||||
|
dumps: dumpsBody.success ? dumpsBody.data : [],
|
||||||
|
votes: votesBody.success ? votesBody.data : [],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
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).
|
||||||
|
useEffect(() => {
|
||||||
|
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));
|
||||||
|
if (toAdd.length === 0) return s;
|
||||||
|
return { ...s, votes: [...toAdd, ...s.votes] };
|
||||||
|
});
|
||||||
|
|
||||||
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
|
}, [myVotes]);
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || state.status !== "loaded") return;
|
||||||
|
|
||||||
|
setAvatarError(null);
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 } };
|
||||||
|
|
||||||
|
if (!res.ok || !body.success) {
|
||||||
|
setAvatarError(body.error?.message ?? "Upload failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedRaw = localStorage.getItem("authResponse");
|
||||||
|
if (storedRaw && body.data) {
|
||||||
|
login({ ...(JSON.parse(storedRaw) as AuthResponse), user: body.data });
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => prev.status === "loaded"
|
||||||
|
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
|
||||||
|
: prev);
|
||||||
|
} catch {
|
||||||
|
setAvatarError("Upload failed");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user: profileUser, dumps, votes } = state;
|
||||||
|
const isOwnProfile = me?.username === profileUser.username;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="profile-header">
|
||||||
|
<div className="profile-avatar-wrapper">
|
||||||
|
<Avatar
|
||||||
|
userId={profileUser.id}
|
||||||
|
username={profileUser.username}
|
||||||
|
hasAvatar={!!profileUser.avatarMime}
|
||||||
|
size={72}
|
||||||
|
/>
|
||||||
|
{isOwnProfile && (
|
||||||
|
<label className="avatar-change-overlay" title="Change avatar">
|
||||||
|
{uploading ? "…" : "✎"}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="profile-username">{profileUser.username}</h1>
|
||||||
|
{avatarError && <p className="form-error">{avatarError}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-columns">
|
||||||
|
<DumpList
|
||||||
|
title={`Dumps (${dumps.length})`}
|
||||||
|
dumps={dumps}
|
||||||
|
voteCounts={voteCounts}
|
||||||
|
myVotes={myVotes}
|
||||||
|
canVote={!!me}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UpvotedDumpList
|
||||||
|
title={`Upvoted (${votes.filter((d) => myVotes.has(d.id)).length})`}
|
||||||
|
dumps={votes}
|
||||||
|
voteCounts={voteCounts}
|
||||||
|
myVotes={myVotes}
|
||||||
|
canVote={!!me}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
|
||||||
|
|
||||||
|
function DumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||||
|
title: string;
|
||||||
|
dumps: Dump[];
|
||||||
|
voteCounts: Record<string, number>;
|
||||||
|
myVotes: Set<string>;
|
||||||
|
canVote: boolean;
|
||||||
|
castVote: (id: string) => void;
|
||||||
|
removeVote: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="profile-section">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{dumps.length === 0
|
||||||
|
? <p className="empty-state">Nothing here yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{dumps.map((dump) => (
|
||||||
|
<DumpCard
|
||||||
|
key={dump.id}
|
||||||
|
dump={dump}
|
||||||
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||||
|
voted={myVotes.has(dump.id)}
|
||||||
|
canVote={canVote}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upvoted list: fades items out when votes are removed ────────────────────
|
||||||
|
|
||||||
|
function UpvotedDumpList({ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||||
|
title: string;
|
||||||
|
dumps: Dump[];
|
||||||
|
voteCounts: Record<string, number>;
|
||||||
|
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">>({});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
useEffect(() => () => { cancels.current.forEach((c) => c()); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// First run: capture baseline without triggering any fades
|
||||||
|
if (prevVotes.current === null) {
|
||||||
|
prevVotes.current = new Set(myVotes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = prevVotes.current;
|
||||||
|
|
||||||
|
// Newly unvoted → start fade (idempotent: skip if already running)
|
||||||
|
for (const id of prev) {
|
||||||
|
if (!myVotes.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; });
|
||||||
|
cancels.current.delete(id);
|
||||||
|
};
|
||||||
|
cancels.current.set(id, () => kill());
|
||||||
|
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
||||||
|
|
||||||
|
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); };
|
||||||
|
}, 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); };
|
||||||
|
cancels.current.set(id, () => kill());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newly re-voted while fading → cancel removal
|
||||||
|
for (const id of myVotes) {
|
||||||
|
if (!prev.has(id) && cancels.current.has(id)) {
|
||||||
|
cancels.current.get(id)!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevVotes.current = new Set(myVotes);
|
||||||
|
}, [myVotes]);
|
||||||
|
|
||||||
|
// Visible = currently voted OR within the fade-out animation window
|
||||||
|
const visibleDumps = dumps.filter((d) => myVotes.has(d.id) || d.id in fading);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="profile-section">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{visibleDumps.length === 0
|
||||||
|
? <p className="empty-state">Nothing here yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{visibleDumps.map((dump) => {
|
||||||
|
const phase = fading[dump.id];
|
||||||
|
const extraCls = phase === "cooldown" ? "dump-card--fading"
|
||||||
|
: phase === "dismissing" ? "dump-card--dismissing"
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<DumpCard
|
||||||
|
key={dump.id}
|
||||||
|
dump={dump}
|
||||||
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||||
|
voted={myVotes.has(dump.id)}
|
||||||
|
canVote={canVote}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
className={extraCls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { SubmitEvent, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import type { SubmitEvent } from "react";
|
||||||
|
import { Link, useNavigate } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
|
||||||
type UserRegisterState =
|
type UserRegisterState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
@@ -32,9 +33,7 @@ export function UserRegister() {
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
|
|
||||||
@@ -42,10 +41,7 @@ export function UserRegister() {
|
|||||||
login(apiResponse.data);
|
login(apiResponse.data);
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error.message,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({
|
setState({
|
||||||
@@ -56,18 +52,22 @@ export function UserRegister() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="registration-container">
|
<PageShell centered>
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-card-title">Register</h1>
|
||||||
|
|
||||||
{state.status === "error" && (
|
{state.status === "error" && (
|
||||||
<div className="error-banner">{state.error}</div>
|
<div className="error-banner">{state.error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="registration-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
<input
|
<input
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={state.status === "submitting"}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
@@ -76,13 +76,15 @@ export function UserRegister() {
|
|||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={state.status === "submitting"}
|
||||||
/>
|
/>
|
||||||
<button
|
<button type="submit" className="btn-primary" disabled={state.status === "submitting"}>
|
||||||
type="submit"
|
{state.status === "submitting" ? "Registering…" : "Register"}
|
||||||
disabled={state.status === "submitting"}
|
|
||||||
>
|
|
||||||
{state.status === "submitting" ? "Registering..." : "Register"}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-card-footer">
|
||||||
|
Already have an account? <Link to="/login">Log in</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/utils/format.ts
Normal file
5
src/utils/format.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
14
src/utils/relativeTime.ts
Normal file
14
src/utils/relativeTime.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||||
|
|
||||||
|
export function relativeTime(dateStr: string): string {
|
||||||
|
const diff = new Date(dateStr).getTime() - Date.now(); // negative = past
|
||||||
|
const abs = Math.abs(diff) / 1000;
|
||||||
|
|
||||||
|
if (abs < 60) return rtf.format(-Math.round(abs), "second");
|
||||||
|
if (abs < 3600) return rtf.format(-Math.round(abs / 60), "minute");
|
||||||
|
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");
|
||||||
|
return rtf.format(-Math.round(abs / 365 / 86400), "year");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user