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