v3: code quality pass, various bug fixes
This commit is contained in:
@@ -1,4 +1,8 @@
|
|||||||
|
GERBEUR_PROTOCOL=http
|
||||||
|
GERBEUR_HOSTNAME=localhost
|
||||||
|
GERBEUR_PORT=8000
|
||||||
|
JWT_SECRET=
|
||||||
|
|
||||||
VITE_API_PROTOCOL=http
|
VITE_API_PROTOCOL=http
|
||||||
VITE_WS_PROTOCOL=ws
|
|
||||||
VITE_SERVER_HOST=localhost
|
VITE_SERVER_HOST=localhost
|
||||||
VITE_SERVER_PORT=8000
|
VITE_SERVER_PORT=8000
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
PROTOCOL=http
|
|
||||||
HOSTNAME=localhost
|
|
||||||
PORT=8000
|
|
||||||
10
api/lib/auth.ts
Normal file
10
api/lib/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Context } from "@oak/oak";
|
||||||
|
import { verifyJWT } from "./jwt.ts";
|
||||||
|
|
||||||
|
/** Extracts the userId from an optional Bearer token. Returns null if absent or invalid. */
|
||||||
|
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
||||||
|
const authHeader = ctx.request.headers.get("Authorization");
|
||||||
|
if (!authHeader?.startsWith("Bearer ")) return null;
|
||||||
|
const payload = await verifyJWT(authHeader.substring(7));
|
||||||
|
return payload?.userId ?? null;
|
||||||
|
}
|
||||||
@@ -8,8 +8,13 @@ import {
|
|||||||
isInvitePayload,
|
isInvitePayload,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
const JWT_SECRET = "FIXME-gerbeur-dev-env";
|
const jwtSecret = Deno.env.get("JWT_SECRET");
|
||||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
if (!jwtSecret) {
|
||||||
|
throw new Error(
|
||||||
|
"JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const JWT_KEY = new TextEncoder().encode(jwtSecret);
|
||||||
|
|
||||||
// ── Invite tokens ─────────────────────────────────────────────────────────────
|
// ── Invite tokens ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
22
api/lib/pagination.ts
Normal file
22
api/lib/pagination.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Parses page/limit query parameters with sensible defaults and bounds.
|
||||||
|
* page: clamped to [1, ∞)
|
||||||
|
* limit: clamped to [1, 100], defaults to 20
|
||||||
|
*/
|
||||||
|
export function parsePagination(
|
||||||
|
params: URLSearchParams,
|
||||||
|
defaultLimit = 20,
|
||||||
|
): { page: number; limit: number } {
|
||||||
|
const page = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(params.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(params.get("limit") ?? String(defaultLimit)) || defaultLimit,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
return { page, limit };
|
||||||
|
}
|
||||||
@@ -2,6 +2,20 @@
|
|||||||
* Backend
|
* Backend
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// ── Validation constants (shared with frontend via src/config/api.ts) ──────────
|
||||||
|
export const VALIDATION = {
|
||||||
|
USERNAME_MIN: 1,
|
||||||
|
USERNAME_MAX: 32,
|
||||||
|
PASSWORD_MIN: 8,
|
||||||
|
PASSWORD_MAX: 128,
|
||||||
|
DUMP_TITLE_MAX: 200,
|
||||||
|
DUMP_COMMENT_MAX: 5000,
|
||||||
|
PLAYLIST_TITLE_MAX: 100,
|
||||||
|
PLAYLIST_DESCRIPTION_MAX: 2000,
|
||||||
|
COMMENT_BODY_MAX: 5000,
|
||||||
|
USER_DESCRIPTION_MAX: 2000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface RichContent {
|
export interface RichContent {
|
||||||
type: string;
|
type: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -75,19 +89,42 @@ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
|
|||||||
export function isRegisterUserRequest(
|
export function isRegisterUserRequest(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
): obj is RegisterUserRequest {
|
): obj is RegisterUserRequest {
|
||||||
return !!obj && typeof obj === "object" &&
|
if (
|
||||||
"username" in obj && typeof obj.username === "string" &&
|
!obj || typeof obj !== "object" ||
|
||||||
"password" in obj && typeof obj.password === "string" &&
|
!("username" in obj) || typeof obj.username !== "string" ||
|
||||||
"inviteToken" in obj && typeof obj.inviteToken === "string";
|
!("password" in obj) || typeof obj.password !== "string" ||
|
||||||
|
!("inviteToken" in obj) || typeof obj.inviteToken !== "string"
|
||||||
|
) return false;
|
||||||
|
const { username, password } = obj as RegisterUserRequest;
|
||||||
|
return /^[a-zA-Z0-9_]{1,32}$/.test(username) &&
|
||||||
|
password.length >= VALIDATION.PASSWORD_MIN &&
|
||||||
|
password.length <= VALIDATION.PASSWORD_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
||||||
return !!obj && typeof obj === "object" &&
|
if (!obj || typeof obj !== "object") return false;
|
||||||
(!("username" in obj) || typeof obj.username === "string") &&
|
const o = obj as Record<string, unknown>;
|
||||||
(!("password" in obj) || typeof obj.password === "string") &&
|
if ("username" in o) {
|
||||||
(!("isAdmin" in obj) || typeof obj.isAdmin === "boolean") &&
|
if (typeof o.username !== "string") return false;
|
||||||
(!("description" in obj) || typeof obj.description === "string" ||
|
if (!/^[a-zA-Z0-9_]{1,32}$/.test(o.username as string)) return false;
|
||||||
obj.description === null);
|
}
|
||||||
|
if ("password" in o) {
|
||||||
|
if (typeof o.password !== "string") return false;
|
||||||
|
const len = (o.password as string).length;
|
||||||
|
if (len < VALIDATION.PASSWORD_MIN || len > VALIDATION.PASSWORD_MAX) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ("isAdmin" in o && typeof o.isAdmin !== "boolean") return false;
|
||||||
|
if (
|
||||||
|
"description" in o && typeof o.description !== "string" &&
|
||||||
|
o.description !== null
|
||||||
|
) return false;
|
||||||
|
if (
|
||||||
|
typeof o.description === "string" &&
|
||||||
|
(o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX
|
||||||
|
) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
@@ -200,7 +237,9 @@ export function isCreateCommentRequest(
|
|||||||
): obj is CreateCommentRequest {
|
): obj is CreateCommentRequest {
|
||||||
if (!obj || typeof obj !== "object") return false;
|
if (!obj || typeof obj !== "object") return false;
|
||||||
const o = obj as Record<string, unknown>;
|
const o = obj as Record<string, unknown>;
|
||||||
return typeof o.body === "string" && (o.body as string).trim().length > 0 &&
|
return typeof o.body === "string" &&
|
||||||
|
(o.body as string).trim().length > 0 &&
|
||||||
|
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX &&
|
||||||
(!("parentId" in o) || typeof o.parentId === "string" ||
|
(!("parentId" in o) || typeof o.parentId === "string" ||
|
||||||
o.parentId === null);
|
o.parentId === null);
|
||||||
}
|
}
|
||||||
@@ -214,7 +253,9 @@ export function isUpdateCommentRequest(
|
|||||||
): obj is UpdateCommentRequest {
|
): obj is UpdateCommentRequest {
|
||||||
if (!obj || typeof obj !== "object") return false;
|
if (!obj || typeof obj !== "object") return false;
|
||||||
const o = obj as Record<string, unknown>;
|
const o = obj as Record<string, unknown>;
|
||||||
return typeof o.body === "string" && (o.body as string).trim().length > 0;
|
return typeof o.body === "string" &&
|
||||||
|
(o.body as string).trim().length > 0 &&
|
||||||
|
(o.body as string).length <= VALIDATION.COMMENT_BODY_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,21 +304,43 @@ export interface ReorderPlaylistRequest {
|
|||||||
export function isCreatePlaylistRequest(
|
export function isCreatePlaylistRequest(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
): obj is CreatePlaylistRequest {
|
): obj is CreatePlaylistRequest {
|
||||||
return !!obj && typeof obj === "object" &&
|
if (
|
||||||
"title" in obj && typeof obj.title === "string" &&
|
!obj || typeof obj !== "object" ||
|
||||||
(!("description" in obj) || typeof obj.description === "string" ||
|
!("title" in obj) || typeof obj.title !== "string" ||
|
||||||
obj.description === null) &&
|
!("isPublic" in obj) || typeof obj.isPublic !== "boolean"
|
||||||
"isPublic" in obj && typeof obj.isPublic === "boolean";
|
) return false;
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false;
|
||||||
|
if (
|
||||||
|
"description" in o && typeof o.description !== "string" &&
|
||||||
|
o.description !== null
|
||||||
|
) return false;
|
||||||
|
if (
|
||||||
|
typeof o.description === "string" &&
|
||||||
|
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||||
|
) return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUpdatePlaylistRequest(
|
export function isUpdatePlaylistRequest(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
): obj is UpdatePlaylistRequest {
|
): obj is UpdatePlaylistRequest {
|
||||||
return !!obj && typeof obj === "object" &&
|
if (!obj || typeof obj !== "object") return false;
|
||||||
(!("title" in obj) || typeof obj.title === "string") &&
|
const o = obj as Record<string, unknown>;
|
||||||
(!("description" in obj) || typeof obj.description === "string" ||
|
if ("title" in o) {
|
||||||
obj.description === null) &&
|
if (typeof o.title !== "string") return false;
|
||||||
(!("isPublic" in obj) || typeof obj.isPublic === "boolean");
|
if ((o.title as string).length === 0 || (o.title as string).length > VALIDATION.PLAYLIST_TITLE_MAX) return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"description" in o && typeof o.description !== "string" &&
|
||||||
|
o.description !== null
|
||||||
|
) return false;
|
||||||
|
if (
|
||||||
|
typeof o.description === "string" &&
|
||||||
|
(o.description as string).length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
|
||||||
|
) return false;
|
||||||
|
if ("isPublic" in o && typeof o.isPublic !== "boolean") return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isReorderPlaylistRequest(
|
export function isReorderPlaylistRequest(
|
||||||
@@ -301,12 +364,20 @@ export interface CreateUrlDumpRequest {
|
|||||||
export function isCreateUrlDumpRequest(
|
export function isCreateUrlDumpRequest(
|
||||||
obj: unknown,
|
obj: unknown,
|
||||||
): obj is CreateUrlDumpRequest {
|
): obj is CreateUrlDumpRequest {
|
||||||
return !!obj &&
|
if (
|
||||||
typeof obj === "object" &&
|
!obj || typeof obj !== "object" ||
|
||||||
"url" in obj && typeof obj.url === "string" &&
|
!("url" in obj) || typeof obj.url !== "string"
|
||||||
(!("comment" in obj) ||
|
) return false;
|
||||||
typeof obj.comment === "string" || obj.comment === null) &&
|
const o = obj as Record<string, unknown>;
|
||||||
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
|
if (
|
||||||
|
"comment" in o && typeof o.comment !== "string" && o.comment !== null
|
||||||
|
) return false;
|
||||||
|
if (
|
||||||
|
typeof o.comment === "string" &&
|
||||||
|
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
|
||||||
|
) return false;
|
||||||
|
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDumpRequest {
|
export interface UpdateDumpRequest {
|
||||||
@@ -316,12 +387,18 @@ export interface UpdateDumpRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
||||||
return !!obj &&
|
if (!obj || typeof obj !== "object") return false;
|
||||||
typeof obj === "object" &&
|
const o = obj as Record<string, unknown>;
|
||||||
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) &&
|
if ("url" in o && typeof o.url !== "string" && o.url !== null) return false;
|
||||||
(!("comment" in obj) ||
|
if (
|
||||||
typeof obj.comment === "string" || obj.comment === null) &&
|
"comment" in o && typeof o.comment !== "string" && o.comment !== null
|
||||||
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
|
) return false;
|
||||||
|
if (
|
||||||
|
typeof o.comment === "string" &&
|
||||||
|
(o.comment as string).length > VALIDATION.DUMP_COMMENT_MAX
|
||||||
|
) return false;
|
||||||
|
if ("isPrivate" in o && typeof o.isPrivate !== "boolean") return false;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { updateClientAvatar } from "../services/ws-service.ts";
|
|||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
import {
|
import {
|
||||||
AVATARS_DIR,
|
AVATARS_DIR,
|
||||||
detectImageMime,
|
|
||||||
MAX_IMAGE_SIZE,
|
|
||||||
serveUploadedFile,
|
serveUploadedFile,
|
||||||
|
validateImageUpload,
|
||||||
} from "../utils/upload.ts";
|
} from "../utils/upload.ts";
|
||||||
|
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
@@ -30,28 +29,19 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
|
|||||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_IMAGE_SIZE) {
|
|
||||||
throw new APIException(
|
|
||||||
APIErrorCode.BAD_REQUEST,
|
|
||||||
400,
|
|
||||||
"File too large (max 5 MB)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = new Uint8Array(await file.arrayBuffer());
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const mime = validateImageUpload(data);
|
||||||
|
|
||||||
const mime = detectImageMime(data);
|
const filePath = `${AVATARS_DIR}/${authPayload.userId}`;
|
||||||
if (!mime) {
|
|
||||||
throw new APIException(
|
|
||||||
APIErrorCode.BAD_REQUEST,
|
|
||||||
400,
|
|
||||||
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Deno.mkdir(AVATARS_DIR, { recursive: true });
|
await Deno.mkdir(AVATARS_DIR, { recursive: true });
|
||||||
await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data);
|
await Deno.writeFile(filePath, data);
|
||||||
updateUserAvatar(authPayload.userId, mime);
|
try {
|
||||||
|
updateUserAvatar(authPayload.userId, mime);
|
||||||
|
} catch (err) {
|
||||||
|
// DB write failed — clean up the orphaned file
|
||||||
|
await Deno.remove(filePath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
updateClientAvatar(authPayload.userId, mime);
|
updateClientAvatar(authPayload.userId, mime);
|
||||||
|
|
||||||
const user = getUserById(authPayload.userId);
|
const user = getUserById(authPayload.userId);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
isUpdateCommentRequest,
|
isUpdateCommentRequest,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
import {
|
import {
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
@@ -26,12 +26,7 @@ const router = new Router({ prefix: "/api" });
|
|||||||
|
|
||||||
// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments)
|
// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments)
|
||||||
router.get("/dumps/:dumpId/comments", async (ctx) => {
|
router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||||
let requestingUserId: string | undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
const payload = await verifyJWT(authHeader.substring(7));
|
|
||||||
if (payload) requestingUserId = payload.userId;
|
|
||||||
}
|
|
||||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||||
const comments = getComments(dump.id);
|
const comments = getComments(dump.id);
|
||||||
const responseBody: APIResponse<Comment[]> = {
|
const responseBody: APIResponse<Comment[]> = {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import {
|
|||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
|
import { parsePagination } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
createFileDump,
|
createFileDump,
|
||||||
createUrlDump,
|
createUrlDump,
|
||||||
@@ -75,35 +76,15 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
router.get("/:dumpId", async (ctx) => {
|
router.get("/:dumpId", async (ctx) => {
|
||||||
let requestingUserId: string | undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
const payload = await verifyJWT(authHeader.substring(7));
|
|
||||||
if (payload) requestingUserId = payload.userId;
|
|
||||||
}
|
|
||||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||||
const responseBody: APIResponse<Dump> = { success: true, data: dump };
|
const responseBody: APIResponse<Dump> = { success: true, data: dump };
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/", async (ctx) => {
|
router.get("/", async (ctx) => {
|
||||||
let requestingUserId: string | undefined;
|
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
const payload = await verifyJWT(authHeader.substring(7));
|
|
||||||
if (payload) requestingUserId = payload.userId;
|
|
||||||
}
|
|
||||||
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 { items, total } = listDumps(page, limit, requestingUserId);
|
const { items, total } = listDumps(page, limit, requestingUserId);
|
||||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -120,7 +101,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
|||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.UNAUTHORIZED,
|
APIErrorCode.UNAUTHORIZED,
|
||||||
401,
|
403,
|
||||||
"Not authorized to update dump",
|
"Not authorized to update dump",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -164,7 +145,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
|||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.UNAUTHORIZED,
|
APIErrorCode.UNAUTHORIZED,
|
||||||
401,
|
403,
|
||||||
"Not authorized to update dump",
|
"Not authorized to update dump",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -182,7 +163,7 @@ router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
|||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.UNAUTHORIZED,
|
APIErrorCode.UNAUTHORIZED,
|
||||||
401,
|
403,
|
||||||
"Not authorized to update dump",
|
"Not authorized to update dump",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,7 +181,7 @@ router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
|||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.UNAUTHORIZED,
|
APIErrorCode.UNAUTHORIZED,
|
||||||
401,
|
403,
|
||||||
"Not authorized to delete dump",
|
"Not authorized to delete dump",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router } from "@oak/oak";
|
import { Router } from "@oak/oak";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
import {
|
import {
|
||||||
APIErrorCode,
|
APIErrorCode,
|
||||||
APIException,
|
APIException,
|
||||||
@@ -21,10 +21,9 @@ import {
|
|||||||
updatePlaylist,
|
updatePlaylist,
|
||||||
} from "../services/playlist-service.ts";
|
} from "../services/playlist-service.ts";
|
||||||
import {
|
import {
|
||||||
detectImageMime,
|
|
||||||
MAX_IMAGE_SIZE,
|
|
||||||
PLAYLIST_IMAGES_DIR,
|
PLAYLIST_IMAGES_DIR,
|
||||||
serveUploadedFile,
|
serveUploadedFile,
|
||||||
|
validateImageUpload,
|
||||||
} from "../utils/upload.ts";
|
} from "../utils/upload.ts";
|
||||||
|
|
||||||
const router = new Router<AuthState>({ prefix: "/api/playlists" });
|
const router = new Router<AuthState>({ prefix: "/api/playlists" });
|
||||||
@@ -54,12 +53,7 @@ router.post("/", authMiddleware, async (ctx) => {
|
|||||||
|
|
||||||
// GET /api/playlists/:playlistId — optional auth
|
// GET /api/playlists/:playlistId — optional auth
|
||||||
router.get("/:playlistId", async (ctx) => {
|
router.get("/:playlistId", async (ctx) => {
|
||||||
let requestingUserId: string | null = null;
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
const payload = await verifyJWT(authHeader.substring(7));
|
|
||||||
if (payload) requestingUserId = payload.userId;
|
|
||||||
}
|
|
||||||
const playlist = getPlaylist(ctx.params.playlistId, requestingUserId);
|
const playlist = getPlaylist(ctx.params.playlistId, requestingUserId);
|
||||||
ctx.response.body = { success: true, data: playlist };
|
ctx.response.body = { success: true, data: playlist };
|
||||||
});
|
});
|
||||||
@@ -126,32 +120,25 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
|
|||||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > MAX_IMAGE_SIZE) {
|
|
||||||
throw new APIException(
|
|
||||||
APIErrorCode.BAD_REQUEST,
|
|
||||||
400,
|
|
||||||
"File too large (max 5 MB)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = new Uint8Array(await file.arrayBuffer());
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
const mime = detectImageMime(data);
|
const mime = validateImageUpload(data);
|
||||||
if (!mime) {
|
|
||||||
throw new APIException(
|
|
||||||
APIErrorCode.BAD_REQUEST,
|
|
||||||
400,
|
|
||||||
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve slug → UUID via service (validates ownership too), then write file
|
// DB update first (validates ownership and resolves slug → UUID), then file write.
|
||||||
|
// If file write fails, attempt to clear the mime we just set.
|
||||||
const playlist = setPlaylistImage(
|
const playlist = setPlaylistImage(
|
||||||
ctx.params.playlistId,
|
ctx.params.playlistId,
|
||||||
mime,
|
mime,
|
||||||
ctx.state.user.userId,
|
ctx.state.user.userId,
|
||||||
);
|
);
|
||||||
|
const filePath = `${PLAYLIST_IMAGES_DIR}/${playlist.id}`;
|
||||||
await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true });
|
await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true });
|
||||||
await Deno.writeFile(`${PLAYLIST_IMAGES_DIR}/${playlist.id}`, data);
|
try {
|
||||||
|
await Deno.writeFile(filePath, data);
|
||||||
|
} catch (err) {
|
||||||
|
// File write failed — attempt best-effort DB rollback
|
||||||
|
await Deno.remove(filePath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
ctx.response.body = { success: true, data: playlist };
|
ctx.response.body = { success: true, data: playlist };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
fetchRichContent,
|
fetchRichContent,
|
||||||
isValidHttpUrl,
|
isValidHttpUrl,
|
||||||
} from "../services/rich-content-service.ts";
|
} from "../services/rich-content-service.ts";
|
||||||
|
import { APIErrorCode } from "../model/interfaces.ts";
|
||||||
|
|
||||||
const previewRouter = new Router();
|
const previewRouter = new Router();
|
||||||
|
|
||||||
@@ -10,7 +11,10 @@ previewRouter.get("/api/preview", async (ctx) => {
|
|||||||
const url = ctx.request.url.searchParams.get("url") ?? "";
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
||||||
if (!isValidHttpUrl(url)) {
|
if (!isValidHttpUrl(url)) {
|
||||||
ctx.response.status = 400;
|
ctx.response.status = 400;
|
||||||
ctx.response.body = { success: false, error: { message: "Invalid URL" } };
|
ctx.response.body = {
|
||||||
|
success: false,
|
||||||
|
error: { code: APIErrorCode.VALIDATION_ERROR, message: "Invalid URL" },
|
||||||
|
};
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await fetchRichContent(url);
|
const data = await fetchRichContent(url);
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
import { createJWT, verifyJWT, verifyPassword } from "../lib/jwt.ts";
|
import { createJWT, verifyPassword } from "../lib/jwt.ts";
|
||||||
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import { parseOptionalAuth } from "../lib/auth.ts";
|
||||||
|
import { parsePagination } from "../lib/pagination.ts";
|
||||||
import {
|
import {
|
||||||
createUser,
|
createUser,
|
||||||
getUserById,
|
getUserById,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
updateUser,
|
updateUser,
|
||||||
} from "../services/user-service.ts";
|
} from "../services/user-service.ts";
|
||||||
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
||||||
|
import { broadcastUserUpdated } from "../services/ws-service.ts";
|
||||||
import {
|
import {
|
||||||
getDumpsByUser,
|
getDumpsByUser,
|
||||||
getVotedDumpsByUser,
|
getVotedDumpsByUser,
|
||||||
@@ -47,7 +50,11 @@ router.post("/register", async (ctx) => {
|
|||||||
const user = await createUser(body, inviterId);
|
const user = await createUser(body, inviterId);
|
||||||
|
|
||||||
// Mark invite as used only after the user row is committed
|
// Mark invite as used only after the user row is committed
|
||||||
redeemInvite(body.inviteToken);
|
try {
|
||||||
|
await redeemInvite(body.inviteToken);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[register] redeemInvite failed (user created):", err);
|
||||||
|
}
|
||||||
|
|
||||||
const authToken = await createJWT({
|
const authToken = await createJWT({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -55,10 +62,11 @@ router.post("/register", async (ctx) => {
|
|||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { passwordHash: _, ...publicUser } = user;
|
||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { token: authToken, user },
|
data: { token: authToken, user: publicUser },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,11 +100,12 @@ router.post("/login", async (ctx) => {
|
|||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { passwordHash: _, ...publicUser } = user;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
token,
|
token,
|
||||||
user,
|
user: publicUser,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -146,6 +155,7 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
|
|||||||
}
|
}
|
||||||
const updated = await updateUser(ctx.state.user.userId, body);
|
const updated = await updateUser(ctx.state.user.userId, body);
|
||||||
const { passwordHash: _, ...publicUser } = updated;
|
const { passwordHash: _, ...publicUser } = updated;
|
||||||
|
broadcastUserUpdated(publicUser);
|
||||||
ctx.response.body = { success: true, data: publicUser };
|
ctx.response.body = { success: true, data: publicUser };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -166,17 +176,7 @@ router.get("/by-id/:userId", (ctx) => {
|
|||||||
// Followed playlists for a user (public only)
|
// Followed playlists for a user (public only)
|
||||||
router.get("/:username/followed-playlists", (ctx) => {
|
router.get("/:username/followed-playlists", (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
const page = Math.max(
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
1,
|
|
||||||
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
|
||||||
);
|
|
||||||
const limit = Math.min(
|
|
||||||
Math.max(
|
|
||||||
1,
|
|
||||||
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
|
||||||
),
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
|
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -191,23 +191,8 @@ router.get("/:username/followed-playlists", (ctx) => {
|
|||||||
// Playlists by user (optional auth: include private only if requester === owner)
|
// Playlists by user (optional auth: include private only if requester === owner)
|
||||||
router.get("/:username/playlists", async (ctx) => {
|
router.get("/:username/playlists", async (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
let requestingUserId: string | null = null;
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
const payload = await verifyJWT(authHeader.substring(7));
|
|
||||||
if (payload) requestingUserId = payload.userId;
|
|
||||||
}
|
|
||||||
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 { items, total } = listPlaylistsByUser(
|
const { items, total } = listPlaylistsByUser(
|
||||||
user.id,
|
user.id,
|
||||||
requestingUserId,
|
requestingUserId,
|
||||||
@@ -234,23 +219,8 @@ router.get("/:username", (ctx) => {
|
|||||||
// Dumps posted by user (optional auth: owner sees their private dumps)
|
// Dumps posted by user (optional auth: owner sees their private dumps)
|
||||||
router.get("/:username/dumps", async (ctx) => {
|
router.get("/:username/dumps", async (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
let requestingUserId: string | null = null;
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
const payload = await verifyJWT(authHeader.substring(7));
|
|
||||||
if (payload) requestingUserId = payload.userId;
|
|
||||||
}
|
|
||||||
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 includePrivate = requestingUserId === user.id;
|
const includePrivate = requestingUserId === user.id;
|
||||||
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
@@ -266,23 +236,8 @@ router.get("/:username/dumps", async (ctx) => {
|
|||||||
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
||||||
router.get("/:username/votes", async (ctx) => {
|
router.get("/:username/votes", async (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
let requestingUserId: string | null = null;
|
const requestingUserId = await parseOptionalAuth(ctx);
|
||||||
const authHeader = ctx.request.headers.get("Authorization");
|
const { page, limit } = parsePagination(ctx.request.url.searchParams);
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
|
||||||
const payload = await verifyJWT(authHeader.substring(7));
|
|
||||||
if (payload) requestingUserId = payload.userId;
|
|
||||||
}
|
|
||||||
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 { items, total } = getVotedDumpsByUser(
|
const { items, total } = getVotedDumpsByUser(
|
||||||
user.id,
|
user.id,
|
||||||
page,
|
page,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
broadcastPresence,
|
broadcastPresence,
|
||||||
broadcastVoteUpdate,
|
broadcastVoteUpdate,
|
||||||
getOnlineUsers,
|
getOnlineUsers,
|
||||||
|
handleClientPong,
|
||||||
register,
|
register,
|
||||||
unregister,
|
unregister,
|
||||||
type WsClient,
|
type WsClient,
|
||||||
@@ -88,6 +89,9 @@ router.get("/ws", async (ctx) => {
|
|||||||
case "ping":
|
case "ping":
|
||||||
socket.send(JSON.stringify({ type: "pong" }));
|
socket.send(JSON.stringify({ type: "pong" }));
|
||||||
break;
|
break;
|
||||||
|
case "pong":
|
||||||
|
handleClientPong(client);
|
||||||
|
break;
|
||||||
case "vote_cast":
|
case "vote_cast":
|
||||||
handleVote(client, msg.dumpId, "cast");
|
handleVote(client, msg.dumpId, "cast");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -374,22 +374,32 @@ export async function replaceFileDump(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = new Uint8Array(await file.arrayBuffer());
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, data);
|
const filePath = `${DUMPS_DIR}/${dumpId}`;
|
||||||
|
// Read old file contents so we can restore on DB failure
|
||||||
|
const oldData = await Deno.readFile(filePath).catch(() => null);
|
||||||
|
await Deno.writeFile(filePath, data);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const newSlug = makeSlug(file.name, dumpId);
|
const newSlug = makeSlug(file.name, dumpId);
|
||||||
db.prepare(
|
try {
|
||||||
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
|
db.prepare(
|
||||||
).run(
|
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
|
||||||
file.name,
|
).run(
|
||||||
newSlug,
|
file.name,
|
||||||
file.name,
|
newSlug,
|
||||||
file.type,
|
file.name,
|
||||||
file.size,
|
file.type,
|
||||||
comment ?? null,
|
file.size,
|
||||||
now.toISOString(),
|
comment ?? null,
|
||||||
dumpId,
|
now.toISOString(),
|
||||||
);
|
dumpId,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Roll back the file to its previous contents on DB failure
|
||||||
|
if (oldData) await Deno.writeFile(filePath, oldData).catch(() => {});
|
||||||
|
else await Deno.remove(filePath).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
|
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
Notification,
|
Notification,
|
||||||
NotificationData,
|
NotificationData,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
|
UserDumpPostedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
|
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
|
||||||
@@ -156,14 +157,53 @@ export function notifyUserFollowersNewDump(
|
|||||||
`SELECT follower_id FROM follows WHERE followed_user_id = ?;`,
|
`SELECT follower_id FROM follows WHERE followed_user_id = ?;`,
|
||||||
).all(dumperId) as { follower_id: string }[];
|
).all(dumperId) as { follower_id: string }[];
|
||||||
|
|
||||||
|
if (followerRows.length === 0) return;
|
||||||
|
|
||||||
|
const data: UserDumpPostedData = {
|
||||||
|
dumperId,
|
||||||
|
dumperUsername: posterRow.username,
|
||||||
|
dumpId,
|
||||||
|
dumpTitle,
|
||||||
|
};
|
||||||
|
const dataJson = JSON.stringify(data);
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const sourceKey = `dump:${dumpId}`;
|
||||||
|
|
||||||
|
// Batch INSERT all follower notifications in a single statement
|
||||||
|
const params: (string | number | null)[] = [];
|
||||||
|
const placeholders: string[] = [];
|
||||||
for (const row of followerRows) {
|
for (const row of followerRows) {
|
||||||
createNotification(
|
const id = crypto.randomUUID();
|
||||||
|
placeholders.push("(?, ?, ?, ?, 0, ?, ?)");
|
||||||
|
params.push(
|
||||||
|
id,
|
||||||
row.follower_id,
|
row.follower_id,
|
||||||
"user_dump_posted",
|
"user_dump_posted",
|
||||||
{ dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle },
|
dataJson,
|
||||||
`dump:${dumpId}`,
|
createdAt,
|
||||||
|
sourceKey,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
|
||||||
|
VALUES ${placeholders.join(", ")};`,
|
||||||
|
).run(...params);
|
||||||
|
|
||||||
|
if ((result.changes as number) > 0) {
|
||||||
|
for (const row of followerRows) {
|
||||||
|
sendToUser(row.follower_id, {
|
||||||
|
type: "notification_created",
|
||||||
|
notification: {
|
||||||
|
userId: row.follower_id,
|
||||||
|
type: "user_dump_posted",
|
||||||
|
data,
|
||||||
|
read: false,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyDumpOwnerUpvote(
|
export function notifyDumpOwnerUpvote(
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ export function getPlaylist(
|
|||||||
|
|
||||||
// For public playlists (or when viewed by non-owner), filter out private dumps
|
// For public playlists (or when viewed by non-owner), filter out private dumps
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT ${dumpCols}
|
`SELECT ${dumpCols},
|
||||||
|
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count
|
||||||
FROM dumps d
|
FROM dumps d
|
||||||
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
|
INNER JOIN playlist_dumps pd ON d.id = pd.dump_id
|
||||||
WHERE pd.playlist_id = ?
|
WHERE pd.playlist_id = ?
|
||||||
|
|||||||
@@ -80,10 +80,18 @@ export function extractOgTag(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPrivateHost(hostname: string): boolean {
|
||||||
|
// Block loopback and RFC-1918 ranges. Note: DNS rebinding is not fully mitigated.
|
||||||
|
if (hostname === "localhost" || hostname === "::1") return true;
|
||||||
|
return /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(hostname);
|
||||||
|
}
|
||||||
|
|
||||||
export function isValidHttpUrl(raw: string): boolean {
|
export function isValidHttpUrl(raw: string): boolean {
|
||||||
try {
|
try {
|
||||||
const u = new URL(raw);
|
const u = new URL(raw);
|
||||||
return u.protocol === "http:" || u.protocol === "https:";
|
if (u.protocol !== "http:" && u.protocol !== "https:") return false;
|
||||||
|
if (isPrivateHost(u.hostname)) return false;
|
||||||
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
Dump,
|
Dump,
|
||||||
OnlineUser,
|
OnlineUser,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
User,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
export interface WsClient {
|
export interface WsClient {
|
||||||
@@ -11,6 +12,7 @@ export interface WsClient {
|
|||||||
username?: string;
|
username?: string;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
avatarVersion?: number;
|
avatarVersion?: number;
|
||||||
|
pongReceived?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clients = new Set<WsClient>();
|
const clients = new Set<WsClient>();
|
||||||
@@ -151,6 +153,12 @@ export function broadcastPlaylistDumpsUpdated(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function broadcastUserUpdated(user: Omit<User, "passwordHash">): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "user_updated", user });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function broadcastCommentCreated(comment: Comment): void {
|
export function broadcastCommentCreated(comment: Comment): void {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
send(client.socket, { type: "comment_created", comment });
|
send(client.socket, { type: "comment_created", comment });
|
||||||
@@ -172,7 +180,11 @@ export function broadcastCommentUpdated(comment: Comment): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
export function handleClientPong(client: WsClient): void {
|
||||||
|
client.pongReceived = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keepalive: ping all clients every 30s, disconnect non-responsive ones
|
||||||
const PING_INTERVAL = 30_000;
|
const PING_INTERVAL = 30_000;
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -181,7 +193,13 @@ setInterval(() => {
|
|||||||
clients.delete(client);
|
clients.delete(client);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Disconnect if no pong since last ping (pongReceived starts undefined, skip first cycle)
|
||||||
|
if (client.pongReceived === false) {
|
||||||
|
client.socket.close(1001, "Ping timeout");
|
||||||
|
clients.delete(client);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.pongReceived = false;
|
||||||
send(client.socket, { type: "ping" });
|
send(client.socket, { type: "ping" });
|
||||||
// Schedule removal if no pong (tracked via heartbeat flag)
|
|
||||||
}
|
}
|
||||||
}, PING_INTERVAL);
|
}, PING_INTERVAL);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Context } from "@oak/oak";
|
import type { Context } from "@oak/oak";
|
||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
|
||||||
export const UPLOADS_DIR = "api/uploads";
|
export const UPLOADS_DIR = "api/uploads";
|
||||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||||
@@ -35,6 +36,26 @@ export function detectImageMime(data: Uint8Array): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */
|
||||||
|
export function validateImageUpload(data: Uint8Array): string {
|
||||||
|
if (data.length > MAX_IMAGE_SIZE) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.BAD_REQUEST,
|
||||||
|
400,
|
||||||
|
"File too large (max 5 MB)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const mime = detectImageMime(data);
|
||||||
|
if (!mime) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.BAD_REQUEST,
|
||||||
|
400,
|
||||||
|
"File content is not a recognised image (JPEG, PNG, GIF, WebP)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return mime;
|
||||||
|
}
|
||||||
|
|
||||||
export async function serveUploadedFile(
|
export async function serveUploadedFile(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run -A npm:vite & deno run -A server:start",
|
"dev": "deno run --env-file -A npm:vite & deno run -A server:start",
|
||||||
"build": "deno run -A npm:vite build",
|
"build": "deno run --env-file -A npm:vite build",
|
||||||
"server:start": "deno run -A --watch api/main.ts",
|
"server:start": "deno run --env-file -A --watch api/main.ts",
|
||||||
"serve": "deno run -A build && deno run -A server:start"
|
"serve": "deno run --env-file -A build && deno run -A server:start"
|
||||||
},
|
},
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
|||||||
12
src/App.css
12
src/App.css
@@ -927,6 +927,7 @@ body.has-player .fab-new {
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -941,11 +942,12 @@ body.has-player .fab-new {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dump-card--fading {
|
.dump-card--fading {
|
||||||
opacity: 0.28;
|
filter: brightness(0.65);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-card--dismissing {
|
.dump-card--dismissing {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
filter: brightness(0.25);
|
||||||
grid-template-rows: 0fr;
|
grid-template-rows: 0fr;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -1154,8 +1156,6 @@ body.has-player .fab-new {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-section {}
|
|
||||||
|
|
||||||
.profile-section ul {
|
.profile-section ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1981,7 +1981,8 @@ body.has-player .fab-new {
|
|||||||
transition:
|
transition:
|
||||||
border-color 0.15s,
|
border-color 0.15s,
|
||||||
grid-template-rows 0.32s ease,
|
grid-template-rows 0.32s ease,
|
||||||
opacity 0.25s ease;
|
opacity 0.25s ease,
|
||||||
|
filter 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-card {
|
.playlist-card {
|
||||||
@@ -1993,6 +1994,7 @@ body.has-player .fab-new {
|
|||||||
.dump-card-inner,
|
.dump-card-inner,
|
||||||
.playlist-card-inner {
|
.playlist-card-inner {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -2086,10 +2088,12 @@ body.has-player .fab-new {
|
|||||||
|
|
||||||
.dump-card-comment {
|
.dump-card-comment {
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
|
line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-card-description {
|
.playlist-card-description {
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Shared card meta row ── */
|
/* ── Shared card meta row ── */
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
|||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { token } = useAuth();
|
const { token, user } = useAuth();
|
||||||
return (
|
return (
|
||||||
<WSProvider token={token}>
|
<WSProvider token={token} userId={user?.id ?? null}>
|
||||||
<FollowProvider>
|
<FollowProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ export function AppHeader(
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
const [_showFab, setShowFab] = useState(false);
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// IntersectionObserver retained here to support a future floating action button
|
||||||
const el = headerRef.current;
|
const el = headerRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const obs = new IntersectionObserver(
|
const obs = new IntersectionObserver(() => {}, { threshold: 0 });
|
||||||
([entry]) => setShowFab(!entry.isIntersecting),
|
|
||||||
{ threshold: 0 },
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
obs.observe(el);
|
||||||
return () => obs.disconnect();
|
return () => obs.disconnect();
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Comment, RawComment, User } from "../model.ts";
|
import type { Comment, RawComment, User } from "../model.ts";
|
||||||
@@ -380,7 +380,7 @@ export function CommentThread({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [topLevelError, setTopLevelError] = useState<string | null>(null);
|
const [topLevelError, setTopLevelError] = useState<string | null>(null);
|
||||||
|
|
||||||
const tree = buildTree(comments);
|
const tree = useMemo(() => buildTree(comments), [comments]);
|
||||||
const roots = tree.get("root") ?? [];
|
const roots = tree.get("root") ?? [];
|
||||||
|
|
||||||
async function handleTopLevelSubmit(e?: React.FormEvent) {
|
async function handleTopLevelSubmit(e?: React.FormEvent) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
@@ -15,9 +16,25 @@ function preprocessMentions(text: string): string {
|
|||||||
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
|
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Static components object — defined once at module scope to avoid recreation on every render
|
||||||
|
const MARKDOWN_COMPONENTS: React.ComponentProps<typeof ReactMarkdown>["components"] = {
|
||||||
|
a: ({ href, children: linkChildren }) => {
|
||||||
|
if (href?.startsWith("/users/")) {
|
||||||
|
return <Link to={href}>{linkChildren}</Link>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
|
{linkChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function Markdown(
|
export function Markdown(
|
||||||
{ children, className, inline = false }: MarkdownProps,
|
{ children, className, inline = false }: MarkdownProps,
|
||||||
) {
|
) {
|
||||||
|
const processed = useMemo(() => preprocessMentions(children), [children]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`md${className ? ` ${className}` : ""}${
|
className={`md${className ? ` ${className}` : ""}${
|
||||||
@@ -26,20 +43,9 @@ export function Markdown(
|
|||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={REMARK_PLUGINS}
|
remarkPlugins={REMARK_PLUGINS}
|
||||||
components={{
|
components={MARKDOWN_COMPONENTS}
|
||||||
a: ({ href, children: linkChildren }) => {
|
|
||||||
if (href?.startsWith("/users/")) {
|
|
||||||
return <Link to={href}>{linkChildren}</Link>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
|
||||||
{linkChildren}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{preprocessMentions(children)}
|
{processed}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,3 +8,20 @@ const serverPort = import.meta.env.VITE_SERVER_PORT || "8000";
|
|||||||
|
|
||||||
export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`;
|
export const API_URL = `${apiProtocol}://${serverHost}:${serverPort}`;
|
||||||
export const WS_URL = API_URL.replace(/^http/, "ws");
|
export const WS_URL = API_URL.replace(/^http/, "ws");
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
export const NOTIFICATIONS_PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
// Validation constants (mirrors api/model/interfaces.ts VALIDATION)
|
||||||
|
export const VALIDATION = {
|
||||||
|
USERNAME_MIN: 1,
|
||||||
|
USERNAME_MAX: 32,
|
||||||
|
PASSWORD_MIN: 8,
|
||||||
|
PASSWORD_MAX: 128,
|
||||||
|
DUMP_TITLE_MAX: 200,
|
||||||
|
DUMP_COMMENT_MAX: 5000,
|
||||||
|
PLAYLIST_TITLE_MAX: 100,
|
||||||
|
PLAYLIST_DESCRIPTION_MAX: 2000,
|
||||||
|
COMMENT_BODY_MAX: 5000,
|
||||||
|
USER_DESCRIPTION_MAX: 2000,
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const stored = localStorage.getItem("authResponse");
|
const stored = localStorage.getItem("authResponse");
|
||||||
if (!stored) return null;
|
if (!stored) return null;
|
||||||
|
|
||||||
const parsed = deserializeAuthResponse(JSON.parse(stored));
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = deserializeAuthResponse(JSON.parse(stored));
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem("authResponse");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (isTokenExpired(parsed.token)) {
|
if (isTokenExpired(parsed.token)) {
|
||||||
localStorage.removeItem("authResponse");
|
localStorage.removeItem("authResponse");
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
|
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
@@ -21,23 +21,24 @@ export function FollowProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoaded(false);
|
setIsLoaded(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
const controller = new AbortController();
|
||||||
fetch(`${API_URL}/api/follows/status`, {
|
fetch(`${API_URL}/api/follows/status`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (cancelled || !body.success) return;
|
if (!body.success) return;
|
||||||
const status = body.data as FollowStatus;
|
const status = body.data as FollowStatus;
|
||||||
setFollowedUserIds(new Set(status.followedUserIds));
|
setFollowedUserIds(new Set(status.followedUserIds));
|
||||||
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
|
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
if (!cancelled) setIsLoaded(true);
|
if (err.name !== "AbortError") setIsLoaded(true);
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ export function FollowProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}, [authFetch]);
|
}, [authFetch]);
|
||||||
|
|
||||||
const value: FollowContextValue = {
|
const value: FollowContextValue = useMemo(() => ({
|
||||||
followedUserIds,
|
followedUserIds,
|
||||||
followedPlaylistIds,
|
followedPlaylistIds,
|
||||||
followUser,
|
followUser,
|
||||||
@@ -115,7 +116,15 @@ export function FollowProvider({ children }: { children: ReactNode }) {
|
|||||||
followPlaylist,
|
followPlaylist,
|
||||||
unfollowPlaylist,
|
unfollowPlaylist,
|
||||||
isLoaded,
|
isLoaded,
|
||||||
};
|
}), [
|
||||||
|
followedUserIds,
|
||||||
|
followedPlaylistIds,
|
||||||
|
followUser,
|
||||||
|
unfollowUser,
|
||||||
|
followPlaylist,
|
||||||
|
unfollowPlaylist,
|
||||||
|
isLoaded,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FollowContext.Provider value={value}>
|
<FollowContext.Provider value={value}>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
|
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
|
||||||
|
|
||||||
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [current, setCurrent] = useState<PlayerItem | null>(null);
|
const [current, setCurrent] = useState<PlayerItem | null>(null);
|
||||||
|
|
||||||
|
const play = setCurrent;
|
||||||
|
const stop = useCallback(() => setCurrent(null), []);
|
||||||
|
const value = useMemo(() => ({ current, play, stop }), [current, play, stop]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlayerContext.Provider
|
<PlayerContext.Provider value={value}>
|
||||||
value={{ current, play: setCurrent, stop: () => setCurrent(null) }}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</PlayerContext.Provider>
|
</PlayerContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
Notification,
|
Notification,
|
||||||
OnlineUser,
|
OnlineUser,
|
||||||
Playlist,
|
Playlist,
|
||||||
|
PublicUser,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
|
|
||||||
export interface VoteEvent {
|
export interface VoteEvent {
|
||||||
@@ -28,6 +29,10 @@ export interface CommentEvent {
|
|||||||
commentId?: string;
|
commentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserEvent {
|
||||||
|
user: PublicUser;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WSContextValue {
|
export interface WSContextValue {
|
||||||
onlineUsers: OnlineUser[];
|
onlineUsers: OnlineUser[];
|
||||||
voteCounts: Record<string, number>;
|
voteCounts: Record<string, number>;
|
||||||
@@ -39,6 +44,7 @@ export interface WSContextValue {
|
|||||||
lastPlaylistEvent: PlaylistEvent | null;
|
lastPlaylistEvent: PlaylistEvent | null;
|
||||||
deletedPlaylistIds: Set<string>;
|
deletedPlaylistIds: Set<string>;
|
||||||
lastCommentEvent: CommentEvent | null;
|
lastCommentEvent: CommentEvent | null;
|
||||||
|
lastUserEvent: UserEvent | null;
|
||||||
unreadNotificationCount: number;
|
unreadNotificationCount: number;
|
||||||
lastNotification: Notification | null;
|
lastNotification: Notification | null;
|
||||||
castVote: (dumpId: string) => void;
|
castVote: (dumpId: string) => void;
|
||||||
@@ -58,6 +64,7 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
lastPlaylistEvent: null,
|
lastPlaylistEvent: null,
|
||||||
deletedPlaylistIds: new Set(),
|
deletedPlaylistIds: new Set(),
|
||||||
lastCommentEvent: null,
|
lastCommentEvent: null,
|
||||||
|
lastUserEvent: null,
|
||||||
unreadNotificationCount: 0,
|
unreadNotificationCount: 0,
|
||||||
lastNotification: null,
|
lastNotification: null,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
type CommentEvent,
|
type CommentEvent,
|
||||||
type PlaylistEvent,
|
type PlaylistEvent,
|
||||||
|
type UserEvent,
|
||||||
type VoteEvent,
|
type VoteEvent,
|
||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
@@ -22,23 +24,79 @@ import type {
|
|||||||
RawDump,
|
RawDump,
|
||||||
RawNotification,
|
RawNotification,
|
||||||
RawPlaylist,
|
RawPlaylist,
|
||||||
|
RawPublicUser,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import {
|
import {
|
||||||
deserializeComment,
|
deserializeComment,
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
deserializeNotification,
|
deserializeNotification,
|
||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
|
deserializePublicUser,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
|
|
||||||
interface WSProviderProps {
|
interface WSProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
userId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_BACKOFF = 30_000;
|
const MAX_BACKOFF = 30_000;
|
||||||
const ACK_TIMEOUT = 5_000;
|
const ACK_TIMEOUT = 5_000;
|
||||||
|
|
||||||
export function WSProvider({ children, token }: WSProviderProps) {
|
// ── 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";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WSProvider({ children, token, userId }: WSProviderProps) {
|
||||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||||
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
const [voteCounts, setVoteCounts] = useState<Record<string, number>>({});
|
||||||
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
const [myVotes, setMyVotes] = useState<Set<string>>(new Set());
|
||||||
@@ -55,6 +113,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [lastUserEvent, setLastUserEvent] = useState<UserEvent | null>(null);
|
||||||
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||||
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
||||||
null,
|
null,
|
||||||
@@ -63,9 +122,11 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
// Refs to avoid stale closures in event handlers
|
// Refs to avoid stale closures in event handlers
|
||||||
const voteCountsRef = useRef(voteCounts);
|
const voteCountsRef = useRef(voteCounts);
|
||||||
const myVotesRef = useRef(myVotes);
|
const myVotesRef = useRef(myVotes);
|
||||||
|
const userIdRef = useRef(userId);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
voteCountsRef.current = voteCounts;
|
voteCountsRef.current = voteCounts;
|
||||||
myVotesRef.current = myVotes;
|
myVotesRef.current = myVotes;
|
||||||
|
userIdRef.current = userId;
|
||||||
});
|
});
|
||||||
|
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
@@ -103,41 +164,48 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
|
|
||||||
case "welcome": {
|
case "welcome": {
|
||||||
backoff = 500; // reset backoff on successful connect
|
backoff = 500; // reset backoff on successful connect
|
||||||
const users = msg.users as OnlineUser[];
|
if (!isOnlineUserArray(msg.users) || !isStringArray(msg.myVotes)) break;
|
||||||
const votes = msg.myVotes as string[];
|
setOnlineUsers(msg.users);
|
||||||
setOnlineUsers(users);
|
setMyVotes(new Set(msg.myVotes));
|
||||||
setMyVotes(new Set(votes));
|
|
||||||
setUnreadNotificationCount(
|
setUnreadNotificationCount(
|
||||||
(msg.unreadNotificationCount as number) ?? 0,
|
typeof msg.unreadNotificationCount === "number"
|
||||||
|
? msg.unreadNotificationCount
|
||||||
|
: 0,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "presence_update":
|
case "presence_update":
|
||||||
setOnlineUsers(msg.users as OnlineUser[]);
|
if (isOnlineUserArray(msg.users)) setOnlineUsers(msg.users);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "votes_update": {
|
case "votes_update": {
|
||||||
const { dumpId, voteCount, voterId, action } = msg as {
|
if (!isVotesUpdatePayload(msg)) break;
|
||||||
dumpId: string;
|
const { dumpId, voteCount, voterId, action } = msg;
|
||||||
voteCount: number;
|
|
||||||
voterId: string;
|
|
||||||
action: "cast" | "remove";
|
|
||||||
};
|
|
||||||
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
setVoteCounts((prev) => ({ ...prev, [dumpId]: voteCount }));
|
||||||
if (voterId && action) {
|
setLastVoteEvent({ dumpId, voterId, action });
|
||||||
setLastVoteEvent({ dumpId, voterId, action });
|
// Keep myVotes in sync across tabs: if this vote event belongs to
|
||||||
|
// the current user (from another tab), update myVotes accordingly.
|
||||||
|
if (voterId === userIdRef.current) {
|
||||||
|
setMyVotes((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (action === "cast") next.add(dumpId);
|
||||||
|
else next.delete(dumpId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "dump_created": {
|
case "dump_created": {
|
||||||
|
if (!msg.dump || typeof msg.dump !== "object") break;
|
||||||
const dump = deserializeDump(msg.dump as RawDump);
|
const dump = deserializeDump(msg.dump as RawDump);
|
||||||
setRecentDumps((prev) => [dump, ...prev]);
|
setRecentDumps((prev) => [dump, ...prev]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "dump_updated": {
|
case "dump_updated": {
|
||||||
|
if (!msg.dump || typeof msg.dump !== "object") break;
|
||||||
const dump = deserializeDump(msg.dump as RawDump);
|
const dump = deserializeDump(msg.dump as RawDump);
|
||||||
setLastDumpEvent(dump);
|
setLastDumpEvent(dump);
|
||||||
// Un-delete if this dump was previously removed from the feed
|
// Un-delete if this dump was previously removed from the feed
|
||||||
@@ -156,18 +224,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "dump_deleted": {
|
case "dump_deleted": {
|
||||||
const dumpId = msg.dumpId as string;
|
if (typeof msg.dumpId !== "string") break;
|
||||||
|
const dumpId = msg.dumpId;
|
||||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||||
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
setRecentDumps((prev) => prev.filter((d) => d.id !== dumpId));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "vote_ack": {
|
case "vote_ack": {
|
||||||
const { dumpId, action, voteCount } = msg as {
|
if (!isVoteAckPayload(msg)) break;
|
||||||
dumpId: string;
|
const { dumpId, action, voteCount } = msg;
|
||||||
action: "cast" | "remove";
|
|
||||||
voteCount: number;
|
|
||||||
};
|
|
||||||
// Clear pending revert timeout
|
// Clear pending revert timeout
|
||||||
const timeout = pendingRef.current.get(dumpId);
|
const timeout = pendingRef.current.get(dumpId);
|
||||||
if (timeout !== undefined) {
|
if (timeout !== undefined) {
|
||||||
@@ -188,6 +254,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
|
|
||||||
case "playlist_created":
|
case "playlist_created":
|
||||||
case "playlist_updated": {
|
case "playlist_updated": {
|
||||||
|
if (!msg.playlist || typeof msg.playlist !== "object") break;
|
||||||
const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
|
const playlist = deserializePlaylist(msg.playlist as RawPlaylist);
|
||||||
setLastPlaylistEvent({
|
setLastPlaylistEvent({
|
||||||
type: msg.type === "playlist_created" ? "created" : "updated",
|
type: msg.type === "playlist_created" ? "created" : "updated",
|
||||||
@@ -198,20 +265,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "playlist_deleted": {
|
case "playlist_deleted": {
|
||||||
const { playlistId, userId } = msg as {
|
if (!isPlaylistDeletedPayload(msg)) break;
|
||||||
playlistId: string;
|
const { playlistId, userId } = msg;
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
setDeletedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||||
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
|
setLastPlaylistEvent({ type: "deleted", playlistId, userId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "playlist_dumps_updated": {
|
case "playlist_dumps_updated": {
|
||||||
const { playlistId, dumpIds } = msg as {
|
if (!isPlaylistDumpsUpdatedPayload(msg)) break;
|
||||||
playlistId: string;
|
const { playlistId, dumpIds } = msg;
|
||||||
dumpIds: string[];
|
|
||||||
};
|
|
||||||
setLastPlaylistEvent({
|
setLastPlaylistEvent({
|
||||||
type: "dumps_updated",
|
type: "dumps_updated",
|
||||||
playlistId,
|
playlistId,
|
||||||
@@ -220,7 +283,15 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "user_updated": {
|
||||||
|
if (!msg.user || typeof msg.user !== "object") break;
|
||||||
|
const user = deserializePublicUser(msg.user as RawPublicUser);
|
||||||
|
setLastUserEvent({ user });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "comment_created": {
|
case "comment_created": {
|
||||||
|
if (!msg.comment || typeof msg.comment !== "object") break;
|
||||||
const comment = deserializeComment(msg.comment as RawComment);
|
const comment = deserializeComment(msg.comment as RawComment);
|
||||||
setLastCommentEvent({
|
setLastCommentEvent({
|
||||||
type: "created",
|
type: "created",
|
||||||
@@ -231,15 +302,14 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "comment_deleted": {
|
case "comment_deleted": {
|
||||||
const { commentId, dumpId } = msg as {
|
if (!isCommentDeletedPayload(msg)) break;
|
||||||
commentId: string;
|
const { commentId, dumpId } = msg;
|
||||||
dumpId: string;
|
|
||||||
};
|
|
||||||
setLastCommentEvent({ type: "deleted", dumpId, commentId });
|
setLastCommentEvent({ type: "deleted", dumpId, commentId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "comment_updated": {
|
case "comment_updated": {
|
||||||
|
if (!msg.comment || typeof msg.comment !== "object") break;
|
||||||
const comment = deserializeComment(msg.comment as RawComment);
|
const comment = deserializeComment(msg.comment as RawComment);
|
||||||
setLastCommentEvent({
|
setLastCommentEvent({
|
||||||
type: "updated",
|
type: "updated",
|
||||||
@@ -250,6 +320,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "notification_created": {
|
case "notification_created": {
|
||||||
|
if (!msg.notification || typeof msg.notification !== "object") break;
|
||||||
const notification = deserializeNotification(
|
const notification = deserializeNotification(
|
||||||
msg.notification as RawNotification,
|
msg.notification as RawNotification,
|
||||||
);
|
);
|
||||||
@@ -361,7 +432,7 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
setUnreadNotificationCount(0);
|
setUnreadNotificationCount(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: WSContextValue = {
|
const value: WSContextValue = useMemo(() => ({
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
voteCounts,
|
voteCounts,
|
||||||
myVotes,
|
myVotes,
|
||||||
@@ -372,13 +443,32 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
lastPlaylistEvent,
|
lastPlaylistEvent,
|
||||||
deletedPlaylistIds,
|
deletedPlaylistIds,
|
||||||
lastCommentEvent,
|
lastCommentEvent,
|
||||||
|
lastUserEvent,
|
||||||
unreadNotificationCount,
|
unreadNotificationCount,
|
||||||
lastNotification,
|
lastNotification,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
injectDump,
|
injectDump,
|
||||||
clearUnreadNotifications,
|
clearUnreadNotifications,
|
||||||
};
|
}), [
|
||||||
|
onlineUsers,
|
||||||
|
voteCounts,
|
||||||
|
myVotes,
|
||||||
|
recentDumps,
|
||||||
|
deletedDumpIds,
|
||||||
|
lastVoteEvent,
|
||||||
|
lastDumpEvent,
|
||||||
|
lastPlaylistEvent,
|
||||||
|
deletedPlaylistIds,
|
||||||
|
lastCommentEvent,
|
||||||
|
lastUserEvent,
|
||||||
|
unreadNotificationCount,
|
||||||
|
lastNotification,
|
||||||
|
castVote,
|
||||||
|
removeVote,
|
||||||
|
injectDump,
|
||||||
|
clearUnreadNotifications,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WSContext.Provider value={value}>
|
<WSContext.Provider value={value}>
|
||||||
|
|||||||
@@ -2,28 +2,39 @@ import { useEffect, useLayoutEffect, useRef } from "react";
|
|||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { useWS } from "./useWS.ts";
|
import { useWS } from "./useWS.ts";
|
||||||
|
|
||||||
|
interface DumpListSyncOptions {
|
||||||
|
/** Keep private dumps visible (caller is the owner). */
|
||||||
|
isOwner?: boolean;
|
||||||
|
/**
|
||||||
|
* Only re-insert dumps created by this user when they become visible again.
|
||||||
|
* Leave undefined to never re-insert (caller handles it, or no re-insertion needed).
|
||||||
|
*/
|
||||||
|
ownerId?: string;
|
||||||
|
/**
|
||||||
|
* When true, don't re-add a dump that isn't in the list (idx === -1).
|
||||||
|
* Use this when the caller handles re-insertion itself (e.g. with a position map).
|
||||||
|
*/
|
||||||
|
skipReinsert?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keeps a dump list in sync with real-time WS events:
|
* Keeps a dump list in sync with real-time WS events:
|
||||||
* - deletedDumpIds: filters out dumps that were deleted or privatised.
|
* - deletedDumpIds growing → filter
|
||||||
* - lastDumpEvent: updates existing dumps in-place; optionally prepends
|
* - lastDumpEvent "updated" → update in-place, or remove if now private
|
||||||
* new ones when `addFilter` returns true for them.
|
* - lastDumpEvent for a not-in-list dump → prepend if ownerId matches and visible
|
||||||
*
|
|
||||||
* @param setDumps Updater that patches the caller's dump array.
|
|
||||||
* @param addFilter Optional predicate: return true to prepend a dump that
|
|
||||||
* isn't already in the list (e.g. became public).
|
|
||||||
*/
|
*/
|
||||||
export function useDumpListSync(
|
export function useDumpListSync(
|
||||||
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||||
addFilter?: (dump: Dump) => boolean,
|
options?: DumpListSyncOptions,
|
||||||
): void {
|
): void {
|
||||||
const { deletedDumpIds, lastDumpEvent } = useWS();
|
const { deletedDumpIds, lastDumpEvent } = useWS();
|
||||||
|
|
||||||
// Keep refs up-to-date so closures in effects are never stale.
|
// Keep refs up-to-date so closures in effects are never stale.
|
||||||
const setDumpsRef = useRef(setDumps);
|
const setDumpsRef = useRef(setDumps);
|
||||||
const addFilterRef = useRef(addFilter);
|
const optionsRef = useRef(options);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
setDumpsRef.current = setDumps;
|
setDumpsRef.current = setDumps;
|
||||||
addFilterRef.current = addFilter;
|
optionsRef.current = options;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -35,17 +46,27 @@ export function useDumpListSync(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastDumpEvent) return;
|
if (!lastDumpEvent) return;
|
||||||
|
const { isOwner, ownerId, skipReinsert } = optionsRef.current ?? {};
|
||||||
|
const dump = lastDumpEvent;
|
||||||
|
|
||||||
setDumpsRef.current((prev) => {
|
setDumpsRef.current((prev) => {
|
||||||
const idx = prev.findIndex((d) => d.id === lastDumpEvent.id);
|
const idx = prev.findIndex((d) => d.id === dump.id);
|
||||||
|
|
||||||
if (idx !== -1) {
|
if (idx !== -1) {
|
||||||
|
// Remove if it became private and the viewer can't see private dumps.
|
||||||
|
if (dump.isPrivate && !isOwner) {
|
||||||
|
return prev.filter((d) => d.id !== dump.id);
|
||||||
|
}
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[idx] = lastDumpEvent;
|
next[idx] = dump;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
if (addFilterRef.current?.(lastDumpEvent)) {
|
|
||||||
return [lastDumpEvent, ...prev];
|
// Dump not in list: only re-insert when ownerId is set and matches.
|
||||||
}
|
if (skipReinsert || !ownerId) return prev;
|
||||||
return prev;
|
if (dump.userId !== ownerId) return prev;
|
||||||
|
if (dump.isPrivate && !isOwner) return prev;
|
||||||
|
return [dump, ...prev];
|
||||||
});
|
});
|
||||||
}, [lastDumpEvent]);
|
}, [lastDumpEvent]);
|
||||||
}
|
}
|
||||||
|
|||||||
78
src/hooks/useFading.ts
Normal file
78
src/hooks/useFading.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages a 2-second cooldown + 350ms dismissal animation for a set of items.
|
||||||
|
*
|
||||||
|
* Call `startFading(id)` when an item should begin fading out.
|
||||||
|
* Call `cancelFading(id)` to abort the animation (e.g. the action was reversed).
|
||||||
|
* Use the returned `fading` record to apply CSS classes:
|
||||||
|
* - `"cooldown"` → item is in the 2-second grace period (darkened)
|
||||||
|
* - `"dismissing"` → item is collapsing (opacity 0 + grid-row collapse)
|
||||||
|
*/
|
||||||
|
export function useFading() {
|
||||||
|
const [fading, setFading] = useState<
|
||||||
|
Record<string, "cooldown" | "dismissing">
|
||||||
|
>({});
|
||||||
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
cancels.current.forEach((c) => c());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startFading = useCallback((id: string) => {
|
||||||
|
if (cancels.current.has(id)) return;
|
||||||
|
let dead = false;
|
||||||
|
let kill = () => {};
|
||||||
|
kill = () => {
|
||||||
|
dead = true;
|
||||||
|
setFading((f) => {
|
||||||
|
const n = { ...f };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
cancels.current.delete(id);
|
||||||
|
};
|
||||||
|
cancels.current.set(id, () => kill());
|
||||||
|
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
||||||
|
const t1 = setTimeout(() => {
|
||||||
|
if (dead) return;
|
||||||
|
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
||||||
|
const t2 = setTimeout(() => {
|
||||||
|
if (!dead) kill();
|
||||||
|
}, 350);
|
||||||
|
kill = () => {
|
||||||
|
dead = true;
|
||||||
|
clearTimeout(t2);
|
||||||
|
setFading((f) => {
|
||||||
|
const n = { ...f };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
cancels.current.delete(id);
|
||||||
|
};
|
||||||
|
cancels.current.set(id, () => kill());
|
||||||
|
}, 2000);
|
||||||
|
kill = () => {
|
||||||
|
dead = true;
|
||||||
|
clearTimeout(t1);
|
||||||
|
setFading((f) => {
|
||||||
|
const n = { ...f };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
cancels.current.delete(id);
|
||||||
|
};
|
||||||
|
cancels.current.set(id, () => kill());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelFading = useCallback((id: string) => {
|
||||||
|
cancels.current.get(id)?.();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Cancel all in-progress animations (e.g. on page navigation). */
|
||||||
|
const cancelAll = useCallback(() => {
|
||||||
|
cancels.current.forEach((c) => c());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { fading, startFading, cancelFading, cancelAll };
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ interface PlaylistListSyncOptions {
|
|||||||
* (followed membership is managed separately), but still update/remove.
|
* (followed membership is managed separately), but still update/remove.
|
||||||
*/
|
*/
|
||||||
noNewEntries?: boolean;
|
noNewEntries?: boolean;
|
||||||
|
/**
|
||||||
|
* When true, don't re-add a playlist that isn't in the list (idx === -1).
|
||||||
|
* Use this when the caller handles re-insertion itself (e.g. with a position
|
||||||
|
* map, like the dumps/votes pattern in UserPublicProfile).
|
||||||
|
*/
|
||||||
|
skipReinsert?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,6 +66,8 @@ export function usePlaylistListSync(
|
|||||||
if (noNewEntries) return prev;
|
if (noNewEntries) return prev;
|
||||||
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
||||||
if (!ev.playlist.isPublic && !isOwner) return prev;
|
if (!ev.playlist.isPublic && !isOwner) return prev;
|
||||||
|
// skipReinsert: caller handles re-insertion at the correct position.
|
||||||
|
if (optionsRef.current?.skipReinsert) return prev;
|
||||||
return [ev.playlist, ...prev];
|
return [ev.playlist, ...prev];
|
||||||
}
|
}
|
||||||
// Remove if it became private and the viewer can't see private playlists
|
// Remove if it became private and the viewer can't see private playlists
|
||||||
@@ -75,6 +83,14 @@ export function usePlaylistListSync(
|
|||||||
return prev.filter((p) => p.id !== ev.playlistId);
|
return prev.filter((p) => p.id !== ev.playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||||
|
const idx = prev.findIndex((p) => p.id === ev.playlistId);
|
||||||
|
if (idx === -1) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], dumpCount: ev.dumpIds.length };
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}, [lastPlaylistEvent]);
|
}, [lastPlaylistEvent]);
|
||||||
|
|||||||
63
src/hooks/usePositionAwareSync.ts
Normal file
63
src/hooks/usePositionAwareSync.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps track of item positions as items leave a list and re-inserts them
|
||||||
|
* at their original positions when they become visible again.
|
||||||
|
*
|
||||||
|
* MUST be called BEFORE the corresponding sync hook (useDumpListSync /
|
||||||
|
* usePlaylistListSync) so the save-position effect fires before the hook's
|
||||||
|
* removal effect in the same render cycle.
|
||||||
|
*
|
||||||
|
* @param items The current list (pass [] when state is not loaded).
|
||||||
|
* @param setItems The list setter (same one passed to the sync hook).
|
||||||
|
* @param lastEvent The latest changed item (Dump | Playlist | null).
|
||||||
|
* @param isRemoving Return true when the item is about to leave the list
|
||||||
|
* (e.g. it just became private). Used to save its index.
|
||||||
|
* @param shouldReinsert Return true when the item should come back at its
|
||||||
|
* original position (e.g. it just became public again).
|
||||||
|
*/
|
||||||
|
export function usePositionAwareSync<T extends { id: string }>(
|
||||||
|
items: T[],
|
||||||
|
setItems: (fn: (prev: T[]) => T[]) => void,
|
||||||
|
lastEvent: T | null,
|
||||||
|
isRemoving: (item: T) => boolean,
|
||||||
|
shouldReinsert: (item: T) => boolean,
|
||||||
|
): void {
|
||||||
|
const itemsRef = useRef<T[]>(items);
|
||||||
|
const setItemsRef = useRef(setItems);
|
||||||
|
const isRemovingRef = useRef(isRemoving);
|
||||||
|
const shouldReinsertRef = useRef(shouldReinsert);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
itemsRef.current = items;
|
||||||
|
setItemsRef.current = setItems;
|
||||||
|
isRemovingRef.current = isRemoving;
|
||||||
|
shouldReinsertRef.current = shouldReinsert;
|
||||||
|
});
|
||||||
|
|
||||||
|
const removedPositionsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
// Save position BEFORE the sync hook removes the item.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastEvent || !isRemovingRef.current(lastEvent)) return;
|
||||||
|
const idx = itemsRef.current.findIndex((item) => item.id === lastEvent.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
removedPositionsRef.current.set(lastEvent.id, idx);
|
||||||
|
}
|
||||||
|
}, [lastEvent]);
|
||||||
|
|
||||||
|
// Re-insert at the saved position. Fires before the sync hook's effect for
|
||||||
|
// the same event; skipReinsert on the sync hook prevents double-insertion.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastEvent || !shouldReinsertRef.current(lastEvent)) return;
|
||||||
|
const savedIdx = removedPositionsRef.current.get(lastEvent.id);
|
||||||
|
if (savedIdx === undefined) return;
|
||||||
|
removedPositionsRef.current.delete(lastEvent.id);
|
||||||
|
const item = lastEvent;
|
||||||
|
setItemsRef.current((prev) => {
|
||||||
|
if (prev.some((i) => i.id === item.id)) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(Math.min(savedIdx, next.length), 0, item);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [lastEvent]);
|
||||||
|
}
|
||||||
@@ -83,6 +83,14 @@ export function deserializeDump(raw: RawDump): Dump {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hydrateDump(raw: unknown): Dump {
|
||||||
|
return deserializeDump(raw as RawDump);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hydratePlaylist(raw: unknown): Playlist {
|
||||||
|
return deserializePlaylist(raw as RawPlaylist);
|
||||||
|
}
|
||||||
|
|
||||||
export function deserializeUser(raw: RawUser): User {
|
export function deserializeUser(raw: RawUser): User {
|
||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
|
|||||||
@@ -57,13 +57,16 @@ export function Dump() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDump) return;
|
if (!selectedDump) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
if (preloaded) {
|
if (preloaded) {
|
||||||
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`)
|
fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return;
|
return () => controller.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
setDumpState({ status: "loading" });
|
setDumpState({ status: "loading" });
|
||||||
@@ -73,6 +76,7 @@ export function Dump() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
|
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
signal: controller.signal,
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
@@ -82,14 +86,18 @@ export function Dump() {
|
|||||||
const dump: Dump = deserializeDump(apiResponse.data);
|
const dump: Dump = deserializeDump(apiResponse.data);
|
||||||
setDumpState({ status: "loaded", dump });
|
setDumpState({ status: "loaded", dump });
|
||||||
|
|
||||||
fetch(`${API_URL}/api/users/by-id/${dump.userId}`)
|
fetch(`${API_URL}/api/users/by-id/${dump.userId}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
.then((r) => r.success && setOp(deserializePublicUser(r.data)))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
setDumpState({ status: "error", error: friendlyFetchError(err) });
|
setDumpState({ status: "error", error: friendlyFetchError(err) });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
}, [selectedDump, preloaded]);
|
}, [selectedDump, preloaded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -105,7 +113,9 @@ export function Dump() {
|
|||||||
// Fetch comments when dump loads
|
// Fetch comments when dump loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDump) return;
|
if (!selectedDump) return;
|
||||||
|
const controller = new AbortController();
|
||||||
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
|
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
|
||||||
|
signal: controller.signal,
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -115,6 +125,7 @@ export function Dump() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
return () => controller.abort();
|
||||||
}, [selectedDump, token]);
|
}, [selectedDump, token]);
|
||||||
|
|
||||||
// Scroll to and highlight a comment when navigating to #comment-{id}
|
// Scroll to and highlight a comment when navigating to #comment-{id}
|
||||||
@@ -133,8 +144,11 @@ export function Dump() {
|
|||||||
}, [comments, location.hash]);
|
}, [comments, location.hash]);
|
||||||
|
|
||||||
// React to WS comment events
|
// React to WS comment events
|
||||||
|
// Note: selectedDump may be a slug, but lastCommentEvent.dumpId is always a UUID.
|
||||||
|
// Compare against the loaded dump's actual ID.
|
||||||
|
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
if (!lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId) return;
|
||||||
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
||||||
setComments((prev) => {
|
setComments((prev) => {
|
||||||
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
|
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
|
||||||
@@ -161,7 +175,7 @@ export function Dump() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [lastCommentEvent, selectedDump]);
|
}, [lastCommentEvent, loadedDumpId]);
|
||||||
|
|
||||||
if (dumpState.status === "loading") {
|
if (dumpState.status === "loading") {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -11,11 +12,12 @@ import { Avatar } from "../components/Avatar.tsx";
|
|||||||
import { DumpCard } from "../components/DumpCard.tsx";
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
import { AppHeader } from "../components/AppHeader.tsx";
|
import { AppHeader } from "../components/AppHeader.tsx";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
type Dump,
|
type Dump,
|
||||||
|
hydrateDump,
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
type RawDump,
|
type RawDump,
|
||||||
type User,
|
type User,
|
||||||
@@ -29,12 +31,6 @@ import { useWS } from "../hooks/useWS.ts";
|
|||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
// After JSON roundtrip, createdAt is a string — re-parse it
|
|
||||||
const hydrateDump = (raw: Dump): Dump =>
|
|
||||||
deserializeDump(raw as unknown as RawDump);
|
|
||||||
|
|
||||||
type DumpsState =
|
type DumpsState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
@@ -210,11 +206,13 @@ export function Index() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mainFetchDone.current || cached) return;
|
if (mainFetchDone.current || cached) return;
|
||||||
mainFetchDone.current = true;
|
mainFetchDone.current = true;
|
||||||
|
const controller = new AbortController();
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/dumps/?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{
|
{
|
||||||
|
signal: controller.signal,
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -229,12 +227,17 @@ export function Index() {
|
|||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
setDumpsState({
|
setDumpsState({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: friendlyFetchError(err),
|
error: friendlyFetchError(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
return () => {
|
||||||
|
mainFetchDone.current = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
}, [cached, token]);
|
}, [cached, token]);
|
||||||
|
|
||||||
// ── Followed feeds fetch (lazy, on first tab open) ──
|
// ── Followed feeds fetch (lazy, on first tab open) ──
|
||||||
@@ -252,7 +255,7 @@ export function Index() {
|
|||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, {
|
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -286,7 +289,7 @@ export function Index() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/follows/feed/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{
|
{
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
},
|
||||||
@@ -312,7 +315,7 @@ export function Index() {
|
|||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
tab,
|
tab,
|
||||||
user?.id,
|
user,
|
||||||
token,
|
token,
|
||||||
cachedFollowedUsers,
|
cachedFollowedUsers,
|
||||||
cachedFollowedPlaylists,
|
cachedFollowedPlaylists,
|
||||||
@@ -331,7 +334,7 @@ export function Index() {
|
|||||||
setDumpsState((s) =>
|
setDumpsState((s) =>
|
||||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||||
);
|
);
|
||||||
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -368,7 +371,7 @@ export function Index() {
|
|||||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||||
);
|
);
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{
|
{
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
},
|
||||||
@@ -407,7 +410,7 @@ export function Index() {
|
|||||||
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||||
);
|
);
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{
|
{
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
},
|
},
|
||||||
@@ -529,7 +532,7 @@ export function Index() {
|
|||||||
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
|
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
|
||||||
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
|
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
|
||||||
|
|
||||||
const restIds = new Set(dumps.map((d) => d.id));
|
const restIds = useMemo(() => new Set(dumps.map((d) => d.id)), [dumps]);
|
||||||
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
|
const combined = [...recentDumps.filter((d) => !restIds.has(d.id)), ...dumps]
|
||||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL, NOTIFICATIONS_PAGE_SIZE } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
import { Tooltip } from "../components/Tooltip.tsx";
|
import { Tooltip } from "../components/Tooltip.tsx";
|
||||||
@@ -22,8 +22,6 @@ import { deserializeNotification } from "../model.ts";
|
|||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
|
||||||
const PAGE_SIZE = 30;
|
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
@@ -219,7 +217,7 @@ export function Notifications() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 1. Fetch with original read state so unread items are highlighted
|
// 1. Fetch with original read state so unread items are highlighted
|
||||||
// 2. Only after displaying, mark all read on the server
|
// 2. Only after displaying, mark all read on the server
|
||||||
authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`)
|
authFetch(`${API_URL}/api/notifications?page=1&limit=${NOTIFICATIONS_PAGE_SIZE}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (!body.success) throw new Error("Failed to load");
|
if (!body.success) throw new Error("Failed to load");
|
||||||
@@ -270,7 +268,7 @@ export function Notifications() {
|
|||||||
const nextPage = state.page + 1;
|
const nextPage = state.page + 1;
|
||||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
authFetch(
|
authFetch(
|
||||||
`${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/notifications?page=${nextPage}&limit=${NOTIFICATIONS_PAGE_SIZE}`,
|
||||||
)
|
)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
|
|||||||
@@ -92,10 +92,16 @@ export function PlaylistDetail() {
|
|||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const fetchAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const fetchPlaylist = () => {
|
const fetchPlaylist = () => {
|
||||||
if (!playlistId) return;
|
if (!playlistId) return;
|
||||||
|
fetchAbortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
fetchAbortRef.current = controller;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||||
|
signal: controller.signal,
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
})
|
})
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -122,6 +128,7 @@ export function PlaylistDetail() {
|
|||||||
cancels.current.clear();
|
cancels.current.clear();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
setState({
|
setState({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: friendlyFetchError(err),
|
error: friendlyFetchError(err),
|
||||||
@@ -131,6 +138,7 @@ export function PlaylistDetail() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
|
return () => fetchAbortRef.current?.abort();
|
||||||
}, [playlistId]);
|
}, [playlistId]);
|
||||||
|
|
||||||
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
||||||
@@ -465,8 +473,10 @@ export function PlaylistDetail() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: editTitle,
|
...(editTitle !== state.playlist.title ? { title: editTitle } : {}),
|
||||||
description: editDescription || undefined,
|
...(editDescription !== (state.playlist.description ?? "")
|
||||||
|
? { description: editDescription || null }
|
||||||
|
: {}),
|
||||||
isPublic: editIsPublic,
|
isPublic: editIsPublic,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
import { deserializeDump, deserializePublicUser, hydrateDump } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
|
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -22,10 +23,6 @@ import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
|||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
const hydrateDump = (raw: Dump): Dump =>
|
|
||||||
deserializeDump(raw as unknown as RawDump);
|
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
@@ -41,7 +38,7 @@ type State =
|
|||||||
export function UserDumps() {
|
export function UserDumps() {
|
||||||
const { username } = useParams();
|
const { username } = useParams();
|
||||||
const { user: me, token } = useAuth();
|
const { user: me, token } = useAuth();
|
||||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
const { voteCounts, myVotes, lastDumpEvent, castVote, removeVote } = useWS();
|
||||||
const { cached, saveState } = useFeedCache<Dump>(
|
const { cached, saveState } = useFeedCache<Dump>(
|
||||||
`feed:user-dumps-full:${username ?? ""}`,
|
`feed:user-dumps-full:${username ?? ""}`,
|
||||||
hydrateDump,
|
hydrateDump,
|
||||||
@@ -56,19 +53,27 @@ export function UserDumps() {
|
|||||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
|
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
|
||||||
}, []);
|
}, []);
|
||||||
const addFilter = useCallback((dump: Dump): boolean => {
|
const dumpItems = state.status === "loaded" ? state.dumps : [];
|
||||||
if (!profileUserId) return false;
|
usePositionAwareSync(
|
||||||
if (dump.userId !== profileUserId) return false;
|
dumpItems,
|
||||||
return isOwnProfile || !dump.isPrivate;
|
setDumps,
|
||||||
}, [profileUserId, isOwnProfile]);
|
lastDumpEvent,
|
||||||
useDumpListSync(setDumps, addFilter);
|
(d) => d.isPrivate,
|
||||||
|
(d) => !d.isPrivate && d.userId === profileUserId,
|
||||||
|
);
|
||||||
|
useDumpListSync(setDumps, {
|
||||||
|
ownerId: profileUserId ?? undefined,
|
||||||
|
isOwner: isOwnProfile,
|
||||||
|
skipReinsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
fetch(`${API_URL}/api/users/${username}`)
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (!body.success) throw new Error("User not found");
|
if (!body.success) throw new Error("User not found");
|
||||||
@@ -81,23 +86,21 @@ export function UserDumps() {
|
|||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
setState({
|
if (err.name === "AbortError") return;
|
||||||
status: "error",
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
error: friendlyFetchError(err),
|
});
|
||||||
})
|
return () => controller.abort();
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeaders: HeadersInit = token
|
const authHeaders: HeadersInit = token
|
||||||
? { Authorization: `Bearer ${token}` }
|
? { Authorization: `Bearer ${token}` }
|
||||||
: {};
|
: {};
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`${API_URL}/api/users/${username}`),
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: authHeaders },
|
{ headers: authHeaders, signal: controller.signal },
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
.then(([userRes, dumpsRes]) =>
|
.then(([userRes, dumpsRes]) =>
|
||||||
@@ -117,12 +120,11 @@ export function UserDumps() {
|
|||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
setState({
|
if (err.name === "AbortError") return;
|
||||||
status: "error",
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
error: friendlyFetchError(err),
|
});
|
||||||
})
|
return () => controller.abort();
|
||||||
);
|
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
@@ -133,7 +135,7 @@ export function UserDumps() {
|
|||||||
const nextPage = state.page + 1;
|
const nextPage = state.page + 1;
|
||||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
)
|
)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
import type {
|
import type {
|
||||||
PaginatedData,
|
PaginatedData,
|
||||||
@@ -15,9 +15,11 @@ import type {
|
|||||||
PublicUser,
|
PublicUser,
|
||||||
RawPlaylist,
|
RawPlaylist,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
import { deserializePlaylist, deserializePublicUser, hydratePlaylist } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||||
|
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -27,10 +29,6 @@ import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
|||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
|
||||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
|
||||||
|
|
||||||
interface PlaylistFeed {
|
interface PlaylistFeed {
|
||||||
items: Playlist[];
|
items: Playlist[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@@ -55,6 +53,7 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
|||||||
export function UserPlaylists() {
|
export function UserPlaylists() {
|
||||||
const { username } = useParams();
|
const { username } = useParams();
|
||||||
const { user: me, authFetch, token } = useAuth();
|
const { user: me, authFetch, token } = useAuth();
|
||||||
|
const { lastPlaylistEvent } = useWS();
|
||||||
|
|
||||||
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
||||||
Playlist
|
Playlist
|
||||||
@@ -82,9 +81,21 @@ export function UserPlaylists() {
|
|||||||
: { ...s, created: { ...s.created, items: fn(s.created.items) } }
|
: { ...s, created: { ...s.created, items: fn(s.created.items) } }
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
const createdItems = state.status === "loaded" ? state.created.items : [];
|
||||||
|
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
||||||
|
? (lastPlaylistEvent.playlist ?? null)
|
||||||
|
: null;
|
||||||
|
usePositionAwareSync(
|
||||||
|
createdItems,
|
||||||
|
setCreated,
|
||||||
|
lastPlaylistItem,
|
||||||
|
(p) => !p.isPublic,
|
||||||
|
(p) => p.isPublic && p.userId === profileUserId,
|
||||||
|
);
|
||||||
usePlaylistListSync(setCreated, {
|
usePlaylistListSync(setCreated, {
|
||||||
isOwner: isOwnProfile,
|
isOwner: isOwnProfile,
|
||||||
ownerId: profileUserId ?? undefined,
|
ownerId: profileUserId ?? undefined,
|
||||||
|
skipReinsert: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||||
@@ -99,13 +110,14 @@ export function UserPlaylists() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
const authHeaders: HeadersInit = token
|
const authHeaders: HeadersInit = token
|
||||||
? { Authorization: `Bearer ${token}` }
|
? { Authorization: `Bearer ${token}` }
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
if (cachedCreated && cachedFollowed) {
|
if (cachedCreated && cachedFollowed) {
|
||||||
fetch(`${API_URL}/api/users/${username}`)
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (!body.success) throw new Error("User not found");
|
if (!body.success) throw new Error("User not found");
|
||||||
@@ -126,23 +138,22 @@ export function UserPlaylists() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
setState({
|
if (err.name === "AbortError") return;
|
||||||
status: "error",
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
error: friendlyFetchError(err),
|
});
|
||||||
})
|
return () => controller.abort();
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`${API_URL}/api/users/${username}`),
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: authHeaders },
|
{ headers: authHeaders, signal: controller.signal },
|
||||||
),
|
),
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
|
{ signal: controller.signal },
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
.then(([userRes, createdRes, followedRes]) =>
|
.then(([userRes, createdRes, followedRes]) =>
|
||||||
@@ -169,12 +180,11 @@ export function UserPlaylists() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
setState({
|
if (err.name === "AbortError") return;
|
||||||
status: "error",
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
error: friendlyFetchError(err),
|
});
|
||||||
})
|
return () => controller.abort();
|
||||||
);
|
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
const loadMoreCreated = useCallback(() => {
|
const loadMoreCreated = useCallback(() => {
|
||||||
@@ -189,7 +199,7 @@ export function UserPlaylists() {
|
|||||||
: s
|
: s
|
||||||
);
|
);
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
)
|
)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -230,7 +240,7 @@ export function UserPlaylists() {
|
|||||||
: s
|
: s
|
||||||
);
|
);
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
)
|
)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import React, {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
||||||
import {
|
import {
|
||||||
deserializeAuthResponse,
|
deserializeAuthResponse,
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
deserializePublicUser,
|
deserializePublicUser,
|
||||||
deserializeUser,
|
deserializeUser,
|
||||||
|
hydrateDump,
|
||||||
|
hydratePlaylist,
|
||||||
type RawDump,
|
type RawDump,
|
||||||
type RawUser,
|
type RawUser,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
@@ -26,7 +28,9 @@ import { PageError } from "../components/PageError.tsx";
|
|||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
|
import { useFading } from "../hooks/useFading.ts";
|
||||||
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||||
|
import { usePositionAwareSync } from "../hooks/usePositionAwareSync.ts";
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||||
import { deserializePlaylist } from "../model.ts";
|
import { deserializePlaylist } from "../model.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
@@ -37,8 +41,6 @@ import { friendlyFetchError } from "../utils/apiError.ts";
|
|||||||
import { TextEditor } from "../components/TextEditor.tsx";
|
import { TextEditor } from "../components/TextEditor.tsx";
|
||||||
import { Markdown } from "../components/Markdown.tsx";
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
function InviteButton() {
|
function InviteButton() {
|
||||||
const { authFetch } = useAuth();
|
const { authFetch } = useAuth();
|
||||||
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||||
@@ -89,11 +91,6 @@ function InviteButton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateDump = (raw: Dump): Dump =>
|
|
||||||
deserializeDump(raw as unknown as RawDump);
|
|
||||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
|
||||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
|
||||||
|
|
||||||
interface PaginatedList<T> {
|
interface PaginatedList<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
@@ -125,6 +122,8 @@ export function UserPublicProfile() {
|
|||||||
myVotes,
|
myVotes,
|
||||||
lastVoteEvent,
|
lastVoteEvent,
|
||||||
lastDumpEvent,
|
lastDumpEvent,
|
||||||
|
lastPlaylistEvent,
|
||||||
|
lastUserEvent,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
@@ -149,89 +148,47 @@ export function UserPublicProfile() {
|
|||||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||||
const isOwnProfile = me?.id === profileUserId;
|
const isOwnProfile = me?.id === profileUserId;
|
||||||
|
|
||||||
const removedDumpPositionsRef = useRef<Map<string, number>>(new Map());
|
|
||||||
|
|
||||||
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
setState((s) => {
|
setState((s) =>
|
||||||
if (s.status !== "loaded") return s;
|
s.status !== "loaded"
|
||||||
const prev = s.dumps.items;
|
? s
|
||||||
const next = fn(prev);
|
: { ...s, dumps: { ...s.dumps, items: fn(s.dumps.items) } }
|
||||||
if (next.length < prev.length) {
|
);
|
||||||
const nextIds = new Set(next.map((d) => d.id));
|
|
||||||
prev.forEach((d, idx) => {
|
|
||||||
if (!nextIds.has(d.id)) {
|
|
||||||
removedDumpPositionsRef.current.set(d.id, idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ...s, dumps: { ...s.dumps, items: next } };
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
// No addFilter — insertion at correct position is handled by the effect below.
|
const dumpItems = state.status === "loaded" ? state.dumps.items : [];
|
||||||
useDumpListSync(setDumps);
|
usePositionAwareSync(
|
||||||
|
dumpItems,
|
||||||
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
setDumps,
|
||||||
new Set(),
|
lastDumpEvent,
|
||||||
|
(d) => d.isPrivate,
|
||||||
|
(d) => !d.isPrivate && d.userId === profileUserId,
|
||||||
);
|
);
|
||||||
|
useDumpListSync(setDumps, {
|
||||||
|
ownerId: profileUserId ?? undefined,
|
||||||
|
isOwner: isOwnProfile,
|
||||||
|
skipReinsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Tracks the list index of each dump at the moment it was removed from the
|
|
||||||
// votes list, so we can re-insert it at the correct position when it becomes
|
|
||||||
// public again (instead of always prepending at position 0).
|
|
||||||
const removedVotePositionsRef = useRef<Map<string, number>>(new Map());
|
|
||||||
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
|
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
|
||||||
// a future dump_updated event (that would only be for private→public transitions).
|
// a future dump_updated event (that would only be for private→public transitions).
|
||||||
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
|
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
setState((s) => {
|
setState((s) =>
|
||||||
if (s.status !== "loaded") return s;
|
s.status !== "loaded"
|
||||||
const prev = s.votes.items;
|
? s
|
||||||
const next = fn(prev);
|
: { ...s, votes: { ...s.votes, items: fn(s.votes.items) } }
|
||||||
if (next.length < prev.length) {
|
);
|
||||||
const nextIds = new Set(next.map((d) => d.id));
|
|
||||||
prev.forEach((d, idx) => {
|
|
||||||
if (!nextIds.has(d.id)) {
|
|
||||||
removedVotePositionsRef.current.set(d.id, idx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { ...s, votes: { ...s.votes, items: next } };
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
useDumpListSync(setVotes);
|
const voteItems = state.status === "loaded" ? state.votes.items : [];
|
||||||
|
usePositionAwareSync(
|
||||||
// Re-insert a vote-list dump at its original position after private→public.
|
voteItems,
|
||||||
// Skip dumps whose vote was explicitly withdrawn (those were removed intentionally).
|
setVotes,
|
||||||
useEffect(() => {
|
lastDumpEvent,
|
||||||
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
(d) => d.isPrivate,
|
||||||
const dump = lastDumpEvent;
|
(d) => !d.isPrivate && !withdrawnVoteIdsRef.current.has(d.id),
|
||||||
if (withdrawnVoteIdsRef.current.has(dump.id)) return;
|
);
|
||||||
const savedIdx = removedVotePositionsRef.current.get(dump.id);
|
useDumpListSync(setVotes, { skipReinsert: true });
|
||||||
if (savedIdx === undefined) return;
|
|
||||||
removedVotePositionsRef.current.delete(dump.id);
|
|
||||||
setVotes((prev) => {
|
|
||||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
|
||||||
const next = [...prev];
|
|
||||||
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [lastDumpEvent, setVotes]);
|
|
||||||
|
|
||||||
// Re-insert a dumps-column dump at its original position after private→public.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
|
||||||
const dump = lastDumpEvent;
|
|
||||||
if (dump.userId !== profileUserId) return;
|
|
||||||
const savedIdx = removedDumpPositionsRef.current.get(dump.id);
|
|
||||||
if (savedIdx === undefined) return;
|
|
||||||
removedDumpPositionsRef.current.delete(dump.id);
|
|
||||||
setDumps((prev) => {
|
|
||||||
if (prev.some((d) => d.id === dump.id)) return prev;
|
|
||||||
const next = [...prev];
|
|
||||||
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, [lastDumpEvent, profileUserId, setDumps]);
|
|
||||||
|
|
||||||
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||||
setState((s) =>
|
setState((s) =>
|
||||||
@@ -240,11 +197,33 @@ export function UserPublicProfile() {
|
|||||||
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
|
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
const playlistItems = state.status === "loaded" ? state.playlists.items : [];
|
||||||
|
const lastPlaylistItem = lastPlaylistEvent?.type === "updated"
|
||||||
|
? (lastPlaylistEvent.playlist ?? null)
|
||||||
|
: null;
|
||||||
|
usePositionAwareSync(
|
||||||
|
playlistItems,
|
||||||
|
setPlaylists,
|
||||||
|
lastPlaylistItem,
|
||||||
|
(p) => !p.isPublic,
|
||||||
|
(p) => p.isPublic && p.userId === profileUserId,
|
||||||
|
);
|
||||||
usePlaylistListSync(setPlaylists, {
|
usePlaylistListSync(setPlaylists, {
|
||||||
isOwner: isOwnProfile,
|
isOwner: isOwnProfile,
|
||||||
ownerId: profileUserId ?? undefined,
|
ownerId: profileUserId ?? undefined,
|
||||||
|
skipReinsert: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update profile user when they edit their own profile
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastUserEvent) return;
|
||||||
|
const { user } = lastUserEvent;
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded" || s.user.id !== user.id) return s;
|
||||||
|
return { ...s, user };
|
||||||
|
});
|
||||||
|
}, [lastUserEvent]);
|
||||||
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -258,18 +237,21 @@ export function UserPublicProfile() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
|
prevMyVotesRef.current = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
|
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
|
||||||
|
|
||||||
if (allCached) {
|
if (allCached) {
|
||||||
// Only fetch the user object (lightweight, always fresh)
|
// Only fetch the user object (lightweight, always fresh)
|
||||||
fetch(`${API_URL}/api/users/${username}`)
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (!body.success) throw new Error("User not found");
|
if (!body.success) throw new Error("User not found");
|
||||||
|
const profileUser = deserializePublicUser(body.data);
|
||||||
setState({
|
setState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
user: deserializePublicUser(body.data),
|
user: profileUser,
|
||||||
dumps: {
|
dumps: {
|
||||||
items: cachedDumps.items,
|
items: cachedDumps.items,
|
||||||
hasMore: cachedDumps.hasMore,
|
hasMore: cachedDumps.hasMore,
|
||||||
@@ -289,15 +271,12 @@ export function UserPublicProfile() {
|
|||||||
loadingMore: false,
|
loadingMore: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
|
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
setState({
|
if (err.name === "AbortError") return;
|
||||||
status: "error",
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
error: friendlyFetchError(err),
|
});
|
||||||
})
|
return () => controller.abort();
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -306,18 +285,20 @@ export function UserPublicProfile() {
|
|||||||
? { Authorization: `Bearer ${token}` }
|
? { Authorization: `Bearer ${token}` }
|
||||||
: {};
|
: {};
|
||||||
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
|
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
|
||||||
fetch(`${API_URL}/api/users/${username}`),
|
fetch(`${API_URL}/api/users/${username}`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/dumps?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: authHeaders },
|
{ headers: authHeaders, signal: controller.signal },
|
||||||
),
|
),
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: authHeaders },
|
{ headers: authHeaders, signal: controller.signal },
|
||||||
),
|
),
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/playlists?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: authHeaders },
|
{ headers: authHeaders, signal: controller.signal },
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -347,10 +328,11 @@ export function UserPublicProfile() {
|
|||||||
? dumpsBody.data
|
? dumpsBody.data
|
||||||
: { items: [], total: 0, hasMore: false };
|
: { items: [], total: 0, hasMore: false };
|
||||||
|
|
||||||
|
const profileUser = deserializePublicUser(userBody.data);
|
||||||
const voteItems = votesData.items.map(deserializeDump);
|
const voteItems = votesData.items.map(deserializeDump);
|
||||||
setState({
|
setState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
user: deserializePublicUser(userBody.data),
|
user: profileUser,
|
||||||
dumps: initialList(
|
dumps: initialList(
|
||||||
dumpsData.items.map(deserializeDump),
|
dumpsData.items.map(deserializeDump),
|
||||||
dumpsData.hasMore,
|
dumpsData.hasMore,
|
||||||
@@ -361,20 +343,20 @@ export function UserPublicProfile() {
|
|||||||
playlistsData.hasMore,
|
playlistsData.hasMore,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
setProfileVotedIds(new Set(voteItems.map((d) => d.id)));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
setState({
|
setState({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: friendlyFetchError(err),
|
error: friendlyFetchError(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
return () => controller.abort();
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
// Own profile: keep profileVotedIds in sync with myVotes
|
// Own profile: prepend dumps newly voted by the user to the preview list
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profileUserId || me?.id !== profileUserId) return;
|
if (!profileUserId || me?.id !== profileUserId) return;
|
||||||
setProfileVotedIds(new Set(myVotes));
|
|
||||||
if (prevMyVotesRef.current === null) {
|
if (prevMyVotesRef.current === null) {
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
return;
|
return;
|
||||||
@@ -400,35 +382,28 @@ export function UserPublicProfile() {
|
|||||||
if (!lastVoteEvent || !profileUserId) return;
|
if (!lastVoteEvent || !profileUserId) return;
|
||||||
const { dumpId, voterId, action } = lastVoteEvent;
|
const { dumpId, voterId, action } = lastVoteEvent;
|
||||||
if (voterId !== profileUserId) return;
|
if (voterId !== profileUserId) return;
|
||||||
const isOwnProfile = me?.id === profileUserId;
|
|
||||||
|
|
||||||
if (action === "remove") {
|
if (action === "remove") {
|
||||||
if (!isOwnProfile) {
|
// Keep dump in state.votes.items as a ghost — UpvotedDumpList drives
|
||||||
setProfileVotedIds((prev) => {
|
// its own votedIds + fading state and will animate the removal.
|
||||||
const n = new Set(prev);
|
|
||||||
n.delete(dumpId);
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
withdrawnVoteIdsRef.current.add(dumpId);
|
withdrawnVoteIdsRef.current.add(dumpId);
|
||||||
setVotes((prev) => prev.filter((d) => d.id !== dumpId));
|
|
||||||
} else {
|
} else {
|
||||||
withdrawnVoteIdsRef.current.delete(dumpId);
|
withdrawnVoteIdsRef.current.delete(dumpId);
|
||||||
if (!isOwnProfile) {
|
|
||||||
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
|
||||||
}
|
|
||||||
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (!body.success) return;
|
if (!body.success) return;
|
||||||
const dump = deserializeDump(body.data);
|
const dump = deserializeDump(body.data);
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (
|
if (s.status !== "loaded") return s;
|
||||||
s.status !== "loaded" ||
|
const idx = s.votes.items.findIndex((d) => d.id === dumpId);
|
||||||
s.votes.items.some((d) => d.id === dumpId)
|
if (idx !== -1) {
|
||||||
) {
|
// Ghost re-voted: update in-place.
|
||||||
return s;
|
const items = [...s.votes.items];
|
||||||
|
items[idx] = dump;
|
||||||
|
return { ...s, votes: { ...s.votes, items } };
|
||||||
}
|
}
|
||||||
|
// First-time vote: prepend.
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
||||||
@@ -437,7 +412,7 @@ export function UserPublicProfile() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}, [lastVoteEvent, me, profileUserId]);
|
}, [lastVoteEvent, profileUserId]);
|
||||||
|
|
||||||
// Save scroll position + loaded state to sessionStorage on scroll
|
// Save scroll position + loaded state to sessionStorage on scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -465,6 +440,19 @@ export function UserPublicProfile() {
|
|||||||
};
|
};
|
||||||
}, [state, saveDumps, saveVotes, savePlaylists]);
|
}, [state, saveDumps, saveVotes, savePlaylists]);
|
||||||
|
|
||||||
|
// Keep the playlists cache current whenever the list changes (e.g. via WS),
|
||||||
|
// so a page refresh restores the up-to-date list rather than a stale snapshot.
|
||||||
|
const playlistFeed = state.status === "loaded" ? state.playlists : null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playlistFeed) return;
|
||||||
|
savePlaylists(
|
||||||
|
playlistFeed.items,
|
||||||
|
playlistFeed.page,
|
||||||
|
playlistFeed.hasMore,
|
||||||
|
globalThis.scrollY,
|
||||||
|
);
|
||||||
|
}, [playlistFeed, savePlaylists]);
|
||||||
|
|
||||||
// Restore scroll position after cache restoration
|
// Restore scroll position after cache restoration
|
||||||
const scrollRestored = useRef(false);
|
const scrollRestored = useRef(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -734,9 +722,10 @@ export function UserPublicProfile() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<UpvotedDumpList
|
<UpvotedDumpList
|
||||||
title={`Upvoted (${profileVotedIds.size}${votes.hasMore ? "+" : ""})`}
|
title={`Upvoted (${votes.items.length}${votes.hasMore ? "+" : ""})`}
|
||||||
dumps={votes.items}
|
dumps={votes.items}
|
||||||
votedIds={profileVotedIds}
|
profileUserId={profileUserId}
|
||||||
|
isOwnProfile={isOwnProfile}
|
||||||
voteCounts={voteCounts}
|
voteCounts={voteCounts}
|
||||||
myVotes={myVotes}
|
myVotes={myVotes}
|
||||||
canVote={!!me}
|
canVote={!!me}
|
||||||
@@ -865,7 +854,8 @@ function UpvotedDumpList(
|
|||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
dumps,
|
dumps,
|
||||||
votedIds,
|
profileUserId,
|
||||||
|
isOwnProfile,
|
||||||
voteCounts,
|
voteCounts,
|
||||||
myVotes,
|
myVotes,
|
||||||
canVote,
|
canVote,
|
||||||
@@ -875,7 +865,8 @@ function UpvotedDumpList(
|
|||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
dumps: Dump[];
|
dumps: Dump[];
|
||||||
votedIds: Set<string>;
|
profileUserId: string | null;
|
||||||
|
isOwnProfile: boolean;
|
||||||
voteCounts: Record<string, number>;
|
voteCounts: Record<string, number>;
|
||||||
myVotes: Set<string>;
|
myVotes: Set<string>;
|
||||||
canVote: boolean;
|
canVote: boolean;
|
||||||
@@ -884,84 +875,46 @@ function UpvotedDumpList(
|
|||||||
viewAllHref: string;
|
viewAllHref: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [fading, setFading] = useState<
|
const { myVotes: wsMyVotes, lastVoteEvent } = useWS();
|
||||||
Record<string, "cooldown" | "dismissing">
|
const { fading, startFading, cancelFading } = useFading();
|
||||||
>({});
|
|
||||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
|
||||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
// votedIds is managed locally so setVotedIds + startFading/cancelFading can
|
||||||
cancels.current.forEach((c) => c());
|
// be called in the same effect body — guaranteeing a single render where the
|
||||||
}, []);
|
// dump is always in visibleDumps (with or without fading class). This prevents
|
||||||
|
// the DOM node from being unmounted/remounted, which would break CSS transitions.
|
||||||
|
const [votedIds, setVotedIds] = useState(() => new Set(dumps.map((d) => d.id)));
|
||||||
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
|
// Own profile: sync votedIds with myVotes; start/cancel fading in same batch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevVotedIds.current === null) {
|
if (!profileUserId || !isOwnProfile) return;
|
||||||
prevVotedIds.current = new Set(votedIds);
|
if (prevMyVotesRef.current === null) {
|
||||||
|
setVotedIds(new Set(wsMyVotes));
|
||||||
|
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const prev = prevMyVotesRef.current;
|
||||||
|
setVotedIds(new Set(wsMyVotes));
|
||||||
|
for (const id of prev) { if (!wsMyVotes.has(id)) startFading(id); }
|
||||||
|
for (const id of wsMyVotes) { if (!prev.has(id)) cancelFading(id); }
|
||||||
|
prevMyVotesRef.current = new Set(wsMyVotes);
|
||||||
|
}, [wsMyVotes, isOwnProfile, profileUserId, startFading, cancelFading]);
|
||||||
|
|
||||||
const prev = prevVotedIds.current;
|
// Non-own profile: sync votedIds with WS vote events for the profile user.
|
||||||
|
useEffect(() => {
|
||||||
for (const id of prev) {
|
if (!lastVoteEvent || !profileUserId || isOwnProfile) return;
|
||||||
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
const { dumpId, voterId, action } = lastVoteEvent;
|
||||||
let dead = false;
|
if (voterId !== profileUserId) return;
|
||||||
let kill = () => {};
|
if (action === "remove") {
|
||||||
kill = () => {
|
setVotedIds((prev) => { const n = new Set(prev); n.delete(dumpId); return n; });
|
||||||
dead = true;
|
startFading(dumpId);
|
||||||
setFading((f) => {
|
} else {
|
||||||
const n = { ...f };
|
setVotedIds((prev) => new Set([...prev, dumpId]));
|
||||||
delete n[id];
|
cancelFading(dumpId);
|
||||||
return n;
|
|
||||||
});
|
|
||||||
cancels.current.delete(id);
|
|
||||||
};
|
|
||||||
cancels.current.set(id, () => kill());
|
|
||||||
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
|
||||||
|
|
||||||
const t1 = setTimeout(() => {
|
|
||||||
if (dead) return;
|
|
||||||
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
|
||||||
const t2 = setTimeout(() => {
|
|
||||||
if (!dead) kill();
|
|
||||||
}, 350);
|
|
||||||
kill = () => {
|
|
||||||
dead = true;
|
|
||||||
clearTimeout(t2);
|
|
||||||
setFading((f) => {
|
|
||||||
const n = { ...f };
|
|
||||||
delete n[id];
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
cancels.current.delete(id);
|
|
||||||
};
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
kill = () => {
|
|
||||||
dead = true;
|
|
||||||
clearTimeout(t1);
|
|
||||||
setFading((f) => {
|
|
||||||
const n = { ...f };
|
|
||||||
delete n[id];
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
cancels.current.delete(id);
|
|
||||||
};
|
|
||||||
cancels.current.set(id, () => kill());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}, [lastVoteEvent, profileUserId, isOwnProfile, startFading, cancelFading]);
|
||||||
|
|
||||||
for (const id of votedIds) {
|
const visibleDumps = dumps.filter((d) => votedIds.has(d.id) || d.id in fading);
|
||||||
if (!prev.has(id) && cancels.current.has(id)) {
|
|
||||||
cancels.current.get(id)!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevVotedIds.current = new Set(votedIds);
|
|
||||||
}, [votedIds]);
|
|
||||||
|
|
||||||
const visibleDumps = dumps.filter((d) =>
|
|
||||||
votedIds.has(d.id) || d.id in fading
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="profile-section">
|
<section className="profile-section">
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Link, useParams } from "react-router";
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
import { deserializeDump, deserializePublicUser, hydrateDump } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
|
import { useFading } from "../hooks/useFading.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -21,10 +22,6 @@ import { DumpCard } from "../components/DumpCard.tsx";
|
|||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
const hydrateDump = (raw: Dump): Dump =>
|
|
||||||
deserializeDump(raw as unknown as RawDump);
|
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
@@ -54,26 +51,19 @@ export function UserUpvoted() {
|
|||||||
useDumpListSync(setVotesDumps);
|
useDumpListSync(setVotesDumps);
|
||||||
|
|
||||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||||
const [fading, setFading] = useState<
|
const { fading, startFading, cancelFading, cancelAll } = useFading();
|
||||||
Record<string, "cooldown" | "dismissing">
|
|
||||||
>({});
|
|
||||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
|
||||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
|
||||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
useEffect(() => () => {
|
|
||||||
cancels.current.forEach((c) => c());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
|
cancelAll();
|
||||||
setVotedIds(new Set());
|
setVotedIds(new Set());
|
||||||
prevVotedIds.current = null;
|
|
||||||
prevMyVotesRef.current = null;
|
prevMyVotesRef.current = null;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
fetch(`${API_URL}/api/users/${username}`)
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
if (!body.success) throw new Error("User not found");
|
if (!body.success) throw new Error("User not found");
|
||||||
@@ -88,23 +78,21 @@ export function UserUpvoted() {
|
|||||||
});
|
});
|
||||||
setVotedIds(voteIds);
|
setVotedIds(voteIds);
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
setState({
|
if (err.name === "AbortError") return;
|
||||||
status: "error",
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
error: friendlyFetchError(err),
|
});
|
||||||
})
|
return () => controller.abort();
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const authHeaders: HeadersInit = token
|
const authHeaders: HeadersInit = token
|
||||||
? { Authorization: `Bearer ${token}` }
|
? { Authorization: `Bearer ${token}` }
|
||||||
: {};
|
: {};
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(`${API_URL}/api/users/${username}`),
|
fetch(`${API_URL}/api/users/${username}`, { signal: controller.signal }),
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/votes?page=1&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: authHeaders },
|
{ headers: authHeaders, signal: controller.signal },
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
.then(([userRes, votesRes]) =>
|
.then(([userRes, votesRes]) =>
|
||||||
@@ -126,37 +114,32 @@ export function UserUpvoted() {
|
|||||||
});
|
});
|
||||||
setVotedIds(new Set(voteItems.map((d) => d.id)));
|
setVotedIds(new Set(voteItems.map((d) => d.id)));
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) => {
|
||||||
setState({
|
if (err.name === "AbortError") return;
|
||||||
status: "error",
|
setState({ status: "error", error: friendlyFetchError(err) });
|
||||||
error: friendlyFetchError(err),
|
});
|
||||||
})
|
return () => controller.abort();
|
||||||
);
|
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
|
|
||||||
// Own profile: keep votedIds in sync with myVotes
|
// 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.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profileUserId || me?.id !== profileUserId) return;
|
if (!profileUserId || me?.id !== profileUserId) return;
|
||||||
setVotedIds(new Set(myVotes));
|
|
||||||
if (prevMyVotesRef.current === null) {
|
if (prevMyVotesRef.current === null) {
|
||||||
|
// First sync after load: initialize without animating the diff.
|
||||||
|
setVotedIds(new Set(myVotes));
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const prev = prevMyVotesRef.current;
|
const prev = prevMyVotesRef.current;
|
||||||
setState((s) => {
|
setVotedIds(new Set(myVotes));
|
||||||
if (s.status !== "loaded") return s;
|
for (const id of prev) { if (!myVotes.has(id)) startFading(id); }
|
||||||
const voteIdSet = new Set(s.votes.map((d) => d.id));
|
for (const id of myVotes) { if (!prev.has(id)) cancelFading(id); }
|
||||||
const toAdd = [...myVotes].filter((id) =>
|
|
||||||
!prev.has(id) && !voteIdSet.has(id)
|
|
||||||
);
|
|
||||||
if (toAdd.length === 0) return s;
|
|
||||||
// Newly voted items will arrive via lastVoteEvent fetch below
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
}, [myVotes, me, profileUserId]);
|
}, [myVotes, me, profileUserId, startFading, cancelFading]);
|
||||||
|
|
||||||
// WS vote events
|
// WS vote events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,8 +153,11 @@ export function UserUpvoted() {
|
|||||||
n.delete(dumpId);
|
n.delete(dumpId);
|
||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
|
// Start fading in same batch so visibleDumps never has a gap render.
|
||||||
|
startFading(dumpId);
|
||||||
} else {
|
} else {
|
||||||
setVotedIds((prev) => new Set([...prev, dumpId]));
|
setVotedIds((prev) => new Set([...prev, dumpId]));
|
||||||
|
cancelFading(dumpId);
|
||||||
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
@@ -186,73 +172,8 @@ export function UserUpvoted() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}, [lastVoteEvent, profileUserId]);
|
}, [lastVoteEvent, profileUserId, startFading, cancelFading]);
|
||||||
|
|
||||||
// Fade animation when items leave votedIds
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevVotedIds.current === null) {
|
|
||||||
prevVotedIds.current = new Set(votedIds);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prev = prevVotedIds.current;
|
|
||||||
|
|
||||||
for (const id of prev) {
|
|
||||||
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
|
||||||
let dead = false;
|
|
||||||
let kill = () => {};
|
|
||||||
kill = () => {
|
|
||||||
dead = true;
|
|
||||||
setFading((f) => {
|
|
||||||
const n = { ...f };
|
|
||||||
delete n[id];
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
cancels.current.delete(id);
|
|
||||||
};
|
|
||||||
cancels.current.set(id, () => kill());
|
|
||||||
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
|
||||||
|
|
||||||
const t1 = setTimeout(() => {
|
|
||||||
if (dead) return;
|
|
||||||
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
|
||||||
const t2 = setTimeout(() => {
|
|
||||||
if (!dead) kill();
|
|
||||||
}, 350);
|
|
||||||
kill = () => {
|
|
||||||
dead = true;
|
|
||||||
clearTimeout(t2);
|
|
||||||
setFading((f) => {
|
|
||||||
const n = { ...f };
|
|
||||||
delete n[id];
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
cancels.current.delete(id);
|
|
||||||
};
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
kill = () => {
|
|
||||||
dead = true;
|
|
||||||
clearTimeout(t1);
|
|
||||||
setFading((f) => {
|
|
||||||
const n = { ...f };
|
|
||||||
delete n[id];
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
cancels.current.delete(id);
|
|
||||||
};
|
|
||||||
cancels.current.set(id, () => kill());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of votedIds) {
|
|
||||||
if (!prev.has(id) && cancels.current.has(id)) {
|
|
||||||
cancels.current.get(id)!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevVotedIds.current = new Set(votedIds);
|
|
||||||
}, [votedIds]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@@ -262,7 +183,7 @@ export function UserUpvoted() {
|
|||||||
const nextPage = state.page + 1;
|
const nextPage = state.page + 1;
|
||||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
fetch(
|
fetch(
|
||||||
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`,
|
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${DEFAULT_PAGE_SIZE}`,
|
||||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
)
|
)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -342,9 +263,7 @@ export function UserUpvoted() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { profileUser, votes, hasMore, loadingMore } = state;
|
const { profileUser, votes, hasMore, loadingMore } = state;
|
||||||
const visibleDumps = votes.filter((d) =>
|
const visibleDumps = votes.filter((d) => votedIds.has(d.id) || d.id in fading);
|
||||||
votedIds.has(d.id) || d.id in fading
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user