v3: code quality pass

This commit is contained in:
khannurien
2026-03-24 18:47:05 +00:00
parent cd4076343b
commit c293f3e706
39 changed files with 1464 additions and 1555 deletions

View File

@@ -75,7 +75,7 @@ export interface UserRow {
* Type Guards
*/
export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
export function isDumpRow(obj: unknown): obj is DumpRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
@@ -102,7 +102,7 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
"is_private" in obj && typeof obj.is_private === "number";
}
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
export function isUserRow(obj: unknown): obj is UserRow {
return !!obj &&
typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
@@ -214,18 +214,21 @@ export interface CommentRow {
}
export function isCommentRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is CommentRow {
return !!obj && typeof obj === "object" &&
typeof obj.id === "string" &&
typeof obj.dump_id === "string" &&
typeof obj.user_id === "string" &&
"id" in obj && typeof obj.id === "string" &&
"dump_id" in obj && typeof obj.dump_id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"parent_id" in obj &&
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
typeof obj.body === "string" &&
typeof obj.created_at === "string" &&
"body" in obj && typeof obj.body === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
typeof obj.deleted === "number" &&
typeof obj.author_username === "string" &&
"deleted" in obj && typeof obj.deleted === "number" &&
"author_username" in obj && typeof obj.author_username === "string" &&
"author_avatar_mime" in obj &&
(typeof obj.author_avatar_mime === "string" ||
obj.author_avatar_mime === null);
}
@@ -259,16 +262,20 @@ export interface PlaylistRow {
}
export function isPlaylistRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is PlaylistRow {
return !!obj && typeof obj.id === "string" &&
typeof obj.user_id === "string" &&
typeof obj.title === "string" &&
(typeof obj.slug === "string" || obj.slug === null) &&
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"title" in obj && typeof obj.title === "string" &&
"slug" in obj && (typeof obj.slug === "string" || obj.slug === null) &&
"description" in obj &&
(typeof obj.description === "string" || obj.description === null) &&
typeof obj.is_public === "number" &&
typeof obj.created_at === "string" &&
"is_public" in obj && typeof obj.is_public === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"updated_at" in obj &&
(typeof obj.updated_at === "string" || obj.updated_at === null) &&
"image_mime" in obj &&
(typeof obj.image_mime === "string" || obj.image_mime === null);
}
@@ -300,15 +307,15 @@ export interface FollowRow {
}
export function isFollowRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is FollowRow {
return !!obj &&
typeof obj.id === "string" &&
typeof obj.follower_id === "string" &&
typeof obj.created_at === "string" &&
(obj.followed_user_id === null ||
return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" &&
"follower_id" in obj && typeof obj.follower_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"followed_user_id" in obj && (obj.followed_user_id === null ||
typeof obj.followed_user_id === "string") &&
(obj.followed_playlist_id === null ||
"followed_playlist_id" in obj && (obj.followed_playlist_id === null ||
typeof obj.followed_playlist_id === "string");
}
@@ -326,15 +333,16 @@ export interface NotificationRow {
}
export function isNotificationRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is NotificationRow {
return !!obj && typeof obj === "object" &&
typeof obj.id === "string" &&
typeof obj.user_id === "string" &&
typeof obj.type === "string" &&
typeof obj.data === "string" &&
typeof obj.read === "number" &&
typeof obj.created_at === "string" &&
"id" in obj && typeof obj.id === "string" &&
"user_id" in obj && typeof obj.user_id === "string" &&
"type" in obj && typeof obj.type === "string" &&
"data" in obj && typeof obj.data === "string" &&
"read" in obj && typeof obj.read === "number" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"source_key" in obj &&
(typeof obj.source_key === "string" || obj.source_key === null);
}
@@ -360,11 +368,12 @@ export interface InviteRow {
}
export function isInviteRow(
obj: Record<string, SQLOutputValue>,
obj: unknown,
): obj is InviteRow {
return !!obj && typeof obj === "object" &&
typeof obj.token === "string" &&
typeof obj.inviter_id === "string" &&
typeof obj.created_at === "string" &&
"token" in obj && typeof obj.token === "string" &&
"inviter_id" in obj && typeof obj.inviter_id === "string" &&
"created_at" in obj && typeof obj.created_at === "string" &&
"used_at" in obj &&
(obj.used_at === null || typeof obj.used_at === "string");
}

View File

@@ -310,7 +310,7 @@ export interface CreatePlaylistRequest {
export interface UpdatePlaylistRequest {
title?: string;
description?: string;
description?: string | null;
isPublic?: boolean;
}
@@ -428,40 +428,33 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
* WebSockets
*/
// ── Client → Server ──────────────────────────────────────────────────────────
export interface PingMessage {
type: "ping";
}
export interface PongMessage {
type: "pong";
}
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 type ClientToServerMessage =
| PingMessage
| PongMessage
| VoteCastMessage
| VoteRemoveMessage;
// ── Server → Client ──────────────────────────────────────────────────────────
export interface OnlineUser {
userId: string;
@@ -474,6 +467,7 @@ export interface WelcomeMessage {
type: "welcome";
users: OnlineUser[];
myVotes: string[];
unreadNotificationCount: number;
}
export interface PresenceUpdateMessage {
@@ -481,14 +475,109 @@ export interface PresenceUpdateMessage {
users: OnlineUser[];
}
export interface PingMessage {
type: "ping";
export interface VotesUpdateMessage {
type: "votes_update";
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
}
export interface PongMessage {
type: "pong";
export interface VoteAckMessage {
type: "vote_ack";
dumpId: string;
action: "cast" | "remove";
voteCount: number;
}
export interface DumpCreatedMessage {
type: "dump_created";
dump: Dump;
}
export interface DumpUpdatedMessage {
type: "dump_updated";
dump: Dump;
}
export interface DumpDeletedMessage {
type: "dump_deleted";
dumpId: string;
}
export interface PlaylistCreatedMessage {
type: "playlist_created";
playlist: Playlist;
}
export interface PlaylistUpdatedMessage {
type: "playlist_updated";
playlist: Playlist;
}
export interface PlaylistDeletedMessage {
type: "playlist_deleted";
playlistId: string;
userId: string;
}
export interface PlaylistDumpsUpdatedMessage {
type: "playlist_dumps_updated";
playlistId: string;
dumpIds: string[];
}
export interface UserUpdatedMessage {
type: "user_updated";
user: Omit<User, "passwordHash">;
}
export interface CommentCreatedMessage {
type: "comment_created";
comment: Comment;
}
export interface CommentUpdatedMessage {
type: "comment_updated";
comment: Comment;
}
export interface CommentDeletedMessage {
type: "comment_deleted";
commentId: string;
dumpId: string;
}
export interface NotificationCreatedMessage {
type: "notification_created";
notification: RawNotification;
}
export interface ErrorMessage {
type: "error";
message?: string;
}
export type ServerToClientMessage =
| PingMessage
| WelcomeMessage
| PresenceUpdateMessage
| VotesUpdateMessage
| VoteAckMessage
| DumpCreatedMessage
| DumpUpdatedMessage
| DumpDeletedMessage
| PlaylistCreatedMessage
| PlaylistUpdatedMessage
| PlaylistDeletedMessage
| PlaylistDumpsUpdatedMessage
| UserUpdatedMessage
| CommentCreatedMessage
| CommentUpdatedMessage
| CommentDeletedMessage
| NotificationCreatedMessage
| ErrorMessage;
/**
* Follows
*/
@@ -568,3 +657,8 @@ export interface Notification {
read: boolean;
createdAt: Date;
}
/** Wire format — createdAt arrives as an ISO string over JSON. */
export type RawNotification = Omit<Notification, "createdAt"> & {
createdAt: string;
};

View File

@@ -44,9 +44,9 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
}
updateClientAvatar(authPayload.userId, mime);
const user = getUserById(authPayload.userId);
const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId);
ctx.response.status = 200;
ctx.response.body = { success: true, data: user };
ctx.response.body = { success: true, data: publicUser };
});
router.get("/api/avatars/:userId", async (ctx) => {

View File

@@ -38,7 +38,7 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
// POST /api/dumps/:dumpId/comments — auth required
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId as string;
const userId = ctx.state.user.userId;
const dump = getDump(ctx.params.dumpId, userId);
const body = await ctx.request.body.json();
if (!isCreateCommentRequest(body)) {
@@ -62,8 +62,8 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
// PATCH /api/comments/:commentId — auth required
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId as string;
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
const userId = ctx.state.user.userId;
const isAdmin = ctx.state.user.isAdmin ?? false;
const body = await ctx.request.body.json();
if (!isUpdateCommentRequest(body)) {
throw new APIException(
@@ -85,8 +85,8 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
// DELETE /api/comments/:commentId — auth required
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
const userId = ctx.state.user.userId as string;
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
const userId = ctx.state.user.userId;
const isAdmin = ctx.state.user.isAdmin ?? false;
const { dumpId, isPrivate } = deleteComment(
ctx.params.commentId,
userId,

View File

@@ -6,6 +6,7 @@ import {
type FollowStatus,
type PaginatedData,
} from "../model/interfaces.ts";
import { parsePagination } from "../lib/pagination.ts";
import {
followPlaylist,
followUser,
@@ -22,26 +23,16 @@ const router = new Router({ prefix: "/api/follows" });
// GET /api/follows/status
router.get("/status", authMiddleware, (ctx) => {
const status = getFollowStatus(ctx.state.user.userId as string);
const status = getFollowStatus(ctx.state.user.userId);
const body: APIResponse<FollowStatus> = { success: true, data: status };
ctx.response.body = body;
});
// GET /api/follows/feed/users?page=&limit=
router.get("/feed/users", authMiddleware, (ctx) => {
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedUsersDumpFeed(
ctx.state.user.userId as string,
ctx.state.user.userId,
page,
limit,
);
@@ -56,19 +47,9 @@ router.get("/feed/users", authMiddleware, (ctx) => {
// GET /api/follows/feed/playlists?page=&limit=
router.get("/feed/playlists", authMiddleware, (ctx) => {
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getFollowedPlaylistsDumpFeed(
ctx.state.user.userId as string,
ctx.state.user.userId,
page,
limit,
);
@@ -83,25 +64,25 @@ router.get("/feed/playlists", authMiddleware, (ctx) => {
// POST /api/follows/users/:userId
router.post("/users/:userId", authMiddleware, (ctx) => {
followUser(ctx.state.user.userId as string, ctx.params.userId);
followUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
// DELETE /api/follows/users/:userId
router.delete("/users/:userId", authMiddleware, (ctx) => {
unfollowUser(ctx.state.user.userId as string, ctx.params.userId);
unfollowUser(ctx.state.user.userId, ctx.params.userId);
ctx.response.status = 204;
});
// POST /api/follows/playlists/:playlistId
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
followPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
followPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;
});
// DELETE /api/follows/playlists/:playlistId
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
unfollowPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId);
ctx.response.status = 204;
});

View File

@@ -19,7 +19,7 @@ router.post("/", authMiddleware, async (ctx: AuthContext) => {
router.get("/:token", async (ctx) => {
try {
await validateInvite(ctx.params.token);
ctx.response.body = { success: true };
ctx.response.body = { success: true, data: null };
} catch {
throw new APIException(
APIErrorCode.NOT_FOUND,

View File

@@ -2,9 +2,9 @@ import { Router } from "@oak/oak";
import {
APIErrorCode,
APIException,
type AuthPayload,
type PaginatedData,
} from "../model/interfaces.ts";
import { parsePagination } from "../lib/pagination.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import {
getNotificationsForUser,
@@ -19,17 +19,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
if (!ctx.state.user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
}
const page = Math.max(
1,
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
);
const limit = Math.min(
Math.max(
1,
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
),
100,
);
const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, total } = getNotificationsForUser(
ctx.state.user.userId,
page,
@@ -56,7 +46,7 @@ router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
// PATCH /api/notifications/:id/read
router.patch("/:id/read", authMiddleware, (ctx) => {
const user = ctx.state.user as AuthPayload;
const user = ctx.state.user;
if (!user) {
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
}

View File

@@ -48,7 +48,7 @@ router.post("/register", async (ctx) => {
// Mark invite as used only after the user row is committed
try {
await redeemInvite(body.inviteToken);
redeemInvite(body.inviteToken);
} catch (err) {
console.error("[register] redeemInvite failed (user created):", err);
}
@@ -123,11 +123,13 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
);
}
const user = getUserById(ctx.state.user.userId);
const { passwordHash: _, ...publicUser } = getUserById(
ctx.state.user.userId,
);
ctx.response.body = {
success: true,
data: user,
data: publicUser,
};
} catch (err) {
console.error(err);

View File

@@ -16,7 +16,10 @@ import {
} from "../services/vote-service.ts";
import { getUnreadCount } from "../services/notification-service.ts";
import { getUserById } from "../services/user-service.ts";
import { APIException } from "../model/interfaces.ts";
import {
APIException,
type ClientToServerMessage,
} from "../model/interfaces.ts";
const router = new Router();
@@ -78,7 +81,7 @@ router.get("/ws", async (ctx) => {
});
socket.addEventListener("message", (event) => {
let msg: { type: string; dumpId?: string };
let msg: ClientToServerMessage;
try {
msg = JSON.parse(event.data as string);
} catch {
@@ -109,7 +112,7 @@ router.get("/ws", async (ctx) => {
function handleVote(
client: WsClient,
dumpId: string | undefined,
dumpId: string,
action: "cast" | "remove",
): void {
const { socket } = client;
@@ -121,11 +124,6 @@ function handleVote(
return;
}
if (!dumpId) {
socket.send(JSON.stringify({ type: "error", message: "Missing dumpId" }));
return;
}
try {
const newCount = action === "cast"
? castVote(dumpId, client.userId)

View File

@@ -4,12 +4,7 @@ import {
type Comment,
} from "../model/interfaces.ts";
import { type SQLOutputValue } from "node:sqlite";
import {
type CommentRow,
commentRowToApi,
db,
isCommentRow,
} from "../model/db.ts";
import { commentRowToApi, db, isCommentRow } from "../model/db.ts";
import { notifyMentions } from "./notification-service.ts";
const SELECT_COLS =
@@ -23,7 +18,14 @@ function fetchComment(commentId: string): Comment {
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) {
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
}
return commentRowToApi(row as CommentRow);
if (!isCommentRow(row)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed comment data",
);
}
return commentRowToApi(row);
}
export function getComments(dumpId: string): Comment[] {
@@ -31,15 +33,14 @@ export function getComments(dumpId: string): Comment[] {
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
WHERE c.dump_id = ? ORDER BY c.created_at ASC;`,
).all(dumpId);
const typed = rows as Parameters<typeof isCommentRow>[0][];
if (!typed.every(isCommentRow)) {
if (!rows.every(isCommentRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
"Malformed comment data",
);
}
return typed.map(commentRowToApi);
return rows.map(commentRowToApi);
}
export function createComment(

View File

@@ -448,11 +448,11 @@ export function getVotedDumpsByUser(
const dumpCols = SELECT_COLS_ALIASED;
let totalRow: { count: number } | undefined;
let rawRows: unknown[];
let rows: unknown[];
if (requestingUserId === userId) {
// Own profile: include private dumps the user themselves voted on and owns.
rawRows = db.prepare(
rows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
@@ -465,7 +465,7 @@ export function getVotedDumpsByUser(
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`,
).get(userId, userId) as { count: number } | undefined;
} else {
rawRows = db.prepare(
rows = db.prepare(
`SELECT ${dumpCols}
FROM dumps d
INNER JOIN votes v ON d.id = v.dump_id
@@ -479,7 +479,6 @@ export function getVotedDumpsByUser(
).get(userId) as { count: number } | undefined;
}
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!rows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,

View File

@@ -114,12 +114,12 @@ export function getFollowStatus(followerId: string): FollowStatus {
const rawUserRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`,
).all(followerId) as Parameters<typeof isFollowRow>[0][];
).all(followerId);
const rawPlaylistRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
FROM follows WHERE follower_id = ? AND followed_playlist_id IS NOT NULL;`,
).all(followerId) as Parameters<typeof isFollowRow>[0][];
).all(followerId);
if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) {
throw new APIException(
@@ -207,8 +207,7 @@ export function getFollowedPlaylistsDumpFeed(
AND d.is_private = 0;`,
).get(followerId) as { count: number } | undefined;
const playlistFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
if (!playlistFeedRows.every(isDumpRow)) {
if (!rawRows.every(isDumpRow)) {
throw new APIException(
APIErrorCode.SERVER_ERROR,
500,
@@ -216,7 +215,7 @@ export function getFollowedPlaylistsDumpFeed(
);
}
return {
items: playlistFeedRows.map(dumpRowToApi),
items: rawRows.map(dumpRowToApi),
total: totalRow?.count ?? 0,
};
}
@@ -246,7 +245,7 @@ export function getFollowedPlaylistsByUser(
AND p.is_public = 1
ORDER BY f.created_at DESC
LIMIT ? OFFSET ?;`,
).all(userId, limit, offset) as Parameters<typeof isPlaylistRow>[0][];
).all(userId, limit, offset);
if (!rawRows.every(isPlaylistRow)) {
throw new APIException(

View File

@@ -57,7 +57,7 @@ export function getNotificationsForUser(
const offset = (page - 1) * limit;
const rawRows = db.prepare(
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
).all(userId, limit, offset) as Parameters<typeof isNotificationRow>[0][];
).all(userId, limit, offset);
const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
@@ -195,6 +195,7 @@ export function notifyUserFollowersNewDump(
sendToUser(row.follower_id, {
type: "notification_created",
notification: {
id: crypto.randomUUID(),
userId: row.follower_id,
type: "user_dump_posted",
data,

View File

@@ -1,4 +1,3 @@
import type { SQLOutputValue } from "node:sqlite";
import {
APIErrorCode,
APIException,
@@ -29,7 +28,7 @@ import {
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
const DUMP_SELECT_COLS =
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
@@ -340,7 +339,7 @@ export function getPlaylistMembershipsForDump(
LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ?
WHERE p.user_id = ?
ORDER BY p.created_at DESC;`,
).all(dumpId, userId) as Array<Record<string, SQLOutputValue>>;
).all(dumpId, userId);
return rows.map((row) => {
if (!isPlaylistRow(row)) {

View File

@@ -3,6 +3,7 @@ import type {
Dump,
OnlineUser,
Playlist,
ServerToClientMessage,
User,
} from "../model/interfaces.ts";
@@ -51,13 +52,13 @@ export function getOnlineUsers(): OnlineUser[] {
return Array.from(seen.values());
}
function send(socket: WebSocket, data: unknown): void {
function send(socket: WebSocket, data: ServerToClientMessage): void {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(data));
}
}
export function sendToUser(userId: string, data: unknown): void {
export function sendToUser(userId: string, data: ServerToClientMessage): void {
for (const client of clients) {
if (client.userId === userId) {
send(client.socket, data);
@@ -109,7 +110,7 @@ export function broadcastVoteUpdate(
function sendToPlaylistAudience(
playlist: Pick<Playlist, "isPublic" | "userId">,
data: unknown,
data: ServerToClientMessage,
): void {
for (const client of clients) {
if (playlist.isPublic || client.userId === playlist.userId) {

View File

@@ -2269,11 +2269,10 @@ body.has-player .fab-new {
.modal-body {
padding: 1rem 1.25rem;
flex: 1;
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.confirm-modal-message {

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEffect, useState } from "react";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
import { deserializePlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
interface AddToPlaylistModalProps {
dumpId: string;
@@ -17,23 +17,6 @@ export function AddToPlaylistModal(
const { authFetch } = useAuth();
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
useEffect(() => {
authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`)
@@ -76,84 +59,16 @@ export function AddToPlaylistModal(
}
};
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">Add to playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{loading
? <p className="page-loading">Loading</p>
: memberships.length === 0 && !showNewForm
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="playlist-membership-list">
{memberships.map((m) => (
<li
key={m.playlist.id}
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
onClick={() => toggleMembership(m)}
>
<span className="playlist-membership-check">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">
{m.playlist.title}
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
</span>
)}
</li>
))}
</ul>
)}
{showNewForm
? (
<PlaylistCreateForm
return (
<Modal title="Add to playlist" onClose={onClose}>
<PlaylistMembershipPanel
dumpId={dumpId}
onCreated={(playlist) => {
setMemberships((prev) => [
{ playlist, hasDump: true },
...prev,
]);
setShowNewForm(false);
}}
onCancel={() => setShowNewForm(false)}
memberships={memberships}
loading={loading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
/>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
</button>
)}
</div>
</div>
</div>,
document.body,
</Modal>
);
}

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import { type ReactNode, useState } from "react";
import { Link, useNavigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts";
import { DumpCreateModal } from "./DumpCreateModal.tsx";
@@ -9,22 +9,11 @@ export function AppHeader(
) {
const { user } = useAuth();
const navigate = useNavigate();
const headerRef = useRef<HTMLElement>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
useEffect(() => {
// IntersectionObserver retained here to support a future floating action button
const el = headerRef.current;
if (!el) return;
const obs = new IntersectionObserver(() => {}, { threshold: 0 });
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<>
<header
ref={headerRef}
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
>
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
@@ -71,20 +60,6 @@ export function AppHeader(
</nav>
</header>
{
/* {user && createPortal(
<button
type="button"
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
onClick={() => setCreateModalOpen(true)}
aria-label="New dump"
>
+ New
</button>,
document.body,
)} */
}
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
)}

View File

@@ -1,8 +1,14 @@
import React, { useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Comment, RawComment, User } from "../model.ts";
import { deserializeComment } from "../model.ts";
import type {
Comment,
CreateCommentRequest,
RawComment,
UpdateCommentRequest,
User,
} from "../model.ts";
import { deserializeComment, parseAPIResponse } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
import { Markdown } from "./Markdown.tsx";
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
@@ -69,7 +75,7 @@ function CommentNode({
const children = tree.get(comment.id) ?? [];
async function handleReply(e?: React.FormEvent) {
async function handleReply(e?: React.SubmitEvent) {
e?.preventDefault();
if (!replyBody.trim() || !token) return;
setSubmitting(true);
@@ -81,15 +87,20 @@ function CommentNode({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: replyBody, parentId: comment.id }),
body: JSON.stringify(
{
body: replyBody,
parentId: comment.id,
} satisfies CreateCommentRequest,
),
});
const data = await res.json();
const data = parseAPIResponse<RawComment>(await res.json());
if (data.success) {
onCommentCreated(deserializeComment(data.data as RawComment));
onCommentCreated(deserializeComment(data.data));
setReplyBody("");
setReplyOpen(false);
} else {
setReplyError(data.error?.message ?? "Failed to post reply.");
setReplyError(data.error.message);
}
} catch {
setReplyError("Could not reach the server. Please try again.");
@@ -109,7 +120,7 @@ function CommentNode({
}
}
async function handleEditSave(e?: React.FormEvent) {
async function handleEditSave(e?: React.SubmitEvent) {
e?.preventDefault();
if (!editBody.trim() || !token) return;
setEditSubmitting(true);
@@ -121,14 +132,14 @@ function CommentNode({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: editBody }),
body: JSON.stringify({ body: editBody } satisfies UpdateCommentRequest),
});
const data = await res.json();
const data = parseAPIResponse<RawComment>(await res.json());
if (data.success) {
onCommentUpdated(deserializeComment(data.data as RawComment));
onCommentUpdated(deserializeComment(data.data));
setEditOpen(false);
} else {
setEditError(data.error?.message ?? "Failed to save edit.");
setEditError(data.error.message);
}
} catch {
setEditError("Could not reach the server. Please try again.");
@@ -383,7 +394,7 @@ export function CommentThread({
const tree = useMemo(() => buildTree(comments), [comments]);
const roots = tree.get("root") ?? [];
async function handleTopLevelSubmit(e?: React.FormEvent) {
async function handleTopLevelSubmit(e?: React.SubmitEvent) {
e?.preventDefault();
if (!topLevelBody.trim() || !token) return;
setSubmitting(true);
@@ -395,14 +406,16 @@ export function CommentThread({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ body: topLevelBody }),
body: JSON.stringify(
{ body: topLevelBody } satisfies CreateCommentRequest,
),
});
const data = await res.json();
const data = parseAPIResponse<RawComment>(await res.json());
if (data.success) {
onCommentCreated(deserializeComment(data.data as RawComment));
onCommentCreated(deserializeComment(data.data));
setTopLevelBody("");
} else {
setTopLevelError(data.error?.message ?? "Failed to post comment.");
setTopLevelError(data.error.message);
}
} catch {
setTopLevelError("Could not reach the server. Please try again.");

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router";
import { API_URL } from "../config/api.ts";
@@ -10,19 +9,24 @@ import type {
RawDump,
RawPlaylistMembership,
} from "../model.ts";
import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
import {
deserializeDump,
deserializePlaylistMembership,
parseAPIResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { dumpUrl } from "../utils/urls.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
import { ErrorCard } from "./ErrorCard.tsx";
import { FileDropZone } from "./FileDropZone.tsx";
import { TextEditor } from "./TextEditor.tsx";
import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { MAX_FILE_SIZE } from "../config/upload.ts";
import { TextEditor } from "./TextEditor.tsx";
type Mode = "url" | "file";
type Phase = "create" | "playlist";
@@ -38,16 +42,10 @@ type UrlPreview =
| { status: "done"; richContent: RichContent | null };
function LocalFilePreview({ file }: { file: File }) {
const [src, setSrc] = useState<string | null>(null);
const src = useMemo(() => URL.createObjectURL(file), [file]);
const mime = file.type;
useEffect(() => {
const url = URL.createObjectURL(file);
setSrc(url);
return () => URL.revokeObjectURL(url);
}, [file]);
if (!src) return null;
useEffect(() => () => URL.revokeObjectURL(src), [src]);
if (mime.startsWith("image/")) {
return <img src={src} alt={file.name} className="local-preview-image" />;
@@ -58,7 +56,6 @@ function LocalFilePreview({ file }: { file: File }) {
if (mime.startsWith("audio/")) {
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
}
// For other types the drop zone chip already shows name + size.
return null;
}
@@ -69,7 +66,6 @@ interface DumpCreateModalProps {
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const { authFetch } = useAuth();
const { injectDump } = useWS();
const backdropRef = useRef<HTMLDivElement>(null);
const [phase, setPhase] = useState<Phase>("create");
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
@@ -89,24 +85,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
// Playlist phase state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const [showNewPlaylistForm, setShowNewPlaylistForm] = useState(false);
// Lock body scroll
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
// Escape key to close (skip if a picker/dropdown already handled it)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
// Debounced URL preview
useEffect(() => {
@@ -172,7 +150,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
return () => globalThis.removeEventListener("paste", handler);
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
e.preventDefault();
setSubmitState({ status: "submitting" });
@@ -215,9 +193,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
});
}
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
const dump = deserializeDump(apiResponse.data as RawDump);
const dump = deserializeDump(apiResponse.data);
injectDump(dump);
setCreatedDump(dump);
setPhase("playlist");
@@ -238,7 +216,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
} else {
setSubmitState({
status: "error",
error: apiResponse.error?.message ?? "Failed to create dump.",
error: apiResponse.error.message,
});
}
} catch (err) {
@@ -274,30 +252,12 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
const submitting = submitState.status === "submitting";
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
return (
<Modal
title={phase === "create" ? "New dump" : "Add to playlist"}
onClose={onClose}
wide
>
<div className="modal-card modal-card--wide">
<div className="modal-header">
<span className="modal-title">
{phase === "create" ? "New dump" : "Add to playlist"}
</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{phase === "create"
? (
<>
@@ -453,59 +413,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
</p>
)}
{playlistsLoading
? <p className="page-loading">Loading playlists</p>
: memberships.length === 0 && !showNewPlaylistForm
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="playlist-membership-list">
{memberships.map((m) => (
<li
key={m.playlist.id}
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
onClick={() => toggleMembership(m)}
>
<span className="playlist-membership-check">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">
{m.playlist.title}
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
</span>
)}
</li>
))}
</ul>
)}
{showNewPlaylistForm
? (
<PlaylistCreateForm
dumpId={createdDump?.id}
onCreated={(playlist) => {
setMemberships((prev) => [
{ playlist, hasDump: true },
...prev,
]);
setShowNewPlaylistForm(false);
}}
onCancel={() => setShowNewPlaylistForm(false)}
<PlaylistMembershipPanel
dumpId={createdDump?.id ?? ""}
memberships={memberships}
loading={playlistsLoading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
/>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewPlaylistForm(true)}
>
+ New playlist
</button>
)}
<div className="form-actions">
<div className="form-actions-right">
@@ -520,9 +435,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
</div>
</>
)}
</div>
</div>
</div>,
document.body,
</Modal>
);
}

56
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { type ReactNode, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
interface ModalProps {
title: string;
onClose: () => void;
children: ReactNode;
wide?: boolean;
}
export function Modal({ title, onClose, children, wide = false }: ModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape" && !e.defaultPrevented) onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
<div className="modal-header">
<span className="modal-title">{title}</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{children}
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useState } from "react";
import type { Playlist } from "../model.ts";
import { Modal } from "./Modal.tsx";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface NewPlaylistFormProps {
@@ -17,26 +17,6 @@ export function NewPlaylistForm(
}: NewPlaylistFormProps,
) {
const [open, setOpen] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
const close = () => setOpen(false);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open]);
return (
<>
@@ -48,38 +28,16 @@ export function NewPlaylistForm(
{toggleLabel}
</button>
{open && createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) close();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">New playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={close}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{open && (
<Modal title="New playlist" onClose={() => setOpen(false)}>
<PlaylistCreateForm
onCreated={(playlist) => {
onCreated(playlist);
close();
setOpen(false);
}}
onCancel={close}
onCancel={() => setOpen(false)}
/>
</div>
</div>
</div>,
document.body,
</Modal>
)}
</>
);

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { TextEditor } from "./TextEditor.tsx";
@@ -23,7 +23,7 @@ export function PlaylistCreateForm(
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSubmitting(true);
@@ -32,15 +32,17 @@ export function PlaylistCreateForm(
const res = await authFetch(`${API_URL}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
body: JSON.stringify(
{
title: title.trim(),
description: description.trim() || undefined,
isPublic,
}),
} satisfies CreatePlaylistRequest,
),
});
const body = await res.json();
const body = parseAPIResponse<RawPlaylist>(await res.json());
if (!body.success) {
setError(body.error?.message ?? "Failed to create playlist");
setError(body.error.message);
return;
}
const playlist = deserializePlaylist(body.data as RawPlaylist);

View File

@@ -0,0 +1,76 @@
import { useState } from "react";
import type { PlaylistMembership } from "../model.ts";
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
interface PlaylistMembershipPanelProps {
dumpId: string;
memberships: PlaylistMembership[];
loading: boolean;
onToggle: (membership: PlaylistMembership) => void;
onPlaylistCreated: (membership: PlaylistMembership) => void;
}
export function PlaylistMembershipPanel({
dumpId,
memberships,
loading,
onToggle,
onPlaylistCreated,
}: PlaylistMembershipPanelProps) {
const [showNewForm, setShowNewForm] = useState(false);
return (
<>
{loading
? <p className="page-loading">Loading</p>
: memberships.length === 0 && !showNewForm
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="playlist-membership-list">
{memberships.map((m) => (
<li
key={m.playlist.id}
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
onClick={() => onToggle(m)}
>
<span className="playlist-membership-check">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">
{m.playlist.title}
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
</span>
)}
</li>
))}
</ul>
)}
{showNewForm
? (
<PlaylistCreateForm
dumpId={dumpId}
onCreated={(playlist) => {
onPlaylistCreated({ playlist, hasDump: true });
setShowNewForm(false);
}}
onCancel={() => setShowNewForm(false)}
/>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
</button>
)}
</>
);
}

View File

@@ -0,0 +1,33 @@
import type { ReactNode } from "react";
import { Link } from "react-router";
import type { PublicUser } from "../model.ts";
import { Avatar } from "./Avatar.tsx";
interface ProfileSubpageHeaderProps {
username: string;
profileUser: PublicUser;
title: string;
actions?: ReactNode;
}
export function ProfileSubpageHeader(
{ username, profileUser, title, actions }: ProfileSubpageHeaderProps,
) {
return (
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">{title}</h1>
{actions}
</div>
</div>
);
}

View File

@@ -18,13 +18,10 @@ import {
import { WS_URL } from "../config/api.ts";
import type {
Dump,
IncomingWSMessage,
Notification,
OnlineUser,
RawComment,
RawDump,
RawNotification,
RawPlaylist,
RawPublicUser,
OutgoingWSMessage,
} from "../model.ts";
import {
deserializeComment,
@@ -43,62 +40,18 @@ interface WSProviderProps {
const MAX_BACKOFF = 30_000;
const ACK_TIMEOUT = 5_000;
// ── Type guards for incoming WS messages ──────────────────────────────────────
function isOnlineUser(obj: unknown): obj is OnlineUser {
if (!obj || typeof obj !== "object") return false;
const o = obj as Record<string, unknown>;
return typeof o.userId === "string" &&
typeof o.username === "string" &&
typeof o.hasAvatar === "boolean";
}
function isOnlineUserArray(val: unknown): val is OnlineUser[] {
return Array.isArray(val) && val.every(isOnlineUser);
}
function isStringArray(val: unknown): val is string[] {
return Array.isArray(val) && val.every((x) => typeof x === "string");
}
function isVotesUpdatePayload(
msg: Record<string, unknown>,
): msg is {
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
} {
return typeof msg.dumpId === "string" &&
typeof msg.voteCount === "number" &&
typeof msg.voterId === "string" &&
(msg.action === "cast" || msg.action === "remove");
}
function isVoteAckPayload(
msg: Record<string, unknown>,
): msg is { dumpId: string; action: "cast" | "remove"; voteCount: number } {
return typeof msg.dumpId === "string" &&
(msg.action === "cast" || msg.action === "remove") &&
typeof msg.voteCount === "number";
}
function isPlaylistDeletedPayload(
msg: Record<string, unknown>,
): msg is { playlistId: string; userId: string } {
return typeof msg.playlistId === "string" && typeof msg.userId === "string";
}
function isPlaylistDumpsUpdatedPayload(
msg: Record<string, unknown>,
): msg is { playlistId: string; dumpIds: string[] } {
return typeof msg.playlistId === "string" && isStringArray(msg.dumpIds);
}
function isCommentDeletedPayload(
msg: Record<string, unknown>,
): msg is { commentId: string; dumpId: string } {
return typeof msg.commentId === "string" && typeof msg.dumpId === "string";
// Minimal runtime check: verify the `type` field is a known string so we can
// safely cast to the discriminated union and let TypeScript narrow from there.
function parseWSMessage(data: string): IncomingWSMessage | null {
try {
const msg = JSON.parse(data);
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
return null;
}
return msg as IncomingWSMessage;
} catch {
return null;
}
}
export function WSProvider({ children, token, userId }: WSProviderProps) {
@@ -155,39 +108,28 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
socketRef.current = ws;
ws.onmessage = (event) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(event.data);
} catch {
return;
}
const msg = parseWSMessage(event.data);
if (!msg) return;
switch (msg.type) {
case "ping":
ws.send(JSON.stringify({ type: "pong" }));
break;
case "welcome": {
backoff = 500; // reset backoff on successful connect
if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) {
break;
}
setOnlineUsers(msg.users);
setMyVotes(new Set(msg.myVotes));
setUnreadNotificationCount(
typeof msg.unreadNotificationCount === "number"
? msg.unreadNotificationCount
: 0,
ws.send(
JSON.stringify({ type: "pong" } satisfies OutgoingWSMessage),
);
break;
}
case "welcome":
backoff = 500; // reset backoff on successful connect
setOnlineUsers(msg.users);
setMyVotes(new Set(msg.myVotes));
setUnreadNotificationCount(msg.unreadNotificationCount);
break;
case "presence_update":
if (isOnlineUserArray(msg.users)) setOnlineUsers(msg.users);
setOnlineUsers(msg.users);
break;
case "votes_update": {
if (!isVotesUpdatePayload(msg)) break;
const { dumpId, voteCount, voterId, action } = msg;
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
setLastVoteEvent({ dumpId, voterId, action });
@@ -205,15 +147,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "dump_created": {
if (!msg.dump || typeof msg.dump !== "object") break;
const dump = deserializeDump(msg.dump as RawDump);
const dump = deserializeDump(msg.dump);
setRecentDumps((prev) => [dump, ...prev]);
break;
}
case "dump_updated": {
if (!msg.dump || typeof msg.dump !== "object") break;
const dump = deserializeDump(msg.dump as RawDump);
const dump = deserializeDump(msg.dump);
setLastDumpEvent(dump);
// Un-delete if this dump was previously removed from the feed
// (e.g. it was made private, and is now public again).
@@ -231,15 +171,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "dump_deleted": {
if (typeof msg.dumpId !== "string") break;
const dumpId = msg.dumpId;
const { dumpId } = msg;
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
break;
}
case "vote_ack": {
if (!isVoteAckPayload(msg)) break;
const { dumpId, action, voteCount } = msg;
// Clear pending revert timeout
const timeout = pendingRef.current.get(dumpId);
@@ -261,8 +199,7 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
case "playlist_created":
case "playlist_updated": {
if (!msg.playlist || typeof msg.playlist !== "object") break;
const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
const playlist = deserializePlaylist(msg.playlist);
setLastPlaylistEvent({
type: msg.type === "playlist_created" ? "created" : "updated",
playlistId: playlist.id,
@@ -272,7 +209,6 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "playlist_deleted": {
if (!isPlaylistDeletedPayload(msg)) break;
const { playlistId, userId } = msg;
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
@@ -280,7 +216,6 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "playlist_dumps_updated": {
if (!isPlaylistDumpsUpdatedPayload(msg)) break;
const { playlistId, dumpIds } = msg;
setLastPlaylistEvent({
type: "dumps_updated",
@@ -291,15 +226,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "user_updated": {
if (!msg.user || typeof msg.user !== "object") break;
const user = deserializePublicUser(msg.user as RawPublicUser);
const user = deserializePublicUser(msg.user);
setLastUserEvent({ user });
break;
}
case "comment_created": {
if (!msg.comment || typeof msg.comment !== "object") break;
const comment = deserializeComment(msg.comment as RawComment);
const comment = deserializeComment(msg.comment);
setLastCommentEvent({
type: "created",
dumpId: comment.dumpId,
@@ -309,15 +242,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "comment_deleted": {
if (!isCommentDeletedPayload(msg)) break;
const { commentId, dumpId } = msg;
setLastCommentEvent({ type: "deleted", dumpId, commentId });
break;
}
case "comment_updated": {
if (!msg.comment || typeof msg.comment !== "object") break;
const comment = deserializeComment(msg.comment as RawComment);
const comment = deserializeComment(msg.comment);
setLastCommentEvent({
type: "updated",
dumpId: comment.dumpId,
@@ -327,12 +258,7 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}
case "notification_created": {
if (!msg.notification || typeof msg.notification !== "object") {
break;
}
const notification = deserializeNotification(
msg.notification as RawNotification,
);
const notification = deserializeNotification(msg.notification);
setLastNotification(notification);
setUnreadNotificationCount((prev) => prev + 1);
break;
@@ -396,7 +322,9 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
socketRef.current?.send(JSON.stringify({ type: "vote_cast", dumpId }));
socketRef.current?.send(
JSON.stringify({ type: "vote_cast", dumpId } satisfies OutgoingWSMessage),
);
}, []);
const removeVote = useCallback((dumpId: string) => {
@@ -427,7 +355,11 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
}, ACK_TIMEOUT);
pendingRef.current.set(dumpId, timeout);
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
socketRef.current?.send(
JSON.stringify(
{ type: "vote_remove", dumpId } satisfies OutgoingWSMessage,
),
);
}, []);
const injectDump = useCallback((dump: Dump) => {

View File

@@ -0,0 +1,24 @@
import { useEffect } from "react";
/**
* Sets up a debounced scroll listener that calls `onSave(scrollY)` when the
* user scrolls, but only while `enabled` is true (e.g. the page is loaded).
*/
export function useScrollSave(
enabled: boolean,
onSave: (scrollY: number) => void,
) {
useEffect(() => {
if (!enabled) return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => onSave(globalThis.scrollY), 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [enabled, onSave]);
}

View File

@@ -0,0 +1,191 @@
import {
type RefObject,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { useAuth } from "./useAuth.ts";
import { useFeedCache } from "./useFeedCache.ts";
import { useInfiniteScroll } from "./useInfiniteScroll.ts";
import { useScrollSave } from "./useScrollSave.ts";
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
items: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
interface UseUserDumpFeedOptions {
/** Called with newly appended items whenever a loadMore succeeds. */
onItemsAppended?: (items: Dump[]) => void;
}
interface UseUserDumpFeedResult {
state: State;
setState: React.Dispatch<React.SetStateAction<State>>;
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
sentinelRef: RefObject<HTMLDivElement | null>;
}
/**
* Shared data-fetching, pagination, and scroll-save logic for profile subpages
* that display a paginated list of dumps (e.g. UserDumps, UserUpvoted).
*
* @param username The route param value (may be undefined during hydration)
* @param endpoint Relative path after /api/users/:username, e.g. "dumps" or "votes"
* @param cacheKey sessionStorage key for the feed cache
* @param options Optional callbacks
*/
export function useUserDumpFeed(
username: string | undefined,
endpoint: string,
cacheKey: string,
options?: UseUserDumpFeedOptions,
): UseUserDumpFeedResult {
const { token } = useAuth();
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
const [state, setState] = useState<State>({ status: "loading" });
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
}, []);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const controller = new AbortController();
if (cached) {
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
items: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/${endpoint}?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, itemsRes]) =>
Promise.all([userRes.json(), itemsRes.json()])
)
.then(([userBody, itemsBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = itemsBody.success
? itemsBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
items: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username, endpoint]);
const { onItemsAppended } = options ?? {};
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"
? {
...s,
items: [...s.items, ...newItems],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
onItemsAppended?.(newItems);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token, endpoint, onItemsAppended]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useScrollSave(
state.status === "loaded",
useCallback((y) => {
if (state.status !== "loaded") return;
saveState(state.items, state.page, state.hasMore, y);
}, [state, saveState]),
);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
return { state, setState, setItems, sentinelRef };
}

View File

@@ -5,7 +5,34 @@ export interface PaginatedData<T> {
}
/**
* Backend
* API response envelope — every endpoint returns this shape.
*/
export type APIResponse<T> =
| { success: true; data: T }
| { success: false; error: { message: string } };
/**
* Parses an unknown JSON payload into a typed APIResponse<T>.
* Performs a minimal runtime check on the `success` discriminant so the
* single internal `as` cast is safe; throws if the shape is unexpected.
*/
export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
if (raw !== null && typeof raw === "object" && "success" in raw) {
return raw as APIResponse<T>;
}
throw new Error("Unexpected response format");
}
/**
* Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage.
* WithStringDate<T> replaces Date fields with string so we can type raw API responses.
*/
type WithStringDate<T extends { createdAt: Date }> =
& Omit<T, "createdAt" | "updatedAt">
& { createdAt: string; updatedAt?: string };
/**
* Dumps
*/
export interface RichContent {
@@ -38,43 +65,8 @@ export interface Dump {
isPrivate: boolean;
}
/**
* Authentication
*/
export interface User {
id: string;
username: string;
isAdmin: boolean;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
description?: string;
invitedByUsername?: string;
}
// Public user profile (no passwordHash)
export interface PublicUser {
id: string;
username: string;
isAdmin: boolean;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
description?: string;
invitedByUsername?: string;
}
// Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage
type WithStringDate<T extends { createdAt: Date }> =
& Omit<T, "createdAt" | "updatedAt">
& { createdAt: string; updatedAt?: string };
export type RawDump = WithStringDate<Dump>;
export type RawUser = WithStringDate<User>;
export type RawPublicUser = WithStringDate<PublicUser>;
export type RawAuthResponse = Omit<AuthResponse, "user"> & { user: RawUser };
// Deserializers — convert wire types to domain types at API/WS/localStorage boundaries
export function deserializeDump(raw: RawDump): Dump {
return {
...raw,
@@ -87,17 +79,28 @@ export function hydrateDump(raw: unknown): Dump {
return deserializeDump(raw as RawDump);
}
export function hydratePlaylist(raw: unknown): Playlist {
return deserializePlaylist(raw as RawPlaylist);
/**
* Users
*/
export interface PublicUser {
id: string;
username: string;
isAdmin: boolean;
createdAt: Date;
updatedAt?: Date;
avatarMime?: string;
description?: string;
invitedByUsername?: string;
}
export function deserializeUser(raw: RawUser): User {
return {
...raw,
createdAt: new Date(raw.createdAt),
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
};
}
// User is the same shape as PublicUser in the frontend; they differ only
// semantically (authenticated self vs. any public profile).
export type User = PublicUser;
export type RawPublicUser = WithStringDate<PublicUser>;
// Alias so imports of RawUser continue to work.
export type RawUser = RawPublicUser;
export function deserializePublicUser(raw: RawPublicUser): PublicUser {
return {
@@ -107,32 +110,26 @@ export function deserializePublicUser(raw: RawPublicUser): PublicUser {
};
}
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
return { ...raw, user: deserializeUser(raw.user) };
}
// Alias so call sites using deserializeUser continue to work.
export const deserializeUser = deserializePublicUser;
export interface LoginUserRequest {
username: string;
password: string;
}
export interface RegisterUserRequest {
username: string;
password: string;
}
export interface UpdateUserRequest {
username?: string;
password?: string;
isAdmin?: boolean;
description?: string | null;
}
/**
* Authentication
*/
export interface AuthResponse {
token: string;
user: User;
}
export type RawAuthResponse = Omit<AuthResponse, "user"> & {
user: RawPublicUser;
};
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
return { ...raw, user: deserializePublicUser(raw.user) };
}
/**
* Comments
*/
@@ -189,11 +186,8 @@ export interface PlaylistMembership {
export type RawPlaylist = WithStringDate<Playlist>;
export type RawPlaylistWithDumps =
& Omit<PlaylistWithDumps, "createdAt" | "dumps">
& {
createdAt: string;
dumps: RawDump[];
};
& Omit<WithStringDate<PlaylistWithDumps>, "dumps">
& { dumps: RawDump[] };
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
export function deserializePlaylist(raw: RawPlaylist): Playlist {
@@ -221,154 +215,8 @@ export function deserializePlaylistMembership(
return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump };
}
export interface CreatePlaylistRequest {
title: string;
description?: string;
isPublic: boolean;
}
export interface UpdatePlaylistRequest {
title?: string;
description?: string;
isPublic?: boolean;
}
/**
* API
*/
export const APIErrorCode = {
BAD_REQUEST: "BAD_REQUEST",
NOT_FOUND: "NOT_FOUND",
SERVER_ERROR: "SERVER_ERROR",
TIMEOUT: "TIMEOUT",
UNAUTHORIZED: "UNAUTHORIZED",
VALIDATION_ERROR: "VALIDATION_ERROR",
} as const;
export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode];
export interface APIError {
code: APIErrorCode;
message: string;
}
export interface APISuccess<T> {
success: true;
data: T;
error?: never;
}
export interface APIFailure {
success: false;
data?: never;
error: APIError;
}
export type APIResponse<T> = APISuccess<T> | APIFailure;
/**
* Request DTOs
*/
export interface CreateUrlDumpRequest {
url: string;
comment?: string;
isPrivate?: boolean;
}
export interface UpdateDumpRequest {
url?: string;
comment?: string;
isPrivate?: boolean;
}
/**
* 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;
avatarVersion?: number;
}
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";
}
/**
* Frontend
*/
export interface ActionResultSuccess {
success: true;
}
export interface ActionResultFailure {
success: false;
error: string;
}
export type ActionResult = ActionResultSuccess | ActionResultFailure;
/**
* Follows
*/
export interface FollowStatus {
followedUserIds: string[];
followedPlaylistIds: string[];
export function hydratePlaylist(raw: unknown): Playlist {
return deserializePlaylist(raw as RawPlaylist);
}
/**
@@ -447,3 +295,209 @@ export type RawNotification = WithStringDate<Notification>;
export function deserializeNotification(raw: RawNotification): Notification {
return { ...raw, createdAt: new Date(raw.createdAt) };
}
/**
* WebSockets — online presence
*/
export interface OnlineUser {
userId: string;
username: string;
hasAvatar: boolean;
avatarVersion?: number;
}
/**
* WebSocket messages — server → client (incoming)
*
* All messages share a `type` discriminant so the full union can be narrowed
* with a switch/case in WSProvider without additional type guards.
*/
export interface WSPingMessage {
type: "ping";
}
export interface WSWelcomeMessage {
type: "welcome";
users: OnlineUser[];
myVotes: string[];
unreadNotificationCount: number;
}
export interface WSPresenceUpdateMessage {
type: "presence_update";
users: OnlineUser[];
}
export interface WSVotesUpdateMessage {
type: "votes_update";
dumpId: string;
voteCount: number;
voterId: string;
action: "cast" | "remove";
}
export interface WSVoteAckMessage {
type: "vote_ack";
dumpId: string;
action: "cast" | "remove";
voteCount: number;
}
export interface WSDumpCreatedMessage {
type: "dump_created";
dump: RawDump;
}
export interface WSDumpUpdatedMessage {
type: "dump_updated";
dump: RawDump;
}
export interface WSDumpDeletedMessage {
type: "dump_deleted";
dumpId: string;
}
export interface WSPlaylistCreatedMessage {
type: "playlist_created";
playlist: RawPlaylist;
}
export interface WSPlaylistUpdatedMessage {
type: "playlist_updated";
playlist: RawPlaylist;
}
export interface WSPlaylistDeletedMessage {
type: "playlist_deleted";
playlistId: string;
userId: string;
}
export interface WSPlaylistDumpsUpdatedMessage {
type: "playlist_dumps_updated";
playlistId: string;
dumpIds: string[];
}
export interface WSUserUpdatedMessage {
type: "user_updated";
user: RawPublicUser;
}
export interface WSCommentCreatedMessage {
type: "comment_created";
comment: RawComment;
}
export interface WSCommentUpdatedMessage {
type: "comment_updated";
comment: RawComment;
}
export interface WSCommentDeletedMessage {
type: "comment_deleted";
commentId: string;
dumpId: string;
}
export interface WSNotificationCreatedMessage {
type: "notification_created";
notification: RawNotification;
}
export interface WSErrorMessage {
type: "error";
message?: string;
}
export type IncomingWSMessage =
| WSPingMessage
| WSWelcomeMessage
| WSPresenceUpdateMessage
| WSVotesUpdateMessage
| WSVoteAckMessage
| WSDumpCreatedMessage
| WSDumpUpdatedMessage
| WSDumpDeletedMessage
| WSPlaylistCreatedMessage
| WSPlaylistUpdatedMessage
| WSPlaylistDeletedMessage
| WSPlaylistDumpsUpdatedMessage
| WSUserUpdatedMessage
| WSCommentCreatedMessage
| WSCommentUpdatedMessage
| WSCommentDeletedMessage
| WSNotificationCreatedMessage
| WSErrorMessage;
/**
* WebSocket messages — client → server (outgoing)
*/
export interface WSPongMessage {
type: "pong";
}
export interface WSVoteCastMessage {
type: "vote_cast";
dumpId: string;
}
export interface WSVoteRemoveMessage {
type: "vote_remove";
dumpId: string;
}
export type OutgoingWSMessage =
| WSPongMessage
| WSVoteCastMessage
| WSVoteRemoveMessage;
/**
* Follows
*/
export interface FollowStatus {
followedUserIds: string[];
followedPlaylistIds: string[];
}
/**
* Request DTOs
*/
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
inviteToken: string;
}
export interface CreateUrlDumpRequest {
url: string;
comment?: string;
isPrivate?: boolean;
}
export interface UpdateDumpRequest {
url?: string;
comment?: string;
isPrivate?: boolean;
}
export interface CreateCommentRequest {
body: string;
parentId?: string;
}
export interface UpdateCommentRequest {
body: string;
}
export interface CreatePlaylistRequest {
title: string;
description?: string;
isPublic: boolean;
}
export interface UpdatePlaylistRequest {
title?: string;
description?: string | null;
isPublic?: boolean;
}
export interface ReorderPlaylistRequest {
dumpIds: string[];
}
export interface UpdateUserRequest {
description?: string;
}

View File

@@ -5,11 +5,18 @@ import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
import type { Comment, Dump, PublicUser, RawComment } from "../model.ts";
import type {
Comment,
Dump,
PublicUser,
RawComment,
RawDump,
} from "../model.ts";
import {
deserializeComment,
deserializeDump,
deserializePublicUser,
parseAPIResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -79,9 +86,9 @@ export function Dump() {
signal: controller.signal,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) {
throw new Error(apiResponse.error?.message ?? "Failed to load dump");
throw new Error(apiResponse.error.message);
}
const dump: Dump = deserializeDump(apiResponse.data);
setDumpState({ status: "loaded", dump });

View File

@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Dump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump } from "../model.ts";
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpUrl } from "../utils/urls.ts";
@@ -45,7 +45,7 @@ export function DumpEdit() {
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
const dump: Dump = deserializeDump(apiResponse.data);
@@ -54,10 +54,7 @@ export function DumpEdit() {
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
} else {
setState({
status: "error",
error: apiResponse.error?.message ?? "Failed to load.",
});
setState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
@@ -92,12 +89,9 @@ export function DumpEdit() {
});
}
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) {
setState({
status: "error",
error: apiResponse.error?.message ?? "Update failed.",
});
setState({ status: "error", error: apiResponse.error.message });
return;
}

View File

@@ -26,6 +26,7 @@ import {
import { ErrorCard } from "../components/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { useScrollSave } from "../hooks/useScrollSave.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
@@ -448,74 +449,39 @@ export function Index() {
!dumpsState.loadingMore,
);
useEffect(() => {
useScrollSave(
dumpsState.status === "loaded",
useCallback((y) => {
if (dumpsState.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (dumpsState.status === "loaded") {
saveState(
dumpsState.dumps,
dumpsState.page,
dumpsState.hasMore,
globalThis.scrollY,
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y);
}, [dumpsState, saveState]),
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [dumpsState, saveState]);
useEffect(() => {
useScrollSave(
followedUsersDumps.status === "loaded",
useCallback((y) => {
if (followedUsersDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedUsersDumps.status === "loaded") {
saveFollowedUsers(
followedUsersDumps.dumps,
followedUsersDumps.page,
followedUsersDumps.hasMore,
globalThis.scrollY,
y,
);
}, [followedUsersDumps, saveFollowedUsers]),
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedUsersDumps, saveFollowedUsers]);
useEffect(() => {
useScrollSave(
followedPlaylistsDumps.status === "loaded",
useCallback((y) => {
if (followedPlaylistsDumps.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (followedPlaylistsDumps.status === "loaded") {
saveFollowedPlaylists(
followedPlaylistsDumps.dumps,
followedPlaylistsDumps.page,
followedPlaylistsDumps.hasMore,
globalThis.scrollY,
y,
);
}, [followedPlaylistsDumps, saveFollowedPlaylists]),
);
}
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
// ── Scroll restoration ──

View File

@@ -6,11 +6,14 @@ import type {
RawDump,
RawPlaylist,
RawPlaylistWithDumps,
ReorderPlaylistRequest,
UpdatePlaylistRequest,
} from "../model.ts";
import {
deserializeDump,
deserializePlaylist,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -59,6 +62,16 @@ export function PlaylistDetail() {
Record<string, "cooldown" | "dismissing">
>({});
const cancels = useRef<Map<string, () => void>>(new Map());
// While an undo-remove is in flight (POST re-add + PUT reorder), holds the
// desired dump order so intermediate WS dumps_updated events don't cause glitches.
const pendingUndoOrderRef = useRef<string[] | null>(null);
// Debounce timer for the reorder setState in dumps_updated so that rapid
// consecutive events (POST re-add followed immediately by PUT reorder) are
// coalesced — only the final order is applied, preventing the glitch on
// other clients who don't have pendingUndoOrderRef.
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
// without stale closure issues (state would only update on next render).
@@ -90,6 +103,7 @@ export function PlaylistDetail() {
useEffect(() => () => {
cancels.current.forEach((c) => c());
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
}, []);
const fetchAbortRef = useRef<AbortController | null>(null);
@@ -126,6 +140,10 @@ export function PlaylistDetail() {
setFading({});
cancels.current.forEach((c) => c());
cancels.current.clear();
if (dumpReorderTimerRef.current) {
clearTimeout(dumpReorderTimerRef.current);
dumpReorderTimerRef.current = null;
}
})
.catch((err) => {
if (err.name === "AbortError") return;
@@ -272,25 +290,36 @@ export function PlaylistDetail() {
}
}
// Apply the server-authoritative order: active dumps in ev.dumpIds order,
// fading dumps (not in newIds) appended at the end.
// Debounce the reorder setState so rapid consecutive dumps_updated events
// (e.g. POST re-add followed immediately by PUT reorder during an undo)
// coalesce into a single update — only the final order is applied.
// On the owner's client, pendingUndoOrderRef also suppresses the wrong
// intermediate order; this debounce protects other clients on the WS.
if (dumpReorderTimerRef.current) {
clearTimeout(dumpReorderTimerRef.current);
}
const orderToApply = pendingUndoOrderRef.current ?? ev.dumpIds!;
const serverOrder = ev.dumpIds!;
dumpReorderTimerRef.current = setTimeout(() => {
dumpReorderTimerRef.current = null;
setState((s) => {
if (s.status !== "loaded") return s;
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
return {
...s,
playlist: {
...s.playlist,
dumps: [
...ev.dumpIds!
const orderedActive = orderToApply
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!),
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
],
},
};
.map((id) => dumpMap.get(id)!);
let ai = 0;
// Replace each active slot with the next server-ordered active dump;
// fading dumps keep their current slot unchanged.
const merged = s.playlist.dumps.map((d) =>
newIds.has(d.id) ? orderedActive[ai++] : d
);
// Append any newly added dumps not yet in the array.
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
return { ...s, playlist: { ...s.playlist, dumps: merged } };
});
dumpOrderRef.current = ev.dumpIds!;
dumpOrderRef.current = serverOrder;
}, 80);
} else if (ev.type === "updated" && ev.playlist) {
setState((prev) => {
if (prev.status !== "loaded") return prev;
@@ -416,7 +445,11 @@ export function PlaylistDetail() {
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dumpIds: activeDumps.map((d) => d.id) }),
body: JSON.stringify(
{
dumpIds: activeDumps.map((d) => d.id),
} satisfies ReorderPlaylistRequest,
),
});
} catch {
fetchPlaylist();
@@ -442,13 +475,36 @@ export function PlaylistDetail() {
};
const handleCancelRemove = (dumpId: string) => {
if (!playlistId) return;
if (!playlistId || state.status !== "loaded") return;
cancels.current.get(dumpId)?.();
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
// Re-add server-side since DELETE already fired
// Capture the desired order now (dump is still in playlist.dumps at its
// original position; activeDumpIds hasn't been updated yet in this closure).
const restoredIds = new Set([...activeDumpIds, dumpId]);
const desiredOrder = state.playlist.dumps
.filter((d) => restoredIds.has(d.id))
.map((d) => d.id);
// Hold the desired order so the WS handler ignores the intermediate
// dumps_updated event from the POST (which puts the dump at the top).
pendingUndoOrderRef.current = desiredOrder;
// Re-add server-side since DELETE already fired, then immediately restore
// the original position (addDumpToPlaylist would otherwise put it at top).
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
method: "POST",
}).catch(() => {});
})
.then(() =>
authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ dumpIds: desiredOrder } satisfies ReorderPlaylistRequest,
),
})
)
.finally(() => {
pendingUndoOrderRef.current = null;
})
.catch(() => {});
};
const openEdit = () => {
@@ -472,19 +528,20 @@ export function PlaylistDetail() {
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(editTitle !== state.playlist.title ? { title: editTitle } : {}),
body: JSON.stringify(
{
...(editTitle !== state.playlist.title
? { title: editTitle }
: {}),
...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic,
}),
} satisfies UpdatePlaylistRequest,
),
},
);
const updateJson = await updateRes.json() as {
success: boolean;
data: RawPlaylist;
};
const updateJson = parseAPIResponse<RawPlaylist>(await updateRes.json());
const updatedPlaylist = updateJson.success
? deserializePlaylist(updateJson.data)
: null;

View File

@@ -1,200 +1,47 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useState } from "react";
import { Link, useParams } from "react-router";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
dumps: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
export function UserDumps() {
const { username } = useParams();
const { user: me, token } = useAuth();
const { user: me } = useAuth();
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>(
const { state, setItems, sentinelRef } = useUserDumpFeed(
username,
"dumps",
`feed:user-dumps-full:${username ?? ""}`,
hydrateDump,
);
const [state, setState] = useState<State>({ status: "loading" });
const [createModalOpen, setCreateModalOpen] = useState(false);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
const isOwnProfile = me?.id === profileUserId;
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
}, []);
const dumpItems = state.status === "loaded" ? state.dumps : [];
const dumpItems = state.status === "loaded" ? state.items : [];
usePositionAwareSync(
dumpItems,
setDumps,
setItems,
lastDumpEvent,
(d) => d.isPrivate,
(d) => !d.isPrivate && d.userId === profileUserId,
);
useDumpListSync(setDumps, {
useDumpListSync(setItems, {
ownerId: profileUserId ?? undefined,
isOwner: isOwnProfile,
skipReinsert: true,
});
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
const controller = new AbortController();
if (cached) {
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
dumps: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, dumpsRes]) =>
Promise.all([userRes.json(), dumpsRes.json()])
)
.then(([userBody, dumpsBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = dumpsBody.success
? dumpsBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
dumps: items.map(deserializeDump),
hasMore,
page: 1,
loadingMore: false,
});
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
setState((s) =>
s.status === "loaded"
? {
...s,
dumps: [...s.dumps, ...items.map(deserializeDump)],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
saveState(state.dumps, state.page, state.hasMore, globalThis.scrollY);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveState]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
if (state.status === "loading") {
return (
<PageShell>
@@ -216,26 +63,15 @@ export function UserDumps() {
);
}
const { profileUser, dumps, hasMore, loadingMore } = state;
const { profileUser, items: dumps, hasMore, loadingMore } = state;
return (
<PageShell>
<div className="profile-subpage-header">
<Link
to={`/users/${username}`}
className="profile-subpage-back"
>
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Dumps</h1>
{isOwnProfile && (
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Dumps"
actions={isOwnProfile && (
<button
type="button"
className="new-playlist-toggle"
@@ -244,8 +80,7 @@ export function UserDumps() {
+ New dump
</button>
)}
</div>
</div>
/>
{createModalOpen && (
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />

View File

@@ -3,7 +3,12 @@ import type { SubmitEvent } from "react";
import { useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import {
deserializeAuthResponse,
type LoginRequest,
parseAPIResponse,
type RawAuthResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
@@ -26,26 +31,23 @@ export function UserLogin() {
setState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const res = await fetch(`${API_URL}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
body: JSON.stringify({ username, password } satisfies LoginRequest),
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setState({
status: "error",
error: apiResponse.error?.message ?? "Login failed.",
});
setState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });

View File

@@ -26,9 +26,10 @@ import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { useScrollSave } from "../hooks/useScrollSave.ts";
import { PlaylistCard } from "../components/PlaylistCard.tsx";
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
@@ -283,15 +284,10 @@ export function UserPlaylists() {
!state.followed.loadingMore,
);
// Scroll save
useEffect(() => {
useScrollSave(
state.status === "loaded",
useCallback((y) => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
const y = globalThis.scrollY;
saveCreated(
state.created.items,
state.created.page,
@@ -304,14 +300,8 @@ export function UserPlaylists() {
state.followed.hasMore,
y,
);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveCreated, saveFollowed]);
}, [state, saveCreated, saveFollowed]),
);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
@@ -364,19 +354,11 @@ export function UserPlaylists() {
return (
<PageShell>
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
/>
<h1 className="profile-subpage-title">Playlists</h1>
{isOwnProfile && (
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Playlists"
actions={isOwnProfile && (
<NewPlaylistForm
toggleClassName="btn-primary"
onCreated={(p) =>
@@ -390,8 +372,7 @@ export function UserPlaylists() {
})}
/>
)}
</div>
</div>
/>
<section className="profile-section">
<div className="profile-section-header">

View File

@@ -13,11 +13,12 @@ import {
deserializeAuthResponse,
deserializeDump,
deserializePublicUser,
deserializeUser,
hydrateDump,
hydratePlaylist,
parseAPIResponse,
type RawDump,
type RawUser,
type RawPublicUser,
type UpdateUserRequest,
} from "../model.ts";
import { Avatar } from "../components/Avatar.tsx";
import { DumpCard } from "../components/DumpCard.tsx";
@@ -478,28 +479,24 @@ export function UserPublicProfile() {
method: "POST",
body: formData,
});
const body = await res.json() as {
success: boolean;
data?: RawUser;
error?: { message: string };
};
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!res.ok || !body.success) {
setAvatarError(body.error?.message ?? "Upload failed");
if (!body.success) {
setAvatarError(body.error.message);
return;
}
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw && body.data) {
if (storedRaw) {
login({
...deserializeAuthResponse(JSON.parse(storedRaw)),
user: deserializeUser(body.data),
user: deserializePublicUser(body.data),
});
}
setState((prev) =>
prev.status === "loaded" && body.data
? { ...prev, user: deserializeUser(body.data) }
prev.status === "loaded"
? { ...prev, user: deserializePublicUser(body.data) }
: prev
);
} catch {
@@ -517,11 +514,16 @@ export function UserPublicProfile() {
try {
const res = await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
body: JSON.stringify({ description: descDraft.trim() }),
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
description: descDraft.trim() || undefined,
} satisfies UpdateUserRequest,
),
});
const body = await res.json();
if (!res.ok || !body.success) {
setDescError(body.error?.message ?? "Failed to save");
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setDescError(body.error.message);
return;
}
setState((s) =>
@@ -949,6 +951,7 @@ function UpvotedDumpList(
canVote={canVote}
castVote={castVote}
removeVote={removeVote}
isOwner={isOwnProfile}
className={extraCls}
/>
);

View File

@@ -3,7 +3,12 @@ import type { SubmitEvent } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { API_URL, VALIDATION } from "../config/api.ts";
import { deserializeAuthResponse } from "../model.ts";
import {
deserializeAuthResponse,
parseAPIResponse,
type RawAuthResponse,
type RegisterRequest,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { PageShell } from "../components/PageShell.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
@@ -47,26 +52,25 @@ export function UserRegister() {
setFormState({ status: "submitting" });
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
const username = formData.get("username") as string;
const password = formData.get("password") as string;
try {
const res = await fetch(`${API_URL}/api/users/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, inviteToken: token }),
body: JSON.stringify(
{ username, password, inviteToken: token } satisfies RegisterRequest,
),
});
const apiResponse = await res.json();
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
if (apiResponse.success) {
login(deserializeAuthResponse(apiResponse.data));
navigate("/");
} else {
setFormState({
status: "error",
error: apiResponse.error?.message ?? "Registration failed.",
});
setFormState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setFormState({ status: "error", error: friendlyFetchError(err) });

View File

@@ -1,139 +1,60 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
import { friendlyFetchError } from "../utils/apiError.ts";
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
import {
deserializeDump,
deserializePublicUser,
hydrateDump,
} from "../model.ts";
import { API_URL } from "../config/api.ts";
import type { Dump } from "../model.ts";
import { deserializeDump } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
import { useFading } from "../hooks/useFading.ts";
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
import { useFeedCache } from "../hooks/useFeedCache.ts";
import { Avatar } from "../components/Avatar.tsx";
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
type State =
| { status: "loading" }
| { status: "error"; error: string }
| {
status: "loaded";
profileUser: PublicUser;
votes: Dump[];
hasMore: boolean;
page: number;
loadingMore: boolean;
};
export function UserUpvoted() {
const { username } = useParams();
const { user: me, token } = useAuth();
const { user: me } = useAuth();
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
const { cached, saveState } = useFeedCache<Dump>(
`feed:user-upvoted-full:${username ?? ""}`,
hydrateDump,
);
const [state, setState] = useState<State>({ status: "loading" });
const setVotesDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
setState((s) => s.status !== "loaded" ? s : { ...s, votes: fn(s.votes) });
}, []);
useDumpListSync(setVotesDumps);
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
const { fading, startFading, cancelFading, cancelAll } = useFading();
const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => {
if (!username) return;
setState({ status: "loading" });
cancelAll();
setVotedIds(new Set());
prevMyVotesRef.current = null;
const controller = new AbortController();
const onItemsAppended = useCallback((newItems: Dump[]) => {
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
}, []);
if (cached) {
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
.then((r) => r.json())
.then((body) => {
if (!body.success) throw new Error("User not found");
const voteIds = new Set(cached.items.map((d) => d.id));
setState({
status: "loaded",
profileUser: deserializePublicUser(body.data),
votes: cached.items,
hasMore: cached.hasMore,
page: cached.page,
loadingMore: false,
});
setVotedIds(voteIds);
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}
const { state, setState, setItems, sentinelRef } = useUserDumpFeed(
username,
"votes",
`feed:user-upvoted-full:${username ?? ""}`,
{ onItemsAppended },
);
const authHeaders: HeadersInit = token
? { Authorization: `Bearer ${token}` }
: {};
Promise.all([
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
fetch(
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: authHeaders, signal: controller.signal },
),
])
.then(([userRes, votesRes]) =>
Promise.all([userRes.json(), votesRes.json()])
)
.then(([userBody, votesBody]) => {
if (!userBody.success) throw new Error("User not found");
const { items, hasMore } = votesBody.success
? votesBody.data as PaginatedData<RawDump>
: { items: [], hasMore: false };
const voteItems = items.map(deserializeDump);
setState({
status: "loaded",
profileUser: deserializePublicUser(userBody.data),
votes: voteItems,
hasMore,
page: 1,
loadingMore: false,
});
setVotedIds(new Set(voteItems.map((d) => d.id)));
})
.catch((err) => {
if (err.name === "AbortError") return;
setState({ status: "error", error: friendlyFetchError(err) });
});
return () => controller.abort();
}, [username]);
useDumpListSync(setItems);
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
// Own profile: keep votedIds in sync with myVotes.
// Fading is triggered directly here to avoid a gap render between
// setVotedIds and the old prevVotedIds tracking effect.
// Reset vote tracking when username changes
useEffect(() => {
cancelAll();
setVotedIds(new Set());
prevMyVotesRef.current = null;
}, [username]);
// Seed votedIds once items are loaded
useEffect(() => {
if (state.status !== "loaded") return;
setVotedIds(new Set(state.items.map((d) => d.id)));
}, [state.status]);
// Own profile: keep votedIds in sync with myVotes
useEffect(() => {
if (!profileUserId || me?.id !== profileUserId) return;
if (prevMyVotesRef.current === null) {
// First sync after load: initialize without animating the diff.
setVotedIds(new Set(myVotes));
prevMyVotesRef.current = new Set(myVotes);
return;
@@ -157,7 +78,6 @@ export function UserUpvoted() {
n.delete(dumpId);
return n;
});
// Start fading in same batch so visibleDumps never has a gap render.
startFading(dumpId);
} else {
setVotedIds((prev) => new Set([...prev, dumpId]));
@@ -168,82 +88,16 @@ export function UserUpvoted() {
if (!body.success) return;
const dump = deserializeDump(body.data);
setState((s) => {
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
if (s.status !== "loaded" || s.items.some((d) => d.id === dumpId)) {
return s;
}
return { ...s, votes: [dump, ...s.votes] };
return { ...s, items: [dump, ...s.items] };
});
})
.catch(() => {});
}
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
const loadMore = useCallback(() => {
if (
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
!username
) return;
const nextPage = state.page + 1;
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
fetch(
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
)
.then((r) => r.json())
.then((body) => {
const { items, hasMore } = body.data as PaginatedData<RawDump>;
const newItems = items.map(deserializeDump);
setState((s) =>
s.status === "loaded"
? {
...s,
votes: [...s.votes, ...newItems],
hasMore,
page: nextPage,
loadingMore: false,
}
: s
);
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
})
.catch(() =>
setState((s) =>
s.status === "loaded" ? { ...s, loadingMore: false } : s
)
);
}, [state, username, token]);
const sentinelRef = useInfiniteScroll(
loadMore,
state.status === "loaded" && state.hasMore && !state.loadingMore,
);
useEffect(() => {
if (state.status !== "loaded") return;
let timer: ReturnType<typeof setTimeout>;
const onScroll = () => {
clearTimeout(timer);
timer = setTimeout(() => {
if (state.status !== "loaded") return;
saveState(state.votes, state.page, state.hasMore, globalThis.scrollY);
}, 100);
};
globalThis.addEventListener("scroll", onScroll, { passive: true });
return () => {
globalThis.removeEventListener("scroll", onScroll);
clearTimeout(timer);
};
}, [state, saveState]);
const scrollRestored = useRef(false);
useLayoutEffect(() => {
if (cached?.scrollY == null || scrollRestored.current) return;
if (state.status === "loaded") {
globalThis.scrollTo(0, cached.scrollY);
scrollRestored.current = true;
}
}, [state.status, cached]);
if (state.status === "loading") {
return (
<PageShell>
@@ -265,27 +119,18 @@ export function UserUpvoted() {
);
}
const { profileUser, votes, hasMore, loadingMore } = state;
const { profileUser, items: votes, hasMore, loadingMore } = state;
const visibleDumps = votes.filter((d) =>
votedIds.has(d.id) || d.id in fading
);
return (
<PageShell>
<div className="profile-subpage-header">
<Link to={`/users/${username}`} className="profile-subpage-back">
{profileUser.username}
</Link>
<div className="profile-subpage-title-row">
<Avatar
userId={profileUser.id}
username={profileUser.username}
hasAvatar={!!profileUser.avatarMime}
size={36}
<ProfileSubpageHeader
username={username!}
profileUser={profileUser}
title="Upvoted"
/>
<h1 className="profile-subpage-title">Upvoted</h1>
</div>
</div>
{visibleDumps.length === 0
? <p className="empty-state">Nothing here yet.</p>
@@ -308,6 +153,7 @@ export function UserUpvoted() {
castVote={castVote}
removeVote={removeVote}
className={extraCls}
isOwner={!!me && me.id === dump.userId}
/>
);
})}