v2: global player, infinite scroll, image picker, threaded comments
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -142,6 +142,7 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
|
*.db-*
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
api/uploads/
|
api/uploads/
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import avatarsRouter from "./routes/avatars.ts";
|
|||||||
import wsRouter from "./routes/ws.ts";
|
import wsRouter from "./routes/ws.ts";
|
||||||
import previewRouter from "./routes/preview.ts";
|
import previewRouter from "./routes/preview.ts";
|
||||||
import playlistsRouter from "./routes/playlists.ts";
|
import playlistsRouter from "./routes/playlists.ts";
|
||||||
|
import commentsRouter from "./routes/comments.ts";
|
||||||
|
|
||||||
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
||||||
import { errorMiddleware } from "./middleware/error.ts";
|
import { errorMiddleware } from "./middleware/error.ts";
|
||||||
@@ -45,6 +46,10 @@ app.use(
|
|||||||
playlistsRouter.routes(),
|
playlistsRouter.routes(),
|
||||||
playlistsRouter.allowedMethods(),
|
playlistsRouter.allowedMethods(),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
commentsRouter.routes(),
|
||||||
|
commentsRouter.allowedMethods(),
|
||||||
|
);
|
||||||
app.use(routeStaticFilesFrom([
|
app.use(routeStaticFilesFrom([
|
||||||
`${Deno.cwd()}/dist`,
|
`${Deno.cwd()}/dist`,
|
||||||
`${Deno.cwd()}/public`,
|
`${Deno.cwd()}/public`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
||||||
import {
|
import {
|
||||||
|
type Comment,
|
||||||
Dump,
|
Dump,
|
||||||
type Playlist,
|
type Playlist,
|
||||||
type RichContent,
|
type RichContent,
|
||||||
@@ -9,6 +10,32 @@ import {
|
|||||||
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
||||||
db.exec("PRAGMA foreign_keys = ON;");
|
db.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
|
// Migration: add is_private column if it doesn't exist yet
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE dumps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;`);
|
||||||
|
} catch { /* column already exists */ }
|
||||||
|
|
||||||
|
// Migration: create comments table if it doesn't exist yet
|
||||||
|
try {
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0
|
||||||
|
);`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_dump ON comments(dump_id, created_at);`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);`);
|
||||||
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_dumps_dump ON playlist_dumps(dump_id);`);
|
||||||
|
} catch { /* already exists */ }
|
||||||
|
|
||||||
|
// Migration: add deleted column to comments if it doesn't exist yet
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE comments ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;`);
|
||||||
|
} catch { /* column already exists */ }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database Row Types
|
* Database Row Types
|
||||||
*/
|
*/
|
||||||
@@ -26,6 +53,8 @@ export interface DumpRow {
|
|||||||
file_mime: string | null;
|
file_mime: string | null;
|
||||||
file_size: number | null;
|
file_size: number | null;
|
||||||
vote_count: number;
|
vote_count: number;
|
||||||
|
comment_count?: number;
|
||||||
|
is_private: number;
|
||||||
[key: string]: SQLOutputValue; // Index signature
|
[key: string]: SQLOutputValue; // Index signature
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +91,8 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
|||||||
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
|
(typeof obj.file_mime === "string" || obj.file_mime === null) &&
|
||||||
"file_size" in obj &&
|
"file_size" in obj &&
|
||||||
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
||||||
"vote_count" in obj && typeof obj.vote_count === "number";
|
"vote_count" in obj && typeof obj.vote_count === "number" &&
|
||||||
|
"is_private" in obj && typeof obj.is_private === "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
||||||
@@ -97,6 +127,8 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
|||||||
fileMime: row.file_mime ?? undefined,
|
fileMime: row.file_mime ?? undefined,
|
||||||
fileSize: row.file_size ?? undefined,
|
fileSize: row.file_size ?? undefined,
|
||||||
voteCount: row.vote_count,
|
voteCount: row.vote_count,
|
||||||
|
commentCount: row.comment_count ?? 0,
|
||||||
|
isPrivate: Boolean(row.is_private),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +146,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
|
|||||||
file_mime: dump.fileMime ?? null,
|
file_mime: dump.fileMime ?? null,
|
||||||
file_size: dump.fileSize ?? null,
|
file_size: dump.fileSize ?? null,
|
||||||
vote_count: dump.voteCount,
|
vote_count: dump.voteCount,
|
||||||
|
is_private: dump.isPrivate ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +172,46 @@ export function userApiToRow(user: User): UserRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommentRow {
|
||||||
|
id: string;
|
||||||
|
dump_id: string;
|
||||||
|
user_id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
body: string;
|
||||||
|
created_at: string;
|
||||||
|
deleted: number;
|
||||||
|
author_username: string;
|
||||||
|
author_avatar_mime: string | null;
|
||||||
|
[key: string]: SQLOutputValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCommentRow(obj: Record<string, SQLOutputValue>): obj is CommentRow {
|
||||||
|
return !!obj && typeof obj === "object" &&
|
||||||
|
typeof obj.id === "string" &&
|
||||||
|
typeof obj.dump_id === "string" &&
|
||||||
|
typeof obj.user_id === "string" &&
|
||||||
|
(typeof obj.parent_id === "string" || obj.parent_id === null) &&
|
||||||
|
typeof obj.body === "string" &&
|
||||||
|
typeof obj.created_at === "string" &&
|
||||||
|
typeof obj.deleted === "number" &&
|
||||||
|
typeof obj.author_username === "string" &&
|
||||||
|
(typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commentRowToApi(row: CommentRow): Comment {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
dumpId: row.dump_id,
|
||||||
|
userId: row.user_id,
|
||||||
|
parentId: row.parent_id ?? undefined,
|
||||||
|
body: row.body,
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
deleted: Boolean(row.deleted),
|
||||||
|
authorUsername: row.author_username,
|
||||||
|
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlaylistRow {
|
export interface PlaylistRow {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface RichContent {
|
|||||||
description?: string;
|
description?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
|
embedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Dump {
|
export interface Dump {
|
||||||
@@ -25,6 +26,8 @@ export interface Dump {
|
|||||||
fileMime?: string;
|
fileMime?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
isPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,6 +133,12 @@ export interface APIFailure {
|
|||||||
|
|
||||||
export type APIResponse<T> = APISuccess<T> | APIFailure;
|
export type APIResponse<T> = APISuccess<T> | APIFailure;
|
||||||
|
|
||||||
|
export interface PaginatedData<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class APIException extends Error {
|
export class APIException extends Error {
|
||||||
readonly code: APIErrorCode;
|
readonly code: APIErrorCode;
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
@@ -141,6 +150,34 @@ export class APIException extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comments
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
dumpId: string;
|
||||||
|
userId: string;
|
||||||
|
parentId?: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: Date;
|
||||||
|
deleted: boolean;
|
||||||
|
authorUsername: string;
|
||||||
|
authorAvatarMime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCommentRequest {
|
||||||
|
body: string;
|
||||||
|
parentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCreateCommentRequest(obj: unknown): obj is CreateCommentRequest {
|
||||||
|
if (!obj || typeof obj !== "object") return false;
|
||||||
|
const o = obj as Record<string, unknown>;
|
||||||
|
return typeof o.body === "string" && (o.body as string).trim().length > 0 &&
|
||||||
|
(!("parentId" in o) || typeof o.parentId === "string" || o.parentId === null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playlists
|
* Playlists
|
||||||
*/
|
*/
|
||||||
@@ -216,6 +253,7 @@ export function isReorderPlaylistRequest(
|
|||||||
export interface CreateUrlDumpRequest {
|
export interface CreateUrlDumpRequest {
|
||||||
url: string;
|
url: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCreateUrlDumpRequest(
|
export function isCreateUrlDumpRequest(
|
||||||
@@ -225,12 +263,14 @@ export function isCreateUrlDumpRequest(
|
|||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
"url" in obj && typeof obj.url === "string" &&
|
"url" in obj && typeof obj.url === "string" &&
|
||||||
(!("comment" in obj) ||
|
(!("comment" in obj) ||
|
||||||
typeof obj.comment === "string" || obj.comment === null);
|
typeof obj.comment === "string" || obj.comment === null) &&
|
||||||
|
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDumpRequest {
|
export interface UpdateDumpRequest {
|
||||||
url?: string;
|
url?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
||||||
@@ -238,7 +278,8 @@ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest {
|
|||||||
typeof obj === "object" &&
|
typeof obj === "object" &&
|
||||||
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) &&
|
(!("url" in obj) || typeof obj.url === "string" || obj.url === null) &&
|
||||||
(!("comment" in obj) ||
|
(!("comment" in obj) ||
|
||||||
typeof obj.comment === "string" || obj.comment === null);
|
typeof obj.comment === "string" || obj.comment === null) &&
|
||||||
|
(!("isPrivate" in obj) || typeof obj.isPrivate === "boolean");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
77
api/routes/comments.ts
Normal file
77
api/routes/comments.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {
|
||||||
|
APIErrorCode,
|
||||||
|
APIException,
|
||||||
|
type APIResponse,
|
||||||
|
type Comment,
|
||||||
|
isCreateCommentRequest,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import { verifyJWT } from "../lib/jwt.ts";
|
||||||
|
import {
|
||||||
|
createComment,
|
||||||
|
deleteComment,
|
||||||
|
getComments,
|
||||||
|
} from "../services/comment-service.ts";
|
||||||
|
import { getDump } from "../services/dump-service.ts";
|
||||||
|
import {
|
||||||
|
broadcastCommentCreated,
|
||||||
|
broadcastCommentDeleted,
|
||||||
|
} from "../services/ws-service.ts";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api" });
|
||||||
|
|
||||||
|
// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments)
|
||||||
|
router.get("/dumps/:dumpId/comments", async (ctx) => {
|
||||||
|
let requestingUserId: string | 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 comments = getComments(dump.id);
|
||||||
|
const responseBody: APIResponse<Comment[]> = { success: true, data: comments };
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/dumps/:dumpId/comments — auth required
|
||||||
|
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||||
|
const userId = ctx.state.user.userId as string;
|
||||||
|
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
||||||
|
const dump = getDump(ctx.params.dumpId, userId);
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
if (!isCreateCommentRequest(body)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Invalid comment data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const comment = createComment(
|
||||||
|
dump.id,
|
||||||
|
userId,
|
||||||
|
body.body,
|
||||||
|
body.parentId ?? undefined,
|
||||||
|
);
|
||||||
|
if (!dump.isPrivate) broadcastCommentCreated(comment);
|
||||||
|
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/comments/:commentId — auth required
|
||||||
|
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||||
|
const userId = ctx.state.user.userId as string;
|
||||||
|
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
||||||
|
const { dumpId, isPrivate } = deleteComment(
|
||||||
|
ctx.params.commentId,
|
||||||
|
userId,
|
||||||
|
isAdmin,
|
||||||
|
);
|
||||||
|
if (!isPrivate) broadcastCommentDeleted(ctx.params.commentId, dumpId);
|
||||||
|
const responseBody: APIResponse<null> = { success: true, data: null };
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -7,15 +7,18 @@ import {
|
|||||||
type Dump,
|
type Dump,
|
||||||
isCreateUrlDumpRequest,
|
isCreateUrlDumpRequest,
|
||||||
isUpdateDumpRequest,
|
isUpdateDumpRequest,
|
||||||
|
type PaginatedData,
|
||||||
} 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 {
|
import {
|
||||||
createFileDump,
|
createFileDump,
|
||||||
createUrlDump,
|
createUrlDump,
|
||||||
deleteDump,
|
deleteDump,
|
||||||
getDump,
|
getDump,
|
||||||
listDumps,
|
listDumps,
|
||||||
|
refreshDumpMetadata,
|
||||||
replaceFileDump,
|
replaceFileDump,
|
||||||
updateDump,
|
updateDump,
|
||||||
} from "../services/dump-service.ts";
|
} from "../services/dump-service.ts";
|
||||||
@@ -35,6 +38,7 @@ router.post(
|
|||||||
const formData = await ctx.request.body.formData();
|
const formData = await ctx.request.body.formData();
|
||||||
const file = formData.get("file");
|
const file = formData.get("file");
|
||||||
const comment = formData.get("comment");
|
const comment = formData.get("comment");
|
||||||
|
const isPrivate = formData.get("isPrivate") === "true";
|
||||||
|
|
||||||
if (!(file instanceof File)) {
|
if (!(file instanceof File)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -48,6 +52,7 @@ router.post(
|
|||||||
file,
|
file,
|
||||||
typeof comment === "string" && comment ? comment : undefined,
|
typeof comment === "string" && comment ? comment : undefined,
|
||||||
userId,
|
userId,
|
||||||
|
isPrivate,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
@@ -69,15 +74,32 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get("/:dumpId", (ctx) => {
|
router.get("/:dumpId", async (ctx) => {
|
||||||
const dump = getDump(ctx.params.dumpId);
|
let requestingUserId: string | 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 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("/", (ctx) => {
|
router.get("/", async (ctx) => {
|
||||||
const dumps = listDumps();
|
let requestingUserId: string | undefined;
|
||||||
const responseBody: APIResponse<Dump[]> = { success: true, data: dumps };
|
const authHeader = ctx.request.headers.get("Authorization");
|
||||||
|
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 responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
|
success: true,
|
||||||
|
data: { items, total, hasMore: page * limit < total },
|
||||||
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,7 +107,7 @@ router.put("/:dumpId/file", authMiddleware, async (ctx) => {
|
|||||||
const dumpId = ctx.params.dumpId;
|
const dumpId = ctx.params.dumpId;
|
||||||
const userId = ctx.state.user?.userId;
|
const userId = ctx.state.user?.userId;
|
||||||
|
|
||||||
const dump = getDump(dumpId);
|
const dump = getDump(dumpId, userId);
|
||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.UNAUTHORIZED,
|
APIErrorCode.UNAUTHORIZED,
|
||||||
@@ -128,7 +150,7 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dump = getDump(dumpId);
|
const dump = getDump(dumpId, userId);
|
||||||
|
|
||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -143,10 +165,28 @@ router.put("/:dumpId", authMiddleware, async (ctx) => {
|
|||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/:dumpId/refresh-metadata", authMiddleware, async (ctx) => {
|
||||||
|
const dumpId = ctx.params.dumpId;
|
||||||
|
const userId = ctx.state.user?.userId;
|
||||||
|
const dump = getDump(dumpId, userId);
|
||||||
|
|
||||||
|
if (userId !== dump.userId) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.UNAUTHORIZED,
|
||||||
|
401,
|
||||||
|
"Not authorized to update dump",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDump = await refreshDumpMetadata(dumpId);
|
||||||
|
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
router.delete("/:dumpId", authMiddleware, async (ctx) => {
|
||||||
const dumpId = ctx.params.dumpId;
|
const dumpId = ctx.params.dumpId;
|
||||||
const userId = ctx.state.user?.userId;
|
const userId = ctx.state.user?.userId;
|
||||||
const dump = getDump(dumpId);
|
const dump = getDump(dumpId, userId);
|
||||||
|
|
||||||
if (userId !== dump.userId) {
|
if (userId !== dump.userId) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
APIException,
|
APIException,
|
||||||
isLoginUserRequest,
|
isLoginUserRequest,
|
||||||
isRegisterUserRequest,
|
isRegisterUserRequest,
|
||||||
|
type PaginatedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
import { createJWT, verifyJWT, verifyPassword } from "../lib/jwt.ts";
|
import { createJWT, verifyJWT, verifyPassword } from "../lib/jwt.ts";
|
||||||
@@ -150,8 +151,13 @@ router.get("/:username/playlists", async (ctx) => {
|
|||||||
const payload = await verifyJWT(authHeader.substring(7));
|
const payload = await verifyJWT(authHeader.substring(7));
|
||||||
if (payload) requestingUserId = payload.userId;
|
if (payload) requestingUserId = payload.userId;
|
||||||
}
|
}
|
||||||
const playlists = listPlaylistsByUser(user.id, requestingUserId);
|
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
||||||
ctx.response.body = { success: true, data: playlists };
|
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
||||||
|
const { items, total } = listPlaylistsByUser(user.id, requestingUserId, page, limit);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Public user profile by username (no passwordHash)
|
// Public user profile by username (no passwordHash)
|
||||||
@@ -161,18 +167,41 @@ router.get("/:username", (ctx) => {
|
|||||||
ctx.response.body = { success: true, data: publicUser };
|
ctx.response.body = { success: true, data: publicUser };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dumps posted by user
|
// Dumps posted by user (optional auth: owner sees their private dumps)
|
||||||
router.get("/:username/dumps", (ctx) => {
|
router.get("/:username/dumps", async (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
const dumps = getDumpsByUser(user.id);
|
let requestingUserId: string | null = null;
|
||||||
ctx.response.body = { success: true, data: dumps };
|
const authHeader = ctx.request.headers.get("Authorization");
|
||||||
|
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 { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dumps upvoted by user
|
// Dumps upvoted by user (optional auth: hide private dump entries for non-owners)
|
||||||
router.get("/:username/votes", (ctx) => {
|
router.get("/:username/votes", async (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
const dumps = getVotedDumpsByUser(user.id);
|
let requestingUserId: string | null = null;
|
||||||
ctx.response.body = { success: true, data: dumps };
|
const authHeader = ctx.request.headers.get("Authorization");
|
||||||
|
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(user.id, page, limit, requestingUserId);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
78
api/services/comment-service.ts
Normal file
78
api/services/comment-service.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
APIErrorCode,
|
||||||
|
APIException,
|
||||||
|
type Comment,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
import {
|
||||||
|
commentRowToApi,
|
||||||
|
type CommentRow,
|
||||||
|
db,
|
||||||
|
isCommentRow,
|
||||||
|
} from "../model/db.ts";
|
||||||
|
|
||||||
|
const SELECT_COLS =
|
||||||
|
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.deleted,
|
||||||
|
u.username as author_username, u.avatar_mime as author_avatar_mime`;
|
||||||
|
|
||||||
|
function fetchComment(commentId: string): Comment {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
|
||||||
|
).get(commentId);
|
||||||
|
if (!row || !isCommentRow(row as Record<string, unknown>)) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||||
|
}
|
||||||
|
return commentRowToApi(row as CommentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComments(dumpId: string): Comment[] {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id
|
||||||
|
WHERE c.dump_id = ? ORDER BY c.created_at ASC;`,
|
||||||
|
).all(dumpId);
|
||||||
|
const typed = rows as Parameters<typeof isCommentRow>[0][];
|
||||||
|
if (!typed.every(isCommentRow)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed comment data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return typed.map(commentRowToApi);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createComment(
|
||||||
|
dumpId: string,
|
||||||
|
userId: string,
|
||||||
|
body: string,
|
||||||
|
parentId?: string,
|
||||||
|
): Comment {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const createdAt = new Date();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
|
||||||
|
).run(id, dumpId, userId, parentId ?? null, body.trim(), createdAt.toISOString());
|
||||||
|
return fetchComment(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteComment(
|
||||||
|
commentId: string,
|
||||||
|
requestingUserId: string,
|
||||||
|
isAdmin: boolean,
|
||||||
|
): { dumpId: string; isPrivate: boolean } {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT c.dump_id, d.is_private FROM comments c JOIN dumps d ON c.dump_id = d.id WHERE c.id = ?;`,
|
||||||
|
).get(commentId) as { dump_id: string; is_private: number } | undefined;
|
||||||
|
if (!row) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||||
|
}
|
||||||
|
const comment = fetchComment(commentId);
|
||||||
|
if (comment.userId !== requestingUserId && !isAdmin) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.UNAUTHORIZED,
|
||||||
|
401,
|
||||||
|
"Not authorized to delete this comment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(commentId);
|
||||||
|
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
|
||||||
|
}
|
||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts";
|
import { db, dumpApiToRow, dumpRowToApi, isDumpRow } from "../model/db.ts";
|
||||||
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
|
import { fetchRichContent, isValidHttpUrl } from "./rich-content-service.ts";
|
||||||
import { broadcastDumpDeleted, broadcastNewDump } from "./ws-service.ts";
|
import {
|
||||||
|
broadcastDumpDeleted,
|
||||||
|
broadcastDumpUpdated,
|
||||||
|
broadcastNewDump,
|
||||||
|
} from "./ws-service.ts";
|
||||||
|
|
||||||
const UPLOADS_DIR = "api/uploads";
|
const UPLOADS_DIR = "api/uploads";
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
@@ -39,8 +43,15 @@ function titleFromUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const SELECT_COLS =
|
const BASE_COLS =
|
||||||
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count";
|
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
||||||
|
|
||||||
|
const SELECT_COLS = `${BASE_COLS},
|
||||||
|
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
|
||||||
|
|
||||||
|
const SELECT_COLS_ALIASED =
|
||||||
|
"d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
|
||||||
|
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
|
||||||
|
|
||||||
export async function createUrlDump(
|
export async function createUrlDump(
|
||||||
request: CreateUrlDumpRequest,
|
request: CreateUrlDumpRequest,
|
||||||
@@ -54,10 +65,11 @@ export async function createUrlDump(
|
|||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
const richContent = await fetchRichContent(request.url);
|
const richContent = await fetchRichContent(request.url);
|
||||||
const title = richContent?.title ?? titleFromUrl(request.url);
|
const title = richContent?.title ?? titleFromUrl(request.url);
|
||||||
|
const isPrivate = request.isPrivate ?? false;
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content)
|
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
dumpId,
|
dumpId,
|
||||||
"url",
|
"url",
|
||||||
@@ -67,6 +79,7 @@ export async function createUrlDump(
|
|||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
request.url,
|
request.url,
|
||||||
richContent ? JSON.stringify(richContent) : null,
|
richContent ? JSON.stringify(richContent) : null,
|
||||||
|
isPrivate ? 1 : 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const dump: Dump = {
|
const dump: Dump = {
|
||||||
@@ -79,8 +92,10 @@ export async function createUrlDump(
|
|||||||
url: request.url,
|
url: request.url,
|
||||||
richContent,
|
richContent,
|
||||||
voteCount: 0,
|
voteCount: 0,
|
||||||
|
commentCount: 0,
|
||||||
|
isPrivate,
|
||||||
};
|
};
|
||||||
broadcastNewDump(dump);
|
if (!isPrivate) broadcastNewDump(dump);
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +103,7 @@ export async function createFileDump(
|
|||||||
file: File,
|
file: File,
|
||||||
comment: string | undefined,
|
comment: string | undefined,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
isPrivate = false,
|
||||||
): Promise<Dump> {
|
): Promise<Dump> {
|
||||||
if (!isAllowedMime(file.type)) {
|
if (!isAllowedMime(file.type)) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
@@ -114,8 +130,8 @@ export async function createFileDump(
|
|||||||
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size)
|
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
dumpId,
|
dumpId,
|
||||||
"file",
|
"file",
|
||||||
@@ -126,6 +142,7 @@ export async function createFileDump(
|
|||||||
file.name,
|
file.name,
|
||||||
file.type,
|
file.type,
|
||||||
file.size,
|
file.size,
|
||||||
|
isPrivate ? 1 : 0,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Roll back the file if DB insert fails
|
// Roll back the file if DB insert fails
|
||||||
@@ -144,55 +161,80 @@ export async function createFileDump(
|
|||||||
fileMime: file.type,
|
fileMime: file.type,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
voteCount: 0,
|
voteCount: 0,
|
||||||
|
commentCount: 0,
|
||||||
|
isPrivate,
|
||||||
};
|
};
|
||||||
broadcastNewDump(dump);
|
if (!isPrivate) broadcastNewDump(dump);
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDump(dumpId: string): Dump {
|
// Internal fetch — no privacy check. Use only when ownership is already enforced.
|
||||||
|
function fetchDump(dumpId: string): Dump {
|
||||||
const row = db.prepare(
|
const row = db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`,
|
`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`,
|
||||||
).get(dumpId);
|
).get(dumpId);
|
||||||
|
|
||||||
if (!row || !isDumpRow(row)) {
|
if (!row || !isDumpRow(row)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return dumpRowToApi(row);
|
return dumpRowToApi(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listDumps(): Dump[] {
|
// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own.
|
||||||
const rows = db.prepare(
|
export function getDump(dumpId: string, requestingUserId?: string): Dump {
|
||||||
`SELECT ${SELECT_COLS} FROM dumps;`,
|
const dump = fetchDump(dumpId);
|
||||||
).all();
|
if (dump.isPrivate && dump.userId !== requestingUserId) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
if (!rows || !rows.every(isDumpRow)) {
|
}
|
||||||
throw new APIException(
|
return dump;
|
||||||
APIErrorCode.SERVER_ERROR,
|
|
||||||
500,
|
|
||||||
"Malformed dump data",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.map(dumpRowToApi);
|
export function listDumps(
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
requestingUserId?: string,
|
||||||
|
): { items: Dump[]; total: number } {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
// Show public dumps + the requesting user's own private dumps
|
||||||
|
const rows = requestingUserId
|
||||||
|
? db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
|
).all(requestingUserId, limit, offset)
|
||||||
|
: db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
|
).all(limit, offset);
|
||||||
|
const totalRow = requestingUserId
|
||||||
|
? db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?);`,
|
||||||
|
).get(requestingUserId) as { count: number } | undefined
|
||||||
|
: db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0;`,
|
||||||
|
).get() as { count: number } | undefined;
|
||||||
|
|
||||||
|
if (!rows || !rows.every(isDumpRow)) {
|
||||||
|
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDump(
|
export async function updateDump(
|
||||||
dumpId: string,
|
dumpId: string,
|
||||||
request: UpdateDumpRequest,
|
request: UpdateDumpRequest,
|
||||||
): Promise<Dump> {
|
): Promise<Dump> {
|
||||||
const dump = getDump(dumpId);
|
const dump = fetchDump(dumpId);
|
||||||
|
|
||||||
// File dumps: only comment is editable
|
// File dumps: only comment and isPrivate are editable
|
||||||
if (dump.kind === "file") {
|
if (dump.kind === "file") {
|
||||||
const updatedDump = {
|
const updatedDump: Dump = {
|
||||||
...dump,
|
...dump,
|
||||||
comment: "comment" in request
|
comment: "comment" in request
|
||||||
? (request.comment ?? undefined)
|
? (request.comment ?? undefined)
|
||||||
: dump.comment,
|
: dump.comment,
|
||||||
|
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
|
||||||
};
|
};
|
||||||
db.prepare(`UPDATE dumps SET comment = ? WHERE id = ?;`)
|
db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`)
|
||||||
.run(updatedDump.comment ?? null, dumpId);
|
.run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId);
|
||||||
|
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,17 +260,19 @@ export async function updateDump(
|
|||||||
: dump.comment,
|
: dump.comment,
|
||||||
url: newUrl,
|
url: newUrl,
|
||||||
richContent,
|
richContent,
|
||||||
|
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const row = dumpApiToRow(updatedDump);
|
const row = dumpApiToRow(updatedDump);
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`,
|
||||||
).run(row.title, row.comment, row.url, row.rich_content, row.id);
|
).run(row.title, row.comment, row.url, row.rich_content, row.is_private, row.id);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +296,7 @@ export async function replaceFileDump(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dump = getDump(dumpId);
|
const dump = fetchDump(dumpId);
|
||||||
if (dump.kind !== "file") {
|
if (dump.kind !== "file") {
|
||||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
|
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
|
||||||
}
|
}
|
||||||
@@ -274,40 +318,98 @@ export async function replaceFileDump(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDumpsByUser(userId: string): Dump[] {
|
export function getDumpsByUser(
|
||||||
|
userId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
includePrivate: boolean,
|
||||||
|
): { items: Dump[]; total: number } {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const privacyFilter = includePrivate ? "" : " AND is_private = 0";
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ? ORDER BY created_at DESC;`,
|
`SELECT ${SELECT_COLS} FROM dumps WHERE user_id = ?${privacyFilter} ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId);
|
).all(userId, limit, offset);
|
||||||
|
const totalRow = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`,
|
||||||
|
).get(userId) as { count: number } | undefined;
|
||||||
if (!rows.every(isDumpRow)) {
|
if (!rows.every(isDumpRow)) {
|
||||||
throw new APIException(
|
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||||
APIErrorCode.SERVER_ERROR,
|
|
||||||
500,
|
|
||||||
"Malformed dump data",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return rows.map(dumpRowToApi);
|
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVotedDumpsByUser(userId: string): Dump[] {
|
export function getVotedDumpsByUser(
|
||||||
const rows = db.prepare(
|
userId: string,
|
||||||
`SELECT ${SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")}
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
requestingUserId: string | null,
|
||||||
|
): { items: Dump[]; total: number } {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const dumpCols = SELECT_COLS_ALIASED;
|
||||||
|
|
||||||
|
let totalRow: { count: number } | undefined;
|
||||||
|
let rawRows: unknown[];
|
||||||
|
|
||||||
|
if (requestingUserId) {
|
||||||
|
rawRows = db.prepare(
|
||||||
|
`SELECT ${dumpCols}
|
||||||
FROM dumps d
|
FROM dumps d
|
||||||
INNER JOIN votes v ON d.id = v.dump_id
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
WHERE v.user_id = ?
|
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
|
||||||
ORDER BY v.created_at DESC;`,
|
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId);
|
).all(userId, requestingUserId, limit, offset);
|
||||||
|
totalRow = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM dumps d
|
||||||
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
|
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`,
|
||||||
|
).get(userId, requestingUserId) as { count: number } | undefined;
|
||||||
|
} else {
|
||||||
|
rawRows = db.prepare(
|
||||||
|
`SELECT ${dumpCols}
|
||||||
|
FROM dumps d
|
||||||
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
|
WHERE v.user_id = ? AND d.is_private = 0
|
||||||
|
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
|
).all(userId, limit, offset);
|
||||||
|
totalRow = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM dumps d
|
||||||
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
|
WHERE v.user_id = ? AND d.is_private = 0;`,
|
||||||
|
).get(userId) as { count: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
|
||||||
if (!rows.every(isDumpRow)) {
|
if (!rows.every(isDumpRow)) {
|
||||||
|
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
||||||
|
}
|
||||||
|
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
|
||||||
|
const dump = fetchDump(dumpId);
|
||||||
|
|
||||||
|
if (dump.kind !== "url" || !dump.url) {
|
||||||
throw new APIException(
|
throw new APIException(
|
||||||
APIErrorCode.SERVER_ERROR,
|
APIErrorCode.BAD_REQUEST,
|
||||||
500,
|
400,
|
||||||
"Malformed dump data",
|
"Only URL dumps support metadata refresh",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return rows.map(dumpRowToApi);
|
|
||||||
|
const richContent = await fetchRichContent(dump.url);
|
||||||
|
const title = richContent?.title ?? titleFromUrl(dump.url);
|
||||||
|
|
||||||
|
const updatedDump: Dump = { ...dump, title, richContent };
|
||||||
|
const row = dumpApiToRow(updatedDump);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE dumps SET title = ?, rich_content = ? WHERE id = ?;`,
|
||||||
|
).run(row.title, row.rich_content, row.id);
|
||||||
|
|
||||||
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteDump(dumpId: string): Promise<void> {
|
export async function deleteDump(dumpId: string): Promise<void> {
|
||||||
const dump = getDump(dumpId);
|
const dump = fetchDump(dumpId);
|
||||||
|
|
||||||
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
const result = db.prepare(`DELETE FROM dumps WHERE id = ?;`).run(dumpId);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from "./ws-service.ts";
|
} from "./ws-service.ts";
|
||||||
|
|
||||||
const DUMP_SELECT_COLS =
|
const DUMP_SELECT_COLS =
|
||||||
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count";
|
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
||||||
|
|
||||||
function getPlaylistById(playlistId: string): Playlist {
|
function getPlaylistById(playlistId: string): Playlist {
|
||||||
const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get(
|
const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get(
|
||||||
@@ -75,32 +75,54 @@ export function getPlaylist(
|
|||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dumpCols = DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ");
|
||||||
|
const isOwner = requestingUserId === playlist.userId;
|
||||||
|
|
||||||
|
// For public playlists (or when viewed by non-owner), filter out private dumps
|
||||||
const rows = db.prepare(
|
const rows = db.prepare(
|
||||||
`SELECT ${DUMP_SELECT_COLS.split(", ").map((c) => `d.${c}`).join(", ")}
|
`SELECT ${dumpCols}
|
||||||
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 = ?
|
||||||
|
AND (d.is_private = 0 OR d.user_id = ?)
|
||||||
ORDER BY pd.position ASC;`,
|
ORDER BY pd.position ASC;`,
|
||||||
).all(playlistId);
|
).all(playlistId, requestingUserId ?? "");
|
||||||
|
|
||||||
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi);
|
const dumps: Dump[] = rows.filter(isDumpRow).map(dumpRowToApi);
|
||||||
|
// Owners always see their own private dumps; strip them for non-owners regardless
|
||||||
|
const visibleDumps = isOwner
|
||||||
|
? dumps
|
||||||
|
: dumps.filter((d) => !d.isPrivate);
|
||||||
|
|
||||||
return { ...playlist, dumps };
|
return { ...playlist, dumps: visibleDumps };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listPlaylistsByUser(
|
export function listPlaylistsByUser(
|
||||||
userId: string,
|
userId: string,
|
||||||
requestingUserId: string | null,
|
requestingUserId: string | null,
|
||||||
): Playlist[] {
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): { items: Playlist[]; total: number } {
|
||||||
const isOwner = requestingUserId === userId;
|
const isOwner = requestingUserId === userId;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const countSql = isOwner
|
||||||
|
? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;`
|
||||||
|
: `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`;
|
||||||
const sql = isOwner
|
const sql = isOwner
|
||||||
? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
||||||
FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC;`
|
FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
|
||||||
: `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
: `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
||||||
FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC;`;
|
FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
|
||||||
|
|
||||||
const rows = db.prepare(sql).all(userId);
|
const totalRow = db.prepare(countSql).get(userId) as
|
||||||
return rows.filter(isPlaylistRow).map(playlistRowToApi);
|
| { count: number }
|
||||||
|
| undefined;
|
||||||
|
const rows = db.prepare(sql).all(userId, limit, offset);
|
||||||
|
return {
|
||||||
|
items: rows.filter(isPlaylistRow).map(playlistRowToApi),
|
||||||
|
total: totalRow?.count ?? 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updatePlaylist(
|
export function updatePlaylist(
|
||||||
@@ -179,11 +201,11 @@ export function addDumpToPlaylist(
|
|||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxRow = db.prepare(
|
const minRow = db.prepare(
|
||||||
`SELECT MAX(position) as max_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
||||||
).get(playlistId) as { max_pos: number | null } | undefined;
|
).get(playlistId) as { min_pos: number | null } | undefined;
|
||||||
|
|
||||||
const nextPos = (maxRow?.max_pos ?? -1) + 1;
|
const nextPos = (minRow?.min_pos ?? 1) - 1;
|
||||||
const addedAt = new Date().toISOString();
|
const addedAt = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const bandcampProvider: RichContentProvider = {
|
|||||||
title: extractOgTag(html, "title"),
|
title: extractOgTag(html, "title"),
|
||||||
description: extractOgTag(html, "description"),
|
description: extractOgTag(html, "description"),
|
||||||
thumbnailUrl: extractOgTag(html, "image"),
|
thumbnailUrl: extractOgTag(html, "image"),
|
||||||
|
embedUrl: extractOgTag(html, "video") ?? undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const soundcloudProvider: RichContentProvider = {
|
|||||||
title: extractOgTag(html, "title"),
|
title: extractOgTag(html, "title"),
|
||||||
description: extractOgTag(html, "description"),
|
description: extractOgTag(html, "description"),
|
||||||
thumbnailUrl: extractOgTag(html, "image"),
|
thumbnailUrl: extractOgTag(html, "image"),
|
||||||
|
embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&visual=true&auto_play=false`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,18 +2,35 @@ import type { RichContent } from "../../model/interfaces.ts";
|
|||||||
import type { RichContentProvider } from "../rich-content-service.ts";
|
import type { RichContentProvider } from "../rich-content-service.ts";
|
||||||
import { fetchWithTimeout } from "../rich-content-service.ts";
|
import { fetchWithTimeout } from "../rich-content-service.ts";
|
||||||
|
|
||||||
const YOUTUBE_REGEX =
|
function extractVideoId(url: string): string | null {
|
||||||
/(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname === "youtu.be") {
|
||||||
|
return u.pathname.slice(1).split("/")[0] || null;
|
||||||
|
}
|
||||||
|
if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") {
|
||||||
|
if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) {
|
||||||
|
return u.searchParams.get("v");
|
||||||
|
}
|
||||||
|
if (u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")) {
|
||||||
|
return u.pathname.split("/")[2] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// invalid URL
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const youtubeProvider: RichContentProvider = {
|
export const youtubeProvider: RichContentProvider = {
|
||||||
name: "youtube",
|
name: "youtube",
|
||||||
|
|
||||||
matches(url: string): boolean {
|
matches(url: string): boolean {
|
||||||
return YOUTUBE_REGEX.test(url);
|
return extractVideoId(url) !== null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetch(url: string): Promise<RichContent> {
|
async fetch(url: string): Promise<RichContent> {
|
||||||
const videoId = url.match(YOUTUBE_REGEX)![1];
|
const videoId = extractVideoId(url)!;
|
||||||
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||||
let title: string | undefined;
|
let title: string | undefined;
|
||||||
|
|
||||||
@@ -36,6 +53,7 @@ export const youtubeProvider: RichContentProvider = {
|
|||||||
videoId,
|
videoId,
|
||||||
title,
|
title,
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
|
embedUrl: `https://www.youtube.com/embed/${videoId}?rel=0`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Dump, OnlineUser, Playlist } from "../model/interfaces.ts";
|
import type { Comment, Dump, OnlineUser, Playlist } from "../model/interfaces.ts";
|
||||||
|
|
||||||
export interface WsClient {
|
export interface WsClient {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
@@ -59,6 +59,12 @@ export function broadcastNewDump(dump: Dump): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function broadcastDumpUpdated(dump: Dump): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "dump_updated", dump });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function broadcastDumpDeleted(dumpId: string): void {
|
export function broadcastDumpDeleted(dumpId: string): void {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
send(client.socket, { type: "dump_deleted", dumpId });
|
send(client.socket, { type: "dump_deleted", dumpId });
|
||||||
@@ -124,6 +130,18 @@ export function broadcastPlaylistDumpsUpdated(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function broadcastCommentCreated(comment: Comment): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "comment_created", comment });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastCommentDeleted(commentId: string, dumpId: string): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "comment_deleted", commentId, dumpId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
// Keepalive: ping all clients every 30s, remove non-responsive ones
|
||||||
const PING_INTERVAL = 30_000;
|
const PING_INTERVAL = 30_000;
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ CREATE TABLE dumps (
|
|||||||
file_mime TEXT,
|
file_mime TEXT,
|
||||||
file_size INTEGER,
|
file_size INTEGER,
|
||||||
vote_count INTEGER NOT NULL DEFAULT 0,
|
vote_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_private INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@@ -35,22 +36,42 @@ CREATE TABLE votes (
|
|||||||
-- v2: playlists
|
-- v2: playlists
|
||||||
CREATE TABLE playlists (
|
CREATE TABLE playlists (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
is_public INTEGER NOT NULL DEFAULT 1,
|
is_public INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
image_mime TEXT
|
image_mime TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE playlist_dumps (
|
CREATE TABLE playlist_dumps (
|
||||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
playlist_id TEXT NOT NULL,
|
||||||
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
dump_id TEXT NOT NULL,
|
||||||
position INTEGER NOT NULL,
|
position INTEGER NOT NULL,
|
||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (playlist_id, dump_id)
|
PRIMARY KEY (playlist_id, dump_id),
|
||||||
|
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- v3: comments
|
||||||
|
CREATE TABLE comments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dump_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
parent_id TEXT,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
CREATE INDEX idx_dumps_user ON dumps(user_id);
|
||||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
CREATE INDEX idx_votes_user ON votes(user_id);
|
||||||
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
CREATE INDEX idx_playlists_user ON playlists(user_id);
|
||||||
|
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||||
|
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
||||||
|
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"dev": "deno run -A npm:vite & deno run -A server:start",
|
"dev": "deno run -A npm:vite & deno run -A server:start",
|
||||||
"build": "deno run -A npm:vite build",
|
"build": "deno run -A npm:vite build",
|
||||||
"server:start": "deno run -A --watch ./api/main.ts",
|
"server:start": "deno run -A --watch api/main.ts",
|
||||||
"serve": "deno run -A build && deno run -A server:start"
|
"serve": "deno run -A build && deno run -A server:start"
|
||||||
},
|
},
|
||||||
"nodeModulesDir": "auto",
|
"nodeModulesDir": "auto",
|
||||||
|
|||||||
711
deno.lock
generated
711
deno.lock
generated
@@ -32,8 +32,10 @@
|
|||||||
"npm:globals@^17.4.0": "17.4.0",
|
"npm:globals@^17.4.0": "17.4.0",
|
||||||
"npm:path-to-regexp@^6.3.0": "6.3.0",
|
"npm:path-to-regexp@^6.3.0": "6.3.0",
|
||||||
"npm:react-dom@^19.2.4": "19.2.4_react@19.2.4",
|
"npm:react-dom@^19.2.4": "19.2.4_react@19.2.4",
|
||||||
|
"npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.2.14_react@19.2.4",
|
||||||
"npm:react-router@^7.13.1": "7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4",
|
"npm:react-router@^7.13.1": "7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4",
|
||||||
"npm:react@^19.2.4": "19.2.4",
|
"npm:react@^19.2.4": "19.2.4",
|
||||||
|
"npm:remark-gfm@^4.0.1": "4.0.1",
|
||||||
"npm:typescript-eslint@^8.56.1": "8.57.0_eslint@9.39.4_typescript@5.9.3",
|
"npm:typescript-eslint@^8.56.1": "8.57.0_eslint@9.39.4_typescript@5.9.3",
|
||||||
"npm:typescript@~5.9.3": "5.9.3",
|
"npm:typescript@~5.9.3": "5.9.3",
|
||||||
"npm:vite@*": "8.0.0_@types+node@24.12.0",
|
"npm:vite@*": "8.0.0_@types+node@24.12.0",
|
||||||
@@ -478,12 +480,39 @@
|
|||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@types/debug@4.1.12": {
|
||||||
|
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/ms"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@types/estree-jsx@1.0.5": {
|
||||||
|
"integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/estree"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@types/estree@1.0.8": {
|
"@types/estree@1.0.8": {
|
||||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
|
||||||
},
|
},
|
||||||
|
"@types/hast@3.0.4": {
|
||||||
|
"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
"@types/json-schema@7.0.15": {
|
"@types/json-schema@7.0.15": {
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
|
||||||
},
|
},
|
||||||
|
"@types/mdast@4.0.4": {
|
||||||
|
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@types/ms@2.1.0": {
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||||
|
},
|
||||||
"@types/node@24.12.0": {
|
"@types/node@24.12.0": {
|
||||||
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -502,6 +531,12 @@
|
|||||||
"csstype"
|
"csstype"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@types/unist@2.0.11": {
|
||||||
|
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="
|
||||||
|
},
|
||||||
|
"@types/unist@3.0.3": {
|
||||||
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
||||||
|
},
|
||||||
"@typescript-eslint/eslint-plugin@8.57.0_@typescript-eslint+parser@8.57.0__eslint@9.39.4__typescript@5.9.3_eslint@9.39.4_typescript@5.9.3": {
|
"@typescript-eslint/eslint-plugin@8.57.0_@typescript-eslint+parser@8.57.0__eslint@9.39.4__typescript@5.9.3_eslint@9.39.4_typescript@5.9.3": {
|
||||||
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==",
|
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -600,6 +635,9 @@
|
|||||||
"eslint-visitor-keys@5.0.1"
|
"eslint-visitor-keys@5.0.1"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"@ungap/structured-clone@1.3.0": {
|
||||||
|
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="
|
||||||
|
},
|
||||||
"@vitejs/plugin-react@6.0.1_vite@8.0.0__@types+node@24.12.0": {
|
"@vitejs/plugin-react@6.0.1_vite@8.0.0__@types+node@24.12.0": {
|
||||||
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
|
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -635,6 +673,9 @@
|
|||||||
"argparse@2.0.1": {
|
"argparse@2.0.1": {
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||||
},
|
},
|
||||||
|
"bail@2.0.2": {
|
||||||
|
"integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="
|
||||||
|
},
|
||||||
"balanced-match@1.0.2": {
|
"balanced-match@1.0.2": {
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||||
},
|
},
|
||||||
@@ -675,6 +716,9 @@
|
|||||||
"caniuse-lite@1.0.30001779": {
|
"caniuse-lite@1.0.30001779": {
|
||||||
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="
|
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="
|
||||||
},
|
},
|
||||||
|
"ccount@2.0.1": {
|
||||||
|
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="
|
||||||
|
},
|
||||||
"chalk@4.1.2": {
|
"chalk@4.1.2": {
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -682,6 +726,18 @@
|
|||||||
"supports-color"
|
"supports-color"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"character-entities-html4@2.1.0": {
|
||||||
|
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="
|
||||||
|
},
|
||||||
|
"character-entities-legacy@3.0.0": {
|
||||||
|
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="
|
||||||
|
},
|
||||||
|
"character-entities@2.0.2": {
|
||||||
|
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="
|
||||||
|
},
|
||||||
|
"character-reference-invalid@2.0.1": {
|
||||||
|
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="
|
||||||
|
},
|
||||||
"color-convert@2.0.1": {
|
"color-convert@2.0.1": {
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -691,6 +747,9 @@
|
|||||||
"color-name@1.1.4": {
|
"color-name@1.1.4": {
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
},
|
},
|
||||||
|
"comma-separated-tokens@2.0.3": {
|
||||||
|
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="
|
||||||
|
},
|
||||||
"concat-map@0.0.1": {
|
"concat-map@0.0.1": {
|
||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
|
||||||
},
|
},
|
||||||
@@ -717,12 +776,27 @@
|
|||||||
"ms"
|
"ms"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"decode-named-character-reference@1.3.0": {
|
||||||
|
"integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"character-entities"
|
||||||
|
]
|
||||||
|
},
|
||||||
"deep-is@0.1.4": {
|
"deep-is@0.1.4": {
|
||||||
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
|
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
|
||||||
},
|
},
|
||||||
|
"dequal@2.0.3": {
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="
|
||||||
|
},
|
||||||
"detect-libc@2.1.2": {
|
"detect-libc@2.1.2": {
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
|
||||||
},
|
},
|
||||||
|
"devlop@1.1.0": {
|
||||||
|
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
||||||
|
"dependencies": [
|
||||||
|
"dequal"
|
||||||
|
]
|
||||||
|
},
|
||||||
"electron-to-chromium@1.5.313": {
|
"electron-to-chromium@1.5.313": {
|
||||||
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="
|
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="
|
||||||
},
|
},
|
||||||
@@ -732,6 +806,9 @@
|
|||||||
"escape-string-regexp@4.0.0": {
|
"escape-string-regexp@4.0.0": {
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
|
||||||
},
|
},
|
||||||
|
"escape-string-regexp@5.0.0": {
|
||||||
|
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="
|
||||||
|
},
|
||||||
"eslint-plugin-react-hooks@7.0.1_eslint@9.39.4": {
|
"eslint-plugin-react-hooks@7.0.1_eslint@9.39.4": {
|
||||||
"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
|
"integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -784,7 +861,7 @@
|
|||||||
"chalk",
|
"chalk",
|
||||||
"cross-spawn",
|
"cross-spawn",
|
||||||
"debug",
|
"debug",
|
||||||
"escape-string-regexp",
|
"escape-string-regexp@4.0.0",
|
||||||
"eslint-scope",
|
"eslint-scope",
|
||||||
"eslint-visitor-keys@4.2.1",
|
"eslint-visitor-keys@4.2.1",
|
||||||
"espree",
|
"espree",
|
||||||
@@ -828,9 +905,15 @@
|
|||||||
"estraverse@5.3.0": {
|
"estraverse@5.3.0": {
|
||||||
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="
|
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="
|
||||||
},
|
},
|
||||||
|
"estree-util-is-identifier-name@3.0.0": {
|
||||||
|
"integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="
|
||||||
|
},
|
||||||
"esutils@2.0.3": {
|
"esutils@2.0.3": {
|
||||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
|
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
|
||||||
},
|
},
|
||||||
|
"extend@3.0.2": {
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||||
|
},
|
||||||
"fast-deep-equal@3.1.3": {
|
"fast-deep-equal@3.1.3": {
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
},
|
},
|
||||||
@@ -895,6 +978,32 @@
|
|||||||
"has-flag@4.0.0": {
|
"has-flag@4.0.0": {
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
|
||||||
},
|
},
|
||||||
|
"hast-util-to-jsx-runtime@2.3.6": {
|
||||||
|
"integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/estree",
|
||||||
|
"@types/hast",
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"comma-separated-tokens",
|
||||||
|
"devlop",
|
||||||
|
"estree-util-is-identifier-name",
|
||||||
|
"hast-util-whitespace",
|
||||||
|
"mdast-util-mdx-expression",
|
||||||
|
"mdast-util-mdx-jsx",
|
||||||
|
"mdast-util-mdxjs-esm",
|
||||||
|
"property-information",
|
||||||
|
"space-separated-tokens",
|
||||||
|
"style-to-js",
|
||||||
|
"unist-util-position",
|
||||||
|
"vfile-message"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hast-util-whitespace@3.0.0": {
|
||||||
|
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/hast"
|
||||||
|
]
|
||||||
|
},
|
||||||
"hermes-estree@0.25.1": {
|
"hermes-estree@0.25.1": {
|
||||||
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="
|
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="
|
||||||
},
|
},
|
||||||
@@ -904,6 +1013,9 @@
|
|||||||
"hermes-estree"
|
"hermes-estree"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"html-url-attributes@3.0.1": {
|
||||||
|
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="
|
||||||
|
},
|
||||||
"ignore@5.3.2": {
|
"ignore@5.3.2": {
|
||||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
|
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
|
||||||
},
|
},
|
||||||
@@ -920,6 +1032,22 @@
|
|||||||
"imurmurhash@0.1.4": {
|
"imurmurhash@0.1.4": {
|
||||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
|
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="
|
||||||
},
|
},
|
||||||
|
"inline-style-parser@0.2.7": {
|
||||||
|
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
|
||||||
|
},
|
||||||
|
"is-alphabetical@2.0.1": {
|
||||||
|
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="
|
||||||
|
},
|
||||||
|
"is-alphanumerical@2.0.1": {
|
||||||
|
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
|
||||||
|
"dependencies": [
|
||||||
|
"is-alphabetical",
|
||||||
|
"is-decimal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"is-decimal@2.0.1": {
|
||||||
|
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="
|
||||||
|
},
|
||||||
"is-extglob@2.1.1": {
|
"is-extglob@2.1.1": {
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
|
||||||
},
|
},
|
||||||
@@ -929,6 +1057,12 @@
|
|||||||
"is-extglob"
|
"is-extglob"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"is-hexadecimal@2.0.1": {
|
||||||
|
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="
|
||||||
|
},
|
||||||
|
"is-plain-obj@4.1.0": {
|
||||||
|
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="
|
||||||
|
},
|
||||||
"isexe@2.0.0": {
|
"isexe@2.0.0": {
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
|
||||||
},
|
},
|
||||||
@@ -1055,12 +1189,424 @@
|
|||||||
"lodash.merge@4.6.2": {
|
"lodash.merge@4.6.2": {
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
|
||||||
},
|
},
|
||||||
|
"longest-streak@3.1.0": {
|
||||||
|
"integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="
|
||||||
|
},
|
||||||
"lru-cache@5.1.1": {
|
"lru-cache@5.1.1": {
|
||||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"yallist"
|
"yallist"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"markdown-table@3.0.4": {
|
||||||
|
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="
|
||||||
|
},
|
||||||
|
"mdast-util-find-and-replace@3.0.2": {
|
||||||
|
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"escape-string-regexp@5.0.0",
|
||||||
|
"unist-util-is",
|
||||||
|
"unist-util-visit-parents"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-from-markdown@2.0.3": {
|
||||||
|
"integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"decode-named-character-reference",
|
||||||
|
"devlop",
|
||||||
|
"mdast-util-to-string",
|
||||||
|
"micromark",
|
||||||
|
"micromark-util-decode-numeric-character-reference",
|
||||||
|
"micromark-util-decode-string",
|
||||||
|
"micromark-util-normalize-identifier",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types",
|
||||||
|
"unist-util-stringify-position"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-gfm-autolink-literal@2.0.1": {
|
||||||
|
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"ccount",
|
||||||
|
"devlop",
|
||||||
|
"mdast-util-find-and-replace",
|
||||||
|
"micromark-util-character"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-gfm-footnote@2.1.0": {
|
||||||
|
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"devlop",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-to-markdown",
|
||||||
|
"micromark-util-normalize-identifier"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-gfm-strikethrough@2.0.0": {
|
||||||
|
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-to-markdown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-gfm-table@2.0.0": {
|
||||||
|
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"devlop",
|
||||||
|
"markdown-table",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-to-markdown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-gfm-task-list-item@2.0.0": {
|
||||||
|
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"devlop",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-to-markdown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-gfm@3.1.0": {
|
||||||
|
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-gfm-autolink-literal",
|
||||||
|
"mdast-util-gfm-footnote",
|
||||||
|
"mdast-util-gfm-strikethrough",
|
||||||
|
"mdast-util-gfm-table",
|
||||||
|
"mdast-util-gfm-task-list-item",
|
||||||
|
"mdast-util-to-markdown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-mdx-expression@2.0.1": {
|
||||||
|
"integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/estree-jsx",
|
||||||
|
"@types/hast",
|
||||||
|
"@types/mdast",
|
||||||
|
"devlop",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-to-markdown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-mdx-jsx@3.2.0": {
|
||||||
|
"integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/estree-jsx",
|
||||||
|
"@types/hast",
|
||||||
|
"@types/mdast",
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"ccount",
|
||||||
|
"devlop",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-to-markdown",
|
||||||
|
"parse-entities",
|
||||||
|
"stringify-entities",
|
||||||
|
"unist-util-stringify-position",
|
||||||
|
"vfile-message"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-mdxjs-esm@2.0.1": {
|
||||||
|
"integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/estree-jsx",
|
||||||
|
"@types/hast",
|
||||||
|
"@types/mdast",
|
||||||
|
"devlop",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"mdast-util-to-markdown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-phrasing@4.1.0": {
|
||||||
|
"integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"unist-util-is"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-to-hast@13.2.1": {
|
||||||
|
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/hast",
|
||||||
|
"@types/mdast",
|
||||||
|
"@ungap/structured-clone",
|
||||||
|
"devlop",
|
||||||
|
"micromark-util-sanitize-uri",
|
||||||
|
"trim-lines",
|
||||||
|
"unist-util-position",
|
||||||
|
"unist-util-visit",
|
||||||
|
"vfile"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-to-markdown@2.1.2": {
|
||||||
|
"integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"longest-streak",
|
||||||
|
"mdast-util-phrasing",
|
||||||
|
"mdast-util-to-string",
|
||||||
|
"micromark-util-classify-character",
|
||||||
|
"micromark-util-decode-string",
|
||||||
|
"unist-util-visit",
|
||||||
|
"zwitch"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mdast-util-to-string@4.0.0": {
|
||||||
|
"integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-core-commonmark@2.0.3": {
|
||||||
|
"integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
|
||||||
|
"dependencies": [
|
||||||
|
"decode-named-character-reference",
|
||||||
|
"devlop",
|
||||||
|
"micromark-factory-destination",
|
||||||
|
"micromark-factory-label",
|
||||||
|
"micromark-factory-space",
|
||||||
|
"micromark-factory-title",
|
||||||
|
"micromark-factory-whitespace",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-chunked",
|
||||||
|
"micromark-util-classify-character",
|
||||||
|
"micromark-util-html-tag-name",
|
||||||
|
"micromark-util-normalize-identifier",
|
||||||
|
"micromark-util-resolve-all",
|
||||||
|
"micromark-util-subtokenize",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-extension-gfm-autolink-literal@2.1.0": {
|
||||||
|
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-sanitize-uri",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-extension-gfm-footnote@2.1.0": {
|
||||||
|
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
||||||
|
"dependencies": [
|
||||||
|
"devlop",
|
||||||
|
"micromark-core-commonmark",
|
||||||
|
"micromark-factory-space",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-normalize-identifier",
|
||||||
|
"micromark-util-sanitize-uri",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-extension-gfm-strikethrough@2.1.0": {
|
||||||
|
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
||||||
|
"dependencies": [
|
||||||
|
"devlop",
|
||||||
|
"micromark-util-chunked",
|
||||||
|
"micromark-util-classify-character",
|
||||||
|
"micromark-util-resolve-all",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-extension-gfm-table@2.1.1": {
|
||||||
|
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
|
||||||
|
"dependencies": [
|
||||||
|
"devlop",
|
||||||
|
"micromark-factory-space",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-extension-gfm-tagfilter@2.0.0": {
|
||||||
|
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-extension-gfm-task-list-item@2.1.0": {
|
||||||
|
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
||||||
|
"dependencies": [
|
||||||
|
"devlop",
|
||||||
|
"micromark-factory-space",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-extension-gfm@3.0.0": {
|
||||||
|
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-extension-gfm-autolink-literal",
|
||||||
|
"micromark-extension-gfm-footnote",
|
||||||
|
"micromark-extension-gfm-strikethrough",
|
||||||
|
"micromark-extension-gfm-table",
|
||||||
|
"micromark-extension-gfm-tagfilter",
|
||||||
|
"micromark-extension-gfm-task-list-item",
|
||||||
|
"micromark-util-combine-extensions",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-factory-destination@2.0.1": {
|
||||||
|
"integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-factory-label@2.0.1": {
|
||||||
|
"integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
|
||||||
|
"dependencies": [
|
||||||
|
"devlop",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-factory-space@2.0.1": {
|
||||||
|
"integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-factory-title@2.0.1": {
|
||||||
|
"integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-factory-space",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-factory-whitespace@2.0.1": {
|
||||||
|
"integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-factory-space",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-character@2.1.1": {
|
||||||
|
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-chunked@2.0.1": {
|
||||||
|
"integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-symbol"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-classify-character@2.0.1": {
|
||||||
|
"integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-combine-extensions@2.0.1": {
|
||||||
|
"integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-chunked",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-decode-numeric-character-reference@2.0.2": {
|
||||||
|
"integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-symbol"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-decode-string@2.0.1": {
|
||||||
|
"integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"decode-named-character-reference",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-decode-numeric-character-reference",
|
||||||
|
"micromark-util-symbol"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-encode@2.0.1": {
|
||||||
|
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="
|
||||||
|
},
|
||||||
|
"micromark-util-html-tag-name@2.0.1": {
|
||||||
|
"integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="
|
||||||
|
},
|
||||||
|
"micromark-util-normalize-identifier@2.0.1": {
|
||||||
|
"integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-symbol"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-resolve-all@2.0.1": {
|
||||||
|
"integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-sanitize-uri@2.0.1": {
|
||||||
|
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-encode",
|
||||||
|
"micromark-util-symbol"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-subtokenize@2.1.0": {
|
||||||
|
"integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
|
||||||
|
"dependencies": [
|
||||||
|
"devlop",
|
||||||
|
"micromark-util-chunked",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"micromark-util-symbol@2.0.1": {
|
||||||
|
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="
|
||||||
|
},
|
||||||
|
"micromark-util-types@2.0.2": {
|
||||||
|
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="
|
||||||
|
},
|
||||||
|
"micromark@4.0.2": {
|
||||||
|
"integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/debug",
|
||||||
|
"debug",
|
||||||
|
"decode-named-character-reference",
|
||||||
|
"devlop",
|
||||||
|
"micromark-core-commonmark",
|
||||||
|
"micromark-factory-space",
|
||||||
|
"micromark-util-character",
|
||||||
|
"micromark-util-chunked",
|
||||||
|
"micromark-util-combine-extensions",
|
||||||
|
"micromark-util-decode-numeric-character-reference",
|
||||||
|
"micromark-util-encode",
|
||||||
|
"micromark-util-normalize-identifier",
|
||||||
|
"micromark-util-resolve-all",
|
||||||
|
"micromark-util-sanitize-uri",
|
||||||
|
"micromark-util-subtokenize",
|
||||||
|
"micromark-util-symbol",
|
||||||
|
"micromark-util-types"
|
||||||
|
]
|
||||||
|
},
|
||||||
"minimatch@10.2.4": {
|
"minimatch@10.2.4": {
|
||||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -1115,6 +1661,18 @@
|
|||||||
"callsites"
|
"callsites"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"parse-entities@4.0.2": {
|
||||||
|
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@2.0.11",
|
||||||
|
"character-entities-legacy",
|
||||||
|
"character-reference-invalid",
|
||||||
|
"decode-named-character-reference",
|
||||||
|
"is-alphanumerical",
|
||||||
|
"is-decimal",
|
||||||
|
"is-hexadecimal"
|
||||||
|
]
|
||||||
|
},
|
||||||
"path-exists@4.0.0": {
|
"path-exists@4.0.0": {
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="
|
||||||
},
|
},
|
||||||
@@ -1141,6 +1699,9 @@
|
|||||||
"prelude-ls@1.2.1": {
|
"prelude-ls@1.2.1": {
|
||||||
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
|
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
|
||||||
},
|
},
|
||||||
|
"property-information@7.1.0": {
|
||||||
|
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="
|
||||||
|
},
|
||||||
"punycode@2.3.1": {
|
"punycode@2.3.1": {
|
||||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
|
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
|
||||||
},
|
},
|
||||||
@@ -1151,6 +1712,24 @@
|
|||||||
"scheduler"
|
"scheduler"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"react-markdown@10.1.0_@types+react@19.2.14_react@19.2.4": {
|
||||||
|
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/hast",
|
||||||
|
"@types/mdast",
|
||||||
|
"@types/react",
|
||||||
|
"devlop",
|
||||||
|
"hast-util-to-jsx-runtime",
|
||||||
|
"html-url-attributes",
|
||||||
|
"mdast-util-to-hast",
|
||||||
|
"react",
|
||||||
|
"remark-parse",
|
||||||
|
"remark-rehype",
|
||||||
|
"unified",
|
||||||
|
"unist-util-visit",
|
||||||
|
"vfile"
|
||||||
|
]
|
||||||
|
},
|
||||||
"react-router@7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4": {
|
"react-router@7.13.1_react@19.2.4_react-dom@19.2.4__react@19.2.4": {
|
||||||
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -1166,6 +1745,44 @@
|
|||||||
"react@19.2.4": {
|
"react@19.2.4": {
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="
|
||||||
},
|
},
|
||||||
|
"remark-gfm@4.0.1": {
|
||||||
|
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"mdast-util-gfm",
|
||||||
|
"micromark-extension-gfm",
|
||||||
|
"remark-parse",
|
||||||
|
"remark-stringify",
|
||||||
|
"unified"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"remark-parse@11.0.0": {
|
||||||
|
"integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"mdast-util-from-markdown",
|
||||||
|
"micromark-util-types",
|
||||||
|
"unified"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"remark-rehype@11.1.2": {
|
||||||
|
"integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/hast",
|
||||||
|
"@types/mdast",
|
||||||
|
"mdast-util-to-hast",
|
||||||
|
"unified",
|
||||||
|
"vfile"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"remark-stringify@11.0.0": {
|
||||||
|
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/mdast",
|
||||||
|
"mdast-util-to-markdown",
|
||||||
|
"unified"
|
||||||
|
]
|
||||||
|
},
|
||||||
"resolve-from@4.0.0": {
|
"resolve-from@4.0.0": {
|
||||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
|
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
|
||||||
},
|
},
|
||||||
@@ -1220,9 +1837,31 @@
|
|||||||
"source-map-js@1.2.1": {
|
"source-map-js@1.2.1": {
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
|
||||||
},
|
},
|
||||||
|
"space-separated-tokens@2.0.2": {
|
||||||
|
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="
|
||||||
|
},
|
||||||
|
"stringify-entities@4.0.4": {
|
||||||
|
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
|
||||||
|
"dependencies": [
|
||||||
|
"character-entities-html4",
|
||||||
|
"character-entities-legacy"
|
||||||
|
]
|
||||||
|
},
|
||||||
"strip-json-comments@3.1.1": {
|
"strip-json-comments@3.1.1": {
|
||||||
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
|
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="
|
||||||
},
|
},
|
||||||
|
"style-to-js@1.1.21": {
|
||||||
|
"integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"style-to-object"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"style-to-object@1.0.14": {
|
||||||
|
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
|
||||||
|
"dependencies": [
|
||||||
|
"inline-style-parser"
|
||||||
|
]
|
||||||
|
},
|
||||||
"supports-color@7.2.0": {
|
"supports-color@7.2.0": {
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -1236,6 +1875,12 @@
|
|||||||
"picomatch"
|
"picomatch"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"trim-lines@3.0.1": {
|
||||||
|
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="
|
||||||
|
},
|
||||||
|
"trough@2.2.0": {
|
||||||
|
"integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="
|
||||||
|
},
|
||||||
"ts-api-utils@2.4.0_typescript@5.9.3": {
|
"ts-api-utils@2.4.0_typescript@5.9.3": {
|
||||||
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -1269,6 +1914,51 @@
|
|||||||
"undici-types@7.16.0": {
|
"undici-types@7.16.0": {
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
|
||||||
},
|
},
|
||||||
|
"unified@11.0.5": {
|
||||||
|
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"bail",
|
||||||
|
"devlop",
|
||||||
|
"extend",
|
||||||
|
"is-plain-obj",
|
||||||
|
"trough",
|
||||||
|
"vfile"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unist-util-is@6.0.1": {
|
||||||
|
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unist-util-position@5.0.0": {
|
||||||
|
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unist-util-stringify-position@4.0.0": {
|
||||||
|
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unist-util-visit-parents@6.0.2": {
|
||||||
|
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"unist-util-is"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"unist-util-visit@5.1.0": {
|
||||||
|
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"unist-util-is",
|
||||||
|
"unist-util-visit-parents"
|
||||||
|
]
|
||||||
|
},
|
||||||
"update-browserslist-db@1.2.3_browserslist@4.28.1": {
|
"update-browserslist-db@1.2.3_browserslist@4.28.1": {
|
||||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -1284,6 +1974,20 @@
|
|||||||
"punycode"
|
"punycode"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"vfile-message@4.0.3": {
|
||||||
|
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"unist-util-stringify-position"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"vfile@6.0.3": {
|
||||||
|
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
|
||||||
|
"dependencies": [
|
||||||
|
"@types/unist@3.0.3",
|
||||||
|
"vfile-message"
|
||||||
|
]
|
||||||
|
},
|
||||||
"vite@8.0.0_@types+node@24.12.0": {
|
"vite@8.0.0_@types+node@24.12.0": {
|
||||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
@@ -1327,6 +2031,9 @@
|
|||||||
},
|
},
|
||||||
"zod@4.3.6": {
|
"zod@4.3.6": {
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="
|
||||||
|
},
|
||||||
|
"zwitch@2.0.4": {
|
||||||
|
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
@@ -1349,8 +2056,10 @@
|
|||||||
"npm:eslint@^9.39.4",
|
"npm:eslint@^9.39.4",
|
||||||
"npm:globals@^17.4.0",
|
"npm:globals@^17.4.0",
|
||||||
"npm:react-dom@^19.2.4",
|
"npm:react-dom@^19.2.4",
|
||||||
|
"npm:react-markdown@^10.1.0",
|
||||||
"npm:react-router@^7.13.1",
|
"npm:react-router@^7.13.1",
|
||||||
"npm:react@^19.2.4",
|
"npm:react@^19.2.4",
|
||||||
|
"npm:remark-gfm@^4.0.1",
|
||||||
"npm:typescript-eslint@^8.56.1",
|
"npm:typescript-eslint@^8.56.1",
|
||||||
"npm:typescript@~5.9.3",
|
"npm:typescript@~5.9.3",
|
||||||
"npm:vite@8"
|
"npm:vite@8"
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router": "^7.13.1"
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router": "^7.13.1",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
742
src/App.css
742
src/App.css
@@ -1,3 +1,71 @@
|
|||||||
|
/* ── Markdown prose ── */
|
||||||
|
.md p {
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
}
|
||||||
|
.md p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.md a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.md a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.md strong { font-weight: 700; }
|
||||||
|
.md em { font-style: italic; }
|
||||||
|
.md code {
|
||||||
|
font-family: monospace;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
.md pre {
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
.md pre code {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.md ul, .md ol {
|
||||||
|
padding-left: 1.5em;
|
||||||
|
margin: 0.4em 0;
|
||||||
|
}
|
||||||
|
.md li { margin: 0.15em 0; }
|
||||||
|
.md blockquote {
|
||||||
|
border-left: 3px solid var(--color-border);
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding: 0.2em 0.75em;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
.md h1, .md h2, .md h3,
|
||||||
|
.md h4, .md h5, .md h6 {
|
||||||
|
margin: 0.6em 0 0.2em;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact / card mode: strip vertical spacing */
|
||||||
|
.md--inline p,
|
||||||
|
.md--inline ul,
|
||||||
|
.md--inline ol,
|
||||||
|
.md--inline pre,
|
||||||
|
.md--inline blockquote {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.md--inline li { margin: 0; }
|
||||||
|
.md--inline ul,
|
||||||
|
.md--inline ol { padding-left: 1.2em; }
|
||||||
|
|
||||||
/* ── Dump detail page ── */
|
/* ── Dump detail page ── */
|
||||||
.dump-detail {
|
.dump-detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -16,9 +84,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dump-header-block {
|
.dump-header-block {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: flex-start;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 1rem;
|
column-gap: 1rem;
|
||||||
|
row-gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-title,
|
||||||
|
.dump-op {
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-header-block .vote-btn,
|
||||||
|
.dump-header-block .btn-add-playlist {
|
||||||
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-header-info {
|
.dump-header-info {
|
||||||
@@ -29,6 +109,7 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.dump-title {
|
.dump-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -513,19 +594,17 @@
|
|||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-text);
|
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-content-card:hover {
|
.rich-content-card:has(.rich-content-body:hover) {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-content-card--youtube {
|
.rich-content-card--youtube {
|
||||||
border-color: var(--color-youtube);
|
border-color: var(--color-youtube);
|
||||||
}
|
}
|
||||||
.rich-content-card--youtube:hover {
|
.rich-content-card--youtube:has(.rich-content-body:hover) {
|
||||||
border-color: var(--color-youtube-hover);
|
border-color: var(--color-youtube-hover);
|
||||||
}
|
}
|
||||||
.rich-content-card--youtube .rich-content-badge {
|
.rich-content-card--youtube .rich-content-badge {
|
||||||
@@ -535,7 +614,7 @@
|
|||||||
.rich-content-card--bandcamp {
|
.rich-content-card--bandcamp {
|
||||||
border-color: var(--color-bandcamp);
|
border-color: var(--color-bandcamp);
|
||||||
}
|
}
|
||||||
.rich-content-card--bandcamp:hover {
|
.rich-content-card--bandcamp:has(.rich-content-body:hover) {
|
||||||
border-color: var(--color-bandcamp-hover);
|
border-color: var(--color-bandcamp-hover);
|
||||||
}
|
}
|
||||||
.rich-content-card--bandcamp .rich-content-badge {
|
.rich-content-card--bandcamp .rich-content-badge {
|
||||||
@@ -545,13 +624,157 @@
|
|||||||
.rich-content-card--soundcloud {
|
.rich-content-card--soundcloud {
|
||||||
border-color: var(--color-soundcloud);
|
border-color: var(--color-soundcloud);
|
||||||
}
|
}
|
||||||
.rich-content-card--soundcloud:hover {
|
.rich-content-card--soundcloud:has(.rich-content-body:hover) {
|
||||||
border-color: var(--color-soundcloud-hover);
|
border-color: var(--color-soundcloud-hover);
|
||||||
}
|
}
|
||||||
.rich-content-card--soundcloud .rich-content-badge {
|
.rich-content-card--soundcloud .rich-content-badge {
|
||||||
background: var(--color-soundcloud);
|
background: var(--color-soundcloud);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rich-content-embed {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.rich-content-embed iframe {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.embed-youtube {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
}
|
||||||
|
.embed-youtube iframe {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.embed-soundcloud {
|
||||||
|
height: 166px;
|
||||||
|
}
|
||||||
|
.embed-bandcamp {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Global persistent player ── */
|
||||||
|
.global-player {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: calc(100% - 2.5rem);
|
||||||
|
max-width: 860px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: player-enter 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||||
|
transition: opacity 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
.global-player--reduced {
|
||||||
|
opacity: 0.6;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.global-player--reduced:hover {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
@keyframes player-enter {
|
||||||
|
from {
|
||||||
|
transform: translateX(-50%) translateY(1.5rem);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.global-player-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.global-player-title {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.global-player-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
transition: grid-template-rows 0.3s ease;
|
||||||
|
}
|
||||||
|
.global-player--reduced .global-player-body {
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
}
|
||||||
|
.global-player-iframe-wrap {
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
.global-player iframe {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.global-player-iframe--youtube {
|
||||||
|
aspect-ratio: 16/9;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
.global-player-iframe--soundcloud {
|
||||||
|
height: 166px;
|
||||||
|
}
|
||||||
|
.global-player-iframe--bandcamp {
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
.global-player.global-player--bandcamp {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.feed-loading-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.has-player {
|
||||||
|
padding-bottom: var(--player-height, 0px);
|
||||||
|
}
|
||||||
|
body.has-player .fab-new {
|
||||||
|
bottom: calc(var(--player-height, 0px) + 1rem);
|
||||||
|
}
|
||||||
|
.rich-content-thumbnail-btn {
|
||||||
|
position: relative;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-self: stretch;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content-play-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content-thumbnail-btn:hover .rich-content-play-overlay {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
.rich-content-thumbnail {
|
.rich-content-thumbnail {
|
||||||
width: 180px;
|
width: 180px;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
@@ -562,6 +785,8 @@
|
|||||||
.rich-content-body {
|
.rich-content-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.9rem 1.1rem;
|
padding: 0.9rem 1.1rem;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -735,6 +960,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Avatar edit overlay ── */
|
/* ── Avatar edit overlay ── */
|
||||||
|
/* ── ImagePicker (reusable clickable cover image) ── */
|
||||||
|
.img-picker {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-picker-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-picker-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px dashed var(--color-border);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-picker-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--color-overlay);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-picker:hover .img-picker-overlay,
|
||||||
|
.img-picker:focus-visible .img-picker-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-avatar-wrapper {
|
.profile-avatar-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -764,13 +1037,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-columns {
|
.profile-columns {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
@@ -781,9 +1053,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-section {
|
.profile-section {}
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-section ul {
|
.profile-section ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -881,24 +1151,23 @@
|
|||||||
|
|
||||||
/* ── Shared layout ── */
|
/* ── Shared layout ── */
|
||||||
.page-shell {
|
.page-shell {
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
flex: 1;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1.25rem;
|
padding: 2rem 1.25rem 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
animation: page-enter 0.2s ease both;
|
animation: page-enter 0.2s ease both;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-content--centered {
|
.page-content--centered {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 2.5rem;
|
padding-top: 2.5rem;
|
||||||
}
|
}
|
||||||
@@ -1138,6 +1407,10 @@
|
|||||||
border-radius: 0 0 12px 12px;
|
border-radius: 0 0 12px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dump-edit-refresh {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dump-form {
|
.dump-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1252,7 +1525,6 @@
|
|||||||
|
|
||||||
/* ── Index page ── */
|
/* ── Index page ── */
|
||||||
.index-page {
|
.index-page {
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
animation: page-enter 0.2s ease both;
|
animation: page-enter 0.2s ease both;
|
||||||
@@ -1354,10 +1626,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Dump feed ── */
|
/* ── Dump feed ── */
|
||||||
|
@keyframes card-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dump-feed {
|
.dump-feed {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -1367,6 +1650,10 @@
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dump-feed > li {
|
||||||
|
animation: card-enter 0.2s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shared card skin (dump-card + playlist-card) ── */
|
/* ── Shared card skin (dump-card + playlist-card) ── */
|
||||||
.dump-card,
|
.dump-card,
|
||||||
.playlist-card {
|
.playlist-card {
|
||||||
@@ -1493,8 +1780,16 @@
|
|||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dump-card-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dump-card-date {
|
.dump-card-date {
|
||||||
display: block;
|
display: block;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-card-meta {
|
.playlist-card-meta {
|
||||||
@@ -1587,6 +1882,21 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-card--wide {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-success {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dump-create-success a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.confirm-modal {
|
.confirm-modal {
|
||||||
max-width: 340px;
|
max-width: 340px;
|
||||||
padding: 1.5rem 1.25rem 1.25rem;
|
padding: 1.5rem 1.25rem 1.25rem;
|
||||||
@@ -1733,7 +2043,6 @@
|
|||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-detail-header-top {
|
.playlist-detail-header-top {
|
||||||
@@ -1772,10 +2081,24 @@
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Playlist edit button ── */
|
/* ── Playlist header inline edit ── */
|
||||||
.playlist-edit-btn {
|
.playlist-detail-content {
|
||||||
margin-left: auto;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-header-actions {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border-subtle);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1793,19 +2116,6 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Playlist edit form ── */
|
|
||||||
.playlist-edit-form {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
border-top: 1px solid var(--color-border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-edit-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-edit-input,
|
.playlist-edit-input,
|
||||||
.playlist-edit-textarea {
|
.playlist-edit-textarea {
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -1813,43 +2123,32 @@
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.4rem 0.65rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-edit-textarea {
|
||||||
|
resize: none;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-edit-input {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-edit-input:focus,
|
.playlist-edit-input:focus,
|
||||||
.playlist-edit-textarea:focus {
|
.playlist-edit-textarea:focus {
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-edit-toggle {
|
.playlist-detail-meta .playlist-edit-toggle {
|
||||||
align-self: flex-start;
|
opacity: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-edit-image-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-edit-img-preview {
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-edit-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Playlist dump list ── */
|
/* ── Playlist dump list ── */
|
||||||
@@ -1927,18 +2226,23 @@
|
|||||||
|
|
||||||
/* ── Add to playlist button (dump detail) ── */
|
/* ── Add to playlist button (dump detail) ── */
|
||||||
.btn-add-playlist {
|
.btn-add-playlist {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
font-size: 0.9rem;
|
font-size: 0.78rem;
|
||||||
opacity: 0.7;
|
font-weight: 600;
|
||||||
padding: 0;
|
white-space: nowrap;
|
||||||
transition: opacity 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-playlist:hover {
|
.btn-add-playlist:hover {
|
||||||
opacity: 1;
|
border-color: var(--color-accent);
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1948,7 +2252,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.my-playlists-title {
|
.my-playlists-title {
|
||||||
@@ -1972,3 +2275,302 @@
|
|||||||
.new-playlist-toggle:hover {
|
.new-playlist-toggle:hover {
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Public/Private toggle ── */
|
||||||
|
.toggle-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 2.4rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-text-muted);
|
||||||
|
transition: background 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-thumb::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0.15rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
transition: left 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-thumb {
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch input:checked + .toggle-thumb::after {
|
||||||
|
left: calc(100% - 1.15rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dump card comment count ── */
|
||||||
|
.dump-card-comment-count {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dump card private badge ── */
|
||||||
|
.dump-card-private-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.1em 0.45em;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--color-text-muted) 18%, transparent);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Comments ── */
|
||||||
|
.comment-section {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
padding-top: 1.75rem;
|
||||||
|
border-top: 2px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-section-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-node-inner {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-node-inner:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-surface) 80%, var(--color-accent) 20%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.15rem 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 0.1s, border-color 0.1s, background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-action-btn:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-border-subtle);
|
||||||
|
background: color-mix(in srgb, var(--color-text) 6%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-delete-btn:hover {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: color-mix(in srgb, var(--color-danger) 40%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-replies {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin-left: 1.1rem;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
border-left: 2px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-top-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--color-bg);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-reply-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit-btn {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, opacity 0.15s;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit-btn:not(:disabled):hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-submit-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-form-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-node-inner--deleted {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-node-inner--deleted:hover {
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar-placeholder {
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-deleted-placeholder {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|||||||
14
src/App.tsx
14
src/App.tsx
@@ -4,7 +4,6 @@ import { Index } from "./pages/Index.tsx";
|
|||||||
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
|
import { RestrictedGuest } from "./pages/RestrictedGuest.tsx";
|
||||||
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
|
import { RestrictedLoggedIn } from "./pages/RestrictedLoggedIn.tsx";
|
||||||
import { Dump } from "./pages/Dump.tsx";
|
import { Dump } from "./pages/Dump.tsx";
|
||||||
import { DumpCreate } from "./pages/DumpCreate.tsx";
|
|
||||||
import { DumpEdit } from "./pages/DumpEdit.tsx";
|
import { DumpEdit } from "./pages/DumpEdit.tsx";
|
||||||
import { UserLogin } from "./pages/UserLogin.tsx";
|
import { UserLogin } from "./pages/UserLogin.tsx";
|
||||||
import { UserPublicProfile } from "./pages/UserPublicProfile.tsx";
|
import { UserPublicProfile } from "./pages/UserPublicProfile.tsx";
|
||||||
@@ -13,8 +12,10 @@ import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
|
|||||||
import { MyPlaylists } from "./pages/MyPlaylists.tsx";
|
import { MyPlaylists } from "./pages/MyPlaylists.tsx";
|
||||||
|
|
||||||
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
||||||
|
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
||||||
import { WSProvider } from "./contexts/WSProvider.tsx";
|
import { WSProvider } from "./contexts/WSProvider.tsx";
|
||||||
import { useAuth } from "./hooks/useAuth.ts";
|
import { useAuth } from "./hooks/useAuth.ts";
|
||||||
|
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||||
|
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
@@ -25,14 +26,6 @@ function AppRoutes() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route
|
|
||||||
path="/dumps/new"
|
|
||||||
element={
|
|
||||||
<RestrictedLoggedIn>
|
|
||||||
<DumpCreate />
|
|
||||||
</RestrictedLoggedIn>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
<Route path="/dumps/:selectedDump" element={<Dump />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dumps/:selectedDump/edit"
|
path="/dumps/:selectedDump/edit"
|
||||||
@@ -77,7 +70,10 @@ function AppRoutes() {
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<PlayerProvider>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
|
<GlobalPlayer />
|
||||||
|
</PlayerProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ import { createPortal } from "react-dom";
|
|||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import type {
|
import type {
|
||||||
CreatePlaylistRequest,
|
|
||||||
PlaylistMembership,
|
PlaylistMembership,
|
||||||
RawPlaylist,
|
|
||||||
RawPlaylistMembership,
|
RawPlaylistMembership,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import {
|
import { deserializePlaylistMembership } from "../model.ts";
|
||||||
deserializePlaylist,
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
deserializePlaylistMembership,
|
|
||||||
} from "../model.ts";
|
|
||||||
|
|
||||||
interface AddToPlaylistModalProps {
|
interface AddToPlaylistModalProps {
|
||||||
dumpId: string;
|
dumpId: string;
|
||||||
@@ -25,10 +21,6 @@ export function AddToPlaylistModal(
|
|||||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showNewForm, setShowNewForm] = useState(false);
|
const [showNewForm, setShowNewForm] = useState(false);
|
||||||
const [newTitle, setNewTitle] = useState("");
|
|
||||||
const [newDescription, setNewDescription] = useState("");
|
|
||||||
const [newIsPublic, setNewIsPublic] = useState(true);
|
|
||||||
const [creating, setCreating] = useState(false);
|
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -87,41 +79,6 @@ export function AddToPlaylistModal(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newTitle.trim()) return;
|
|
||||||
setCreating(true);
|
|
||||||
try {
|
|
||||||
const req: CreatePlaylistRequest = {
|
|
||||||
title: newTitle.trim(),
|
|
||||||
description: newDescription.trim() || undefined,
|
|
||||||
isPublic: newIsPublic,
|
|
||||||
};
|
|
||||||
const res = await authFetch(`${API_URL}/api/playlists`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(req),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
if (!body.success) return;
|
|
||||||
const playlist = deserializePlaylist(body.data as RawPlaylist);
|
|
||||||
|
|
||||||
await authFetch(
|
|
||||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
setMemberships((prev) => [{ playlist, hasDump: true }, ...prev]);
|
|
||||||
setNewTitle("");
|
|
||||||
setNewDescription("");
|
|
||||||
setShowNewForm(false);
|
|
||||||
} finally {
|
|
||||||
setCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="modal-backdrop"
|
className="modal-backdrop"
|
||||||
@@ -176,49 +133,17 @@ export function AddToPlaylistModal(
|
|||||||
|
|
||||||
{showNewForm
|
{showNewForm
|
||||||
? (
|
? (
|
||||||
<form className="modal-new-playlist-form" onSubmit={handleCreate}>
|
<PlaylistCreateForm
|
||||||
<input
|
dumpId={dumpId}
|
||||||
type="text"
|
onCreated={(playlist) => {
|
||||||
placeholder="Title"
|
setMemberships((prev) => [
|
||||||
value={newTitle}
|
{ playlist, hasDump: true },
|
||||||
onChange={(e) => setNewTitle(e.target.value)}
|
...prev,
|
||||||
autoFocus
|
]);
|
||||||
required
|
setShowNewForm(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowNewForm(false)}
|
||||||
/>
|
/>
|
||||||
<textarea
|
|
||||||
placeholder="Description (optional)"
|
|
||||||
value={newDescription}
|
|
||||||
onChange={(e) => setNewDescription(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
<div className="dump-mode-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={newIsPublic ? "active" : ""}
|
|
||||||
onClick={() => setNewIsPublic(true)}
|
|
||||||
>
|
|
||||||
Public
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={!newIsPublic ? "active" : ""}
|
|
||||||
onClick={() => setNewIsPublic(false)}
|
|
||||||
>
|
|
||||||
Private
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="submit" disabled={creating}>
|
|
||||||
{creating ? "Creating…" : "Create & Add"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewForm(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||||
|
|
||||||
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||||
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 [showFab, setShowFab] = useState(false);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = headerRef.current;
|
const el = headerRef.current;
|
||||||
@@ -46,7 +48,7 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={() => navigate("/dumps/new")}
|
onClick={() => setCreateModalOpen(true)}
|
||||||
>
|
>
|
||||||
+ New
|
+ New
|
||||||
</button>
|
</button>
|
||||||
@@ -69,16 +71,20 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{user && createPortal(
|
{/* {user && createPortal(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
||||||
onClick={() => navigate("/dumps/new")}
|
onClick={() => setCreateModalOpen(true)}
|
||||||
aria-label="New dump"
|
aria-label="New dump"
|
||||||
>
|
>
|
||||||
+ New
|
+ New
|
||||||
</button>,
|
</button>,
|
||||||
document.body,
|
document.body,
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{createModalOpen && (
|
||||||
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
353
src/components/CommentThread.tsx
Normal file
353
src/components/CommentThread.tsx
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type { Comment, RawComment, User } from "../model.ts";
|
||||||
|
import { deserializeComment } from "../model.ts";
|
||||||
|
import { Avatar } from "./Avatar.tsx";
|
||||||
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
|
|
||||||
|
interface CommentThreadProps {
|
||||||
|
dumpId: string;
|
||||||
|
comments: Comment[];
|
||||||
|
currentUser: User | null;
|
||||||
|
token: string | null;
|
||||||
|
onCommentCreated: (comment: Comment) => void;
|
||||||
|
onCommentDeleted: (commentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||||
|
const map = new Map<string, Comment[]>();
|
||||||
|
for (const c of comments) {
|
||||||
|
const key = c.parentId ?? "root";
|
||||||
|
if (!map.has(key)) map.set(key, []);
|
||||||
|
map.get(key)!.push(c);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_INDENT_DEPTH = 4;
|
||||||
|
|
||||||
|
interface CommentNodeProps {
|
||||||
|
comment: Comment;
|
||||||
|
tree: Map<string, Comment[]>;
|
||||||
|
depth: number;
|
||||||
|
dumpId: string;
|
||||||
|
currentUser: User | null;
|
||||||
|
token: string | null;
|
||||||
|
onCommentCreated: (comment: Comment) => void;
|
||||||
|
onCommentDeleted: (commentId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentNode({
|
||||||
|
comment,
|
||||||
|
tree,
|
||||||
|
depth,
|
||||||
|
dumpId,
|
||||||
|
currentUser,
|
||||||
|
token,
|
||||||
|
onCommentCreated,
|
||||||
|
onCommentDeleted,
|
||||||
|
}: CommentNodeProps) {
|
||||||
|
const [replyOpen, setReplyOpen] = useState(false);
|
||||||
|
const [replyBody, setReplyBody] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [replyError, setReplyError] = useState<string | null>(null);
|
||||||
|
const replyTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const children = tree.get(comment.id) ?? [];
|
||||||
|
|
||||||
|
async function handleReply(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!replyBody.trim() || !token) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setReplyError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body: replyBody, parentId: comment.id }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
onCommentCreated(deserializeComment(data.data as RawComment));
|
||||||
|
setReplyBody("");
|
||||||
|
setReplyOpen(false);
|
||||||
|
} else {
|
||||||
|
setReplyError(data.error?.message ?? "Failed to post reply.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setReplyError("Could not reach the server. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!token) return;
|
||||||
|
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}).catch(() => null);
|
||||||
|
if (res?.ok) {
|
||||||
|
onCommentDeleted(comment.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canDelete = !comment.deleted && !!currentUser &&
|
||||||
|
(currentUser.id === comment.userId || currentUser.isAdmin);
|
||||||
|
|
||||||
|
if (comment.deleted) {
|
||||||
|
return (
|
||||||
|
<li className="comment-node">
|
||||||
|
<div className="comment-node-inner comment-node-inner--deleted">
|
||||||
|
<div className="comment-avatar comment-avatar--deleted">
|
||||||
|
<div className="comment-avatar-placeholder" style={{ width: 28, height: 28 }} />
|
||||||
|
</div>
|
||||||
|
<div className="comment-content">
|
||||||
|
<p className="comment-deleted-placeholder">[deleted]</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children.length > 0 && (
|
||||||
|
<ul
|
||||||
|
className="comment-replies"
|
||||||
|
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
|
||||||
|
>
|
||||||
|
{children.map((child) => (
|
||||||
|
<CommentNode
|
||||||
|
key={child.id}
|
||||||
|
comment={child}
|
||||||
|
tree={tree}
|
||||||
|
depth={depth + 1}
|
||||||
|
dumpId={dumpId}
|
||||||
|
currentUser={currentUser}
|
||||||
|
token={token}
|
||||||
|
onCommentCreated={onCommentCreated}
|
||||||
|
onCommentDeleted={onCommentDeleted}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="comment-node">
|
||||||
|
<div className="comment-node-inner">
|
||||||
|
<div className="comment-avatar">
|
||||||
|
<Avatar
|
||||||
|
userId={comment.userId}
|
||||||
|
username={comment.authorUsername}
|
||||||
|
hasAvatar={!!comment.authorAvatarMime}
|
||||||
|
size={28}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="comment-content">
|
||||||
|
<div className="comment-meta">
|
||||||
|
<Link
|
||||||
|
to={`/users/${comment.authorUsername}`}
|
||||||
|
className="comment-author"
|
||||||
|
>
|
||||||
|
{comment.authorUsername}
|
||||||
|
</Link>
|
||||||
|
<time
|
||||||
|
className="comment-time"
|
||||||
|
dateTime={comment.createdAt.toISOString()}
|
||||||
|
title={comment.createdAt.toLocaleString()}
|
||||||
|
>
|
||||||
|
{relativeTime(comment.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
<Markdown className="comment-body">{comment.body}</Markdown>
|
||||||
|
<div className="comment-actions">
|
||||||
|
{currentUser && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setReplyOpen((v) => !v);
|
||||||
|
setTimeout(() => replyTextareaRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-action-btn comment-delete-btn"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{replyOpen && (
|
||||||
|
<form className="comment-form" onSubmit={handleReply}>
|
||||||
|
<textarea
|
||||||
|
ref={replyTextareaRef}
|
||||||
|
className="comment-reply-textarea"
|
||||||
|
value={replyBody}
|
||||||
|
onChange={(e) => setReplyBody(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
|
||||||
|
}}
|
||||||
|
placeholder="Write a reply…"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
{replyError && (
|
||||||
|
<p className="comment-form-error">{replyError}</p>
|
||||||
|
)}
|
||||||
|
<div className="comment-form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="comment-submit-btn"
|
||||||
|
disabled={submitting || !replyBody.trim()}
|
||||||
|
>
|
||||||
|
{submitting ? "Posting…" : "Post reply"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setReplyOpen(false);
|
||||||
|
setReplyBody("");
|
||||||
|
setReplyError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{children.length > 0 && (
|
||||||
|
<ul
|
||||||
|
className="comment-replies"
|
||||||
|
style={depth >= MAX_INDENT_DEPTH
|
||||||
|
? { paddingLeft: 0 }
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
{children.map((child) => (
|
||||||
|
<CommentNode
|
||||||
|
key={child.id}
|
||||||
|
comment={child}
|
||||||
|
tree={tree}
|
||||||
|
depth={depth + 1}
|
||||||
|
dumpId={dumpId}
|
||||||
|
currentUser={currentUser}
|
||||||
|
token={token}
|
||||||
|
onCommentCreated={onCommentCreated}
|
||||||
|
onCommentDeleted={onCommentDeleted}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentThread({
|
||||||
|
dumpId,
|
||||||
|
comments,
|
||||||
|
currentUser,
|
||||||
|
token,
|
||||||
|
onCommentCreated,
|
||||||
|
onCommentDeleted,
|
||||||
|
}: CommentThreadProps) {
|
||||||
|
const [topLevelBody, setTopLevelBody] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [topLevelError, setTopLevelError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const tree = buildTree(comments);
|
||||||
|
const roots = tree.get("root") ?? [];
|
||||||
|
|
||||||
|
async function handleTopLevelSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!topLevelBody.trim() || !token) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setTopLevelError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/dumps/${dumpId}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body: topLevelBody }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
onCommentCreated(deserializeComment(data.data as RawComment));
|
||||||
|
setTopLevelBody("");
|
||||||
|
} else {
|
||||||
|
setTopLevelError(data.error?.message ?? "Failed to post comment.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTopLevelError("Could not reach the server. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="comment-section">
|
||||||
|
<h2 className="comment-section-title">
|
||||||
|
{(() => {
|
||||||
|
const n = comments.filter((c) => !c.deleted).length;
|
||||||
|
return n === 1 ? "1 comment" : `${n} comments`;
|
||||||
|
})()}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<form className="comment-form comment-top-form" onSubmit={handleTopLevelSubmit}>
|
||||||
|
<textarea
|
||||||
|
className="comment-reply-textarea"
|
||||||
|
value={topLevelBody}
|
||||||
|
onChange={(e) => setTopLevelBody(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
|
||||||
|
}}
|
||||||
|
placeholder="Add a comment…"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
{topLevelError && (
|
||||||
|
<p className="comment-form-error">{topLevelError}</p>
|
||||||
|
)}
|
||||||
|
<div className="comment-form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="comment-submit-btn"
|
||||||
|
disabled={submitting || !topLevelBody.trim()}
|
||||||
|
>
|
||||||
|
{submitting ? "Posting…" : "Post comment"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{roots.length > 0 && (
|
||||||
|
<ul className="comment-list">
|
||||||
|
{roots.map((comment) => (
|
||||||
|
<CommentNode
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
tree={tree}
|
||||||
|
depth={0}
|
||||||
|
dumpId={dumpId}
|
||||||
|
currentUser={currentUser}
|
||||||
|
token={token}
|
||||||
|
onCommentCreated={onCommentCreated}
|
||||||
|
onCommentDeleted={onCommentDeleted}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { relativeTime } from "../utils/relativeTime.ts";
|
|||||||
import FilePreview from "./FilePreview.tsx";
|
import FilePreview from "./FilePreview.tsx";
|
||||||
import RichContentCard from "./RichContentCard.tsx";
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
import { VoteButton } from "./VoteButton.tsx";
|
import { VoteButton } from "./VoteButton.tsx";
|
||||||
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
|
||||||
interface DumpCardProps {
|
interface DumpCardProps {
|
||||||
dump: Dump;
|
dump: Dump;
|
||||||
@@ -13,10 +14,11 @@ interface DumpCardProps {
|
|||||||
castVote: (id: string) => void;
|
castVote: (id: string) => void;
|
||||||
removeVote: (id: string) => void;
|
removeVote: (id: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DumpCard(
|
export function DumpCard(
|
||||||
{ dump, voteCount, voted, canVote, castVote, removeVote, className }:
|
{ dump, voteCount, voted, canVote, castVote, removeVote, className, isOwner }:
|
||||||
DumpCardProps,
|
DumpCardProps,
|
||||||
) {
|
) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -46,7 +48,10 @@ export function DumpCard(
|
|||||||
>
|
>
|
||||||
{dump.title}
|
{dump.title}
|
||||||
</Link>
|
</Link>
|
||||||
{dump.comment && <p className="dump-card-comment">{dump.comment}</p>}
|
{dump.comment && (
|
||||||
|
<Markdown className="dump-card-comment" inline>{dump.comment}</Markdown>
|
||||||
|
)}
|
||||||
|
<div className="dump-card-meta">
|
||||||
<time
|
<time
|
||||||
className="dump-card-date"
|
className="dump-card-date"
|
||||||
dateTime={dump.createdAt.toISOString()}
|
dateTime={dump.createdAt.toISOString()}
|
||||||
@@ -54,6 +59,15 @@ export function DumpCard(
|
|||||||
>
|
>
|
||||||
{relativeTime(dump.createdAt)}
|
{relativeTime(dump.createdAt)}
|
||||||
</time>
|
</time>
|
||||||
|
{dump.commentCount > 0 && (
|
||||||
|
<span className="dump-card-comment-count">
|
||||||
|
{dump.commentCount} {dump.commentCount === 1 ? "comment" : "comments"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{dump.isPrivate && isOwner && (
|
||||||
|
<span className="dump-card-private-badge">private</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
<div className="dump-card-vote" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
536
src/components/DumpCreateModal.tsx
Normal file
536
src/components/DumpCreateModal.tsx
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type {
|
||||||
|
CreateUrlDumpRequest,
|
||||||
|
Dump,
|
||||||
|
PlaylistMembership,
|
||||||
|
RawDump,
|
||||||
|
RawPlaylistMembership,
|
||||||
|
} from "../model.ts";
|
||||||
|
import {
|
||||||
|
deserializeDump,
|
||||||
|
deserializePlaylistMembership,
|
||||||
|
} from "../model.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
|
import type { RichContent } from "../model.ts";
|
||||||
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
type Mode = "url" | "file";
|
||||||
|
type Phase = "create" | "playlist";
|
||||||
|
|
||||||
|
type SubmitState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "submitting" }
|
||||||
|
| { status: "error"; error: string };
|
||||||
|
|
||||||
|
type UrlPreview =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "done"; richContent: RichContent | null };
|
||||||
|
|
||||||
|
function LocalFilePreview({ file }: { file: File }) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
const mime = file.type;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setSrc(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
if (!src) return null;
|
||||||
|
|
||||||
|
if (mime.startsWith("image/")) {
|
||||||
|
return <img src={src} alt={file.name} className="local-preview-image" />;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("video/")) {
|
||||||
|
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
|
||||||
|
}
|
||||||
|
if (mime.startsWith("audio/")) {
|
||||||
|
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="local-preview-generic">
|
||||||
|
<span className="local-preview-icon">
|
||||||
|
{mime.startsWith("application/pdf") ? "📄" : "📎"}
|
||||||
|
</span>
|
||||||
|
<span className="local-preview-name">{file.name}</span>
|
||||||
|
<span className="local-preview-size">{formatBytes(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DumpCreateModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>("create");
|
||||||
|
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
|
||||||
|
|
||||||
|
// Create phase state
|
||||||
|
const [mode, setMode] = useState<Mode>("url");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [isPrivate, setIsPrivate] = useState(false);
|
||||||
|
const [submitState, setSubmitState] = useState<SubmitState>({
|
||||||
|
status: "idle",
|
||||||
|
});
|
||||||
|
const [urlPreview, setUrlPreview] = useState<UrlPreview>({ status: "idle" });
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Playlist phase state
|
||||||
|
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||||
|
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||||
|
const [showNewPlaylistForm, setShowNewPlaylistForm] = useState(false);
|
||||||
|
|
||||||
|
// Lock body scroll
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Escape key to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handler);
|
||||||
|
return () => document.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Debounced URL preview
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
|
||||||
|
let trimmed: string;
|
||||||
|
try {
|
||||||
|
const u = new URL(url.trim());
|
||||||
|
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error();
|
||||||
|
trimmed = u.toString();
|
||||||
|
} catch {
|
||||||
|
setUrlPreview({ status: "idle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUrlPreview({ status: "loading" });
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/api/preview?url=${encodeURIComponent(trimmed)}`,
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
setUrlPreview({
|
||||||
|
status: "done",
|
||||||
|
richContent: body.success ? body.data : null,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setUrlPreview({ status: "done", richContent: null });
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
// Paste handler
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: ClipboardEvent) => {
|
||||||
|
const pastedFile = e.clipboardData?.files[0];
|
||||||
|
if (pastedFile) {
|
||||||
|
setMode("file");
|
||||||
|
setUrl("");
|
||||||
|
setUrlPreview({ status: "idle" });
|
||||||
|
setFile(pastedFile);
|
||||||
|
setSubmitState({ status: "idle" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tag = (e.target as HTMLElement).tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||||
|
const text = e.clipboardData?.getData("text") ?? "";
|
||||||
|
try {
|
||||||
|
const u = new URL(text.trim());
|
||||||
|
if (u.protocol === "http:" || u.protocol === "https:") {
|
||||||
|
setMode("url");
|
||||||
|
setFile(null);
|
||||||
|
setUrl(text.trim());
|
||||||
|
setSubmitState({ status: "idle" });
|
||||||
|
}
|
||||||
|
} catch { /* not a URL */ }
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("paste", handler);
|
||||||
|
return () => globalThis.removeEventListener("paste", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitState({ status: "submitting" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res: Response;
|
||||||
|
|
||||||
|
if (mode === "url") {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setSubmitState({ status: "error", error: "URL is required." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: CreateUrlDumpRequest = {
|
||||||
|
url: url.trim(),
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
isPrivate,
|
||||||
|
};
|
||||||
|
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!file) {
|
||||||
|
setSubmitState({ status: "error", error: "Please select a file." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
setSubmitState({
|
||||||
|
status: "error",
|
||||||
|
error: "File too large (max 50 MB).",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
if (comment.trim()) formData.append("comment", comment.trim());
|
||||||
|
formData.append("isPrivate", String(isPrivate));
|
||||||
|
res = await authFetch(`${API_URL}/api/dumps`, {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const apiResponse = await res.json();
|
||||||
|
if (apiResponse.success) {
|
||||||
|
const dump = deserializeDump(apiResponse.data as RawDump);
|
||||||
|
setCreatedDump(dump);
|
||||||
|
setPhase("playlist");
|
||||||
|
setPlaylistsLoading(true);
|
||||||
|
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (body.success) {
|
||||||
|
setMemberships(
|
||||||
|
(body.data as RawPlaylistMembership[]).map(
|
||||||
|
deserializePlaylistMembership,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setPlaylistsLoading(false));
|
||||||
|
} else {
|
||||||
|
setSubmitState({
|
||||||
|
status: "error",
|
||||||
|
error: apiResponse.error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setSubmitState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to create dump.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||||
|
if (!createdDump) return;
|
||||||
|
const { playlist, hasDump } = membership;
|
||||||
|
if (hasDump) {
|
||||||
|
await authFetch(
|
||||||
|
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
setMemberships((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await authFetch(
|
||||||
|
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
setMemberships((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const submitting = submitState.status === "submitting";
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="modal-backdrop"
|
||||||
|
ref={backdropRef}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === backdropRef.current) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="modal-card modal-card--wide">
|
||||||
|
<div className="modal-header">
|
||||||
|
<span className="modal-title">
|
||||||
|
{phase === "create" ? "New dump" : "Add to playlist"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-close-btn"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{phase === "create"
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<div className="dump-mode-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "url" ? "active" : ""}
|
||||||
|
onClick={() => {
|
||||||
|
setMode("url");
|
||||||
|
setFile(null);
|
||||||
|
setSubmitState({ status: "idle" });
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
🔗 URL
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "file" ? "active" : ""}
|
||||||
|
onClick={() => {
|
||||||
|
setMode("file");
|
||||||
|
setUrl("");
|
||||||
|
setUrlPreview({ status: "idle" });
|
||||||
|
setSubmitState({ status: "idle" });
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
📎 File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="dump-form">
|
||||||
|
{submitState.status === "error" && (
|
||||||
|
<p className="form-error">{submitState.error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "url"
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="dc-url">URL</label>
|
||||||
|
<input
|
||||||
|
id="dc-url"
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onPaste={(e) => {
|
||||||
|
const pastedFile = e.clipboardData.files[0];
|
||||||
|
if (pastedFile) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMode("file");
|
||||||
|
setUrl("");
|
||||||
|
setUrlPreview({ status: "idle" });
|
||||||
|
setFile(pastedFile);
|
||||||
|
setSubmitState({ status: "idle" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="https://..."
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{urlPreview.status === "loading" && (
|
||||||
|
<p className="preview-loading">Fetching preview…</p>
|
||||||
|
)}
|
||||||
|
{urlPreview.status === "done" &&
|
||||||
|
urlPreview.richContent && (
|
||||||
|
<RichContentCard richContent={urlPreview.richContent} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="dc-file">File</label>
|
||||||
|
<input
|
||||||
|
id="dc-file"
|
||||||
|
type="file"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFile(e.target.files?.[0] ?? null)}
|
||||||
|
disabled={submitting}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{file && <LocalFilePreview file={file} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="dc-comment">
|
||||||
|
Why are you dumping this?
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="dc-comment"
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
placeholder="Tell the community what makes this worth their time..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="toggle-row">
|
||||||
|
<span className="toggle-label">Public</span>
|
||||||
|
<span className="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!isPrivate}
|
||||||
|
onChange={(e) => setIsPrivate(!e.target.checked)}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<span className="toggle-thumb" />
|
||||||
|
</span>
|
||||||
|
{isPrivate && (
|
||||||
|
<span className="toggle-hint">Only visible to you</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<div className="form-actions-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting
|
||||||
|
? (mode === "url" ? "Fetching…" : "Uploading…")
|
||||||
|
: "Dump it"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
{createdDump && (
|
||||||
|
<p className="dump-create-success">
|
||||||
|
Dumped!{" "}
|
||||||
|
<Link to={`/dumps/${createdDump.id}`} onClick={onClose}>
|
||||||
|
View dump →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{playlistsLoading
|
||||||
|
? <p className="page-loading">Loading playlists…</p>
|
||||||
|
: memberships.length === 0 && !showNewPlaylistForm
|
||||||
|
? <p className="empty-state">No playlists yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="playlist-membership-list">
|
||||||
|
{memberships.map((m) => (
|
||||||
|
<li
|
||||||
|
key={m.playlist.id}
|
||||||
|
className={`playlist-membership-row${
|
||||||
|
m.hasDump ? " playlist-membership-row--active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleMembership(m)}
|
||||||
|
>
|
||||||
|
<span className="playlist-membership-check">
|
||||||
|
{m.hasDump ? "✓" : "○"}
|
||||||
|
</span>
|
||||||
|
<span className="playlist-membership-name">
|
||||||
|
{m.playlist.title}
|
||||||
|
</span>
|
||||||
|
{!m.playlist.isPublic && (
|
||||||
|
<span className="playlist-badge playlist-badge--private">
|
||||||
|
private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNewPlaylistForm
|
||||||
|
? (
|
||||||
|
<PlaylistCreateForm
|
||||||
|
dumpId={createdDump?.id}
|
||||||
|
onCreated={(playlist) => {
|
||||||
|
setMemberships((prev) => [
|
||||||
|
{ playlist, hasDump: true },
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
setShowNewPlaylistForm(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowNewPlaylistForm(false)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="modal-new-playlist-toggle"
|
||||||
|
onClick={() => setShowNewPlaylistForm(true)}
|
||||||
|
>
|
||||||
|
+ New playlist
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<div className="form-actions-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
62
src/components/GlobalPlayer.tsx
Normal file
62
src/components/GlobalPlayer.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||||
|
|
||||||
|
export function GlobalPlayer() {
|
||||||
|
const { current, stop } = useContext(PlayerContext);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [reduced, setReduced] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!current) {
|
||||||
|
document.body.classList.remove("has-player");
|
||||||
|
document.body.style.removeProperty("--player-height");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
|
||||||
|
document.body.classList.add("has-player");
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
document.body.classList.remove("has-player");
|
||||||
|
document.body.style.removeProperty("--player-height");
|
||||||
|
};
|
||||||
|
}, [current]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (current) setReduced(false);
|
||||||
|
}, [current?.embedUrl]);
|
||||||
|
|
||||||
|
if (!current) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`global-player global-player--${current.type}${reduced ? " global-player--reduced" : ""}`} ref={ref}>
|
||||||
|
<div className="global-player-header">
|
||||||
|
<span className="global-player-title">{current.title ?? current.embedUrl}</span>
|
||||||
|
<button className="btn btn--ghost" onClick={() => setReduced((r) => !r)}>
|
||||||
|
{reduced ? "▲" : "▼"}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn--ghost" onClick={stop}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="global-player-body">
|
||||||
|
<div className="global-player-iframe-wrap">
|
||||||
|
<iframe
|
||||||
|
src={current.embedUrl}
|
||||||
|
className={`global-player-iframe--${current.type}`}
|
||||||
|
allow="autoplay; encrypted-media"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/ImagePicker.tsx
Normal file
70
src/components/ImagePicker.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
|
||||||
|
interface ImagePickerProps {
|
||||||
|
src: string | null;
|
||||||
|
alt?: string;
|
||||||
|
size?: number;
|
||||||
|
borderRadius?: number;
|
||||||
|
onChange: (file: File) => void;
|
||||||
|
uploading?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImagePicker({
|
||||||
|
src,
|
||||||
|
alt = "",
|
||||||
|
size = 80,
|
||||||
|
borderRadius = 8,
|
||||||
|
onChange,
|
||||||
|
uploading = false,
|
||||||
|
accept = "image/jpeg,image/png,image/gif,image/webp",
|
||||||
|
}: ImagePickerProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const sizeStyle = { width: size, height: size, borderRadius };
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onChange(file);
|
||||||
|
e.target.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="img-picker"
|
||||||
|
style={sizeStyle}
|
||||||
|
onClick={() => !uploading && inputRef.current?.click()}
|
||||||
|
title={src ? "Change image" : "Add image"}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") inputRef.current?.click();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{src
|
||||||
|
? (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="img-picker-img"
|
||||||
|
style={{ borderRadius }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="img-picker-placeholder" style={{ borderRadius }}>
|
||||||
|
<span>+</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="img-picker-overlay" style={{ borderRadius }}>
|
||||||
|
{uploading ? "…" : "✎"}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/Markdown.tsx
Normal file
29
src/components/Markdown.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
|
interface MarkdownProps {
|
||||||
|
children: string;
|
||||||
|
className?: string;
|
||||||
|
inline?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REMARK_PLUGINS = [remarkGfm];
|
||||||
|
|
||||||
|
export function Markdown({ children, className, inline = false }: MarkdownProps) {
|
||||||
|
return (
|
||||||
|
<div className={`md${className ? ` ${className}` : ""}${inline ? " md--inline" : ""}`}>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={REMARK_PLUGINS}
|
||||||
|
components={{
|
||||||
|
a: ({ href, children }) => (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { API_URL } from "../config/api.ts";
|
import type { Playlist } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
|
||||||
import { deserializePlaylist } from "../model.ts";
|
|
||||||
|
|
||||||
interface NewPlaylistFormProps {
|
interface NewPlaylistFormProps {
|
||||||
onCreated: (playlist: Playlist) => void;
|
onCreated: (playlist: Playlist) => void;
|
||||||
@@ -18,15 +16,11 @@ export function NewPlaylistForm(
|
|||||||
toggleClassName = "new-playlist-toggle",
|
toggleClassName = "new-playlist-toggle",
|
||||||
}: NewPlaylistFormProps,
|
}: NewPlaylistFormProps,
|
||||||
) {
|
) {
|
||||||
const { authFetch } = useAuth();
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
const [isPublic, setIsPublic] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const close = () => setOpen(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
document.body.style.overflow = "hidden";
|
document.body.style.overflow = "hidden";
|
||||||
@@ -44,43 +38,6 @@ export function NewPlaylistForm(
|
|||||||
return () => document.removeEventListener("keydown", handler);
|
return () => document.removeEventListener("keydown", handler);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
setOpen(false);
|
|
||||||
setTitle("");
|
|
||||||
setDescription("");
|
|
||||||
setIsPublic(true);
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!title.trim()) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await authFetch(`${API_URL}/api/playlists`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: title.trim(),
|
|
||||||
description: description.trim() || undefined,
|
|
||||||
isPublic,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const body = await res.json();
|
|
||||||
if (!body.success) {
|
|
||||||
setError(body.error?.message ?? "Failed to create playlist");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onCreated(deserializePlaylist(body.data as RawPlaylist));
|
|
||||||
close();
|
|
||||||
} catch {
|
|
||||||
setError("Failed to create playlist");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -112,45 +69,13 @@ export function NewPlaylistForm(
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
<PlaylistCreateForm
|
||||||
<input
|
onCreated={(playlist) => {
|
||||||
type="text"
|
onCreated(playlist);
|
||||||
placeholder="Title"
|
close();
|
||||||
value={title}
|
}}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onCancel={close}
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
<textarea
|
|
||||||
placeholder="Description (optional)"
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="dump-mode-toggle">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={isPublic ? "active" : ""}
|
|
||||||
onClick={() => setIsPublic(true)}
|
|
||||||
>
|
|
||||||
Public
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={!isPublic ? "active" : ""}
|
|
||||||
onClick={() => setIsPublic(false)}
|
|
||||||
>
|
|
||||||
Private
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && <p className="form-error">{error}</p>}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
||||||
<button type="submit" disabled={submitting}>
|
|
||||||
{submitting ? "Creating…" : "Create"}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={close}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
112
src/components/PlaylistCreateForm.tsx
Normal file
112
src/components/PlaylistCreateForm.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||||
|
import { deserializePlaylist } from "../model.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
|
||||||
|
interface PlaylistCreateFormProps {
|
||||||
|
/** If provided, the new playlist will have this dump added to it. */
|
||||||
|
dumpId?: string;
|
||||||
|
onCreated: (playlist: Playlist) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistCreateForm(
|
||||||
|
{ dumpId, onCreated, onCancel }: PlaylistCreateFormProps,
|
||||||
|
) {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${API_URL}/api/playlists`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const body = await res.json();
|
||||||
|
if (!body.success) {
|
||||||
|
setError(body.error?.message ?? "Failed to create playlist");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const playlist = deserializePlaylist(body.data as RawPlaylist);
|
||||||
|
if (dumpId) {
|
||||||
|
await authFetch(
|
||||||
|
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
onCreated(playlist);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to create playlist");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Description (optional)"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="dump-mode-toggle">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={isPublic ? "active" : ""}
|
||||||
|
onClick={() => setIsPublic(true)}
|
||||||
|
>
|
||||||
|
Public
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={!isPublic ? "active" : ""}
|
||||||
|
onClick={() => setIsPublic(false)}
|
||||||
|
>
|
||||||
|
Private
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<div className="form-actions">
|
||||||
|
<div className="form-actions-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? "Creating…" : dumpId ? "Create & Add" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
import type { RichContent } from "../model.ts";
|
import type { RichContent } from "../model.ts";
|
||||||
|
import { PlayerContext } from "../contexts/PlayerContext.ts";
|
||||||
|
|
||||||
interface RichContentCardProps {
|
interface RichContentCardProps {
|
||||||
richContent: RichContent;
|
richContent: RichContent;
|
||||||
@@ -8,6 +10,8 @@ interface RichContentCardProps {
|
|||||||
export default function RichContentCard(
|
export default function RichContentCard(
|
||||||
{ richContent, compact = false }: RichContentCardProps,
|
{ richContent, compact = false }: RichContentCardProps,
|
||||||
) {
|
) {
|
||||||
|
const { play } = useContext(PlayerContext);
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -33,14 +37,22 @@ export default function RichContentCard(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const canPlay = !!richContent.embedUrl;
|
||||||
<a
|
|
||||||
href={richContent.url}
|
const thumbnail = richContent.thumbnailUrl && (
|
||||||
target="_blank"
|
canPlay
|
||||||
rel="noopener noreferrer"
|
? (
|
||||||
className={`rich-content-card rich-content-card--${richContent.type}`}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rich-content-thumbnail-btn"
|
||||||
|
onClick={() =>
|
||||||
|
play({
|
||||||
|
embedUrl: richContent.embedUrl!,
|
||||||
|
title: richContent.title,
|
||||||
|
type: richContent.type,
|
||||||
|
})}
|
||||||
|
aria-label="Play"
|
||||||
>
|
>
|
||||||
{richContent.thumbnailUrl && (
|
|
||||||
<img
|
<img
|
||||||
src={richContent.thumbnailUrl}
|
src={richContent.thumbnailUrl}
|
||||||
alt={richContent.title ?? ""}
|
alt={richContent.title ?? ""}
|
||||||
@@ -49,8 +61,30 @@ export default function RichContentCard(
|
|||||||
(e.target as HTMLImageElement).style.display = "none";
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
<span className="rich-content-play-overlay">▶</span>
|
||||||
<div className="rich-content-body">
|
</button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<img
|
||||||
|
src={richContent.thumbnailUrl}
|
||||||
|
alt={richContent.title ?? ""}
|
||||||
|
className="rich-content-thumbnail"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.target as HTMLImageElement).style.display = "none";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rich-content-card rich-content-card--${richContent.type}`}>
|
||||||
|
{thumbnail}
|
||||||
|
<a
|
||||||
|
href={richContent.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rich-content-body"
|
||||||
|
>
|
||||||
{richContent.siteName && (
|
{richContent.siteName && (
|
||||||
<span className="rich-content-badge">{richContent.siteName}</span>
|
<span className="rich-content-badge">{richContent.siteName}</span>
|
||||||
)}
|
)}
|
||||||
@@ -58,10 +92,12 @@ export default function RichContentCard(
|
|||||||
<p className="rich-content-title">{richContent.title}</p>
|
<p className="rich-content-title">{richContent.title}</p>
|
||||||
)}
|
)}
|
||||||
{richContent.description && (
|
{richContent.description && (
|
||||||
<p className="rich-content-description">{richContent.description}</p>
|
<p className="rich-content-description">
|
||||||
|
{richContent.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<span className="rich-content-url">{richContent.url}</span>
|
<span className="rich-content-url">{richContent.url}</span>
|
||||||
</div>
|
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,27 @@ import { AuthContext, type AuthContextValue } from "./AuthContext.ts";
|
|||||||
|
|
||||||
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
|
import { type AuthResponse, deserializeAuthResponse } from "../model.ts";
|
||||||
|
|
||||||
|
function isTokenExpired(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
|
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => {
|
const [authResponse, setAuthResponse] = useState<AuthResponse | null>(() => {
|
||||||
const stored = localStorage.getItem("authResponse");
|
const stored = localStorage.getItem("authResponse");
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
return stored ? deserializeAuthResponse(JSON.parse(stored)) : null;
|
const parsed = deserializeAuthResponse(JSON.parse(stored));
|
||||||
|
if (isTokenExpired(parsed.token)) {
|
||||||
|
localStorage.removeItem("authResponse");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
});
|
});
|
||||||
|
|
||||||
const value: AuthContextValue = { authResponse, setAuthResponse };
|
const value: AuthContextValue = { authResponse, setAuthResponse };
|
||||||
|
|||||||
19
src/contexts/PlayerContext.ts
Normal file
19
src/contexts/PlayerContext.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export interface PlayerItem {
|
||||||
|
embedUrl: string;
|
||||||
|
title?: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerContextValue {
|
||||||
|
current: PlayerItem | null;
|
||||||
|
play(item: PlayerItem): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlayerContext = createContext<PlayerContextValue>({
|
||||||
|
current: null,
|
||||||
|
play: () => {},
|
||||||
|
stop: () => {},
|
||||||
|
});
|
||||||
14
src/contexts/PlayerProvider.tsx
Normal file
14
src/contexts/PlayerProvider.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { PlayerContext, type PlayerItem } from "./PlayerContext.ts";
|
||||||
|
|
||||||
|
export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [current, setCurrent] = useState<PlayerItem | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlayerContext.Provider
|
||||||
|
value={{ current, play: setCurrent, stop: () => setCurrent(null) }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PlayerContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
import type { Dump, OnlineUser, Playlist } from "../model.ts";
|
import type { Comment, Dump, OnlineUser, Playlist } from "../model.ts";
|
||||||
|
|
||||||
export interface VoteEvent {
|
export interface VoteEvent {
|
||||||
dumpId: string;
|
dumpId: string;
|
||||||
@@ -15,6 +15,13 @@ export interface PlaylistEvent {
|
|||||||
dumpIds?: string[];
|
dumpIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CommentEvent {
|
||||||
|
type: "created" | "deleted";
|
||||||
|
dumpId: string;
|
||||||
|
comment?: Comment;
|
||||||
|
commentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WSContextValue {
|
export interface WSContextValue {
|
||||||
onlineUsers: OnlineUser[];
|
onlineUsers: OnlineUser[];
|
||||||
voteCounts: Record<string, number>;
|
voteCounts: Record<string, number>;
|
||||||
@@ -22,8 +29,10 @@ export interface WSContextValue {
|
|||||||
recentDumps: Dump[];
|
recentDumps: Dump[];
|
||||||
deletedDumpIds: Set<string>;
|
deletedDumpIds: Set<string>;
|
||||||
lastVoteEvent: VoteEvent | null;
|
lastVoteEvent: VoteEvent | null;
|
||||||
|
lastDumpEvent: Dump | null;
|
||||||
lastPlaylistEvent: PlaylistEvent | null;
|
lastPlaylistEvent: PlaylistEvent | null;
|
||||||
deletedPlaylistIds: Set<string>;
|
deletedPlaylistIds: Set<string>;
|
||||||
|
lastCommentEvent: CommentEvent | null;
|
||||||
castVote: (dumpId: string) => void;
|
castVote: (dumpId: string) => void;
|
||||||
removeVote: (dumpId: string) => void;
|
removeVote: (dumpId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -35,8 +44,10 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
recentDumps: [],
|
recentDumps: [],
|
||||||
deletedDumpIds: new Set(),
|
deletedDumpIds: new Set(),
|
||||||
lastVoteEvent: null,
|
lastVoteEvent: null,
|
||||||
|
lastDumpEvent: null,
|
||||||
lastPlaylistEvent: null,
|
lastPlaylistEvent: null,
|
||||||
deletedPlaylistIds: new Set(),
|
deletedPlaylistIds: new Set(),
|
||||||
|
lastCommentEvent: null,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
removeVote: () => {},
|
removeVote: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,25 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
type CommentEvent,
|
||||||
type PlaylistEvent,
|
type PlaylistEvent,
|
||||||
type VoteEvent,
|
type VoteEvent,
|
||||||
WSContext,
|
WSContext,
|
||||||
type WSContextValue,
|
type WSContextValue,
|
||||||
} from "./WSContext.ts";
|
} from "./WSContext.ts";
|
||||||
import { WS_URL } from "../config/api.ts";
|
import { WS_URL } from "../config/api.ts";
|
||||||
import type { Dump, OnlineUser, RawDump, RawPlaylist } from "../model.ts";
|
import type {
|
||||||
import { deserializeDump, deserializePlaylist } from "../model.ts";
|
Dump,
|
||||||
|
OnlineUser,
|
||||||
|
RawComment,
|
||||||
|
RawDump,
|
||||||
|
RawPlaylist,
|
||||||
|
} from "../model.ts";
|
||||||
|
import {
|
||||||
|
deserializeComment,
|
||||||
|
deserializeDump,
|
||||||
|
deserializePlaylist,
|
||||||
|
} from "../model.ts";
|
||||||
|
|
||||||
interface WSProviderProps {
|
interface WSProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -31,12 +42,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
const [recentDumps, setRecentDumps] = useState<Dump[]>([]);
|
||||||
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
const [deletedDumpIds, setDeletedDumpIds] = useState<Set<string>>(new Set());
|
||||||
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
|
const [lastVoteEvent, setLastVoteEvent] = useState<VoteEvent | null>(null);
|
||||||
|
const [lastDumpEvent, setLastDumpEvent] = useState<Dump | null>(null);
|
||||||
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
|
const [lastPlaylistEvent, setLastPlaylistEvent] = useState<
|
||||||
PlaylistEvent | null
|
PlaylistEvent | null
|
||||||
>(null);
|
>(null);
|
||||||
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
|
const [deletedPlaylistIds, setDeletedPlaylistIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Refs to avoid stale closures in event handlers
|
// Refs to avoid stale closures in event handlers
|
||||||
const voteCountsRef = useRef(voteCounts);
|
const voteCountsRef = useRef(voteCounts);
|
||||||
@@ -112,6 +127,12 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "dump_updated": {
|
||||||
|
const dump = deserializeDump(msg.dump as RawDump);
|
||||||
|
setLastDumpEvent(dump);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "dump_deleted": {
|
case "dump_deleted": {
|
||||||
const dumpId = msg.dumpId as string;
|
const dumpId = msg.dumpId as string;
|
||||||
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
setDeletedDumpIds((prev) => new Set([...prev, dumpId]));
|
||||||
@@ -177,6 +198,25 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "comment_created": {
|
||||||
|
const comment = deserializeComment(msg.comment as RawComment);
|
||||||
|
setLastCommentEvent({
|
||||||
|
type: "created",
|
||||||
|
dumpId: comment.dumpId,
|
||||||
|
comment,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "comment_deleted": {
|
||||||
|
const { commentId, dumpId } = msg as {
|
||||||
|
commentId: string;
|
||||||
|
dumpId: string;
|
||||||
|
};
|
||||||
|
setLastCommentEvent({ type: "deleted", dumpId, commentId });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
// On error, revert any pending optimistic update for the affected dump
|
// On error, revert any pending optimistic update for the affected dump
|
||||||
// (the revert timeout will handle it)
|
// (the revert timeout will handle it)
|
||||||
@@ -276,8 +316,10 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
recentDumps,
|
recentDumps,
|
||||||
deletedDumpIds,
|
deletedDumpIds,
|
||||||
lastVoteEvent,
|
lastVoteEvent,
|
||||||
|
lastDumpEvent,
|
||||||
lastPlaylistEvent,
|
lastPlaylistEvent,
|
||||||
deletedPlaylistIds,
|
deletedPlaylistIds,
|
||||||
|
lastCommentEvent,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,15 @@ import { AuthContext } from "../contexts/AuthContext.ts";
|
|||||||
|
|
||||||
import { type AuthResponse } from "../model.ts";
|
import { type AuthResponse } from "../model.ts";
|
||||||
|
|
||||||
|
function isTokenExpired(token: string): boolean {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||||
|
return typeof payload.exp === "number" && payload.exp * 1000 < Date.now();
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const { authResponse, setAuthResponse } = useContext(AuthContext);
|
const { authResponse, setAuthResponse } = useContext(AuthContext);
|
||||||
|
|
||||||
@@ -19,6 +28,13 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
|
const authFetch = async (input: RequestInfo, init: RequestInit = {}) => {
|
||||||
const token = authResponse?.token;
|
const token = authResponse?.token;
|
||||||
|
|
||||||
|
if (token && isTokenExpired(token)) {
|
||||||
|
logout();
|
||||||
|
// Return a synthetic 401 so callers handle it consistently
|
||||||
|
return new Response(null, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
const isFormData = init.body instanceof FormData;
|
const isFormData = init.body instanceof FormData;
|
||||||
|
|
||||||
const res = await fetch(input, {
|
const res = await fetch(input, {
|
||||||
|
|||||||
56
src/hooks/useFeedCache.ts
Normal file
56
src/hooks/useFeedCache.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useNavigationType } from "react-router";
|
||||||
|
|
||||||
|
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
|
interface FeedCacheEntry<T> {
|
||||||
|
items: T[];
|
||||||
|
page: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
scrollY: number;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeedCacheResult<T> {
|
||||||
|
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
||||||
|
saveState: (items: T[], page: number, hasMore: boolean, scrollY: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeedCache<T>(
|
||||||
|
key: string,
|
||||||
|
hydrateItem: (raw: T) => T,
|
||||||
|
): FeedCacheResult<T> {
|
||||||
|
const navType = useNavigationType();
|
||||||
|
|
||||||
|
// Read ONCE on mount. Empty deps is intentional — nav type and cache are only
|
||||||
|
// relevant at the moment the component first mounts.
|
||||||
|
const cached = useMemo<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||||
|
if (navType !== "POP") return null;
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(key);
|
||||||
|
if (!raw) return null;
|
||||||
|
const entry = JSON.parse(raw) as FeedCacheEntry<T>;
|
||||||
|
if (Date.now() - entry.savedAt > TTL) return null;
|
||||||
|
return { ...entry, items: entry.items.map(hydrateItem) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveState = useCallback(
|
||||||
|
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
key,
|
||||||
|
JSON.stringify({ items, page, hasMore, scrollY, savedAt: Date.now() }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// QuotaExceededError or SecurityError — degrade silently
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[key],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { cached, saveState };
|
||||||
|
}
|
||||||
26
src/hooks/useInfiniteScroll.ts
Normal file
26
src/hooks/useInfiniteScroll.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useInfiniteScroll(onLoadMore: () => void, enabled: boolean) {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const handleIntersect = useCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
if (entries[0].isIntersecting && enabled) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onLoadMore, enabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new IntersectionObserver(handleIntersect, {
|
||||||
|
rootMargin: "200px",
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [handleIntersect]);
|
||||||
|
|
||||||
|
return sentinelRef;
|
||||||
|
}
|
||||||
@@ -82,7 +82,8 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
min-height: 100vh;
|
/* min-height: 100vh; */
|
||||||
|
padding-bottom: 2rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
33
src/model.ts
33
src/model.ts
@@ -1,3 +1,9 @@
|
|||||||
|
export interface PaginatedData<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backend
|
* Backend
|
||||||
*/
|
*/
|
||||||
@@ -10,6 +16,7 @@ export interface RichContent {
|
|||||||
description?: string;
|
description?: string;
|
||||||
thumbnailUrl?: string;
|
thumbnailUrl?: string;
|
||||||
videoId?: string;
|
videoId?: string;
|
||||||
|
embedUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Dump {
|
export interface Dump {
|
||||||
@@ -25,6 +32,8 @@ export interface Dump {
|
|||||||
fileMime?: string;
|
fileMime?: string;
|
||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
isPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +104,28 @@ export interface AuthResponse {
|
|||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comments
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string;
|
||||||
|
dumpId: string;
|
||||||
|
userId: string;
|
||||||
|
parentId?: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: Date;
|
||||||
|
deleted: boolean;
|
||||||
|
authorUsername: string;
|
||||||
|
authorAvatarMime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RawComment = WithStringDate<Comment>;
|
||||||
|
|
||||||
|
export function deserializeComment(raw: RawComment): Comment {
|
||||||
|
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playlists
|
* Playlists
|
||||||
*/
|
*/
|
||||||
@@ -200,11 +231,13 @@ export type APIResponse<T> = APISuccess<T> | APIFailure;
|
|||||||
export interface CreateUrlDumpRequest {
|
export interface CreateUrlDumpRequest {
|
||||||
url: string;
|
url: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateDumpRequest {
|
export interface UpdateDumpRequest {
|
||||||
url?: string;
|
url?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
|||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import type { Dump, PublicUser } from "../model.ts";
|
import type { Comment, Dump, PublicUser, RawComment } from "../model.ts";
|
||||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
import {
|
||||||
|
deserializeComment,
|
||||||
|
deserializeDump,
|
||||||
|
deserializePublicUser,
|
||||||
|
} from "../model.ts";
|
||||||
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
@@ -16,6 +20,8 @@ import FilePreview from "../components/FilePreview.tsx";
|
|||||||
import { VoteButton } from "../components/VoteButton.tsx";
|
import { VoteButton } from "../components/VoteButton.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";
|
||||||
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
|
import { CommentThread } from "../components/CommentThread.tsx";
|
||||||
|
|
||||||
type DumpState =
|
type DumpState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -34,8 +40,10 @@ export function Dump() {
|
|||||||
const [op, setOp] = useState<PublicUser | null>(null);
|
const [op, setOp] = useState<PublicUser | null>(null);
|
||||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||||
|
|
||||||
const { user } = useAuth();
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
|
||||||
|
const { user, token } = useAuth();
|
||||||
|
const { voteCounts, myVotes, castVote, removeVote, lastDumpEvent, lastCommentEvent } = useWS();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDump) return;
|
if (!selectedDump) return;
|
||||||
@@ -55,6 +63,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",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
@@ -75,6 +84,52 @@ export function Dump() {
|
|||||||
})();
|
})();
|
||||||
}, [selectedDump, preloaded]);
|
}, [selectedDump, preloaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastDumpEvent) return;
|
||||||
|
setDumpState((prev) => {
|
||||||
|
if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return { status: "loaded", dump: lastDumpEvent };
|
||||||
|
});
|
||||||
|
}, [lastDumpEvent]);
|
||||||
|
|
||||||
|
// Fetch comments when dump loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedDump) return;
|
||||||
|
fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (body.success) {
|
||||||
|
setComments((body.data as RawComment[]).map(deserializeComment));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [selectedDump, token]);
|
||||||
|
|
||||||
|
// React to WS comment events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
||||||
|
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
||||||
|
setComments((prev) => {
|
||||||
|
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) return prev;
|
||||||
|
return [...prev, lastCommentEvent.comment!];
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
lastCommentEvent.type === "deleted" && lastCommentEvent.commentId
|
||||||
|
) {
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === lastCommentEvent.commentId
|
||||||
|
? { ...c, deleted: true, body: "" }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [lastCommentEvent, selectedDump]);
|
||||||
|
|
||||||
if (dumpState.status === "loading") {
|
if (dumpState.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -126,8 +181,16 @@ export function Dump() {
|
|||||||
onCast={castVote}
|
onCast={castVote}
|
||||||
onRemove={removeVote}
|
onRemove={removeVote}
|
||||||
/>
|
/>
|
||||||
<div className="dump-header-info">
|
|
||||||
<h1 className="dump-title">{dump.title}</h1>
|
<h1 className="dump-title">{dump.title}</h1>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-add-playlist"
|
||||||
|
onClick={() => setPlaylistModalOpen(true)}
|
||||||
|
>
|
||||||
|
+ Playlist
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="dump-op">
|
<div className="dump-op">
|
||||||
<Avatar
|
<Avatar
|
||||||
userId={dump.userId}
|
userId={dump.userId}
|
||||||
@@ -149,12 +212,14 @@ export function Dump() {
|
|||||||
>
|
>
|
||||||
{relativeTime(dump.createdAt)}
|
{relativeTime(dump.createdAt)}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
{dump.isPrivate && (
|
||||||
|
<span className="dump-card-private-badge">private</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dump.comment && (
|
{dump.comment && (
|
||||||
<blockquote className="dump-comment">{dump.comment}</blockquote>
|
<Markdown className="dump-comment">{dump.comment}</Markdown>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,16 +245,25 @@ export function Dump() {
|
|||||||
<div className="dump-actions">
|
<div className="dump-actions">
|
||||||
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
|
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
|
||||||
<Link to="/">← Back to all dumps</Link>
|
<Link to="/">← Back to all dumps</Link>
|
||||||
{user && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-add-playlist"
|
|
||||||
onClick={() => setPlaylistModalOpen(true)}
|
|
||||||
>
|
|
||||||
+ Playlist
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<CommentThread
|
||||||
|
dumpId={dump.id}
|
||||||
|
comments={comments}
|
||||||
|
currentUser={user}
|
||||||
|
token={token}
|
||||||
|
onCommentCreated={(c) =>
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.some((x) => x.id === c.id) ? prev : [...prev, c]
|
||||||
|
)}
|
||||||
|
onCommentDeleted={(id) =>
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === id ? { ...c, deleted: true, body: "" } : c
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{playlistModalOpen && (
|
{playlistModalOpen && (
|
||||||
<AddToPlaylistModal
|
<AddToPlaylistModal
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ type DumpEditState =
|
|||||||
export function DumpEdit() {
|
export function DumpEdit() {
|
||||||
const { selectedDump } = useParams();
|
const { selectedDump } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { authFetch } = useRequiredAuth();
|
const { authFetch, token } = useRequiredAuth();
|
||||||
|
|
||||||
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
const [state, setState] = useState<DumpEditState>({ status: "loading" });
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
const [comment, setComment] = useState("");
|
const [comment, setComment] = useState("");
|
||||||
|
const [isPrivate, setIsPrivate] = useState(false);
|
||||||
const [newFile, setNewFile] = useState<File | null>(null);
|
const [newFile, setNewFile] = useState<File | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDump) return;
|
if (!selectedDump) return;
|
||||||
@@ -37,6 +39,7 @@ export function DumpEdit() {
|
|||||||
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",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ export function DumpEdit() {
|
|||||||
const dump: Dump = deserializeDump(apiResponse.data);
|
const dump: Dump = deserializeDump(apiResponse.data);
|
||||||
setUrl(dump.url ?? "");
|
setUrl(dump.url ?? "");
|
||||||
setComment(dump.comment ?? "");
|
setComment(dump.comment ?? "");
|
||||||
|
setIsPrivate(dump.isPrivate);
|
||||||
setState({ status: "loaded", dump });
|
setState({ status: "loaded", dump });
|
||||||
} else {
|
} else {
|
||||||
setState({ status: "error", error: apiResponse.error.message });
|
setState({ status: "error", error: apiResponse.error.message });
|
||||||
@@ -74,8 +78,8 @@ export function DumpEdit() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const body: UpdateDumpRequest = state.dump.kind === "url"
|
const body: UpdateDumpRequest = state.dump.kind === "url"
|
||||||
? { url: url.trim() || undefined, comment: comment.trim() || undefined }
|
? { url: url.trim() || undefined, comment: comment.trim() || undefined, isPrivate }
|
||||||
: { comment: comment.trim() || undefined };
|
: { comment: comment.trim() || undefined, isPrivate };
|
||||||
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -102,6 +106,25 @@ export function DumpEdit() {
|
|||||||
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
|
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRefreshMetadata = async () => {
|
||||||
|
if (state.status !== "loaded" || state.dump.kind !== "url") return;
|
||||||
|
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
const apiResponse = await res.json();
|
||||||
|
if (apiResponse.success) {
|
||||||
|
const updatedDump: Dump = deserializeDump(apiResponse.data);
|
||||||
|
setState({ status: "loaded", dump: updatedDump });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (state.status !== "loaded") return;
|
if (state.status !== "loaded") return;
|
||||||
|
|
||||||
@@ -176,6 +199,16 @@ export function DumpEdit() {
|
|||||||
{dump.url}
|
{dump.url}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{dump.kind === "url" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-secondary dump-edit-refresh"
|
||||||
|
onClick={handleRefreshMetadata}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
{refreshing ? "Refreshing…" : "Refresh metadata"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
@@ -230,6 +263,21 @@ export function DumpEdit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="toggle-row">
|
||||||
|
<span className="toggle-label">Public</span>
|
||||||
|
<span className="toggle-switch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!isPrivate}
|
||||||
|
onChange={(e) => setIsPrivate(!e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="toggle-thumb" />
|
||||||
|
</span>
|
||||||
|
{isPrivate && (
|
||||||
|
<span className="toggle-hint">Only visible to you</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
|
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -7,15 +7,24 @@ import { AppHeader } from "../components/AppHeader.tsx";
|
|||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import { deserializeDump, type Dump } from "../model.ts";
|
import { deserializeDump, type Dump, type PaginatedData, type RawDump } from "../model.ts";
|
||||||
|
|
||||||
|
import { useFeedCache } from "../hooks/useFeedCache.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 { 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 }
|
||||||
| { status: "loaded"; dumps: Dump[] };
|
| { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean };
|
||||||
|
|
||||||
type SortMode = "new" | "hot";
|
type SortMode = "new" | "hot";
|
||||||
|
|
||||||
@@ -29,7 +38,7 @@ export function Index() {
|
|||||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||||
?.deletedDumpId;
|
?.deletedDumpId;
|
||||||
|
|
||||||
const { user } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const {
|
const {
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
voteCounts,
|
voteCounts,
|
||||||
@@ -40,20 +49,31 @@ export function Index() {
|
|||||||
removeVote,
|
removeVote,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
const [dumpsState, setDumpsState] = useState<DumpsState>({
|
const { cached, saveState } = useFeedCache<Dump>(`feed:index:${user?.id ?? "guest"}`, hydrateDump);
|
||||||
status: "loading",
|
|
||||||
});
|
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
|
||||||
|
cached
|
||||||
|
? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false }
|
||||||
|
: { status: "loading" }
|
||||||
|
);
|
||||||
const [sort, setSort] = useState<SortMode>("hot");
|
const [sort, setSort] = useState<SortMode>("hot");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (cached) return; // restored from cache, skip fetch
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/dumps/`);
|
const res = await fetch(`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
setDumpsState({
|
setDumpsState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
dumps: body.data.map(deserializeDump),
|
dumps: items.map(deserializeDump),
|
||||||
|
hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDumpsState({
|
setDumpsState({
|
||||||
@@ -62,11 +82,82 @@ export function Index() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (
|
||||||
|
dumpsState.status !== "loaded" ||
|
||||||
|
!dumpsState.hasMore ||
|
||||||
|
dumpsState.loadingMore
|
||||||
|
) return;
|
||||||
|
const nextPage = dumpsState.page + 1;
|
||||||
|
setDumpsState((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||||
|
);
|
||||||
|
fetch(`${API_URL}/api/dumps/?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setDumpsState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setDumpsState((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [dumpsState, token]);
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll(
|
||||||
|
loadMore,
|
||||||
|
dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save scroll position + loaded state to sessionStorage on scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (dumpsState.status !== "loaded") return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (dumpsState.status === "loaded") {
|
||||||
|
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, window.scrollY);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
|
||||||
|
}, [dumpsState, saveState]);
|
||||||
|
|
||||||
|
// Restore scroll position after cache restoration
|
||||||
|
const scrollRestored = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||||
|
if (dumpsState.status === "loaded") {
|
||||||
|
window.scrollTo(0, cached.scrollY);
|
||||||
|
scrollRestored.current = true;
|
||||||
|
}
|
||||||
|
// cached is stable (read once), safe to omit
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dumpsState.status]);
|
||||||
|
|
||||||
const loading = dumpsState.status === "loading";
|
const loading = dumpsState.status === "loading";
|
||||||
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||||
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
|
const dumps = dumpsState.status === "loaded" ? dumpsState.dumps : [];
|
||||||
|
const loadingMore = dumpsState.status === "loaded" && dumpsState.loadingMore;
|
||||||
|
|
||||||
const restIds = new Set(dumps.map((d) => d.id));
|
const restIds = new Set(dumps.map((d) => d.id));
|
||||||
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);
|
||||||
@@ -141,7 +232,6 @@ export function Index() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && combined.length > 0 && (
|
{!loading && !error && combined.length > 0 && (
|
||||||
<>
|
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{sortedDumps.map((dump) => (
|
{sortedDumps.map((dump) => (
|
||||||
<DumpCard
|
<DumpCard
|
||||||
@@ -152,11 +242,14 @@ export function Index() {
|
|||||||
canVote={!!user}
|
canVote={!!user}
|
||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
|
isOwner={user?.id === dump.userId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||||
import { deserializePlaylist } from "../model.ts";
|
import { deserializePlaylist, type PaginatedData } 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 { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
type State =
|
type State =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
| { status: "loaded"; playlists: Playlist[] };
|
| { status: "loaded"; playlists: Playlist[]; hasMore: boolean; page: number; loadingMore: boolean };
|
||||||
|
|
||||||
export function MyPlaylists() {
|
export function MyPlaylists() {
|
||||||
const { user, authFetch, token } = useAuth();
|
const { user, authFetch, token } = useAuth();
|
||||||
@@ -22,27 +25,62 @@ export function MyPlaylists() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
fetch(`${API_URL}/api/users/${user.username}/playlists`, {
|
fetch(`${API_URL}/api/users/${user.username}/playlists?page=1&limit=${PAGE_SIZE}`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
.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");
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||||
setState({
|
setState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
playlists: (body.data as RawPlaylist[]).map(deserializePlaylist),
|
playlists: items.map(deserializePlaylist),
|
||||||
|
hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setState({
|
setState({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: err instanceof Error
|
error: err instanceof Error ? err.message : "Failed to load playlists",
|
||||||
? err.message
|
|
||||||
: "Failed to load playlists",
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}, [user?.username]);
|
}, [user?.username]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (state.status !== "loaded" || !state.hasMore || state.loadingMore || !user) return;
|
||||||
|
const nextPage = state.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${user.username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: { Authorization: `Bearer ${token}` } },
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
playlists: [...s.playlists, ...items.map(deserializePlaylist)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s)
|
||||||
|
);
|
||||||
|
}, [state, user, token]);
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll(
|
||||||
|
loadMore,
|
||||||
|
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||||
|
);
|
||||||
|
|
||||||
// Real-time WS updates
|
// Real-time WS updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastPlaylistEvent || !user) return;
|
if (!lastPlaylistEvent || !user) return;
|
||||||
@@ -133,6 +171,11 @@ export function MyPlaylists() {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{state.status === "loaded" && state.loadingMore && (
|
||||||
|
<p className="feed-loading-more">Loading more…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{confirmDeleteId && (
|
{confirmDeleteId && (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
message="Delete this playlist? This cannot be undone."
|
message="Delete this playlist? This cannot be undone."
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import { relativeTime } from "../utils/relativeTime.ts";
|
|||||||
import { DumpCard } from "../components/DumpCard.tsx";
|
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";
|
||||||
|
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||||
|
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||||
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -48,12 +51,13 @@ export function PlaylistDetail() {
|
|||||||
const [editIsPublic, setEditIsPublic] = useState(true);
|
const [editIsPublic, setEditIsPublic] = useState(true);
|
||||||
const [editSaving, setEditSaving] = useState(false);
|
const [editSaving, setEditSaving] = useState(false);
|
||||||
const [editError, setEditError] = useState<string | null>(null);
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
|
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
|
||||||
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
|
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
|
||||||
|
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
@@ -205,20 +209,22 @@ export function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder active dumps in state to match the new dumpIds order
|
// Reorder active dumps to match the new server order,
|
||||||
|
// keeping fading dumps at their current visual positions.
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.status !== "loaded") return prev;
|
if (prev.status !== "loaded") return prev;
|
||||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||||
const reordered = ev.dumpIds!
|
const activeQueue = ev.dumpIds!
|
||||||
.filter((id) => dumpMap.has(id))
|
.filter((id) => dumpMap.has(id))
|
||||||
.map((id) => dumpMap.get(id)!);
|
.map((id) => dumpMap.get(id)!);
|
||||||
// Keep fading dumps appended at the end so they stay visible
|
let qi = 0;
|
||||||
const fadingDumps = prev.playlist.dumps.filter(
|
const result = prev.playlist.dumps
|
||||||
(d) => !newIds.has(d.id) && dumpMap.has(d.id),
|
.filter((d) => dumpMap.has(d.id))
|
||||||
);
|
.map((d) => newIds.has(d.id) ? activeQueue[qi++] : d);
|
||||||
|
while (qi < activeQueue.length) result.push(activeQueue[qi++]);
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
playlist: { ...prev.playlist, dumps: result },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,6 +338,13 @@ export function PlaylistDetail() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = descriptionRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}, [editDescription, editOpen]);
|
||||||
|
|
||||||
const openEdit = () => {
|
const openEdit = () => {
|
||||||
if (state.status !== "loaded") return;
|
if (state.status !== "loaded") return;
|
||||||
setEditTitle(state.playlist.title);
|
setEditTitle(state.playlist.title);
|
||||||
@@ -343,16 +356,8 @@ export function PlaylistDetail() {
|
|||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
setImageFile(file);
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setImagePreview(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEditSave = async (e: React.FormEvent) => {
|
const handleEditSave = async () => {
|
||||||
e.preventDefault();
|
|
||||||
if (!playlistId || state.status !== "loaded") return;
|
if (!playlistId || state.status !== "loaded") return;
|
||||||
setEditSaving(true);
|
setEditSaving(true);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
@@ -385,6 +390,12 @@ export function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!playlistId) return;
|
||||||
|
await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE" });
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
if (state.status === "loading") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -423,67 +434,62 @@ export function PlaylistDetail() {
|
|||||||
<PageShell>
|
<PageShell>
|
||||||
<div className="playlist-detail-header">
|
<div className="playlist-detail-header">
|
||||||
<div className="playlist-detail-header-top">
|
<div className="playlist-detail-header-top">
|
||||||
{playlist.imageMime && (
|
{editOpen
|
||||||
|
? (
|
||||||
|
<ImagePicker
|
||||||
|
src={imagePreview ??
|
||||||
|
(playlist.imageMime
|
||||||
|
? `${API_URL}/api/playlists/${playlist.id}/image`
|
||||||
|
: null)}
|
||||||
|
alt="Cover"
|
||||||
|
size={72}
|
||||||
|
onChange={(file) => {
|
||||||
|
setImageFile(file);
|
||||||
|
setImagePreview(URL.createObjectURL(file));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: playlist.imageMime && (
|
||||||
<img
|
<img
|
||||||
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
||||||
alt=""
|
alt=""
|
||||||
className="playlist-detail-img"
|
className="playlist-detail-img"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div>
|
|
||||||
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
|
||||||
{playlist.description && (
|
|
||||||
<p className="playlist-detail-description">
|
|
||||||
{playlist.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="playlist-detail-meta">
|
|
||||||
<span
|
|
||||||
className={`playlist-badge${
|
|
||||||
playlist.isPublic ? "" : " playlist-badge--private"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{playlist.isPublic ? "public" : "private"}
|
|
||||||
</span>
|
|
||||||
<time
|
|
||||||
dateTime={playlist.createdAt.toISOString()}
|
|
||||||
title={playlist.createdAt.toLocaleString()}
|
|
||||||
>
|
|
||||||
{relativeTime(playlist.createdAt)}
|
|
||||||
</time>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isOwner && !editOpen && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="playlist-edit-btn"
|
|
||||||
onClick={openEdit}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOwner && editOpen && (
|
<div className="playlist-detail-content">
|
||||||
<form className="playlist-edit-form" onSubmit={handleEditSave}>
|
{editOpen
|
||||||
<div className="playlist-edit-fields">
|
? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="playlist-edit-input"
|
className="playlist-edit-input"
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
setEditTitle(e.target.value)}
|
autoFocus
|
||||||
placeholder="Title"
|
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
: <h1 className="playlist-detail-title">{playlist.title}</h1>}
|
||||||
|
|
||||||
|
{editOpen
|
||||||
|
? (
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={descriptionRef}
|
||||||
className="playlist-edit-textarea"
|
className="playlist-edit-textarea"
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) =>
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
setEditDescription(e.target.value)}
|
|
||||||
placeholder="Description (optional)"
|
placeholder="Description (optional)"
|
||||||
rows={2}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
: playlist.description && (
|
||||||
|
<Markdown className="playlist-detail-description">
|
||||||
|
{playlist.description}
|
||||||
|
</Markdown>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="playlist-detail-meta">
|
||||||
|
{editOpen
|
||||||
|
? (
|
||||||
<div className="dump-mode-toggle playlist-edit-toggle">
|
<div className="dump-mode-toggle playlist-edit-toggle">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -500,46 +506,38 @@ export function PlaylistDetail() {
|
|||||||
Private
|
Private
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="playlist-edit-image-row">
|
|
||||||
{imagePreview
|
|
||||||
? (
|
|
||||||
<img
|
|
||||||
src={imagePreview}
|
|
||||||
alt="Preview"
|
|
||||||
className="playlist-edit-img-preview"
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
: playlist.imageMime && (
|
: (
|
||||||
<img
|
<>
|
||||||
src={`${API_URL}/api/playlists/${playlist.id}/image`}
|
<span
|
||||||
alt="Current"
|
className={`playlist-badge${
|
||||||
className="playlist-edit-img-preview"
|
playlist.isPublic ? "" : " playlist-badge--private"
|
||||||
/>
|
}`}
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={() => imageInputRef.current?.click()}
|
|
||||||
>
|
>
|
||||||
{playlist.imageMime || imageFile
|
{playlist.isPublic ? "public" : "private"}
|
||||||
? "Change image"
|
</span>
|
||||||
: "Add image"}
|
<time
|
||||||
</button>
|
dateTime={playlist.createdAt.toISOString()}
|
||||||
<input
|
title={playlist.createdAt.toLocaleString()}
|
||||||
ref={imageInputRef}
|
>
|
||||||
type="file"
|
{relativeTime(playlist.createdAt)}
|
||||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
</time>
|
||||||
style={{ display: "none" }}
|
</>
|
||||||
onChange={handleImageChange}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{editError && <p className="form-error">{editError}</p>}
|
{editError && <p className="form-error">{editError}</p>}
|
||||||
<div className="playlist-edit-actions">
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div className="playlist-header-actions">
|
||||||
|
{editOpen
|
||||||
|
? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={editSaving}
|
disabled={editSaving}
|
||||||
|
onClick={handleEditSave}
|
||||||
>
|
>
|
||||||
{editSaving ? "Saving…" : "Save"}
|
{editSaving ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
@@ -550,10 +548,28 @@ export function PlaylistDetail() {
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
<button
|
||||||
</form>
|
type="button"
|
||||||
|
className="btn-danger"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="playlist-edit-btn"
|
||||||
|
onClick={openEdit}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{visibleDumps.length === 0
|
{visibleDumps.length === 0
|
||||||
? <p className="empty-state">No dumps in this playlist yet.</p>
|
? <p className="empty-state">No dumps in this playlist yet.</p>
|
||||||
@@ -598,6 +614,7 @@ export function PlaylistDetail() {
|
|||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
className={cardCls}
|
className={cardCls}
|
||||||
|
isOwner={!!user && user.id === dump.userId}
|
||||||
/>
|
/>
|
||||||
{isOwner && (isActive
|
{isOwner && (isActive
|
||||||
? (
|
? (
|
||||||
@@ -625,6 +642,14 @@ export function PlaylistDetail() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
message="Delete this playlist? This cannot be undone."
|
||||||
|
confirmLabel="Delete playlist"
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Dump, PublicUser } from "../model.ts";
|
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
||||||
import {
|
import {
|
||||||
deserializeAuthResponse,
|
deserializeAuthResponse,
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
deserializePublicUser,
|
deserializePublicUser,
|
||||||
deserializeUser,
|
deserializeUser,
|
||||||
|
type RawDump,
|
||||||
type RawUser,
|
type RawUser,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -18,8 +19,28 @@ import { PageShell } from "../components/PageShell.tsx";
|
|||||||
import { PageError } from "../components/PageError.tsx";
|
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 { useInfiniteScroll } from "../hooks/useInfiniteScroll.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 { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
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> {
|
||||||
|
items: T[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
loadingMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialList<T>(items: T[], hasMore: boolean): PaginatedList<T> {
|
||||||
|
return { items, hasMore, page: 1, loadingMore: false };
|
||||||
|
}
|
||||||
|
|
||||||
type ProfileState =
|
type ProfileState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -27,9 +48,9 @@ type ProfileState =
|
|||||||
| {
|
| {
|
||||||
status: "loaded";
|
status: "loaded";
|
||||||
user: PublicUser;
|
user: PublicUser;
|
||||||
dumps: Dump[];
|
dumps: PaginatedList<Dump>;
|
||||||
votes: Dump[];
|
votes: PaginatedList<Dump>;
|
||||||
playlists: Playlist[];
|
playlists: PaginatedList<Playlist>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UserPublicProfile() {
|
export function UserPublicProfile() {
|
||||||
@@ -46,11 +67,22 @@ export function UserPublicProfile() {
|
|||||||
deletedPlaylistIds,
|
deletedPlaylistIds,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
|
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
|
||||||
|
`feed:profile-dumps:${username ?? ""}`,
|
||||||
|
hydrateDump,
|
||||||
|
);
|
||||||
|
const { cached: cachedVotes, saveState: saveVotes } = useFeedCache<Dump>(
|
||||||
|
`feed:profile-votes:${username ?? ""}`,
|
||||||
|
hydrateDump,
|
||||||
|
);
|
||||||
|
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
|
||||||
|
`feed:profile-playlists:${username ?? ""}`,
|
||||||
|
hydratePlaylist,
|
||||||
|
);
|
||||||
|
|
||||||
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
// Tracks which dumps the profile user currently has voted on (real-time).
|
|
||||||
// For own profile this mirrors myVotes; for others it's maintained separately.
|
|
||||||
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
@@ -61,22 +93,42 @@ export function UserPublicProfile() {
|
|||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
|
|
||||||
|
const allCached = cachedDumps && cachedVotes && cachedPlaylists;
|
||||||
|
|
||||||
|
if (allCached) {
|
||||||
|
// Only fetch the user object (lightweight, always fresh)
|
||||||
|
fetch(`${API_URL}/api/users/${username}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!body.success) throw new Error("User not found");
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
user: deserializePublicUser(body.data),
|
||||||
|
dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, page: cachedDumps.page, loadingMore: false },
|
||||||
|
votes: { items: cachedVotes.items, hasMore: cachedVotes.hasMore, page: cachedVotes.page, loadingMore: false },
|
||||||
|
playlists: { items: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false },
|
||||||
|
});
|
||||||
|
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
const authHeaders = 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}`),
|
||||||
fetch(`${API_URL}/api/users/${username}/dumps`),
|
fetch(`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||||
fetch(`${API_URL}/api/users/${username}/votes`),
|
fetch(`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||||
fetch(`${API_URL}/api/users/${username}/playlists`, {
|
fetch(`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!userRes.ok) {
|
if (!userRes.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
userRes.status === 404
|
userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`,
|
||||||
? "User not found"
|
|
||||||
: `HTTP ${userRes.status}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,20 +140,28 @@ export function UserPublicProfile() {
|
|||||||
playlistsRes.json(),
|
playlistsRes.json(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const votes: Dump[] = votesBody.success
|
const votesData: PaginatedData<RawDump> = votesBody.success
|
||||||
? votesBody.data.map(deserializeDump)
|
? votesBody.data
|
||||||
: [];
|
: { items: [], total: 0, hasMore: false };
|
||||||
const playlists: Playlist[] = playlistsBody.success
|
const playlistsData: PaginatedData<RawPlaylist> = playlistsBody.success
|
||||||
? (playlistsBody.data as RawPlaylist[]).map(deserializePlaylist)
|
? playlistsBody.data
|
||||||
: [];
|
: { items: [], total: 0, hasMore: false };
|
||||||
|
const dumpsData: PaginatedData<RawDump> = dumpsBody.success
|
||||||
|
? dumpsBody.data
|
||||||
|
: { items: [], total: 0, hasMore: false };
|
||||||
|
|
||||||
|
const voteItems = votesData.items.map(deserializeDump);
|
||||||
setState({
|
setState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
user: deserializePublicUser(userBody.data),
|
user: deserializePublicUser(userBody.data),
|
||||||
dumps: dumpsBody.success ? dumpsBody.data.map(deserializeDump) : [],
|
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
|
||||||
votes,
|
votes: initialList(voteItems, votesData.hasMore),
|
||||||
playlists,
|
playlists: initialList(
|
||||||
|
playlistsData.items.map(deserializePlaylist),
|
||||||
|
playlistsData.hasMore,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
setProfileVotedIds(new Set(votes.map((d: Dump) => d.id)));
|
setProfileVotedIds(new Set(voteItems.map((d) => d.id)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({
|
setState({
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -111,17 +171,12 @@ export function UserPublicProfile() {
|
|||||||
})();
|
})();
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
// Stable primitive derived from state — only changes when navigating to a different profile.
|
|
||||||
// Using this instead of `state` directly avoids re-running effects on every vote update.
|
|
||||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||||
|
|
||||||
// Own profile: keep profileVotedIds in sync with myVotes, and add newly-voted
|
// Own profile: keep profileVotedIds in sync with myVotes
|
||||||
// dumps (that belong to this user) to the votes list without a fetch.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profileUserId || me?.id !== profileUserId) return;
|
if (!profileUserId || me?.id !== profileUserId) return;
|
||||||
|
|
||||||
setProfileVotedIds(new Set(myVotes));
|
setProfileVotedIds(new Set(myVotes));
|
||||||
|
|
||||||
if (prevMyVotesRef.current === null) {
|
if (prevMyVotesRef.current === null) {
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
return;
|
return;
|
||||||
@@ -129,17 +184,17 @@ export function UserPublicProfile() {
|
|||||||
const prev = prevMyVotesRef.current;
|
const prev = prevMyVotesRef.current;
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
const voteIds = new Set(s.votes.map((d) => d.id));
|
const voteIds = new Set(s.votes.items.map((d) => d.id));
|
||||||
const toAdd = s.dumps.filter((d) =>
|
const toAdd = s.dumps.items.filter((d) =>
|
||||||
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
|
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
|
||||||
);
|
);
|
||||||
if (toAdd.length === 0) return s;
|
if (toAdd.length === 0) return s;
|
||||||
return { ...s, votes: [...toAdd, ...s.votes] };
|
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
|
||||||
});
|
});
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
}, [myVotes, me, profileUserId]);
|
}, [myVotes, me, profileUserId]);
|
||||||
|
|
||||||
// Real-time upvoted list sync for any profile via WS vote events.
|
// Real-time upvoted list sync via WS vote events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastVoteEvent || !profileUserId) return;
|
if (!lastVoteEvent || !profileUserId) return;
|
||||||
const { dumpId, voterId, action } = lastVoteEvent;
|
const { dumpId, voterId, action } = lastVoteEvent;
|
||||||
@@ -158,17 +213,16 @@ export function UserPublicProfile() {
|
|||||||
if (!isOwnProfile) {
|
if (!isOwnProfile) {
|
||||||
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
||||||
}
|
}
|
||||||
// Always fetch on cast; the setState callback below deduplicates.
|
|
||||||
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 (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
|
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
return { ...s, votes: [dump, ...s.votes] };
|
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -182,34 +236,39 @@ export function UserPublicProfile() {
|
|||||||
const isOwnProfile = me?.id === profileUserId;
|
const isOwnProfile = me?.id === profileUserId;
|
||||||
const ev = lastPlaylistEvent;
|
const ev = lastPlaylistEvent;
|
||||||
|
|
||||||
if (
|
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
|
||||||
ev.type === "created" && ev.playlist &&
|
|
||||||
ev.playlist.userId === profileUserId
|
|
||||||
) {
|
|
||||||
if (ev.playlist.isPublic || isOwnProfile) {
|
if (ev.playlist.isPublic || isOwnProfile) {
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
|
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||||
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
|
return {
|
||||||
|
...s,
|
||||||
|
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (ev.type === "updated" && ev.playlist?.userId === profileUserId) {
|
||||||
ev.type === "updated" && ev.playlist &&
|
|
||||||
ev.playlist.userId === profileUserId
|
|
||||||
) {
|
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
const updated = s.playlists.map((p) =>
|
return {
|
||||||
p.id === ev.playlist!.id ? ev.playlist! : p
|
...s,
|
||||||
).filter((p) => p.isPublic || isOwnProfile);
|
playlists: {
|
||||||
return { ...s, playlists: updated };
|
...s.playlists,
|
||||||
|
items: s.playlists.items
|
||||||
|
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
|
||||||
|
.filter((p) => p.isPublic || isOwnProfile),
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
} else if (ev.type === "deleted") {
|
} else if (ev.type === "deleted") {
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
|
playlists: {
|
||||||
|
...s.playlists,
|
||||||
|
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -219,12 +278,124 @@ export function UserPublicProfile() {
|
|||||||
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
const filtered = s.playlists.filter((p) => !deletedPlaylistIds.has(p.id));
|
const filtered = s.playlists.items.filter((p) => !deletedPlaylistIds.has(p.id));
|
||||||
if (filtered.length === s.playlists.length) return s;
|
if (filtered.length === s.playlists.items.length) return s;
|
||||||
return { ...s, playlists: filtered };
|
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
||||||
});
|
});
|
||||||
}, [deletedPlaylistIds]);
|
}, [deletedPlaylistIds]);
|
||||||
|
|
||||||
|
// Save scroll position + loaded state to sessionStorage on scroll
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
const y = window.scrollY;
|
||||||
|
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
|
||||||
|
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
|
||||||
|
savePlaylists(state.playlists.items, state.playlists.page, state.playlists.hasMore, y);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
|
||||||
|
}, [state, saveDumps, saveVotes, savePlaylists]);
|
||||||
|
|
||||||
|
// Restore scroll position after cache restoration
|
||||||
|
const scrollRestored = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
|
||||||
|
if (state.status === "loaded") {
|
||||||
|
window.scrollTo(0, cachedDumps.scrollY);
|
||||||
|
scrollRestored.current = true;
|
||||||
|
}
|
||||||
|
// cachedDumps is stable (read once), safe to omit
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state.status]);
|
||||||
|
|
||||||
|
const loadMoreDumps = useCallback(() => {
|
||||||
|
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore || !username) return;
|
||||||
|
const nextPage = state.dumps.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: true } } : s);
|
||||||
|
fetch(`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
dumps: {
|
||||||
|
items: [...s.dumps.items, ...items.map(deserializeDump)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: false } } : s));
|
||||||
|
}, [state, username, token]);
|
||||||
|
|
||||||
|
const loadMoreVotes = useCallback(() => {
|
||||||
|
if (state.status !== "loaded" || !state.votes.hasMore || state.votes.loadingMore || !username) return;
|
||||||
|
const nextPage = state.votes.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: true } } : s);
|
||||||
|
fetch(`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
votes: {
|
||||||
|
items: [...s.votes.items, ...items.map(deserializeDump)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: false } } : s));
|
||||||
|
}, [state, username, token]);
|
||||||
|
|
||||||
|
const loadMorePlaylists = useCallback(() => {
|
||||||
|
if (state.status !== "loaded" || !state.playlists.hasMore || state.playlists.loadingMore || !username) return;
|
||||||
|
const nextPage = state.playlists.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: true } } : s);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
playlists: {
|
||||||
|
items: [...s.playlists.items, ...items.map(deserializePlaylist)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: false } } : s));
|
||||||
|
}, [state, username, token]);
|
||||||
|
|
||||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file || state.status !== "loaded") return;
|
if (!file || state.status !== "loaded") return;
|
||||||
@@ -261,10 +432,7 @@ export function UserPublicProfile() {
|
|||||||
|
|
||||||
setState((prev) =>
|
setState((prev) =>
|
||||||
prev.status === "loaded"
|
prev.status === "loaded"
|
||||||
? {
|
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
|
||||||
...prev,
|
|
||||||
user: { ...prev.user, avatarMime: body.data?.avatarMime },
|
|
||||||
}
|
|
||||||
: prev
|
: prev
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -347,57 +515,91 @@ export function UserPublicProfile() {
|
|||||||
|
|
||||||
<div className="profile-columns">
|
<div className="profile-columns">
|
||||||
<DumpList
|
<DumpList
|
||||||
title={`Dumps (${dumps.length})`}
|
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
|
||||||
dumps={dumps}
|
dumps={dumps.items}
|
||||||
voteCounts={voteCounts}
|
voteCounts={voteCounts}
|
||||||
myVotes={myVotes}
|
myVotes={myVotes}
|
||||||
canVote={!!me}
|
canVote={!!me}
|
||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
isOwnProfile={isOwnProfile}
|
isOwnProfile={isOwnProfile}
|
||||||
|
hasMore={dumps.hasMore}
|
||||||
|
loadingMore={dumps.loadingMore}
|
||||||
|
onLoadMore={loadMoreDumps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpvotedDumpList
|
<UpvotedDumpList
|
||||||
title={`Upvoted (${profileVotedIds.size})`}
|
title={`Upvoted (${profileVotedIds.size}${votes.hasMore ? "+" : ""})`}
|
||||||
dumps={votes}
|
dumps={votes.items}
|
||||||
votedIds={profileVotedIds}
|
votedIds={profileVotedIds}
|
||||||
voteCounts={voteCounts}
|
voteCounts={voteCounts}
|
||||||
myVotes={myVotes}
|
myVotes={myVotes}
|
||||||
canVote={!!me}
|
canVote={!!me}
|
||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
|
hasMore={votes.hasMore}
|
||||||
|
loadingMore={votes.loadingMore}
|
||||||
|
onLoadMore={loadMoreVotes}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="profile-section" id="playlists">
|
<section className="profile-section" id="playlists">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<h2 className="profile-section-title">
|
<h2 className="profile-section-title">
|
||||||
Playlists ({playlists.length})
|
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
|
||||||
</h2>
|
</h2>
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<NewPlaylistForm
|
<NewPlaylistForm
|
||||||
onCreated={(p) =>
|
onCreated={(p) =>
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
if (s.playlists.some((pl) => pl.id === p.id)) return s;
|
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
|
||||||
return { ...s, playlists: [p, ...s.playlists] };
|
return {
|
||||||
|
...s,
|
||||||
|
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
|
||||||
|
};
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{playlists.length === 0
|
{playlists.items.length === 0
|
||||||
? <p className="empty-state">No playlists yet.</p>
|
? <p className="empty-state">No playlists yet.</p>
|
||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{playlists.map((p) => <PlaylistCard key={p.id} playlist={p} />)}
|
{playlists.items.map((p) => (
|
||||||
|
<PlaylistCard key={p.id} playlist={p} />
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
<PlaylistSentinel
|
||||||
|
hasMore={playlists.hasMore}
|
||||||
|
loadingMore={playlists.loadingMore}
|
||||||
|
onLoadMore={loadMorePlaylists}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
|
// ── Sentinel wrapper (keeps hooks at top level) ──────────────────────────────
|
||||||
|
|
||||||
|
function PlaylistSentinel(
|
||||||
|
{ hasMore, loadingMore, onLoadMore }: {
|
||||||
|
hasMore: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Plain dump list ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DumpList(
|
function DumpList(
|
||||||
{
|
{
|
||||||
@@ -409,6 +611,9 @@ function DumpList(
|
|||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
isOwnProfile,
|
isOwnProfile,
|
||||||
|
hasMore,
|
||||||
|
loadingMore,
|
||||||
|
onLoadMore,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
dumps: Dump[];
|
dumps: Dump[];
|
||||||
@@ -418,9 +623,13 @@ function DumpList(
|
|||||||
castVote: (id: string) => void;
|
castVote: (id: string) => void;
|
||||||
removeVote: (id: string) => void;
|
removeVote: (id: string) => void;
|
||||||
isOwnProfile?: boolean;
|
isOwnProfile?: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
onLoadMore: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const navigate = useNavigate();
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||||
return (
|
return (
|
||||||
<section className="profile-section">
|
<section className="profile-section">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
@@ -429,12 +638,15 @@ function DumpList(
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="new-playlist-toggle"
|
className="new-playlist-toggle"
|
||||||
onClick={() => navigate("/dumps/new")}
|
onClick={() => setCreateModalOpen(true)}
|
||||||
>
|
>
|
||||||
+ New dump
|
+ New dump
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{createModalOpen && (
|
||||||
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
|
)}
|
||||||
{dumps.length === 0
|
{dumps.length === 0
|
||||||
? <p className="empty-state">Nothing here yet.</p>
|
? <p className="empty-state">Nothing here yet.</p>
|
||||||
: (
|
: (
|
||||||
@@ -448,15 +660,18 @@ function DumpList(
|
|||||||
canVote={canVote}
|
canVote={canVote}
|
||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
|
isOwner={isOwnProfile}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Upvoted list: fades items out when votes are removed ────────────────────
|
// ── Upvoted list: fades items out when votes are removed ─────────────────────
|
||||||
|
|
||||||
function UpvotedDumpList(
|
function UpvotedDumpList(
|
||||||
{
|
{
|
||||||
@@ -468,36 +683,33 @@ function UpvotedDumpList(
|
|||||||
canVote,
|
canVote,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
|
hasMore,
|
||||||
|
loadingMore,
|
||||||
|
onLoadMore,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
dumps: Dump[];
|
dumps: Dump[];
|
||||||
/** Which dumps the profile user currently has voted on. Drives visibility and animation. */
|
|
||||||
votedIds: Set<string>;
|
votedIds: Set<string>;
|
||||||
voteCounts: Record<string, number>;
|
voteCounts: Record<string, number>;
|
||||||
/** Logged-in user's votes — used only for the vote button state on each card. */
|
|
||||||
myVotes: Set<string>;
|
myVotes: Set<string>;
|
||||||
canVote: boolean;
|
canVote: boolean;
|
||||||
castVote: (id: string) => void;
|
castVote: (id: string) => void;
|
||||||
removeVote: (id: string) => void;
|
removeVote: (id: string) => void;
|
||||||
|
hasMore: boolean;
|
||||||
|
loadingMore: boolean;
|
||||||
|
onLoadMore: () => void;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// fading: items whose vote was just removed — dimmed during cooldown, then animating out
|
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
|
||||||
const [fading, setFading] = useState<
|
|
||||||
Record<string, "cooldown" | "dismissing">
|
|
||||||
>({});
|
|
||||||
|
|
||||||
// cancels: id → function that aborts the pending removal sequence
|
|
||||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||||
|
|
||||||
// prevVotedIds: null on first render (skip initial diff), then previous votedIds snapshot
|
|
||||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||||
|
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// First run: capture baseline without triggering any fades
|
|
||||||
if (prevVotedIds.current === null) {
|
if (prevVotedIds.current === null) {
|
||||||
prevVotedIds.current = new Set(votedIds);
|
prevVotedIds.current = new Set(votedIds);
|
||||||
return;
|
return;
|
||||||
@@ -505,7 +717,6 @@ function UpvotedDumpList(
|
|||||||
|
|
||||||
const prev = prevVotedIds.current;
|
const prev = prevVotedIds.current;
|
||||||
|
|
||||||
// Newly unvoted → start fade (idempotent: skip if already running)
|
|
||||||
for (const id of prev) {
|
for (const id of prev) {
|
||||||
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
||||||
let dead = false;
|
let dead = false;
|
||||||
@@ -554,7 +765,6 @@ function UpvotedDumpList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newly re-voted while fading → cancel removal
|
|
||||||
for (const id of votedIds) {
|
for (const id of votedIds) {
|
||||||
if (!prev.has(id) && cancels.current.has(id)) {
|
if (!prev.has(id) && cancels.current.has(id)) {
|
||||||
cancels.current.get(id)!();
|
cancels.current.get(id)!();
|
||||||
@@ -564,7 +774,6 @@ function UpvotedDumpList(
|
|||||||
prevVotedIds.current = new Set(votedIds);
|
prevVotedIds.current = new Set(votedIds);
|
||||||
}, [votedIds]);
|
}, [votedIds]);
|
||||||
|
|
||||||
// Visible = currently voted OR within the fade-out animation window
|
|
||||||
const visibleDumps = dumps.filter((d) =>
|
const visibleDumps = dumps.filter((d) =>
|
||||||
votedIds.has(d.id) || d.id in fading
|
votedIds.has(d.id) || d.id in fading
|
||||||
);
|
);
|
||||||
@@ -600,6 +809,8 @@ function UpvotedDumpList(
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import react from "@vitejs/plugin-react";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
watch: {
|
||||||
|
ignored: ["**/api/**"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
|||||||
Reference in New Issue
Block a user