v3: code quality pass
This commit is contained in:
@@ -75,7 +75,7 @@ export interface UserRow {
|
|||||||
* Type Guards
|
* Type Guards
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
export function isDumpRow(obj: unknown): obj is DumpRow {
|
||||||
return !!obj &&
|
return !!obj &&
|
||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
"id" in obj && typeof obj.id === "string" &&
|
"id" in obj && typeof obj.id === "string" &&
|
||||||
@@ -102,7 +102,7 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
|||||||
"is_private" in obj && typeof obj.is_private === "number";
|
"is_private" in obj && typeof obj.is_private === "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
export function isUserRow(obj: unknown): obj is UserRow {
|
||||||
return !!obj &&
|
return !!obj &&
|
||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
"id" in obj && typeof obj.id === "string" &&
|
"id" in obj && typeof obj.id === "string" &&
|
||||||
@@ -214,18 +214,21 @@ export interface CommentRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isCommentRow(
|
export function isCommentRow(
|
||||||
obj: Record<string, SQLOutputValue>,
|
obj: unknown,
|
||||||
): obj is CommentRow {
|
): obj is CommentRow {
|
||||||
return !!obj && typeof obj === "object" &&
|
return !!obj && typeof obj === "object" &&
|
||||||
typeof obj.id === "string" &&
|
"id" in obj && typeof obj.id === "string" &&
|
||||||
typeof obj.dump_id === "string" &&
|
"dump_id" in obj && typeof obj.dump_id === "string" &&
|
||||||
typeof obj.user_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.parent_id === "string" || obj.parent_id === null) &&
|
||||||
typeof obj.body === "string" &&
|
"body" in obj && typeof obj.body === "string" &&
|
||||||
typeof obj.created_at === "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.updated_at === "string" || obj.updated_at === null) &&
|
||||||
typeof obj.deleted === "number" &&
|
"deleted" in obj && typeof obj.deleted === "number" &&
|
||||||
typeof obj.author_username === "string" &&
|
"author_username" in obj && typeof obj.author_username === "string" &&
|
||||||
|
"author_avatar_mime" in obj &&
|
||||||
(typeof obj.author_avatar_mime === "string" ||
|
(typeof obj.author_avatar_mime === "string" ||
|
||||||
obj.author_avatar_mime === null);
|
obj.author_avatar_mime === null);
|
||||||
}
|
}
|
||||||
@@ -259,16 +262,20 @@ export interface PlaylistRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isPlaylistRow(
|
export function isPlaylistRow(
|
||||||
obj: Record<string, SQLOutputValue>,
|
obj: unknown,
|
||||||
): obj is PlaylistRow {
|
): obj is PlaylistRow {
|
||||||
return !!obj && typeof obj.id === "string" &&
|
return !!obj && typeof obj === "object" &&
|
||||||
typeof obj.user_id === "string" &&
|
"id" in obj && typeof obj.id === "string" &&
|
||||||
typeof obj.title === "string" &&
|
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||||
(typeof obj.slug === "string" || obj.slug === null) &&
|
"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.description === "string" || obj.description === null) &&
|
||||||
typeof obj.is_public === "number" &&
|
"is_public" in obj && typeof obj.is_public === "number" &&
|
||||||
typeof obj.created_at === "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.updated_at === "string" || obj.updated_at === null) &&
|
||||||
|
"image_mime" in obj &&
|
||||||
(typeof obj.image_mime === "string" || obj.image_mime === null);
|
(typeof obj.image_mime === "string" || obj.image_mime === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,15 +307,15 @@ export interface FollowRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isFollowRow(
|
export function isFollowRow(
|
||||||
obj: Record<string, SQLOutputValue>,
|
obj: unknown,
|
||||||
): obj is FollowRow {
|
): obj is FollowRow {
|
||||||
return !!obj &&
|
return !!obj && typeof obj === "object" &&
|
||||||
typeof obj.id === "string" &&
|
"id" in obj && typeof obj.id === "string" &&
|
||||||
typeof obj.follower_id === "string" &&
|
"follower_id" in obj && typeof obj.follower_id === "string" &&
|
||||||
typeof obj.created_at === "string" &&
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||||
(obj.followed_user_id === null ||
|
"followed_user_id" in obj && (obj.followed_user_id === null ||
|
||||||
typeof obj.followed_user_id === "string") &&
|
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");
|
typeof obj.followed_playlist_id === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,15 +333,16 @@ export interface NotificationRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isNotificationRow(
|
export function isNotificationRow(
|
||||||
obj: Record<string, SQLOutputValue>,
|
obj: unknown,
|
||||||
): obj is NotificationRow {
|
): obj is NotificationRow {
|
||||||
return !!obj && typeof obj === "object" &&
|
return !!obj && typeof obj === "object" &&
|
||||||
typeof obj.id === "string" &&
|
"id" in obj && typeof obj.id === "string" &&
|
||||||
typeof obj.user_id === "string" &&
|
"user_id" in obj && typeof obj.user_id === "string" &&
|
||||||
typeof obj.type === "string" &&
|
"type" in obj && typeof obj.type === "string" &&
|
||||||
typeof obj.data === "string" &&
|
"data" in obj && typeof obj.data === "string" &&
|
||||||
typeof obj.read === "number" &&
|
"read" in obj && typeof obj.read === "number" &&
|
||||||
typeof obj.created_at === "string" &&
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||||
|
"source_key" in obj &&
|
||||||
(typeof obj.source_key === "string" || obj.source_key === null);
|
(typeof obj.source_key === "string" || obj.source_key === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,11 +368,12 @@ export interface InviteRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isInviteRow(
|
export function isInviteRow(
|
||||||
obj: Record<string, SQLOutputValue>,
|
obj: unknown,
|
||||||
): obj is InviteRow {
|
): obj is InviteRow {
|
||||||
return !!obj && typeof obj === "object" &&
|
return !!obj && typeof obj === "object" &&
|
||||||
typeof obj.token === "string" &&
|
"token" in obj && typeof obj.token === "string" &&
|
||||||
typeof obj.inviter_id === "string" &&
|
"inviter_id" in obj && typeof obj.inviter_id === "string" &&
|
||||||
typeof obj.created_at === "string" &&
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||||
|
"used_at" in obj &&
|
||||||
(obj.used_at === null || typeof obj.used_at === "string");
|
(obj.used_at === null || typeof obj.used_at === "string");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export interface CreatePlaylistRequest {
|
|||||||
|
|
||||||
export interface UpdatePlaylistRequest {
|
export interface UpdatePlaylistRequest {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,40 +428,33 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
|||||||
* WebSockets
|
* WebSockets
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── Client → Server ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PingMessage {
|
||||||
|
type: "ping";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PongMessage {
|
||||||
|
type: "pong";
|
||||||
|
}
|
||||||
|
|
||||||
export interface VoteCastMessage {
|
export interface VoteCastMessage {
|
||||||
type: "vote_cast";
|
type: "vote_cast";
|
||||||
dumpId: string;
|
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 {
|
export interface VoteRemoveMessage {
|
||||||
type: "vote_remove";
|
type: "vote_remove";
|
||||||
dumpId: string;
|
dumpId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VotesUpdateMessage {
|
export type ClientToServerMessage =
|
||||||
type: "votes_update";
|
| PingMessage
|
||||||
dumpId: string;
|
| PongMessage
|
||||||
voteCount: number;
|
| VoteCastMessage
|
||||||
}
|
| VoteRemoveMessage;
|
||||||
|
|
||||||
|
// ── Server → Client ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface OnlineUser {
|
export interface OnlineUser {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -474,6 +467,7 @@ export interface WelcomeMessage {
|
|||||||
type: "welcome";
|
type: "welcome";
|
||||||
users: OnlineUser[];
|
users: OnlineUser[];
|
||||||
myVotes: string[];
|
myVotes: string[];
|
||||||
|
unreadNotificationCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PresenceUpdateMessage {
|
export interface PresenceUpdateMessage {
|
||||||
@@ -481,14 +475,109 @@ export interface PresenceUpdateMessage {
|
|||||||
users: OnlineUser[];
|
users: OnlineUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PingMessage {
|
export interface VotesUpdateMessage {
|
||||||
type: "ping";
|
type: "votes_update";
|
||||||
|
dumpId: string;
|
||||||
|
voteCount: number;
|
||||||
|
voterId: string;
|
||||||
|
action: "cast" | "remove";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PongMessage {
|
export interface VoteAckMessage {
|
||||||
type: "pong";
|
type: "vote_ack";
|
||||||
|
dumpId: string;
|
||||||
|
action: "cast" | "remove";
|
||||||
|
voteCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DumpCreatedMessage {
|
||||||
|
type: "dump_created";
|
||||||
|
dump: Dump;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DumpUpdatedMessage {
|
||||||
|
type: "dump_updated";
|
||||||
|
dump: Dump;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DumpDeletedMessage {
|
||||||
|
type: "dump_deleted";
|
||||||
|
dumpId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistCreatedMessage {
|
||||||
|
type: "playlist_created";
|
||||||
|
playlist: Playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistUpdatedMessage {
|
||||||
|
type: "playlist_updated";
|
||||||
|
playlist: Playlist;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistDeletedMessage {
|
||||||
|
type: "playlist_deleted";
|
||||||
|
playlistId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistDumpsUpdatedMessage {
|
||||||
|
type: "playlist_dumps_updated";
|
||||||
|
playlistId: string;
|
||||||
|
dumpIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserUpdatedMessage {
|
||||||
|
type: "user_updated";
|
||||||
|
user: Omit<User, "passwordHash">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentCreatedMessage {
|
||||||
|
type: "comment_created";
|
||||||
|
comment: Comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentUpdatedMessage {
|
||||||
|
type: "comment_updated";
|
||||||
|
comment: Comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentDeletedMessage {
|
||||||
|
type: "comment_deleted";
|
||||||
|
commentId: string;
|
||||||
|
dumpId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationCreatedMessage {
|
||||||
|
type: "notification_created";
|
||||||
|
notification: RawNotification;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorMessage {
|
||||||
|
type: "error";
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServerToClientMessage =
|
||||||
|
| PingMessage
|
||||||
|
| WelcomeMessage
|
||||||
|
| PresenceUpdateMessage
|
||||||
|
| VotesUpdateMessage
|
||||||
|
| VoteAckMessage
|
||||||
|
| DumpCreatedMessage
|
||||||
|
| DumpUpdatedMessage
|
||||||
|
| DumpDeletedMessage
|
||||||
|
| PlaylistCreatedMessage
|
||||||
|
| PlaylistUpdatedMessage
|
||||||
|
| PlaylistDeletedMessage
|
||||||
|
| PlaylistDumpsUpdatedMessage
|
||||||
|
| UserUpdatedMessage
|
||||||
|
| CommentCreatedMessage
|
||||||
|
| CommentUpdatedMessage
|
||||||
|
| CommentDeletedMessage
|
||||||
|
| NotificationCreatedMessage
|
||||||
|
| ErrorMessage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Follows
|
* Follows
|
||||||
*/
|
*/
|
||||||
@@ -568,3 +657,8 @@ export interface Notification {
|
|||||||
read: boolean;
|
read: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Wire format — createdAt arrives as an ISO string over JSON. */
|
||||||
|
export type RawNotification = Omit<Notification, "createdAt"> & {
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
|||||||
}
|
}
|
||||||
updateClientAvatar(authPayload.userId, mime);
|
updateClientAvatar(authPayload.userId, mime);
|
||||||
|
|
||||||
const user = getUserById(authPayload.userId);
|
const { passwordHash: _, ...publicUser } = getUserById(authPayload.userId);
|
||||||
ctx.response.status = 200;
|
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) => {
|
router.get("/api/avatars/:userId", async (ctx) => {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
|
|||||||
|
|
||||||
// POST /api/dumps/:dumpId/comments — auth required
|
// POST /api/dumps/:dumpId/comments — auth required
|
||||||
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
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 dump = getDump(ctx.params.dumpId, userId);
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
if (!isCreateCommentRequest(body)) {
|
if (!isCreateCommentRequest(body)) {
|
||||||
@@ -62,8 +62,8 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
|||||||
|
|
||||||
// PATCH /api/comments/:commentId — auth required
|
// PATCH /api/comments/:commentId — auth required
|
||||||
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||||
const userId = ctx.state.user.userId as string;
|
const userId = ctx.state.user.userId;
|
||||||
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
const isAdmin = ctx.state.user.isAdmin ?? false;
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
if (!isUpdateCommentRequest(body)) {
|
if (!isUpdateCommentRequest(body)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -85,8 +85,8 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
|||||||
|
|
||||||
// DELETE /api/comments/:commentId — auth required
|
// DELETE /api/comments/:commentId — auth required
|
||||||
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||||
const userId = ctx.state.user.userId as string;
|
const userId = ctx.state.user.userId;
|
||||||
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
const isAdmin = ctx.state.user.isAdmin ?? false;
|
||||||
const { dumpId, isPrivate } = deleteComment(
|
const { dumpId, isPrivate } = deleteComment(
|
||||||
ctx.params.commentId,
|
ctx.params.commentId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type FollowStatus,
|
type FollowStatus,
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
import { parsePagination } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
followPlaylist,
|
followPlaylist,
|
||||||
followUser,
|
followUser,
|
||||||
@@ -22,26 +23,16 @@ const router = new Router({ prefix: "/api/follows" });
|
|||||||
|
|
||||||
// GET /api/follows/status
|
// GET /api/follows/status
|
||||||
router.get("/status", authMiddleware, (ctx) => {
|
router.get("/status", authMiddleware, (ctx) => {
|
||||||
const status = getFollowStatus(ctx.state.user.userId as string);
|
const status = getFollowStatus(ctx.state.user.userId);
|
||||||
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||||
ctx.response.body = body;
|
ctx.response.body = body;
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/follows/feed/users?page=&limit=
|
// GET /api/follows/feed/users?page=&limit=
|
||||||
router.get("/feed/users", authMiddleware, (ctx) => {
|
router.get("/feed/users", authMiddleware, (ctx) => {
|
||||||
const page = Math.max(
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
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 { items, total } = getFollowedUsersDumpFeed(
|
const { items, total } = getFollowedUsersDumpFeed(
|
||||||
ctx.state.user.userId as string,
|
ctx.state.user.userId,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
);
|
);
|
||||||
@@ -56,19 +47,9 @@ router.get("/feed/users", authMiddleware, (ctx) => {
|
|||||||
|
|
||||||
// GET /api/follows/feed/playlists?page=&limit=
|
// GET /api/follows/feed/playlists?page=&limit=
|
||||||
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||||
const page = Math.max(
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
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 { items, total } = getFollowedPlaylistsDumpFeed(
|
const { items, total } = getFollowedPlaylistsDumpFeed(
|
||||||
ctx.state.user.userId as string,
|
ctx.state.user.userId,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
);
|
);
|
||||||
@@ -83,25 +64,25 @@ router.get("/feed/playlists", authMiddleware, (ctx) => {
|
|||||||
|
|
||||||
// POST /api/follows/users/:userId
|
// POST /api/follows/users/:userId
|
||||||
router.post("/users/:userId", authMiddleware, (ctx) => {
|
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;
|
ctx.response.status = 204;
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/follows/users/:userId
|
// DELETE /api/follows/users/:userId
|
||||||
router.delete("/users/:userId", authMiddleware, (ctx) => {
|
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;
|
ctx.response.status = 204;
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/follows/playlists/:playlistId
|
// POST /api/follows/playlists/:playlistId
|
||||||
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
|
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;
|
ctx.response.status = 204;
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/follows/playlists/:playlistId
|
// DELETE /api/follows/playlists/:playlistId
|
||||||
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
|
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;
|
ctx.response.status = 204;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ router.post("/", authMiddleware, async (ctx: AuthContext) => {
|
|||||||
router.get("/:token", async (ctx) => {
|
router.get("/:token", async (ctx) => {
|
||||||
try {
|
try {
|
||||||
await validateInvite(ctx.params.token);
|
await validateInvite(ctx.params.token);
|
||||||
ctx.response.body = { success: true };
|
ctx.response.body = { success: true, data: null };
|
||||||
} catch {
|
} catch {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.NOT_FOUND,
|
APIErrorCode.NOT_FOUND,
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { Router } from "@oak/oak";
|
|||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
APIException,
|
||||||
type AuthPayload,
|
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
import { parsePagination } from "../lib/pagination.ts";
|
||||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||||
import {
|
import {
|
||||||
getNotificationsForUser,
|
getNotificationsForUser,
|
||||||
@@ -19,17 +19,7 @@ router.get("/", authMiddleware, (ctx: AuthContext) => {
|
|||||||
if (!ctx.state.user) {
|
if (!ctx.state.user) {
|
||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
}
|
}
|
||||||
const page = Math.max(
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
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 { items, total } = getNotificationsForUser(
|
const { items, total } = getNotificationsForUser(
|
||||||
ctx.state.user.userId,
|
ctx.state.user.userId,
|
||||||
page,
|
page,
|
||||||
@@ -56,7 +46,7 @@ router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
|||||||
|
|
||||||
// PATCH /api/notifications/:id/read
|
// PATCH /api/notifications/:id/read
|
||||||
router.patch("/:id/read", authMiddleware, (ctx) => {
|
router.patch("/:id/read", authMiddleware, (ctx) => {
|
||||||
const user = ctx.state.user as AuthPayload;
|
const user = ctx.state.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ router.post("/register", async (ctx) => {
|
|||||||
|
|
||||||
// Mark invite as used only after the user row is committed
|
// Mark invite as used only after the user row is committed
|
||||||
try {
|
try {
|
||||||
await redeemInvite(body.inviteToken);
|
redeemInvite(body.inviteToken);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[register] redeemInvite failed (user created):", 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 = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: user,
|
data: publicUser,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
} from "../services/vote-service.ts";
|
} from "../services/vote-service.ts";
|
||||||
import { getUnreadCount } from "../services/notification-service.ts";
|
import { getUnreadCount } from "../services/notification-service.ts";
|
||||||
import { getUserById } from "../services/user-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();
|
const router = new Router();
|
||||||
|
|
||||||
@@ -78,7 +81,7 @@ router.get("/ws", async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.addEventListener("message", (event) => {
|
socket.addEventListener("message", (event) => {
|
||||||
let msg: { type: string; dumpId?: string };
|
let msg: ClientToServerMessage;
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(event.data as string);
|
msg = JSON.parse(event.data as string);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -109,7 +112,7 @@ router.get("/ws", async (ctx) => {
|
|||||||
|
|
||||||
function handleVote(
|
function handleVote(
|
||||||
client: WsClient,
|
client: WsClient,
|
||||||
dumpId: string | undefined,
|
dumpId: string,
|
||||||
action: "cast" | "remove",
|
action: "cast" | "remove",
|
||||||
): void {
|
): void {
|
||||||
const { socket } = client;
|
const { socket } = client;
|
||||||
@@ -121,11 +124,6 @@ function handleVote(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dumpId) {
|
|
||||||
socket.send(JSON.stringify({ type: "error", message: "Missing dumpId" }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newCount = action === "cast"
|
const newCount = action === "cast"
|
||||||
? castVote(dumpId, client.userId)
|
? castVote(dumpId, client.userId)
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import {
|
|||||||
type Comment,
|
type Comment,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { type SQLOutputValue } from "node:sqlite";
|
import { type SQLOutputValue } from "node:sqlite";
|
||||||
import {
|
import { commentRowToApi, db, isCommentRow } from "../model/db.ts";
|
||||||
type CommentRow,
|
|
||||||
commentRowToApi,
|
|
||||||
db,
|
|
||||||
isCommentRow,
|
|
||||||
} from "../model/db.ts";
|
|
||||||
import { notifyMentions } from "./notification-service.ts";
|
import { notifyMentions } from "./notification-service.ts";
|
||||||
|
|
||||||
const SELECT_COLS =
|
const SELECT_COLS =
|
||||||
@@ -23,7 +18,14 @@ function fetchComment(commentId: string): Comment {
|
|||||||
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) {
|
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
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[] {
|
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
|
`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;`,
|
WHERE c.dump_id = ? ORDER BY c.created_at ASC;`,
|
||||||
).all(dumpId);
|
).all(dumpId);
|
||||||
const typed = rows as Parameters<typeof isCommentRow>[0][];
|
if (!rows.every(isCommentRow)) {
|
||||||
if (!typed.every(isCommentRow)) {
|
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.SERVER_ERROR,
|
||||||
500,
|
500,
|
||||||
"Malformed comment data",
|
"Malformed comment data",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return typed.map(commentRowToApi);
|
return rows.map(commentRowToApi);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createComment(
|
export function createComment(
|
||||||
|
|||||||
@@ -448,11 +448,11 @@ export function getVotedDumpsByUser(
|
|||||||
const dumpCols = SELECT_COLS_ALIASED;
|
const dumpCols = SELECT_COLS_ALIASED;
|
||||||
|
|
||||||
let totalRow: { count: number } | undefined;
|
let totalRow: { count: number } | undefined;
|
||||||
let rawRows: unknown[];
|
let rows: unknown[];
|
||||||
|
|
||||||
if (requestingUserId === userId) {
|
if (requestingUserId === userId) {
|
||||||
// Own profile: include private dumps the user themselves voted on and owns.
|
// Own profile: include private dumps the user themselves voted on and owns.
|
||||||
rawRows = db.prepare(
|
rows = db.prepare(
|
||||||
`SELECT ${dumpCols}
|
`SELECT ${dumpCols}
|
||||||
FROM dumps d
|
FROM dumps d
|
||||||
INNER JOIN votes v ON d.id = v.dump_id
|
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 = ?);`,
|
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`,
|
||||||
).get(userId, userId) as { count: number } | undefined;
|
).get(userId, userId) as { count: number } | undefined;
|
||||||
} else {
|
} else {
|
||||||
rawRows = db.prepare(
|
rows = db.prepare(
|
||||||
`SELECT ${dumpCols}
|
`SELECT ${dumpCols}
|
||||||
FROM dumps d
|
FROM dumps d
|
||||||
INNER JOIN votes v ON d.id = v.dump_id
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
@@ -479,7 +479,6 @@ export function getVotedDumpsByUser(
|
|||||||
).get(userId) as { count: number } | undefined;
|
).get(userId) as { count: number } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
|
|
||||||
if (!rows.every(isDumpRow)) {
|
if (!rows.every(isDumpRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
|||||||
@@ -114,12 +114,12 @@ export function getFollowStatus(followerId: string): FollowStatus {
|
|||||||
const rawUserRows = db.prepare(
|
const rawUserRows = db.prepare(
|
||||||
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
||||||
FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`,
|
FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`,
|
||||||
).all(followerId) as Parameters<typeof isFollowRow>[0][];
|
).all(followerId);
|
||||||
|
|
||||||
const rawPlaylistRows = db.prepare(
|
const rawPlaylistRows = db.prepare(
|
||||||
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
||||||
FROM follows WHERE follower_id = ? AND followed_playlist_id IS NOT NULL;`,
|
FROM follows WHERE follower_id = ? AND followed_playlist_id IS NOT NULL;`,
|
||||||
).all(followerId) as Parameters<typeof isFollowRow>[0][];
|
).all(followerId);
|
||||||
|
|
||||||
if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) {
|
if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -207,8 +207,7 @@ export function getFollowedPlaylistsDumpFeed(
|
|||||||
AND d.is_private = 0;`,
|
AND d.is_private = 0;`,
|
||||||
).get(followerId) as { count: number } | undefined;
|
).get(followerId) as { count: number } | undefined;
|
||||||
|
|
||||||
const playlistFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
|
if (!rawRows.every(isDumpRow)) {
|
||||||
if (!playlistFeedRows.every(isDumpRow)) {
|
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.SERVER_ERROR,
|
||||||
500,
|
500,
|
||||||
@@ -216,7 +215,7 @@ export function getFollowedPlaylistsDumpFeed(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
items: playlistFeedRows.map(dumpRowToApi),
|
items: rawRows.map(dumpRowToApi),
|
||||||
total: totalRow?.count ?? 0,
|
total: totalRow?.count ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -246,7 +245,7 @@ export function getFollowedPlaylistsByUser(
|
|||||||
AND p.is_public = 1
|
AND p.is_public = 1
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY f.created_at DESC
|
||||||
LIMIT ? OFFSET ?;`,
|
LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit, offset) as Parameters<typeof isPlaylistRow>[0][];
|
).all(userId, limit, offset);
|
||||||
|
|
||||||
if (!rawRows.every(isPlaylistRow)) {
|
if (!rawRows.every(isPlaylistRow)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function getNotificationsForUser(
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const rawRows = db.prepare(
|
const rawRows = db.prepare(
|
||||||
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, limit, offset) as Parameters<typeof isNotificationRow>[0][];
|
).all(userId, limit, offset);
|
||||||
|
|
||||||
const totalRow = db.prepare(
|
const totalRow = db.prepare(
|
||||||
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
|
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
|
||||||
@@ -195,6 +195,7 @@ export function notifyUserFollowersNewDump(
|
|||||||
sendToUser(row.follower_id, {
|
sendToUser(row.follower_id, {
|
||||||
type: "notification_created",
|
type: "notification_created",
|
||||||
notification: {
|
notification: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
userId: row.follower_id,
|
userId: row.follower_id,
|
||||||
type: "user_dump_posted",
|
type: "user_dump_posted",
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { SQLOutputValue } from "node:sqlite";
|
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
APIException,
|
||||||
@@ -29,7 +28,7 @@ import {
|
|||||||
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
import { makeSlug, UUID_RE } from "../lib/slugify.ts";
|
||||||
|
|
||||||
const DUMP_SELECT_COLS =
|
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,
|
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
|
||||||
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
(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 = ?
|
LEFT JOIN playlist_dumps pd ON pd.playlist_id = p.id AND pd.dump_id = ?
|
||||||
WHERE p.user_id = ?
|
WHERE p.user_id = ?
|
||||||
ORDER BY p.created_at DESC;`,
|
ORDER BY p.created_at DESC;`,
|
||||||
).all(dumpId, userId) as Array<Record<string, SQLOutputValue>>;
|
).all(dumpId, userId);
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
if (!isPlaylistRow(row)) {
|
if (!isPlaylistRow(row)) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
Dump,
|
Dump,
|
||||||
OnlineUser,
|
OnlineUser,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
ServerToClientMessage,
|
||||||
User,
|
User,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
@@ -51,13 +52,13 @@ export function getOnlineUsers(): OnlineUser[] {
|
|||||||
return Array.from(seen.values());
|
return Array.from(seen.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
function send(socket: WebSocket, data: unknown): void {
|
function send(socket: WebSocket, data: ServerToClientMessage): void {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify(data));
|
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) {
|
for (const client of clients) {
|
||||||
if (client.userId === userId) {
|
if (client.userId === userId) {
|
||||||
send(client.socket, data);
|
send(client.socket, data);
|
||||||
@@ -109,7 +110,7 @@ export function broadcastVoteUpdate(
|
|||||||
|
|
||||||
function sendToPlaylistAudience(
|
function sendToPlaylistAudience(
|
||||||
playlist: Pick<Playlist, "isPublic" | "userId">,
|
playlist: Pick<Playlist, "isPublic" | "userId">,
|
||||||
data: unknown,
|
data: ServerToClientMessage,
|
||||||
): void {
|
): void {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (playlist.isPublic || client.userId === playlist.userId) {
|
if (playlist.isPublic || client.userId === playlist.userId) {
|
||||||
|
|||||||
@@ -2269,11 +2269,10 @@ body.has-player .fab-new {
|
|||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.confirm-modal-message {
|
.confirm-modal-message {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
||||||
import { deserializePlaylistMembership } 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 {
|
interface AddToPlaylistModalProps {
|
||||||
dumpId: string;
|
dumpId: string;
|
||||||
@@ -17,23 +17,6 @@ export function AddToPlaylistModal(
|
|||||||
const { authFetch } = useAuth();
|
const { authFetch } = useAuth();
|
||||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showNewForm, setShowNewForm] = useState(false);
|
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handler);
|
|
||||||
return () => document.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`)
|
authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`)
|
||||||
@@ -76,84 +59,16 @@ export function AddToPlaylistModal(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<div
|
<Modal title="Add to playlist" onClose={onClose}>
|
||||||
className="modal-backdrop"
|
<PlaylistMembershipPanel
|
||||||
ref={backdropRef}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === backdropRef.current) onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="modal-card">
|
|
||||||
<div className="modal-header">
|
|
||||||
<span className="modal-title">Add to playlist</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-close-btn"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body">
|
|
||||||
{loading
|
|
||||||
? <p className="page-loading">Loading…</p>
|
|
||||||
: memberships.length === 0 && !showNewForm
|
|
||||||
? <p className="empty-state">No playlists yet.</p>
|
|
||||||
: (
|
|
||||||
<ul className="playlist-membership-list">
|
|
||||||
{memberships.map((m) => (
|
|
||||||
<li
|
|
||||||
key={m.playlist.id}
|
|
||||||
className={`playlist-membership-row${
|
|
||||||
m.hasDump ? " playlist-membership-row--active" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleMembership(m)}
|
|
||||||
>
|
|
||||||
<span className="playlist-membership-check">
|
|
||||||
{m.hasDump ? "✓" : "○"}
|
|
||||||
</span>
|
|
||||||
<span className="playlist-membership-name">
|
|
||||||
{m.playlist.title}
|
|
||||||
</span>
|
|
||||||
{!m.playlist.isPublic && (
|
|
||||||
<span className="playlist-badge playlist-badge--private">
|
|
||||||
private
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNewForm
|
|
||||||
? (
|
|
||||||
<PlaylistCreateForm
|
|
||||||
dumpId={dumpId}
|
dumpId={dumpId}
|
||||||
onCreated={(playlist) => {
|
memberships={memberships}
|
||||||
setMemberships((prev) => [
|
loading={loading}
|
||||||
{ playlist, hasDump: true },
|
onToggle={toggleMembership}
|
||||||
...prev,
|
onPlaylistCreated={(membership) =>
|
||||||
]);
|
setMemberships((prev) => [membership, ...prev])}
|
||||||
setShowNewForm(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => setShowNewForm(false)}
|
|
||||||
/>
|
/>
|
||||||
)
|
</Modal>
|
||||||
: (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-new-playlist-toggle"
|
|
||||||
onClick={() => setShowNewForm(true)}
|
|
||||||
>
|
|
||||||
+ New playlist
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Link, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||||
@@ -9,22 +9,11 @@ export function AppHeader(
|
|||||||
) {
|
) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
ref={headerRef}
|
|
||||||
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
||||||
>
|
>
|
||||||
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
|
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
|
||||||
@@ -71,20 +60,6 @@ export function AppHeader(
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{
|
|
||||||
/* {user && createPortal(
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
|
||||||
onClick={() => setCreateModalOpen(true)}
|
|
||||||
aria-label="New dump"
|
|
||||||
>
|
|
||||||
+ New
|
|
||||||
</button>,
|
|
||||||
document.body,
|
|
||||||
)} */
|
|
||||||
}
|
|
||||||
|
|
||||||
{createModalOpen && (
|
{createModalOpen && (
|
||||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import React, { useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Comment, RawComment, User } from "../model.ts";
|
import type {
|
||||||
import { deserializeComment } from "../model.ts";
|
Comment,
|
||||||
|
CreateCommentRequest,
|
||||||
|
RawComment,
|
||||||
|
UpdateCommentRequest,
|
||||||
|
User,
|
||||||
|
} from "../model.ts";
|
||||||
|
import { deserializeComment, parseAPIResponse } from "../model.ts";
|
||||||
import { Avatar } from "./Avatar.tsx";
|
import { Avatar } from "./Avatar.tsx";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||||
@@ -69,7 +75,7 @@ function CommentNode({
|
|||||||
|
|
||||||
const children = tree.get(comment.id) ?? [];
|
const children = tree.get(comment.id) ?? [];
|
||||||
|
|
||||||
async function handleReply(e?: React.FormEvent) {
|
async function handleReply(e?: React.SubmitEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!replyBody.trim() || !token) return;
|
if (!replyBody.trim() || !token) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -81,15 +87,20 @@ function CommentNode({
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ body: replyBody, parentId: comment.id }),
|
body: JSON.stringify(
|
||||||
|
{
|
||||||
|
body: replyBody,
|
||||||
|
parentId: comment.id,
|
||||||
|
} satisfies CreateCommentRequest,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = parseAPIResponse<RawComment>(await res.json());
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
onCommentCreated(deserializeComment(data.data as RawComment));
|
onCommentCreated(deserializeComment(data.data));
|
||||||
setReplyBody("");
|
setReplyBody("");
|
||||||
setReplyOpen(false);
|
setReplyOpen(false);
|
||||||
} else {
|
} else {
|
||||||
setReplyError(data.error?.message ?? "Failed to post reply.");
|
setReplyError(data.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setReplyError("Could not reach the server. Please try again.");
|
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();
|
e?.preventDefault();
|
||||||
if (!editBody.trim() || !token) return;
|
if (!editBody.trim() || !token) return;
|
||||||
setEditSubmitting(true);
|
setEditSubmitting(true);
|
||||||
@@ -121,14 +132,14 @@ function CommentNode({
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ body: editBody }),
|
body: JSON.stringify({ body: editBody } satisfies UpdateCommentRequest),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = parseAPIResponse<RawComment>(await res.json());
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
onCommentUpdated(deserializeComment(data.data as RawComment));
|
onCommentUpdated(deserializeComment(data.data));
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
} else {
|
} else {
|
||||||
setEditError(data.error?.message ?? "Failed to save edit.");
|
setEditError(data.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setEditError("Could not reach the server. Please try again.");
|
setEditError("Could not reach the server. Please try again.");
|
||||||
@@ -383,7 +394,7 @@ export function CommentThread({
|
|||||||
const tree = useMemo(() => buildTree(comments), [comments]);
|
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||||
const roots = tree.get("root") ?? [];
|
const roots = tree.get("root") ?? [];
|
||||||
|
|
||||||
async function handleTopLevelSubmit(e?: React.FormEvent) {
|
async function handleTopLevelSubmit(e?: React.SubmitEvent) {
|
||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
if (!topLevelBody.trim() || !token) return;
|
if (!topLevelBody.trim() || !token) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -395,14 +406,16 @@ export function CommentThread({
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ body: topLevelBody }),
|
body: JSON.stringify(
|
||||||
|
{ body: topLevelBody } satisfies CreateCommentRequest,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = parseAPIResponse<RawComment>(await res.json());
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
onCommentCreated(deserializeComment(data.data as RawComment));
|
onCommentCreated(deserializeComment(data.data));
|
||||||
setTopLevelBody("");
|
setTopLevelBody("");
|
||||||
} else {
|
} else {
|
||||||
setTopLevelError(data.error?.message ?? "Failed to post comment.");
|
setTopLevelError(data.error.message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setTopLevelError("Could not reach the server. Please try again.");
|
setTopLevelError("Could not reach the server. Please try again.");
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
@@ -10,19 +9,24 @@ import type {
|
|||||||
RawDump,
|
RawDump,
|
||||||
RawPlaylistMembership,
|
RawPlaylistMembership,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
|
import {
|
||||||
|
deserializeDump,
|
||||||
|
deserializePlaylistMembership,
|
||||||
|
parseAPIResponse,
|
||||||
|
} from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { dumpUrl } from "../utils/urls.ts";
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
import RichContentCard from "./RichContentCard.tsx";
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
import type { RichContent } from "../model.ts";
|
import type { RichContent } from "../model.ts";
|
||||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
|
||||||
import { ErrorCard } from "./ErrorCard.tsx";
|
import { ErrorCard } from "./ErrorCard.tsx";
|
||||||
import { FileDropZone } from "./FileDropZone.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 { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
import { MAX_FILE_SIZE } from "../config/upload.ts";
|
import { MAX_FILE_SIZE } from "../config/upload.ts";
|
||||||
import { TextEditor } from "./TextEditor.tsx";
|
|
||||||
|
|
||||||
type Mode = "url" | "file";
|
type Mode = "url" | "file";
|
||||||
type Phase = "create" | "playlist";
|
type Phase = "create" | "playlist";
|
||||||
@@ -38,16 +42,10 @@ type UrlPreview =
|
|||||||
| { status: "done"; richContent: RichContent | null };
|
| { status: "done"; richContent: RichContent | null };
|
||||||
|
|
||||||
function LocalFilePreview({ file }: { file: File }) {
|
function LocalFilePreview({ file }: { file: File }) {
|
||||||
const [src, setSrc] = useState<string | null>(null);
|
const src = useMemo(() => URL.createObjectURL(file), [file]);
|
||||||
const mime = file.type;
|
const mime = file.type;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => () => URL.revokeObjectURL(src), [src]);
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setSrc(url);
|
|
||||||
return () => URL.revokeObjectURL(url);
|
|
||||||
}, [file]);
|
|
||||||
|
|
||||||
if (!src) return null;
|
|
||||||
|
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
return <img src={src} alt={file.name} className="local-preview-image" />;
|
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||||
@@ -58,7 +56,6 @@ function LocalFilePreview({ file }: { file: File }) {
|
|||||||
if (mime.startsWith("audio/")) {
|
if (mime.startsWith("audio/")) {
|
||||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||||
}
|
}
|
||||||
// For other types the drop zone chip already shows name + size.
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +66,6 @@ interface DumpCreateModalProps {
|
|||||||
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||||
const { authFetch } = useAuth();
|
const { authFetch } = useAuth();
|
||||||
const { injectDump } = useWS();
|
const { injectDump } = useWS();
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const [phase, setPhase] = useState<Phase>("create");
|
const [phase, setPhase] = useState<Phase>("create");
|
||||||
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
|
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
|
||||||
@@ -89,24 +85,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
// Playlist phase state
|
// Playlist phase state
|
||||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
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
|
// Debounced URL preview
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -172,7 +150,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
return () => globalThis.removeEventListener("paste", handler);
|
return () => globalThis.removeEventListener("paste", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitState({ status: "submitting" });
|
setSubmitState({ status: "submitting" });
|
||||||
|
|
||||||
@@ -215,9 +193,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
const dump = deserializeDump(apiResponse.data as RawDump);
|
const dump = deserializeDump(apiResponse.data);
|
||||||
injectDump(dump);
|
injectDump(dump);
|
||||||
setCreatedDump(dump);
|
setCreatedDump(dump);
|
||||||
setPhase("playlist");
|
setPhase("playlist");
|
||||||
@@ -238,7 +216,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
} else {
|
} else {
|
||||||
setSubmitState({
|
setSubmitState({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: apiResponse.error?.message ?? "Failed to create dump.",
|
error: apiResponse.error.message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -274,30 +252,12 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
|
|
||||||
const submitting = submitState.status === "submitting";
|
const submitting = submitState.status === "submitting";
|
||||||
|
|
||||||
return createPortal(
|
return (
|
||||||
<div
|
<Modal
|
||||||
className="modal-backdrop"
|
title={phase === "create" ? "New dump" : "Add to playlist"}
|
||||||
ref={backdropRef}
|
onClose={onClose}
|
||||||
onClick={(e) => {
|
wide
|
||||||
if (e.target === backdropRef.current) onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="modal-card modal-card--wide">
|
|
||||||
<div className="modal-header">
|
|
||||||
<span className="modal-title">
|
|
||||||
{phase === "create" ? "New dump" : "Add to playlist"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-close-btn"
|
|
||||||
onClick={onClose}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="modal-body">
|
|
||||||
{phase === "create"
|
{phase === "create"
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
@@ -453,59 +413,14 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{playlistsLoading
|
<PlaylistMembershipPanel
|
||||||
? <p className="page-loading">Loading playlists…</p>
|
dumpId={createdDump?.id ?? ""}
|
||||||
: memberships.length === 0 && !showNewPlaylistForm
|
memberships={memberships}
|
||||||
? <p className="empty-state">No playlists yet.</p>
|
loading={playlistsLoading}
|
||||||
: (
|
onToggle={toggleMembership}
|
||||||
<ul className="playlist-membership-list">
|
onPlaylistCreated={(membership) =>
|
||||||
{memberships.map((m) => (
|
setMemberships((prev) => [membership, ...prev])}
|
||||||
<li
|
|
||||||
key={m.playlist.id}
|
|
||||||
className={`playlist-membership-row${
|
|
||||||
m.hasDump ? " playlist-membership-row--active" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => toggleMembership(m)}
|
|
||||||
>
|
|
||||||
<span className="playlist-membership-check">
|
|
||||||
{m.hasDump ? "✓" : "○"}
|
|
||||||
</span>
|
|
||||||
<span className="playlist-membership-name">
|
|
||||||
{m.playlist.title}
|
|
||||||
</span>
|
|
||||||
{!m.playlist.isPublic && (
|
|
||||||
<span className="playlist-badge playlist-badge--private">
|
|
||||||
private
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showNewPlaylistForm
|
|
||||||
? (
|
|
||||||
<PlaylistCreateForm
|
|
||||||
dumpId={createdDump?.id}
|
|
||||||
onCreated={(playlist) => {
|
|
||||||
setMemberships((prev) => [
|
|
||||||
{ playlist, hasDump: true },
|
|
||||||
...prev,
|
|
||||||
]);
|
|
||||||
setShowNewPlaylistForm(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => setShowNewPlaylistForm(false)}
|
|
||||||
/>
|
/>
|
||||||
)
|
|
||||||
: (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-new-playlist-toggle"
|
|
||||||
onClick={() => setShowNewPlaylistForm(true)}
|
|
||||||
>
|
|
||||||
+ New playlist
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<div className="form-actions-right">
|
<div className="form-actions-right">
|
||||||
@@ -520,9 +435,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/components/Modal.tsx
Normal file
56
src/components/Modal.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { type ReactNode, useEffect, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ title, onClose, children, wide = false }: ModalProps) {
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && !e.defaultPrevented) onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-backdrop"
|
||||||
|
ref={backdropRef}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === backdropRef.current) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`modal-card${wide ? " modal-card--wide" : ""}`}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<span className="modal-title">{title}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import type { Playlist } from "../model.ts";
|
import type { Playlist } from "../model.ts";
|
||||||
|
import { Modal } from "./Modal.tsx";
|
||||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
|
|
||||||
interface NewPlaylistFormProps {
|
interface NewPlaylistFormProps {
|
||||||
@@ -17,26 +17,6 @@ export function NewPlaylistForm(
|
|||||||
}: NewPlaylistFormProps,
|
}: NewPlaylistFormProps,
|
||||||
) {
|
) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const close = () => setOpen(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") close();
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", handler);
|
|
||||||
return () => document.removeEventListener("keydown", handler);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -48,38 +28,16 @@ export function NewPlaylistForm(
|
|||||||
{toggleLabel}
|
{toggleLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && createPortal(
|
{open && (
|
||||||
<div
|
<Modal title="New playlist" onClose={() => setOpen(false)}>
|
||||||
className="modal-backdrop"
|
|
||||||
ref={backdropRef}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (e.target === backdropRef.current) close();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="modal-card">
|
|
||||||
<div className="modal-header">
|
|
||||||
<span className="modal-title">New playlist</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="modal-close-btn"
|
|
||||||
onClick={close}
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="modal-body">
|
|
||||||
<PlaylistCreateForm
|
<PlaylistCreateForm
|
||||||
onCreated={(playlist) => {
|
onCreated={(playlist) => {
|
||||||
onCreated(playlist);
|
onCreated(playlist);
|
||||||
close();
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
onCancel={close}
|
onCancel={() => setOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
import type { CreatePlaylistRequest, Playlist, RawPlaylist } from "../model.ts";
|
||||||
import { deserializePlaylist } from "../model.ts";
|
import { deserializePlaylist, parseAPIResponse } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { ErrorCard } from "./ErrorCard.tsx";
|
import { ErrorCard } from "./ErrorCard.tsx";
|
||||||
import { TextEditor } from "./TextEditor.tsx";
|
import { TextEditor } from "./TextEditor.tsx";
|
||||||
@@ -23,7 +23,7 @@ export function PlaylistCreateForm(
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.SubmitEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -32,15 +32,17 @@ export function PlaylistCreateForm(
|
|||||||
const res = await authFetch(`${API_URL}/api/playlists`, {
|
const res = await authFetch(`${API_URL}/api/playlists`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(
|
||||||
|
{
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
isPublic,
|
isPublic,
|
||||||
}),
|
} satisfies CreatePlaylistRequest,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const body = await res.json();
|
const body = parseAPIResponse<RawPlaylist>(await res.json());
|
||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
setError(body.error?.message ?? "Failed to create playlist");
|
setError(body.error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const playlist = deserializePlaylist(body.data as RawPlaylist);
|
const playlist = deserializePlaylist(body.data as RawPlaylist);
|
||||||
|
|||||||
76
src/components/PlaylistMembershipPanel.tsx
Normal file
76
src/components/PlaylistMembershipPanel.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { PlaylistMembership } from "../model.ts";
|
||||||
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
|
|
||||||
|
interface PlaylistMembershipPanelProps {
|
||||||
|
dumpId: string;
|
||||||
|
memberships: PlaylistMembership[];
|
||||||
|
loading: boolean;
|
||||||
|
onToggle: (membership: PlaylistMembership) => void;
|
||||||
|
onPlaylistCreated: (membership: PlaylistMembership) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistMembershipPanel({
|
||||||
|
dumpId,
|
||||||
|
memberships,
|
||||||
|
loading,
|
||||||
|
onToggle,
|
||||||
|
onPlaylistCreated,
|
||||||
|
}: PlaylistMembershipPanelProps) {
|
||||||
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loading
|
||||||
|
? <p className="page-loading">Loading…</p>
|
||||||
|
: memberships.length === 0 && !showNewForm
|
||||||
|
? <p className="empty-state">No playlists yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="playlist-membership-list">
|
||||||
|
{memberships.map((m) => (
|
||||||
|
<li
|
||||||
|
key={m.playlist.id}
|
||||||
|
className={`playlist-membership-row${
|
||||||
|
m.hasDump ? " playlist-membership-row--active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => onToggle(m)}
|
||||||
|
>
|
||||||
|
<span className="playlist-membership-check">
|
||||||
|
{m.hasDump ? "✓" : "○"}
|
||||||
|
</span>
|
||||||
|
<span className="playlist-membership-name">
|
||||||
|
{m.playlist.title}
|
||||||
|
</span>
|
||||||
|
{!m.playlist.isPublic && (
|
||||||
|
<span className="playlist-badge playlist-badge--private">
|
||||||
|
private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewForm
|
||||||
|
? (
|
||||||
|
<PlaylistCreateForm
|
||||||
|
dumpId={dumpId}
|
||||||
|
onCreated={(playlist) => {
|
||||||
|
onPlaylistCreated({ playlist, hasDump: true });
|
||||||
|
setShowNewForm(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowNewForm(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-new-playlist-toggle"
|
||||||
|
onClick={() => setShowNewForm(true)}
|
||||||
|
>
|
||||||
|
+ New playlist
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
src/components/ProfileSubpageHeader.tsx
Normal file
33
src/components/ProfileSubpageHeader.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import type { PublicUser } from "../model.ts";
|
||||||
|
import { Avatar } from "./Avatar.tsx";
|
||||||
|
|
||||||
|
interface ProfileSubpageHeaderProps {
|
||||||
|
username: string;
|
||||||
|
profileUser: PublicUser;
|
||||||
|
title: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileSubpageHeader(
|
||||||
|
{ username, profileUser, title, actions }: ProfileSubpageHeaderProps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="profile-subpage-header">
|
||||||
|
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||||
|
← {profileUser.username}
|
||||||
|
</Link>
|
||||||
|
<div className="profile-subpage-title-row">
|
||||||
|
<Avatar
|
||||||
|
userId={profileUser.id}
|
||||||
|
username={profileUser.username}
|
||||||
|
hasAvatar={!!profileUser.avatarMime}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<h1 className="profile-subpage-title">{title}</h1>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,13 +18,10 @@ import {
|
|||||||
import { WS_URL } from "../config/api.ts";
|
import { WS_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
Dump,
|
Dump,
|
||||||
|
IncomingWSMessage,
|
||||||
Notification,
|
Notification,
|
||||||
OnlineUser,
|
OnlineUser,
|
||||||
RawComment,
|
OutgoingWSMessage,
|
||||||
RawDump,
|
|
||||||
RawNotification,
|
|
||||||
RawPlaylist,
|
|
||||||
RawPublicUser,
|
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import {
|
import {
|
||||||
deserializeComment,
|
deserializeComment,
|
||||||
@@ -43,62 +40,18 @@ interface WSProviderProps {
|
|||||||
const MAX_BACKOFF = 30_000;
|
const MAX_BACKOFF = 30_000;
|
||||||
const ACK_TIMEOUT = 5_000;
|
const ACK_TIMEOUT = 5_000;
|
||||||
|
|
||||||
// ── Type guards for incoming WS messages ──────────────────────────────────────
|
// 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 isOnlineUser(obj: unknown): obj is OnlineUser {
|
function parseWSMessage(data: string): IncomingWSMessage | null {
|
||||||
if (!obj || typeof obj !== "object") return false;
|
try {
|
||||||
const o = obj as Record<string, unknown>;
|
const msg = JSON.parse(data);
|
||||||
return typeof o.userId === "string" &&
|
if (!msg || typeof msg !== "object" || typeof msg.type !== "string") {
|
||||||
typeof o.username === "string" &&
|
return null;
|
||||||
typeof o.hasAvatar === "boolean";
|
|
||||||
}
|
}
|
||||||
|
return msg as IncomingWSMessage;
|
||||||
function isOnlineUserArray(val: unknown): val is OnlineUser[] {
|
} catch {
|
||||||
return Array.isArray(val) && val.every(isOnlineUser);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isStringArray(val: unknown): val is string[] {
|
|
||||||
return Array.isArray(val) && val.every((x) => typeof x === "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVotesUpdatePayload(
|
|
||||||
msg: Record<string, unknown>,
|
|
||||||
): msg is {
|
|
||||||
dumpId: string;
|
|
||||||
voteCount: number;
|
|
||||||
voterId: string;
|
|
||||||
action: "cast" | "remove";
|
|
||||||
} {
|
|
||||||
return typeof msg.dumpId === "string" &&
|
|
||||||
typeof msg.voteCount === "number" &&
|
|
||||||
typeof msg.voterId === "string" &&
|
|
||||||
(msg.action === "cast" || msg.action === "remove");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVoteAckPayload(
|
|
||||||
msg: Record<string, unknown>,
|
|
||||||
): msg is { dumpId: string; action: "cast" | "remove"; voteCount: number } {
|
|
||||||
return typeof msg.dumpId === "string" &&
|
|
||||||
(msg.action === "cast" || msg.action === "remove") &&
|
|
||||||
typeof msg.voteCount === "number";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlaylistDeletedPayload(
|
|
||||||
msg: Record<string, unknown>,
|
|
||||||
): msg is { playlistId: string; userId: string } {
|
|
||||||
return typeof msg.playlistId === "string" && typeof msg.userId === "string";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlaylistDumpsUpdatedPayload(
|
|
||||||
msg: Record<string, unknown>,
|
|
||||||
): msg is { playlistId: string; dumpIds: string[] } {
|
|
||||||
return typeof msg.playlistId === "string" && isStringArray(msg.dumpIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCommentDeletedPayload(
|
|
||||||
msg: Record<string, unknown>,
|
|
||||||
): msg is { commentId: string; dumpId: string } {
|
|
||||||
return typeof msg.commentId === "string" && typeof msg.dumpId === "string";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WSProvider({ children, token, userId }: WSProviderProps) {
|
export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||||
@@ -155,39 +108,28 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
socketRef.current = ws;
|
socketRef.current = ws;
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
let msg: Record<string, unknown>;
|
const msg = parseWSMessage(event.data);
|
||||||
try {
|
if (!msg) return;
|
||||||
msg = JSON.parse(event.data);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case "ping":
|
case "ping":
|
||||||
ws.send(JSON.stringify({ type: "pong" }));
|
ws.send(
|
||||||
break;
|
JSON.stringify({ type: "pong" } satisfies OutgoingWSMessage),
|
||||||
|
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
break;
|
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":
|
case "presence_update":
|
||||||
if (isOnlineUserArray(msg.users)) setOnlineUsers(msg.users);
|
setOnlineUsers(msg.users);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "votes_update": {
|
case "votes_update": {
|
||||||
if (!isVotesUpdatePayload(msg)) break;
|
|
||||||
const { dumpId, voteCount, voterId, action } = msg;
|
const { dumpId, voteCount, voterId, action } = msg;
|
||||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||||
setLastVoteEvent({ dumpId, voterId, action });
|
setLastVoteEvent({ dumpId, voterId, action });
|
||||||
@@ -205,15 +147,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "dump_created": {
|
case "dump_created": {
|
||||||
if (!msg.dump || typeof msg.dump !== "object") break;
|
const dump = deserializeDump(msg.dump);
|
||||||
const dump = deserializeDump(msg.dump as RawDump);
|
|
||||||
setRecentDumps((prev) => [dump, ...prev]);
|
setRecentDumps((prev) => [dump, ...prev]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "dump_updated": {
|
case "dump_updated": {
|
||||||
if (!msg.dump || typeof msg.dump !== "object") break;
|
const dump = deserializeDump(msg.dump);
|
||||||
const dump = deserializeDump(msg.dump as RawDump);
|
|
||||||
setLastDumpEvent(dump);
|
setLastDumpEvent(dump);
|
||||||
// Un-delete if this dump was previously removed from the feed
|
// Un-delete if this dump was previously removed from the feed
|
||||||
// (e.g. it was made private, and is now public again).
|
// (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": {
|
case "dump_deleted": {
|
||||||
if (typeof msg.dumpId !== "string") break;
|
const { dumpId } = msg;
|
||||||
const dumpId = msg.dumpId;
|
|
||||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||||
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "vote_ack": {
|
case "vote_ack": {
|
||||||
if (!isVoteAckPayload(msg)) break;
|
|
||||||
const { dumpId, action, voteCount } = msg;
|
const { dumpId, action, voteCount } = msg;
|
||||||
// Clear pending revert timeout
|
// Clear pending revert timeout
|
||||||
const timeout = pendingRef.current.get(dumpId);
|
const timeout = pendingRef.current.get(dumpId);
|
||||||
@@ -261,8 +199,7 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
|
|
||||||
case "playlist_created":
|
case "playlist_created":
|
||||||
case "playlist_updated": {
|
case "playlist_updated": {
|
||||||
if (!msg.playlist || typeof msg.playlist !== "object") break;
|
const playlist = deserializePlaylist(msg.playlist);
|
||||||
const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
|
|
||||||
setLastPlaylistEvent({
|
setLastPlaylistEvent({
|
||||||
type: msg.type === "playlist_created" ? "created" : "updated",
|
type: msg.type === "playlist_created" ? "created" : "updated",
|
||||||
playlistId: playlist.id,
|
playlistId: playlist.id,
|
||||||
@@ -272,7 +209,6 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "playlist_deleted": {
|
case "playlist_deleted": {
|
||||||
if (!isPlaylistDeletedPayload(msg)) break;
|
|
||||||
const { playlistId, userId } = msg;
|
const { playlistId, userId } = msg;
|
||||||
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||||
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
|
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
|
||||||
@@ -280,7 +216,6 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "playlist_dumps_updated": {
|
case "playlist_dumps_updated": {
|
||||||
if (!isPlaylistDumpsUpdatedPayload(msg)) break;
|
|
||||||
const { playlistId, dumpIds } = msg;
|
const { playlistId, dumpIds } = msg;
|
||||||
setLastPlaylistEvent({
|
setLastPlaylistEvent({
|
||||||
type: "dumps_updated",
|
type: "dumps_updated",
|
||||||
@@ -291,15 +226,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "user_updated": {
|
case "user_updated": {
|
||||||
if (!msg.user || typeof msg.user !== "object") break;
|
const user = deserializePublicUser(msg.user);
|
||||||
const user = deserializePublicUser(msg.user as RawPublicUser);
|
|
||||||
setLastUserEvent({ user });
|
setLastUserEvent({ user });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "comment_created": {
|
case "comment_created": {
|
||||||
if (!msg.comment || typeof msg.comment !== "object") break;
|
const comment = deserializeComment(msg.comment);
|
||||||
const comment = deserializeComment(msg.comment as RawComment);
|
|
||||||
setLastCommentEvent({
|
setLastCommentEvent({
|
||||||
type: "created",
|
type: "created",
|
||||||
dumpId: comment.dumpId,
|
dumpId: comment.dumpId,
|
||||||
@@ -309,15 +242,13 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "comment_deleted": {
|
case "comment_deleted": {
|
||||||
if (!isCommentDeletedPayload(msg)) break;
|
|
||||||
const { commentId, dumpId } = msg;
|
const { commentId, dumpId } = msg;
|
||||||
setLastCommentEvent({ type: "deleted", dumpId, commentId });
|
setLastCommentEvent({ type: "deleted", dumpId, commentId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "comment_updated": {
|
case "comment_updated": {
|
||||||
if (!msg.comment || typeof msg.comment !== "object") break;
|
const comment = deserializeComment(msg.comment);
|
||||||
const comment = deserializeComment(msg.comment as RawComment);
|
|
||||||
setLastCommentEvent({
|
setLastCommentEvent({
|
||||||
type: "updated",
|
type: "updated",
|
||||||
dumpId: comment.dumpId,
|
dumpId: comment.dumpId,
|
||||||
@@ -327,12 +258,7 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "notification_created": {
|
case "notification_created": {
|
||||||
if (!msg.notification || typeof msg.notification !== "object") {
|
const notification = deserializeNotification(msg.notification);
|
||||||
break;
|
|
||||||
}
|
|
||||||
const notification = deserializeNotification(
|
|
||||||
msg.notification as RawNotification,
|
|
||||||
);
|
|
||||||
setLastNotification(notification);
|
setLastNotification(notification);
|
||||||
setUnreadNotificationCount((prev) => prev + 1);
|
setUnreadNotificationCount((prev) => prev + 1);
|
||||||
break;
|
break;
|
||||||
@@ -396,7 +322,9 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}, ACK_TIMEOUT);
|
}, ACK_TIMEOUT);
|
||||||
pendingRef.current.set(dumpId, 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) => {
|
const removeVote = useCallback((dumpId: string) => {
|
||||||
@@ -427,7 +355,11 @@ export function WSProvider({ children, token, userId }: WSProviderProps) {
|
|||||||
}, ACK_TIMEOUT);
|
}, ACK_TIMEOUT);
|
||||||
pendingRef.current.set(dumpId, 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) => {
|
const injectDump = useCallback((dump: Dump) => {
|
||||||
|
|||||||
24
src/hooks/useScrollSave.ts
Normal file
24
src/hooks/useScrollSave.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up a debounced scroll listener that calls `onSave(scrollY)` when the
|
||||||
|
* user scrolls, but only while `enabled` is true (e.g. the page is loaded).
|
||||||
|
*/
|
||||||
|
export function useScrollSave(
|
||||||
|
enabled: boolean,
|
||||||
|
onSave: (scrollY: number) => void,
|
||||||
|
) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => onSave(globalThis.scrollY), 100);
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [enabled, onSave]);
|
||||||
|
}
|
||||||
191
src/hooks/useUserDumpFeed.ts
Normal file
191
src/hooks/useUserDumpFeed.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
type RefObject,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||||
|
import {
|
||||||
|
deserializeDump,
|
||||||
|
deserializePublicUser,
|
||||||
|
hydrateDump,
|
||||||
|
} from "../model.ts";
|
||||||
|
import { useAuth } from "./useAuth.ts";
|
||||||
|
import { useFeedCache } from "./useFeedCache.ts";
|
||||||
|
import { useInfiniteScroll } from "./useInfiniteScroll.ts";
|
||||||
|
import { useScrollSave } from "./useScrollSave.ts";
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "error"; error: string }
|
||||||
|
| {
|
||||||
|
status: "loaded";
|
||||||
|
profileUser: PublicUser;
|
||||||
|
items: Dump[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
loadingMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface UseUserDumpFeedOptions {
|
||||||
|
/** Called with newly appended items whenever a loadMore succeeds. */
|
||||||
|
onItemsAppended?: (items: Dump[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseUserDumpFeedResult {
|
||||||
|
state: State;
|
||||||
|
setState: React.Dispatch<React.SetStateAction<State>>;
|
||||||
|
setItems: (fn: (prev: Dump[]) => Dump[]) => void;
|
||||||
|
sentinelRef: RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared data-fetching, pagination, and scroll-save logic for profile subpages
|
||||||
|
* that display a paginated list of dumps (e.g. UserDumps, UserUpvoted).
|
||||||
|
*
|
||||||
|
* @param username The route param value (may be undefined during hydration)
|
||||||
|
* @param endpoint Relative path after /api/users/:username, e.g. "dumps" or "votes"
|
||||||
|
* @param cacheKey sessionStorage key for the feed cache
|
||||||
|
* @param options Optional callbacks
|
||||||
|
*/
|
||||||
|
export function useUserDumpFeed(
|
||||||
|
username: string | undefined,
|
||||||
|
endpoint: string,
|
||||||
|
cacheKey: string,
|
||||||
|
options?: UseUserDumpFeedOptions,
|
||||||
|
): UseUserDumpFeedResult {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const { cached, saveState } = useFeedCache<Dump>(cacheKey, hydrateDump);
|
||||||
|
|
||||||
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
|
||||||
|
const setItems = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
|
setState((s) => s.status !== "loaded" ? s : { ...s, items: fn(s.items) });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!body.success) throw new Error("User not found");
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(body.data),
|
||||||
|
items: cached.items,
|
||||||
|
hasMore: cached.hasMore,
|
||||||
|
page: cached.page,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders: HeadersInit = token
|
||||||
|
? { Authorization: `Bearer ${token}` }
|
||||||
|
: {};
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/${endpoint}?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
|
{ headers: authHeaders, signal: controller.signal },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([userRes, itemsRes]) =>
|
||||||
|
Promise.all([userRes.json(), itemsRes.json()])
|
||||||
|
)
|
||||||
|
.then(([userBody, itemsBody]) => {
|
||||||
|
if (!userBody.success) throw new Error("User not found");
|
||||||
|
const { items, hasMore } = itemsBody.success
|
||||||
|
? itemsBody.data as PaginatedData<RawDump>
|
||||||
|
: { items: [], hasMore: false };
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(userBody.data),
|
||||||
|
items: items.map(deserializeDump),
|
||||||
|
hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [username, endpoint]);
|
||||||
|
|
||||||
|
const { onItemsAppended } = options ?? {};
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (
|
||||||
|
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||||
|
!username
|
||||||
|
) return;
|
||||||
|
const nextPage = state.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/${endpoint}?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
const newItems = items.map(deserializeDump);
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
items: [...s.items, ...newItems],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
onItemsAppended?.(newItems);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [state, username, token, endpoint, onItemsAppended]);
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll(
|
||||||
|
loadMore,
|
||||||
|
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||||
|
);
|
||||||
|
|
||||||
|
useScrollSave(
|
||||||
|
state.status === "loaded",
|
||||||
|
useCallback((y) => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
saveState(state.items, state.page, state.hasMore, y);
|
||||||
|
}, [state, saveState]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollRestored = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||||
|
if (state.status === "loaded") {
|
||||||
|
globalThis.scrollTo(0, cached.scrollY);
|
||||||
|
scrollRestored.current = true;
|
||||||
|
}
|
||||||
|
}, [state.status, cached]);
|
||||||
|
|
||||||
|
return { state, setState, setItems, sentinelRef };
|
||||||
|
}
|
||||||
488
src/model.ts
488
src/model.ts
@@ -5,7 +5,34 @@ export interface PaginatedData<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend
|
* API response envelope — every endpoint returns this shape.
|
||||||
|
*/
|
||||||
|
export type APIResponse<T> =
|
||||||
|
| { success: true; data: T }
|
||||||
|
| { success: false; error: { message: string } };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an unknown JSON payload into a typed APIResponse<T>.
|
||||||
|
* Performs a minimal runtime check on the `success` discriminant so the
|
||||||
|
* single internal `as` cast is safe; throws if the shape is unexpected.
|
||||||
|
*/
|
||||||
|
export function parseAPIResponse<T>(raw: unknown): APIResponse<T> {
|
||||||
|
if (raw !== null && typeof raw === "object" && "success" in raw) {
|
||||||
|
return raw as APIResponse<T>;
|
||||||
|
}
|
||||||
|
throw new Error("Unexpected response format");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage.
|
||||||
|
* WithStringDate<T> replaces Date fields with string so we can type raw API responses.
|
||||||
|
*/
|
||||||
|
type WithStringDate<T extends { createdAt: Date }> =
|
||||||
|
& Omit<T, "createdAt" | "updatedAt">
|
||||||
|
& { createdAt: string; updatedAt?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dumps
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface RichContent {
|
export interface RichContent {
|
||||||
@@ -38,43 +65,8 @@ export interface Dump {
|
|||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt?: Date;
|
|
||||||
avatarMime?: string;
|
|
||||||
description?: string;
|
|
||||||
invitedByUsername?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public user profile (no passwordHash)
|
|
||||||
export interface PublicUser {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
isAdmin: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt?: Date;
|
|
||||||
avatarMime?: string;
|
|
||||||
description?: string;
|
|
||||||
invitedByUsername?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage
|
|
||||||
type WithStringDate<T extends { createdAt: Date }> =
|
|
||||||
& Omit<T, "createdAt" | "updatedAt">
|
|
||||||
& { createdAt: string; updatedAt?: string };
|
|
||||||
export type RawDump = WithStringDate<Dump>;
|
export type RawDump = WithStringDate<Dump>;
|
||||||
export type RawUser = WithStringDate<User>;
|
|
||||||
export type RawPublicUser = WithStringDate<PublicUser>;
|
|
||||||
export type RawAuthResponse = Omit<AuthResponse, "user"> & { user: RawUser };
|
|
||||||
|
|
||||||
// Deserializers — convert wire types to domain types at API/WS/localStorage boundaries
|
|
||||||
export function deserializeDump(raw: RawDump): Dump {
|
export function deserializeDump(raw: RawDump): Dump {
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
@@ -87,17 +79,28 @@ export function hydrateDump(raw: unknown): Dump {
|
|||||||
return deserializeDump(raw as RawDump);
|
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 {
|
// User is the same shape as PublicUser in the frontend; they differ only
|
||||||
return {
|
// semantically (authenticated self vs. any public profile).
|
||||||
...raw,
|
export type User = PublicUser;
|
||||||
createdAt: new Date(raw.createdAt),
|
|
||||||
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
export type RawPublicUser = WithStringDate<PublicUser>;
|
||||||
};
|
// Alias so imports of RawUser continue to work.
|
||||||
}
|
export type RawUser = RawPublicUser;
|
||||||
|
|
||||||
export function deserializePublicUser(raw: RawPublicUser): PublicUser {
|
export function deserializePublicUser(raw: RawPublicUser): PublicUser {
|
||||||
return {
|
return {
|
||||||
@@ -107,32 +110,26 @@ export function deserializePublicUser(raw: RawPublicUser): PublicUser {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
|
// Alias so call sites using deserializeUser continue to work.
|
||||||
return { ...raw, user: deserializeUser(raw.user) };
|
export const deserializeUser = deserializePublicUser;
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginUserRequest {
|
/**
|
||||||
username: string;
|
* Authentication
|
||||||
password: string;
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterUserRequest {
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
description?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
token: string;
|
token: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RawAuthResponse = Omit<AuthResponse, "user"> & {
|
||||||
|
user: RawPublicUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
|
||||||
|
return { ...raw, user: deserializePublicUser(raw.user) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Comments
|
* Comments
|
||||||
*/
|
*/
|
||||||
@@ -189,11 +186,8 @@ export interface PlaylistMembership {
|
|||||||
|
|
||||||
export type RawPlaylist = WithStringDate<Playlist>;
|
export type RawPlaylist = WithStringDate<Playlist>;
|
||||||
export type RawPlaylistWithDumps =
|
export type RawPlaylistWithDumps =
|
||||||
& Omit<PlaylistWithDumps, "createdAt" | "dumps">
|
& Omit<WithStringDate<PlaylistWithDumps>, "dumps">
|
||||||
& {
|
& { dumps: RawDump[] };
|
||||||
createdAt: string;
|
|
||||||
dumps: RawDump[];
|
|
||||||
};
|
|
||||||
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
|
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
|
||||||
|
|
||||||
export function deserializePlaylist(raw: RawPlaylist): Playlist {
|
export function deserializePlaylist(raw: RawPlaylist): Playlist {
|
||||||
@@ -221,154 +215,8 @@ export function deserializePlaylistMembership(
|
|||||||
return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump };
|
return { playlist: deserializePlaylist(raw.playlist), hasDump: raw.hasDump };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePlaylistRequest {
|
export function hydratePlaylist(raw: unknown): Playlist {
|
||||||
title: string;
|
return deserializePlaylist(raw as RawPlaylist);
|
||||||
description?: string;
|
|
||||||
isPublic: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdatePlaylistRequest {
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
isPublic?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const APIErrorCode = {
|
|
||||||
BAD_REQUEST: "BAD_REQUEST",
|
|
||||||
NOT_FOUND: "NOT_FOUND",
|
|
||||||
SERVER_ERROR: "SERVER_ERROR",
|
|
||||||
TIMEOUT: "TIMEOUT",
|
|
||||||
UNAUTHORIZED: "UNAUTHORIZED",
|
|
||||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
|
||||||
} as const;
|
|
||||||
export type APIErrorCode = typeof APIErrorCode[keyof typeof APIErrorCode];
|
|
||||||
|
|
||||||
export interface APIError {
|
|
||||||
code: APIErrorCode;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface APISuccess<T> {
|
|
||||||
success: true;
|
|
||||||
data: T;
|
|
||||||
error?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface APIFailure {
|
|
||||||
success: false;
|
|
||||||
data?: never;
|
|
||||||
error: APIError;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type APIResponse<T> = APISuccess<T> | APIFailure;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request DTOs
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface CreateUrlDumpRequest {
|
|
||||||
url: string;
|
|
||||||
comment?: string;
|
|
||||||
isPrivate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateDumpRequest {
|
|
||||||
url?: string;
|
|
||||||
comment?: string;
|
|
||||||
isPrivate?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSockets
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface VoteCastMessage {
|
|
||||||
type: "vote_cast";
|
|
||||||
dumpId: string;
|
|
||||||
userId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoteAckMessageFailure {
|
|
||||||
type: "vote_ack";
|
|
||||||
dumpId: string;
|
|
||||||
success: false;
|
|
||||||
error: APIError;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VoteAckMessageSuccess {
|
|
||||||
type: "vote_ack";
|
|
||||||
dumpId: string;
|
|
||||||
action: "cast" | "remove";
|
|
||||||
success: true;
|
|
||||||
voteCount: number;
|
|
||||||
error?: never;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VoteAckMessage = VoteAckMessageSuccess | VoteAckMessageFailure;
|
|
||||||
|
|
||||||
export interface VoteRemoveMessage {
|
|
||||||
type: "vote_remove";
|
|
||||||
dumpId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VotesUpdateMessage {
|
|
||||||
type: "votes_update";
|
|
||||||
dumpId: string;
|
|
||||||
voteCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnlineUser {
|
|
||||||
userId: string;
|
|
||||||
username: string;
|
|
||||||
hasAvatar: boolean;
|
|
||||||
avatarVersion?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WelcomeMessage {
|
|
||||||
type: "welcome";
|
|
||||||
users: OnlineUser[];
|
|
||||||
myVotes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PresenceUpdateMessage {
|
|
||||||
type: "presence_update";
|
|
||||||
users: OnlineUser[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PingMessage {
|
|
||||||
type: "ping";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PongMessage {
|
|
||||||
type: "pong";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Frontend
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ActionResultSuccess {
|
|
||||||
success: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionResultFailure {
|
|
||||||
success: false;
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActionResult = ActionResultSuccess | ActionResultFailure;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follows
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface FollowStatus {
|
|
||||||
followedUserIds: string[];
|
|
||||||
followedPlaylistIds: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -447,3 +295,209 @@ export type RawNotification = WithStringDate<Notification>;
|
|||||||
export function deserializeNotification(raw: RawNotification): Notification {
|
export function deserializeNotification(raw: RawNotification): Notification {
|
||||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
|||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
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 {
|
import {
|
||||||
deserializeComment,
|
deserializeComment,
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
deserializePublicUser,
|
deserializePublicUser,
|
||||||
|
parseAPIResponse,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
@@ -79,9 +86,9 @@ export function Dump() {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||||
if (!apiResponse.success) {
|
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);
|
const dump: Dump = deserializeDump(apiResponse.data);
|
||||||
setDumpState({ status: "loaded", dump });
|
setDumpState({ status: "loaded", dump });
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Dump, UpdateDumpRequest } from "../model.ts";
|
import type { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
|
||||||
import { deserializeDump } from "../model.ts";
|
import { deserializeDump, parseAPIResponse } from "../model.ts";
|
||||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||||
import { formatBytes } from "../utils/format.ts";
|
import { formatBytes } from "../utils/format.ts";
|
||||||
import { dumpUrl } from "../utils/urls.ts";
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
@@ -45,7 +45,7 @@ export function DumpEdit() {
|
|||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||||
|
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
const dump: Dump = deserializeDump(apiResponse.data);
|
const dump: Dump = deserializeDump(apiResponse.data);
|
||||||
@@ -54,10 +54,7 @@ export function DumpEdit() {
|
|||||||
setIsPrivate(dump.isPrivate);
|
setIsPrivate(dump.isPrivate);
|
||||||
setState({ status: "loaded", dump });
|
setState({ status: "loaded", dump });
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error?.message ?? "Failed to load.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
@@ -92,12 +89,9 @@ export function DumpEdit() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawDump>(await res.json());
|
||||||
if (!apiResponse.success) {
|
if (!apiResponse.success) {
|
||||||
setState({
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error?.message ?? "Update failed.",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
|
import { useScrollSave } from "../hooks/useScrollSave.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
@@ -448,74 +449,39 @@ export function Index() {
|
|||||||
!dumpsState.loadingMore,
|
!dumpsState.loadingMore,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useScrollSave(
|
||||||
|
dumpsState.status === "loaded",
|
||||||
|
useCallback((y) => {
|
||||||
if (dumpsState.status !== "loaded") return;
|
if (dumpsState.status !== "loaded") return;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, y);
|
||||||
const onScroll = () => {
|
}, [dumpsState, saveState]),
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useScrollSave(
|
||||||
|
followedUsersDumps.status === "loaded",
|
||||||
|
useCallback((y) => {
|
||||||
if (followedUsersDumps.status !== "loaded") return;
|
if (followedUsersDumps.status !== "loaded") return;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
|
||||||
const onScroll = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (followedUsersDumps.status === "loaded") {
|
|
||||||
saveFollowedUsers(
|
saveFollowedUsers(
|
||||||
followedUsersDumps.dumps,
|
followedUsersDumps.dumps,
|
||||||
followedUsersDumps.page,
|
followedUsersDumps.page,
|
||||||
followedUsersDumps.hasMore,
|
followedUsersDumps.hasMore,
|
||||||
globalThis.scrollY,
|
y,
|
||||||
|
);
|
||||||
|
}, [followedUsersDumps, saveFollowedUsers]),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
globalThis.removeEventListener("scroll", onScroll);
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [followedUsersDumps, saveFollowedUsers]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useScrollSave(
|
||||||
|
followedPlaylistsDumps.status === "loaded",
|
||||||
|
useCallback((y) => {
|
||||||
if (followedPlaylistsDumps.status !== "loaded") return;
|
if (followedPlaylistsDumps.status !== "loaded") return;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
|
||||||
const onScroll = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (followedPlaylistsDumps.status === "loaded") {
|
|
||||||
saveFollowedPlaylists(
|
saveFollowedPlaylists(
|
||||||
followedPlaylistsDumps.dumps,
|
followedPlaylistsDumps.dumps,
|
||||||
followedPlaylistsDumps.page,
|
followedPlaylistsDumps.page,
|
||||||
followedPlaylistsDumps.hasMore,
|
followedPlaylistsDumps.hasMore,
|
||||||
globalThis.scrollY,
|
y,
|
||||||
|
);
|
||||||
|
}, [followedPlaylistsDumps, saveFollowedPlaylists]),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
globalThis.removeEventListener("scroll", onScroll);
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
|
|
||||||
|
|
||||||
// ── Scroll restoration ──
|
// ── Scroll restoration ──
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ import type {
|
|||||||
RawDump,
|
RawDump,
|
||||||
RawPlaylist,
|
RawPlaylist,
|
||||||
RawPlaylistWithDumps,
|
RawPlaylistWithDumps,
|
||||||
|
ReorderPlaylistRequest,
|
||||||
|
UpdatePlaylistRequest,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import {
|
import {
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
deserializePlaylistWithDumps,
|
deserializePlaylistWithDumps,
|
||||||
|
parseAPIResponse,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { playlistUrl } from "../utils/urls.ts";
|
import { playlistUrl } from "../utils/urls.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
@@ -59,6 +62,16 @@ export function PlaylistDetail() {
|
|||||||
Record<string, "cooldown" | "dismissing">
|
Record<string, "cooldown" | "dismissing">
|
||||||
>({});
|
>({});
|
||||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||||
|
// While an undo-remove is in flight (POST re-add + PUT reorder), holds the
|
||||||
|
// desired dump order so intermediate WS dumps_updated events don't cause glitches.
|
||||||
|
const pendingUndoOrderRef = useRef<string[] | null>(null);
|
||||||
|
// Debounce timer for the reorder setState in dumps_updated so that rapid
|
||||||
|
// consecutive events (POST re-add followed immediately by PUT reorder) are
|
||||||
|
// coalesced — only the final order is applied, preventing the glitch on
|
||||||
|
// other clients who don't have pendingUndoOrderRef.
|
||||||
|
const dumpReorderTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
|
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
|
||||||
// without stale closure issues (state would only update on next render).
|
// without stale closure issues (state would only update on next render).
|
||||||
@@ -90,6 +103,7 @@ export function PlaylistDetail() {
|
|||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
|
if (dumpReorderTimerRef.current) clearTimeout(dumpReorderTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchAbortRef = useRef<AbortController | null>(null);
|
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||||
@@ -126,6 +140,10 @@ export function PlaylistDetail() {
|
|||||||
setFading({});
|
setFading({});
|
||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
cancels.current.clear();
|
cancels.current.clear();
|
||||||
|
if (dumpReorderTimerRef.current) {
|
||||||
|
clearTimeout(dumpReorderTimerRef.current);
|
||||||
|
dumpReorderTimerRef.current = null;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (err.name === "AbortError") return;
|
if (err.name === "AbortError") return;
|
||||||
@@ -272,25 +290,36 @@ export function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the server-authoritative order: active dumps in ev.dumpIds order,
|
// Debounce the reorder setState so rapid consecutive dumps_updated events
|
||||||
// fading dumps (not in newIds) appended at the end.
|
// (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) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||||
return {
|
const orderedActive = orderToApply
|
||||||
...s,
|
|
||||||
playlist: {
|
|
||||||
...s.playlist,
|
|
||||||
dumps: [
|
|
||||||
...ev.dumpIds!
|
|
||||||
.filter((id) => dumpMap.has(id))
|
.filter((id) => dumpMap.has(id))
|
||||||
.map((id) => dumpMap.get(id)!),
|
.map((id) => dumpMap.get(id)!);
|
||||||
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
let ai = 0;
|
||||||
],
|
// Replace each active slot with the next server-ordered active dump;
|
||||||
},
|
// fading dumps keep their current slot unchanged.
|
||||||
};
|
const merged = s.playlist.dumps.map((d) =>
|
||||||
|
newIds.has(d.id) ? orderedActive[ai++] : d
|
||||||
|
);
|
||||||
|
// Append any newly added dumps not yet in the array.
|
||||||
|
while (ai < orderedActive.length) merged.push(orderedActive[ai++]);
|
||||||
|
return { ...s, playlist: { ...s.playlist, dumps: merged } };
|
||||||
});
|
});
|
||||||
dumpOrderRef.current = ev.dumpIds!;
|
dumpOrderRef.current = serverOrder;
|
||||||
|
}, 80);
|
||||||
} else if (ev.type === "updated" && ev.playlist) {
|
} else if (ev.type === "updated" && ev.playlist) {
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.status !== "loaded") return prev;
|
if (prev.status !== "loaded") return prev;
|
||||||
@@ -416,7 +445,11 @@ export function PlaylistDetail() {
|
|||||||
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 {
|
} catch {
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
@@ -442,13 +475,36 @@ export function PlaylistDetail() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelRemove = (dumpId: string) => {
|
const handleCancelRemove = (dumpId: string) => {
|
||||||
if (!playlistId) return;
|
if (!playlistId || state.status !== "loaded") return;
|
||||||
cancels.current.get(dumpId)?.();
|
cancels.current.get(dumpId)?.();
|
||||||
setActiveDumpIds((prev) => new Set([...prev, 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}`, {
|
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||||
method: "POST",
|
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 = () => {
|
const openEdit = () => {
|
||||||
@@ -472,19 +528,20 @@ export function PlaylistDetail() {
|
|||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(
|
||||||
...(editTitle !== state.playlist.title ? { title: editTitle } : {}),
|
{
|
||||||
|
...(editTitle !== state.playlist.title
|
||||||
|
? { title: editTitle }
|
||||||
|
: {}),
|
||||||
...(editDescription !== (state.playlist.description ?? "")
|
...(editDescription !== (state.playlist.description ?? "")
|
||||||
? { description: editDescription || null }
|
? { description: editDescription || null }
|
||||||
: {}),
|
: {}),
|
||||||
isPublic: editIsPublic,
|
isPublic: editIsPublic,
|
||||||
}),
|
} satisfies UpdatePlaylistRequest,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const updateJson = await updateRes.json() as {
|
const updateJson = parseAPIResponse<RawPlaylist>(await updateRes.json());
|
||||||
success: boolean;
|
|
||||||
data: RawPlaylist;
|
|
||||||
};
|
|
||||||
const updatedPlaylist = updateJson.success
|
const updatedPlaylist = updateJson.success
|
||||||
? deserializePlaylist(updateJson.data)
|
? deserializePlaylist(updateJson.data)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -1,200 +1,47 @@
|
|||||||
import {
|
import { useState } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Link, useParams } from "react-router";
|
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 { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
|
||||||
import { DumpCard } from "../components/DumpCard.tsx";
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||||
|
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.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() {
|
export function UserDumps() {
|
||||||
const { username } = useParams();
|
const { username } = useParams();
|
||||||
const { user: me, token } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
|
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
|
||||||
const { cached, saveState } = useFeedCache<Dump>(
|
|
||||||
|
const { state, setItems, sentinelRef } = useUserDumpFeed(
|
||||||
|
username,
|
||||||
|
"dumps",
|
||||||
`feed:user-dumps-full:${username ?? ""}`,
|
`feed:user-dumps-full:${username ?? ""}`,
|
||||||
hydrateDump,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [state, setState] = useState<State>({ status: "loading" });
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
const isOwnProfile = me?.id === profileUserId;
|
const isOwnProfile = me?.id === profileUserId;
|
||||||
|
|
||||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
const dumpItems = state.status === "loaded" ? state.items : [];
|
||||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
|
|
||||||
}, []);
|
|
||||||
const dumpItems = state.status === "loaded" ? state.dumps : [];
|
|
||||||
usePositionAwareSync(
|
usePositionAwareSync(
|
||||||
dumpItems,
|
dumpItems,
|
||||||
setDumps,
|
setItems,
|
||||||
lastDumpEvent,
|
lastDumpEvent,
|
||||||
(d) => d.isPrivate,
|
(d) => d.isPrivate,
|
||||||
(d) => !d.isPrivate && d.userId === profileUserId,
|
(d) => !d.isPrivate && d.userId === profileUserId,
|
||||||
);
|
);
|
||||||
useDumpListSync(setDumps, {
|
useDumpListSync(setItems, {
|
||||||
ownerId: profileUserId ?? undefined,
|
ownerId: profileUserId ?? undefined,
|
||||||
isOwner: isOwnProfile,
|
isOwner: isOwnProfile,
|
||||||
skipReinsert: true,
|
skipReinsert: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!username) return;
|
|
||||||
setState({ status: "loading" });
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
if (!body.success) throw new Error("User not found");
|
|
||||||
setState({
|
|
||||||
status: "loaded",
|
|
||||||
profileUser: deserializePublicUser(body.data),
|
|
||||||
dumps: cached.items,
|
|
||||||
hasMore: cached.hasMore,
|
|
||||||
page: cached.page,
|
|
||||||
loadingMore: false,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name === "AbortError") return;
|
|
||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeaders: HeadersInit = token
|
|
||||||
? { Authorization: `Bearer ${token}` }
|
|
||||||
: {};
|
|
||||||
Promise.all([
|
|
||||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
|
|
||||||
fetch(
|
|
||||||
`${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
|
||||||
{ headers: authHeaders, signal: controller.signal },
|
|
||||||
),
|
|
||||||
])
|
|
||||||
.then(([userRes, dumpsRes]) =>
|
|
||||||
Promise.all([userRes.json(), dumpsRes.json()])
|
|
||||||
)
|
|
||||||
.then(([userBody, dumpsBody]) => {
|
|
||||||
if (!userBody.success) throw new Error("User not found");
|
|
||||||
const { items, hasMore } = dumpsBody.success
|
|
||||||
? dumpsBody.data as PaginatedData<RawDump>
|
|
||||||
: { items: [], hasMore: false };
|
|
||||||
setState({
|
|
||||||
status: "loaded",
|
|
||||||
profileUser: deserializePublicUser(userBody.data),
|
|
||||||
dumps: items.map(deserializeDump),
|
|
||||||
hasMore,
|
|
||||||
page: 1,
|
|
||||||
loadingMore: false,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name === "AbortError") return;
|
|
||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [username]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
if (
|
|
||||||
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
|
||||||
!username
|
|
||||||
) return;
|
|
||||||
const nextPage = state.page + 1;
|
|
||||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
|
||||||
fetch(
|
|
||||||
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
|
||||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
|
||||||
)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
|
||||||
hasMore,
|
|
||||||
page: nextPage,
|
|
||||||
loadingMore: false,
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [state, username, token]);
|
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll(
|
|
||||||
loadMore,
|
|
||||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.status !== "loaded") return;
|
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
|
||||||
const onScroll = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (state.status !== "loaded") return;
|
|
||||||
saveState(state.dumps, state.page, state.hasMore, globalThis.scrollY);
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
globalThis.removeEventListener("scroll", onScroll);
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [state, saveState]);
|
|
||||||
|
|
||||||
const scrollRestored = useRef(false);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
|
||||||
if (state.status === "loaded") {
|
|
||||||
globalThis.scrollTo(0, cached.scrollY);
|
|
||||||
scrollRestored.current = true;
|
|
||||||
}
|
|
||||||
}, [state.status, cached]);
|
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -216,26 +63,15 @@ export function UserDumps() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { profileUser, dumps, hasMore, loadingMore } = state;
|
const { profileUser, items: dumps, hasMore, loadingMore } = state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="profile-subpage-header">
|
<ProfileSubpageHeader
|
||||||
<Link
|
username={username!}
|
||||||
to={`/users/${username}`}
|
profileUser={profileUser}
|
||||||
className="profile-subpage-back"
|
title="Dumps"
|
||||||
>
|
actions={isOwnProfile && (
|
||||||
← {profileUser.username}
|
|
||||||
</Link>
|
|
||||||
<div className="profile-subpage-title-row">
|
|
||||||
<Avatar
|
|
||||||
userId={profileUser.id}
|
|
||||||
username={profileUser.username}
|
|
||||||
hasAvatar={!!profileUser.avatarMime}
|
|
||||||
size={36}
|
|
||||||
/>
|
|
||||||
<h1 className="profile-subpage-title">Dumps</h1>
|
|
||||||
{isOwnProfile && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="new-playlist-toggle"
|
className="new-playlist-toggle"
|
||||||
@@ -244,8 +80,7 @@ export function UserDumps() {
|
|||||||
+ New dump
|
+ New dump
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{createModalOpen && (
|
{createModalOpen && (
|
||||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import type { SubmitEvent } from "react";
|
|||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
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 { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
@@ -26,26 +31,23 @@ export function UserLogin() {
|
|||||||
setState({ status: "submitting" });
|
setState({ status: "submitting" });
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const username = formData.get("username");
|
const username = formData.get("username") as string;
|
||||||
const password = formData.get("password");
|
const password = formData.get("password") as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/users/login`, {
|
const res = await fetch(`${API_URL}/api/users/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password } satisfies LoginRequest),
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
|
||||||
|
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
login(deserializeAuthResponse(apiResponse.data));
|
login(deserializeAuthResponse(apiResponse.data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error?.message ?? "Login failed.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
|
|||||||
@@ -26,9 +26,10 @@ import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
|||||||
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.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 { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||||
|
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
|
||||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
@@ -283,15 +284,10 @@ export function UserPlaylists() {
|
|||||||
!state.followed.loadingMore,
|
!state.followed.loadingMore,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Scroll save
|
useScrollSave(
|
||||||
useEffect(() => {
|
state.status === "loaded",
|
||||||
|
useCallback((y) => {
|
||||||
if (state.status !== "loaded") return;
|
if (state.status !== "loaded") return;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
|
||||||
const onScroll = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (state.status !== "loaded") return;
|
|
||||||
const y = globalThis.scrollY;
|
|
||||||
saveCreated(
|
saveCreated(
|
||||||
state.created.items,
|
state.created.items,
|
||||||
state.created.page,
|
state.created.page,
|
||||||
@@ -304,14 +300,8 @@ export function UserPlaylists() {
|
|||||||
state.followed.hasMore,
|
state.followed.hasMore,
|
||||||
y,
|
y,
|
||||||
);
|
);
|
||||||
}, 100);
|
}, [state, saveCreated, saveFollowed]),
|
||||||
};
|
);
|
||||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
globalThis.removeEventListener("scroll", onScroll);
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [state, saveCreated, saveFollowed]);
|
|
||||||
|
|
||||||
const scrollRestored = useRef(false);
|
const scrollRestored = useRef(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -364,19 +354,11 @@ export function UserPlaylists() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="profile-subpage-header">
|
<ProfileSubpageHeader
|
||||||
<Link to={`/users/${username}`} className="profile-subpage-back">
|
username={username!}
|
||||||
← {profileUser.username}
|
profileUser={profileUser}
|
||||||
</Link>
|
title="Playlists"
|
||||||
<div className="profile-subpage-title-row">
|
actions={isOwnProfile && (
|
||||||
<Avatar
|
|
||||||
userId={profileUser.id}
|
|
||||||
username={profileUser.username}
|
|
||||||
hasAvatar={!!profileUser.avatarMime}
|
|
||||||
size={36}
|
|
||||||
/>
|
|
||||||
<h1 className="profile-subpage-title">Playlists</h1>
|
|
||||||
{isOwnProfile && (
|
|
||||||
<NewPlaylistForm
|
<NewPlaylistForm
|
||||||
toggleClassName="btn-primary"
|
toggleClassName="btn-primary"
|
||||||
onCreated={(p) =>
|
onCreated={(p) =>
|
||||||
@@ -390,8 +372,7 @@ export function UserPlaylists() {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="profile-section">
|
<section className="profile-section">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import {
|
|||||||
deserializeAuthResponse,
|
deserializeAuthResponse,
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
deserializePublicUser,
|
deserializePublicUser,
|
||||||
deserializeUser,
|
|
||||||
hydrateDump,
|
hydrateDump,
|
||||||
hydratePlaylist,
|
hydratePlaylist,
|
||||||
|
parseAPIResponse,
|
||||||
type RawDump,
|
type RawDump,
|
||||||
type RawUser,
|
type RawPublicUser,
|
||||||
|
type UpdateUserRequest,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
import { DumpCard } from "../components/DumpCard.tsx";
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
@@ -478,28 +479,24 @@ export function UserPublicProfile() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
const body = await res.json() as {
|
const body = parseAPIResponse<RawPublicUser>(await res.json());
|
||||||
success: boolean;
|
|
||||||
data?: RawUser;
|
|
||||||
error?: { message: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!res.ok || !body.success) {
|
if (!body.success) {
|
||||||
setAvatarError(body.error?.message ?? "Upload failed");
|
setAvatarError(body.error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedRaw = localStorage.getItem("authResponse");
|
const storedRaw = localStorage.getItem("authResponse");
|
||||||
if (storedRaw && body.data) {
|
if (storedRaw) {
|
||||||
login({
|
login({
|
||||||
...deserializeAuthResponse(JSON.parse(storedRaw)),
|
...deserializeAuthResponse(JSON.parse(storedRaw)),
|
||||||
user: deserializeUser(body.data),
|
user: deserializePublicUser(body.data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setState((prev) =>
|
setState((prev) =>
|
||||||
prev.status === "loaded" && body.data
|
prev.status === "loaded"
|
||||||
? { ...prev, user: deserializeUser(body.data) }
|
? { ...prev, user: deserializePublicUser(body.data) }
|
||||||
: prev
|
: prev
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -517,11 +514,16 @@ export function UserPublicProfile() {
|
|||||||
try {
|
try {
|
||||||
const res = await authFetch(`${API_URL}/api/users/me`, {
|
const res = await authFetch(`${API_URL}/api/users/me`, {
|
||||||
method: "PATCH",
|
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();
|
const body = parseAPIResponse<RawPublicUser>(await res.json());
|
||||||
if (!res.ok || !body.success) {
|
if (!body.success) {
|
||||||
setDescError(body.error?.message ?? "Failed to save");
|
setDescError(body.error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState((s) =>
|
setState((s) =>
|
||||||
@@ -949,6 +951,7 @@ function UpvotedDumpList(
|
|||||||
canVote={canVote}
|
canVote={canVote}
|
||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
|
isOwner={isOwnProfile}
|
||||||
className={extraCls}
|
className={extraCls}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import type { SubmitEvent } from "react";
|
|||||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL, VALIDATION } from "../config/api.ts";
|
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 { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
@@ -47,26 +52,25 @@ export function UserRegister() {
|
|||||||
setFormState({ status: "submitting" });
|
setFormState({ status: "submitting" });
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const username = formData.get("username");
|
const username = formData.get("username") as string;
|
||||||
const password = formData.get("password");
|
const password = formData.get("password") as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/users/register`, {
|
const res = await fetch(`${API_URL}/api/users/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password, inviteToken: token }),
|
body: JSON.stringify(
|
||||||
|
{ username, password, inviteToken: token } satisfies RegisterRequest,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = parseAPIResponse<RawAuthResponse>(await res.json());
|
||||||
|
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
login(deserializeAuthResponse(apiResponse.data));
|
login(deserializeAuthResponse(apiResponse.data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} else {
|
} else {
|
||||||
setFormState({
|
setFormState({ status: "error", error: apiResponse.error.message });
|
||||||
status: "error",
|
|
||||||
error: apiResponse.error?.message ?? "Registration failed.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormState({ status: "error", error: friendlyFetchError(err) });
|
setFormState({ status: "error", error: friendlyFetchError(err) });
|
||||||
|
|||||||
@@ -1,139 +1,60 @@
|
|||||||
import {
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useLayoutEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
import { deserializeDump } from "../model.ts";
|
||||||
import {
|
|
||||||
deserializeDump,
|
|
||||||
deserializePublicUser,
|
|
||||||
hydrateDump,
|
|
||||||
} from "../model.ts";
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { useFading } from "../hooks/useFading.ts";
|
import { useFading } from "../hooks/useFading.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useUserDumpFeed } from "../hooks/useUserDumpFeed.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
|
||||||
import { DumpCard } from "../components/DumpCard.tsx";
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
|
import { ProfileSubpageHeader } from "../components/ProfileSubpageHeader.tsx";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.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() {
|
export function UserUpvoted() {
|
||||||
const { username } = useParams();
|
const { username } = useParams();
|
||||||
const { user: me, token } = useAuth();
|
const { user: me } = useAuth();
|
||||||
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
||||||
const { cached, saveState } = useFeedCache<Dump>(
|
|
||||||
`feed:user-upvoted-full:${username ?? ""}`,
|
|
||||||
hydrateDump,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [state, setState] = useState<State>({ status: "loading" });
|
|
||||||
|
|
||||||
const setVotesDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
|
||||||
setState((s) => s.status !== "loaded" ? s : { ...s, votes: fn(s.votes) });
|
|
||||||
}, []);
|
|
||||||
useDumpListSync(setVotesDumps);
|
|
||||||
|
|
||||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||||
const { fading, startFading, cancelFading, cancelAll } = useFading();
|
const { fading, startFading, cancelFading, cancelAll } = useFading();
|
||||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const onItemsAppended = useCallback((newItems: Dump[]) => {
|
||||||
if (!username) return;
|
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
|
||||||
setState({ status: "loading" });
|
}, []);
|
||||||
cancelAll();
|
|
||||||
setVotedIds(new Set());
|
|
||||||
prevMyVotesRef.current = null;
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
if (cached) {
|
const { state, setState, setItems, sentinelRef } = useUserDumpFeed(
|
||||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
username,
|
||||||
.then((r) => r.json())
|
"votes",
|
||||||
.then((body) => {
|
`feed:user-upvoted-full:${username ?? ""}`,
|
||||||
if (!body.success) throw new Error("User not found");
|
{ onItemsAppended },
|
||||||
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 authHeaders: HeadersInit = token
|
useDumpListSync(setItems);
|
||||||
? { Authorization: `Bearer ${token}` }
|
|
||||||
: {};
|
|
||||||
Promise.all([
|
|
||||||
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
|
|
||||||
fetch(
|
|
||||||
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
|
||||||
{ headers: authHeaders, signal: controller.signal },
|
|
||||||
),
|
|
||||||
])
|
|
||||||
.then(([userRes, votesRes]) =>
|
|
||||||
Promise.all([userRes.json(), votesRes.json()])
|
|
||||||
)
|
|
||||||
.then(([userBody, votesBody]) => {
|
|
||||||
if (!userBody.success) throw new Error("User not found");
|
|
||||||
const { items, hasMore } = votesBody.success
|
|
||||||
? votesBody.data as PaginatedData<RawDump>
|
|
||||||
: { items: [], hasMore: false };
|
|
||||||
const voteItems = items.map(deserializeDump);
|
|
||||||
setState({
|
|
||||||
status: "loaded",
|
|
||||||
profileUser: deserializePublicUser(userBody.data),
|
|
||||||
votes: voteItems,
|
|
||||||
hasMore,
|
|
||||||
page: 1,
|
|
||||||
loadingMore: false,
|
|
||||||
});
|
|
||||||
setVotedIds(new Set(voteItems.map((d) => d.id)));
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name === "AbortError") return;
|
|
||||||
setState({ status: "error", error: friendlyFetchError(err) });
|
|
||||||
});
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [username]);
|
|
||||||
|
|
||||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
|
|
||||||
// Own profile: keep votedIds in sync with myVotes.
|
// Reset vote tracking when username changes
|
||||||
// Fading is triggered directly here to avoid a gap render between
|
useEffect(() => {
|
||||||
// setVotedIds and the old prevVotedIds tracking effect.
|
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(() => {
|
useEffect(() => {
|
||||||
if (!profileUserId || me?.id !== profileUserId) return;
|
if (!profileUserId || me?.id !== profileUserId) return;
|
||||||
if (prevMyVotesRef.current === null) {
|
if (prevMyVotesRef.current === null) {
|
||||||
// First sync after load: initialize without animating the diff.
|
|
||||||
setVotedIds(new Set(myVotes));
|
setVotedIds(new Set(myVotes));
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
return;
|
return;
|
||||||
@@ -157,7 +78,6 @@ export function UserUpvoted() {
|
|||||||
n.delete(dumpId);
|
n.delete(dumpId);
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
// Start fading in same batch so visibleDumps never has a gap render.
|
|
||||||
startFading(dumpId);
|
startFading(dumpId);
|
||||||
} else {
|
} else {
|
||||||
setVotedIds((prev) => new Set([...prev, dumpId]));
|
setVotedIds((prev) => new Set([...prev, dumpId]));
|
||||||
@@ -168,82 +88,16 @@ export function UserUpvoted() {
|
|||||||
if (!body.success) return;
|
if (!body.success) return;
|
||||||
const dump = deserializeDump(body.data);
|
const dump = deserializeDump(body.data);
|
||||||
setState((s) => {
|
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;
|
||||||
}
|
}
|
||||||
return { ...s, votes: [dump, ...s.votes] };
|
return { ...s, items: [dump, ...s.items] };
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
|
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
if (
|
|
||||||
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
|
||||||
!username
|
|
||||||
) return;
|
|
||||||
const nextPage = state.page + 1;
|
|
||||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
|
||||||
fetch(
|
|
||||||
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
|
||||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
|
||||||
)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
||||||
const newItems = items.map(deserializeDump);
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
votes: [...s.votes, ...newItems],
|
|
||||||
hasMore,
|
|
||||||
page: nextPage,
|
|
||||||
loadingMore: false,
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, [state, username, token]);
|
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll(
|
|
||||||
loadMore,
|
|
||||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.status !== "loaded") return;
|
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
|
||||||
const onScroll = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
if (state.status !== "loaded") return;
|
|
||||||
saveState(state.votes, state.page, state.hasMore, globalThis.scrollY);
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => {
|
|
||||||
globalThis.removeEventListener("scroll", onScroll);
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [state, saveState]);
|
|
||||||
|
|
||||||
const scrollRestored = useRef(false);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
|
||||||
if (state.status === "loaded") {
|
|
||||||
globalThis.scrollTo(0, cached.scrollY);
|
|
||||||
scrollRestored.current = true;
|
|
||||||
}
|
|
||||||
}, [state.status, cached]);
|
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -265,27 +119,18 @@ export function UserUpvoted() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { profileUser, votes, hasMore, loadingMore } = state;
|
const { profileUser, items: votes, hasMore, loadingMore } = state;
|
||||||
const visibleDumps = votes.filter((d) =>
|
const visibleDumps = votes.filter((d) =>
|
||||||
votedIds.has(d.id) || d.id in fading
|
votedIds.has(d.id) || d.id in fading
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="profile-subpage-header">
|
<ProfileSubpageHeader
|
||||||
<Link to={`/users/${username}`} className="profile-subpage-back">
|
username={username!}
|
||||||
← {profileUser.username}
|
profileUser={profileUser}
|
||||||
</Link>
|
title="Upvoted"
|
||||||
<div className="profile-subpage-title-row">
|
|
||||||
<Avatar
|
|
||||||
userId={profileUser.id}
|
|
||||||
username={profileUser.username}
|
|
||||||
hasAvatar={!!profileUser.avatarMime}
|
|
||||||
size={36}
|
|
||||||
/>
|
/>
|
||||||
<h1 className="profile-subpage-title">Upvoted</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visibleDumps.length === 0
|
{visibleDumps.length === 0
|
||||||
? <p className="empty-state">Nothing here yet.</p>
|
? <p className="empty-state">Nothing here yet.</p>
|
||||||
@@ -308,6 +153,7 @@ export function UserUpvoted() {
|
|||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
className={extraCls}
|
className={extraCls}
|
||||||
|
isOwner={!!me && me.id === dump.userId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user