diff --git a/api/model/db.ts b/api/model/db.ts index c529234..d05769e 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -75,7 +75,7 @@ export interface UserRow { * Type Guards */ -export function isDumpRow(obj: Record): 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): obj is DumpRow { "is_private" in obj && typeof obj.is_private === "number"; } -export function isUserRow(obj: Record): 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, + 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, + 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, + 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, + 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, + 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"); } diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 232c281..572d862 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -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; +} + +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 & { + createdAt: string; +}; diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 8180336..50e20bb 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -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) => { diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 65ccbba..ee07730 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -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, diff --git a/api/routes/follows.ts b/api/routes/follows.ts index a99642e..6585405 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -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 = { 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; }); diff --git a/api/routes/invites.ts b/api/routes/invites.ts index 1732856..5e043e6 100644 --- a/api/routes/invites.ts +++ b/api/routes/invites.ts @@ -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, diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index e2ab6bd..8b7de9b 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -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"); } diff --git a/api/routes/users.ts b/api/routes/users.ts index 8e823a0..54c275b 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -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); diff --git a/api/routes/ws.ts b/api/routes/ws.ts index d3977e0..91c9e1d 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -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) diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index 7168863..6ec4951 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -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)) { 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[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( diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 93b14b2..f2519ad 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -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[0][]; if (!rows.every(isDumpRow)) { throw new APIException( APIErrorCode.SERVER_ERROR, diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index 31a2a5a..ac4d69a 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -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[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[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[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[0][]; + ).all(userId, limit, offset); if (!rawRows.every(isPlaylistRow)) { throw new APIException( diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 1c00234..6f3e21c 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -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[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, diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index d65e84f..7c4e433 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -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>; + ).all(dumpId, userId); return rows.map((row) => { if (!isPlaylistRow(row)) { diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 642441f..8396429 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -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, - data: unknown, + data: ServerToClientMessage, ): void { for (const client of clients) { if (playlist.isPublic || client.userId === playlist.userId) { diff --git a/src/App.css b/src/App.css index 282b31d..79428ed 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/components/AddToPlaylistModal.tsx b/src/components/AddToPlaylistModal.tsx index 5bddaba..4aaf71a 100644 --- a/src/components/AddToPlaylistModal.tsx +++ b/src/components/AddToPlaylistModal.tsx @@ -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([]); const [loading, setLoading] = useState(true); - const [showNewForm, setShowNewForm] = useState(false); - const backdropRef = useRef(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( -
{ - if (e.target === backdropRef.current) onClose(); - }} - > -
-
- Add to playlist - -
- -
- {loading - ?

Loading…

- : memberships.length === 0 && !showNewForm - ?

No playlists yet.

- : ( -
    - {memberships.map((m) => ( -
  • toggleMembership(m)} - > - - {m.hasDump ? "✓" : "○"} - - - {m.playlist.title} - - {!m.playlist.isPublic && ( - - private - - )} -
  • - ))} -
- )} - - {showNewForm - ? ( - { - setMemberships((prev) => [ - { playlist, hasDump: true }, - ...prev, - ]); - setShowNewForm(false); - }} - onCancel={() => setShowNewForm(false)} - /> - ) - : ( - - )} -
-
-
, - document.body, + return ( + + + setMemberships((prev) => [membership, ...prev])} + /> + ); } diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 9beae8c..31e8d5a 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -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(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 ( <>
@@ -71,20 +60,6 @@ export function AppHeader(
- { - /* {user && createPortal( - , - document.body, - )} */ - } - {createModalOpen && ( setCreateModalOpen(false)} /> )} diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index 8eadd0b..8d81fab 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -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(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(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(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."); diff --git a/src/components/DumpCreateModal.tsx b/src/components/DumpCreateModal.tsx index 79c94f1..a249508 100644 --- a/src/components/DumpCreateModal.tsx +++ b/src/components/DumpCreateModal.tsx @@ -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(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 {file.name}; @@ -58,7 +56,6 @@ function LocalFilePreview({ file }: { file: File }) { if (mime.startsWith("audio/")) { return ; } - // 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(null); const [phase, setPhase] = useState("create"); const [createdDump, setCreatedDump] = useState(null); @@ -89,24 +85,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) { // Playlist phase state const [memberships, setMemberships] = useState([]); 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) => { + const handleSubmit = async (e: React.SubmitEvent) => { e.preventDefault(); setSubmitState({ status: "submitting" }); @@ -215,9 +193,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) { }); } - const apiResponse = await res.json(); + const apiResponse = parseAPIResponse(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,255 +252,189 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) { const submitting = submitState.status === "submitting"; - return createPortal( -
{ - if (e.target === backdropRef.current) onClose(); - }} + return ( + -
-
- - {phase === "create" ? "New dump" : "Add to playlist"} - - -
+ {phase === "create" + ? ( + <> +
+ + +
-
- {phase === "create" - ? ( - <> -
- - -
+
+ {submitState.status === "error" && ( + + )} - - {submitState.status === "error" && ( - - )} - - {mode === "url" - ? ( - <> -
- - setUrl(e.target.value)} - onPaste={(e) => { - const pastedFile = e.clipboardData.files[0]; - if (pastedFile) { - e.preventDefault(); - setMode("file"); - setUrl(""); - setUrlPreview({ status: "idle" }); - setFile(pastedFile); - setSubmitState({ status: "idle" }); - } - }} - disabled={submitting} - placeholder="https://..." - required - autoFocus - /> -
- {urlPreview.status === "loading" && ( -

Fetching preview…

- )} - {urlPreview.status === "done" && - urlPreview.richContent && ( - - )} - - ) - : ( - <> - - {file && } - - )} - -
- - -
- -
- - -
- -
-
- - + placeholder="https://..." + required + autoFocus + />
-
- - - ) - : ( - <> - {createdDump && ( -

- Dumped!{" "} - - View dump → - -

+ {urlPreview.status === "loading" && ( +

Fetching preview…

+ )} + {urlPreview.status === "done" && + urlPreview.richContent && ( + + )} + + ) + : ( + <> + + {file && } + )} - {playlistsLoading - ?

Loading playlists…

- : memberships.length === 0 && !showNewPlaylistForm - ?

No playlists yet.

- : ( -
    - {memberships.map((m) => ( -
  • toggleMembership(m)} - > - - {m.hasDump ? "✓" : "○"} - - - {m.playlist.title} - - {!m.playlist.isPublic && ( - - private - - )} -
  • - ))} -
- )} +
+ + +
- {showNewPlaylistForm - ? ( - { - setMemberships((prev) => [ - { playlist, hasDump: true }, - ...prev, - ]); - setShowNewPlaylistForm(false); - }} - onCancel={() => setShowNewPlaylistForm(false)} - /> - ) - : ( - - )} +
+ + +
-
-
- -
+
+
+ +
- +
+ + + ) + : ( + <> + {createdDump && ( +

+ Dumped!{" "} + + View dump → + +

)} -
-
-
, - document.body, + + + setMemberships((prev) => [membership, ...prev])} + /> + +
+
+ +
+
+ + )} +
); } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..a8ce0c3 --- /dev/null +++ b/src/components/Modal.tsx @@ -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(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( +
{ + if (e.target === backdropRef.current) onClose(); + }} + > +
+
+ {title} + +
+
+ {children} +
+
+
, + document.body, + ); +} diff --git a/src/components/NewPlaylistForm.tsx b/src/components/NewPlaylistForm.tsx index 710b762..e4f9077 100644 --- a/src/components/NewPlaylistForm.tsx +++ b/src/components/NewPlaylistForm.tsx @@ -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(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} - {open && createPortal( -
{ - if (e.target === backdropRef.current) close(); - }} - > -
-
- New playlist - -
-
- { - onCreated(playlist); - close(); - }} - onCancel={close} - /> -
-
-
, - document.body, + {open && ( + setOpen(false)}> + { + onCreated(playlist); + setOpen(false); + }} + onCancel={() => setOpen(false)} + /> + )} ); diff --git a/src/components/PlaylistCreateForm.tsx b/src/components/PlaylistCreateForm.tsx index 488a0a0..2c98f8b 100644 --- a/src/components/PlaylistCreateForm.tsx +++ b/src/components/PlaylistCreateForm.tsx @@ -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(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({ - title: title.trim(), - description: description.trim() || undefined, - isPublic, - }), + body: JSON.stringify( + { + title: title.trim(), + description: description.trim() || undefined, + isPublic, + } satisfies CreatePlaylistRequest, + ), }); - const body = await res.json(); + const body = parseAPIResponse(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); diff --git a/src/components/PlaylistMembershipPanel.tsx b/src/components/PlaylistMembershipPanel.tsx new file mode 100644 index 0000000..096e3cb --- /dev/null +++ b/src/components/PlaylistMembershipPanel.tsx @@ -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 + ?

Loading…

+ : memberships.length === 0 && !showNewForm + ?

No playlists yet.

+ : ( +
    + {memberships.map((m) => ( +
  • onToggle(m)} + > + + {m.hasDump ? "✓" : "○"} + + + {m.playlist.title} + + {!m.playlist.isPublic && ( + + private + + )} +
  • + ))} +
+ )} + + {showNewForm + ? ( + { + onPlaylistCreated({ playlist, hasDump: true }); + setShowNewForm(false); + }} + onCancel={() => setShowNewForm(false)} + /> + ) + : ( + + )} + + ); +} diff --git a/src/components/ProfileSubpageHeader.tsx b/src/components/ProfileSubpageHeader.tsx new file mode 100644 index 0000000..47a6981 --- /dev/null +++ b/src/components/ProfileSubpageHeader.tsx @@ -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 ( +
+ + ← {profileUser.username} + +
+ +

{title}

+ {actions} +
+
+ ); +} diff --git a/src/contexts/WSProvider.tsx b/src/contexts/WSProvider.tsx index 418bf93..98425c4 100644 --- a/src/contexts/WSProvider.tsx +++ b/src/contexts/WSProvider.tsx @@ -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; - 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, -): 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, -): 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, -): msg is { playlistId: string; userId: string } { - return typeof msg.playlistId === "string" && typeof msg.userId === "string"; -} - -function isPlaylistDumpsUpdatedPayload( - msg: Record, -): msg is { playlistId: string; dumpIds: string[] } { - return typeof msg.playlistId === "string" && isStringArray(msg.dumpIds); -} - -function isCommentDeletedPayload( - msg: Record, -): 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; - 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) => { diff --git a/src/hooks/useScrollSave.ts b/src/hooks/useScrollSave.ts new file mode 100644 index 0000000..d368420 --- /dev/null +++ b/src/hooks/useScrollSave.ts @@ -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; + 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]); +} diff --git a/src/hooks/useUserDumpFeed.ts b/src/hooks/useUserDumpFeed.ts new file mode 100644 index 0000000..6709fef --- /dev/null +++ b/src/hooks/useUserDumpFeed.ts @@ -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>; + setItems: (fn: (prev: Dump[]) => Dump[]) => void; + sentinelRef: RefObject; +} + +/** + * 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(cacheKey, hydrateDump); + + const [state, setState] = useState({ 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 + : { 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; + 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 }; +} diff --git a/src/model.ts b/src/model.ts index 9eb7cb1..35bf346 100644 --- a/src/model.ts +++ b/src/model.ts @@ -5,7 +5,34 @@ export interface PaginatedData { } /** - * Backend + * API response envelope — every endpoint returns this shape. + */ +export type APIResponse = + | { success: true; data: T } + | { success: false; error: { message: string } }; + +/** + * Parses an unknown JSON payload into a typed APIResponse. + * 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(raw: unknown): APIResponse { + if (raw !== null && typeof raw === "object" && "success" in raw) { + return raw as APIResponse; + } + throw new Error("Unexpected response format"); +} + +/** + * Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage. + * WithStringDate replaces Date fields with string so we can type raw API responses. + */ +type WithStringDate = + & Omit + & { 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 = - & Omit - & { createdAt: string; updatedAt?: string }; export type RawDump = WithStringDate; -export type RawUser = WithStringDate; -export type RawPublicUser = WithStringDate; -export type RawAuthResponse = Omit & { 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; +// 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 & { + 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; export type RawPlaylistWithDumps = - & Omit - & { - createdAt: string; - dumps: RawDump[]; - }; + & Omit, "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 { - success: true; - data: T; - error?: never; -} - -export interface APIFailure { - success: false; - data?: never; - error: APIError; -} - -export type APIResponse = APISuccess | 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; 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; +} diff --git a/src/pages/Dump.tsx b/src/pages/Dump.tsx index f1a1ce8..32b4caf 100644 --- a/src/pages/Dump.tsx +++ b/src/pages/Dump.tsx @@ -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(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 }); diff --git a/src/pages/DumpEdit.tsx b/src/pages/DumpEdit.tsx index 9832bfc..d15282e 100644 --- a/src/pages/DumpEdit.tsx +++ b/src/pages/DumpEdit.tsx @@ -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(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(await res.json()); if (!apiResponse.success) { - setState({ - status: "error", - error: apiResponse.error?.message ?? "Update failed.", - }); + setState({ status: "error", error: apiResponse.error.message }); return; } diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 54d3471..90de880 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -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(() => { - if (dumpsState.status !== "loaded") return; - let timer: ReturnType; - const onScroll = () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (dumpsState.status === "loaded") { - saveState( - dumpsState.dumps, - dumpsState.page, - dumpsState.hasMore, - globalThis.scrollY, - ); - } - }, 100); - }; - globalThis.addEventListener("scroll", onScroll, { passive: true }); - return () => { - globalThis.removeEventListener("scroll", onScroll); - clearTimeout(timer); - }; - }, [dumpsState, saveState]); + useScrollSave( + dumpsState.status === "loaded", + useCallback((y) => { + if (dumpsState.status !== "loaded") return; + saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y); + }, [dumpsState, saveState]), + ); - useEffect(() => { - if (followedUsersDumps.status !== "loaded") return; - let timer: ReturnType; - const onScroll = () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (followedUsersDumps.status === "loaded") { - saveFollowedUsers( - followedUsersDumps.dumps, - followedUsersDumps.page, - followedUsersDumps.hasMore, - globalThis.scrollY, - ); - } - }, 100); - }; - globalThis.addEventListener("scroll", onScroll, { passive: true }); - return () => { - globalThis.removeEventListener("scroll", onScroll); - clearTimeout(timer); - }; - }, [followedUsersDumps, saveFollowedUsers]); + useScrollSave( + followedUsersDumps.status === "loaded", + useCallback((y) => { + if (followedUsersDumps.status !== "loaded") return; + saveFollowedUsers( + followedUsersDumps.dumps, + followedUsersDumps.page, + followedUsersDumps.hasMore, + y, + ); + }, [followedUsersDumps, saveFollowedUsers]), + ); - useEffect(() => { - if (followedPlaylistsDumps.status !== "loaded") return; - let timer: ReturnType; - const onScroll = () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (followedPlaylistsDumps.status === "loaded") { - saveFollowedPlaylists( - followedPlaylistsDumps.dumps, - followedPlaylistsDumps.page, - followedPlaylistsDumps.hasMore, - globalThis.scrollY, - ); - } - }, 100); - }; - globalThis.addEventListener("scroll", onScroll, { passive: true }); - return () => { - globalThis.removeEventListener("scroll", onScroll); - clearTimeout(timer); - }; - }, [followedPlaylistsDumps, saveFollowedPlaylists]); + useScrollSave( + followedPlaylistsDumps.status === "loaded", + useCallback((y) => { + if (followedPlaylistsDumps.status !== "loaded") return; + saveFollowedPlaylists( + followedPlaylistsDumps.dumps, + followedPlaylistsDumps.page, + followedPlaylistsDumps.hasMore, + y, + ); + }, [followedPlaylistsDumps, saveFollowedPlaylists]), + ); // ── Scroll restoration ── diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index ba8aa8d..229118a 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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 >({}); const cancels = useRef 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(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 | 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(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. - 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! - .filter((id) => dumpMap.has(id)) - .map((id) => dumpMap.get(id)!), - ...s.playlist.dumps.filter((d) => !newIds.has(d.id)), - ], - }, - }; - }); - dumpOrderRef.current = ev.dumpIds!; + // 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])); + const orderedActive = orderToApply + .filter((id) => dumpMap.has(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 = 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 } : {}), - ...(editDescription !== (state.playlist.description ?? "") - ? { description: editDescription || null } - : {}), - isPublic: editIsPublic, - }), + 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(await updateRes.json()); const updatedPlaylist = updateJson.success ? deserializePlaylist(updateJson.data) : null; diff --git a/src/pages/UserDumps.tsx b/src/pages/UserDumps.tsx index d0c32e4..4d5fc63 100644 --- a/src/pages/UserDumps.tsx +++ b/src/pages/UserDumps.tsx @@ -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( + + const { state, setItems, sentinelRef } = useUserDumpFeed( + username, + "dumps", `feed:user-dumps-full:${username ?? ""}`, - hydrateDump, ); - const [state, setState] = useState({ 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 - : { 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; - 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; - 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 ( @@ -216,36 +63,24 @@ export function UserDumps() { ); } - const { profileUser, dumps, hasMore, loadingMore } = state; + const { profileUser, items: dumps, hasMore, loadingMore } = state; return ( -
- - ← {profileUser.username} - -
- -

Dumps

- {isOwnProfile && ( - - )} -
-
+ setCreateModalOpen(true)} + > + + New dump + + )} + /> {createModalOpen && ( setCreateModalOpen(false)} /> diff --git a/src/pages/UserLogin.tsx b/src/pages/UserLogin.tsx index b3ab6de..e6fc686 100644 --- a/src/pages/UserLogin.tsx +++ b/src/pages/UserLogin.tsx @@ -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(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) }); diff --git a/src/pages/UserPlaylists.tsx b/src/pages/UserPlaylists.tsx index cb6dcab..c025346 100644 --- a/src/pages/UserPlaylists.tsx +++ b/src/pages/UserPlaylists.tsx @@ -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,35 +284,24 @@ export function UserPlaylists() { !state.followed.loadingMore, ); - // Scroll save - useEffect(() => { - if (state.status !== "loaded") return; - let timer: ReturnType; - const onScroll = () => { - clearTimeout(timer); - timer = setTimeout(() => { - if (state.status !== "loaded") return; - const y = globalThis.scrollY; - saveCreated( - state.created.items, - state.created.page, - state.created.hasMore, - y, - ); - saveFollowed( - state.followed.items, - state.followed.page, - state.followed.hasMore, - y, - ); - }, 100); - }; - globalThis.addEventListener("scroll", onScroll, { passive: true }); - return () => { - globalThis.removeEventListener("scroll", onScroll); - clearTimeout(timer); - }; - }, [state, saveCreated, saveFollowed]); + useScrollSave( + state.status === "loaded", + useCallback((y) => { + if (state.status !== "loaded") return; + saveCreated( + state.created.items, + state.created.page, + state.created.hasMore, + y, + ); + saveFollowed( + state.followed.items, + state.followed.page, + state.followed.hasMore, + y, + ); + }, [state, saveCreated, saveFollowed]), + ); const scrollRestored = useRef(false); useLayoutEffect(() => { @@ -364,34 +354,25 @@ export function UserPlaylists() { return ( -
- - ← {profileUser.username} - -
- + setState((s) => { + if (s.status !== "loaded") return s; + if (s.created.items.some((pl) => pl.id === p.id)) return s; + return { + ...s, + created: { ...s.created, items: [p, ...s.created.items] }, + }; + })} /> -

Playlists

- {isOwnProfile && ( - - setState((s) => { - if (s.status !== "loaded") return s; - if (s.created.items.some((pl) => pl.id === p.id)) return s; - return { - ...s, - created: { ...s.created, items: [p, ...s.created.items] }, - }; - })} - /> - )} -
-
+ )} + />
diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index 56b3425..d2fbcf7 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -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(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(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} /> ); diff --git a/src/pages/UserRegister.tsx b/src/pages/UserRegister.tsx index d282228..96147cc 100644 --- a/src/pages/UserRegister.tsx +++ b/src/pages/UserRegister.tsx @@ -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(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) }); diff --git a/src/pages/UserUpvoted.tsx b/src/pages/UserUpvoted.tsx index 8fa8ced..654778d 100644 --- a/src/pages/UserUpvoted.tsx +++ b/src/pages/UserUpvoted.tsx @@ -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( - `feed:user-upvoted-full:${username ?? ""}`, - hydrateDump, - ); - - const [state, setState] = useState({ 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>(new Set()); const { fading, startFading, cancelFading, cancelAll } = useFading(); const prevMyVotesRef = useRef | 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 - : { 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; - 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; - 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 ( @@ -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 ( -
- - ← {profileUser.username} - -
- -

Upvoted

-
-
+ {visibleDumps.length === 0 ?

Nothing here yet.

@@ -308,6 +153,7 @@ export function UserUpvoted() { castVote={castVote} removeVote={removeVote} className={extraCls} + isOwner={!!me && me.id === dump.userId} /> ); })}