v3: added content slugs, fixed real-time updates in client, added @mentions across the app, added new file selector and drop zone
This commit is contained in:
18
api/lib/slugify.ts
Normal file
18
api/lib/slugify.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function slugify(title: string): string {
|
||||||
|
const slug = title
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
|
||||||
|
.replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash
|
||||||
|
.replace(/^-+|-+$/g, "") // trim leading/trailing dashes
|
||||||
|
.substring(0, 60);
|
||||||
|
return slug || "untitled";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable slug tied to the record's id — unique by construction. */
|
||||||
|
export function makeSlug(title: string, id: string): string {
|
||||||
|
return `${slugify(title)}-${id.substring(0, 8)}`;
|
||||||
|
}
|
||||||
@@ -8,10 +8,41 @@ import {
|
|||||||
type RichContent,
|
type RichContent,
|
||||||
type User,
|
type User,
|
||||||
} from "./interfaces.ts";
|
} from "./interfaces.ts";
|
||||||
|
import { makeSlug } from "../lib/slugify.ts";
|
||||||
|
|
||||||
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;");
|
||||||
|
|
||||||
|
// Add columns to existing tables if missing (idempotent migrations)
|
||||||
|
for (
|
||||||
|
const [table, col, def] of [
|
||||||
|
["dumps", "updated_at", "TEXT"],
|
||||||
|
["users", "updated_at", "TEXT"],
|
||||||
|
["playlists", "updated_at", "TEXT"],
|
||||||
|
["comments", "updated_at", "TEXT"],
|
||||||
|
["dumps", "slug", "TEXT"],
|
||||||
|
["playlists", "slug", "TEXT"],
|
||||||
|
] as [string, string, string][]
|
||||||
|
) {
|
||||||
|
const cols = db.prepare(`PRAGMA table_info(${table})`).all() as {
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
if (!cols.some((c) => c.name === col)) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def};`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill slugs for any records created before this migration
|
||||||
|
for (const table of ["dumps", "playlists"] as const) {
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, title FROM ${table} WHERE slug IS NULL;`,
|
||||||
|
).all() as { id: string; title: string }[];
|
||||||
|
const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`);
|
||||||
|
for (const row of rows) {
|
||||||
|
update.run(makeSlug(row.title, row.id), row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Purge expired unused invites on startup
|
// Purge expired unused invites on startup
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
||||||
@@ -28,6 +59,8 @@ export interface DumpRow {
|
|||||||
comment: string | null;
|
comment: string | null;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
|
slug: string | null;
|
||||||
url: string | null;
|
url: string | null;
|
||||||
rich_content: string | null;
|
rich_content: string | null;
|
||||||
file_name: string | null;
|
file_name: string | null;
|
||||||
@@ -45,6 +78,7 @@ export interface UserRow {
|
|||||||
password_hash: string;
|
password_hash: string;
|
||||||
is_admin: number;
|
is_admin: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
avatar_mime: string | null;
|
avatar_mime: string | null;
|
||||||
invited_by: string | null;
|
invited_by: string | null;
|
||||||
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
|
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
|
||||||
@@ -100,9 +134,11 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
kind: row.kind as "url" | "file",
|
kind: row.kind as "url" | "file",
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
slug: row.slug ?? undefined,
|
||||||
comment: row.comment ?? undefined,
|
comment: row.comment ?? undefined,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||||
url: row.url ?? undefined,
|
url: row.url ?? undefined,
|
||||||
richContent: row.rich_content
|
richContent: row.rich_content
|
||||||
? (JSON.parse(row.rich_content) as RichContent)
|
? (JSON.parse(row.rich_content) as RichContent)
|
||||||
@@ -121,9 +157,11 @@ export function dumpApiToRow(dump: Dump): DumpRow {
|
|||||||
id: dump.id,
|
id: dump.id,
|
||||||
kind: dump.kind,
|
kind: dump.kind,
|
||||||
title: dump.title,
|
title: dump.title,
|
||||||
|
slug: dump.slug ?? null,
|
||||||
comment: dump.comment ?? null,
|
comment: dump.comment ?? null,
|
||||||
user_id: dump.userId,
|
user_id: dump.userId,
|
||||||
created_at: dump.createdAt.toISOString(),
|
created_at: dump.createdAt.toISOString(),
|
||||||
|
updated_at: dump.updatedAt?.toISOString() ?? null,
|
||||||
url: dump.url ?? null,
|
url: dump.url ?? null,
|
||||||
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
|
rich_content: dump.richContent ? JSON.stringify(dump.richContent) : null,
|
||||||
file_name: dump.fileName ?? null,
|
file_name: dump.fileName ?? null,
|
||||||
@@ -142,6 +180,7 @@ export function userRowToApi(row: UserRow): User {
|
|||||||
passwordHash: row.password_hash,
|
passwordHash: row.password_hash,
|
||||||
isAdmin: Boolean(row.is_admin),
|
isAdmin: Boolean(row.is_admin),
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||||
avatarMime: row.avatar_mime ?? undefined,
|
avatarMime: row.avatar_mime ?? undefined,
|
||||||
invitedByUsername: typeof row.invited_by_username === "string"
|
invitedByUsername: typeof row.invited_by_username === "string"
|
||||||
? row.invited_by_username
|
? row.invited_by_username
|
||||||
@@ -156,6 +195,7 @@ export function userApiToRow(user: User): UserRow {
|
|||||||
password_hash: user.passwordHash,
|
password_hash: user.passwordHash,
|
||||||
is_admin: user.isAdmin ? 1 : 0,
|
is_admin: user.isAdmin ? 1 : 0,
|
||||||
created_at: user.createdAt.toISOString(),
|
created_at: user.createdAt.toISOString(),
|
||||||
|
updated_at: user.updatedAt?.toISOString() ?? null,
|
||||||
avatar_mime: user.avatarMime ?? null,
|
avatar_mime: user.avatarMime ?? null,
|
||||||
invited_by: null,
|
invited_by: null,
|
||||||
invited_by_username: null,
|
invited_by_username: null,
|
||||||
@@ -169,6 +209,7 @@ export interface CommentRow {
|
|||||||
parent_id: string | null;
|
parent_id: string | null;
|
||||||
body: string;
|
body: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
deleted: number;
|
deleted: number;
|
||||||
author_username: string;
|
author_username: string;
|
||||||
author_avatar_mime: string | null;
|
author_avatar_mime: string | null;
|
||||||
@@ -199,6 +240,7 @@ export function commentRowToApi(row: CommentRow): Comment {
|
|||||||
parentId: row.parent_id ?? undefined,
|
parentId: row.parent_id ?? undefined,
|
||||||
body: row.body,
|
body: row.body,
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||||
deleted: Boolean(row.deleted),
|
deleted: Boolean(row.deleted),
|
||||||
authorUsername: row.author_username,
|
authorUsername: row.author_username,
|
||||||
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
authorAvatarMime: row.author_avatar_mime ?? undefined,
|
||||||
@@ -209,9 +251,11 @@ export interface PlaylistRow {
|
|||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
slug: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
is_public: number;
|
is_public: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string | null;
|
||||||
image_mime: string | null;
|
image_mime: string | null;
|
||||||
[key: string]: SQLOutputValue;
|
[key: string]: SQLOutputValue;
|
||||||
}
|
}
|
||||||
@@ -231,9 +275,11 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
|
slug: row.slug ?? undefined,
|
||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
isPublic: Boolean(row.is_public),
|
isPublic: Boolean(row.is_public),
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||||
imageMime: row.image_mime ?? undefined,
|
imageMime: row.image_mime ?? undefined,
|
||||||
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
|
dumpCount: typeof row.dump_count === "number" ? row.dump_count : undefined,
|
||||||
ownerUsername: typeof row.owner_username === "string"
|
ownerUsername: typeof row.owner_username === "string"
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ export interface Dump {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: "url" | "file";
|
kind: "url" | "file";
|
||||||
title: string;
|
title: string;
|
||||||
|
slug?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
url?: string;
|
url?: string;
|
||||||
richContent?: RichContent;
|
richContent?: RichContent;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
@@ -40,6 +42,7 @@ export interface User {
|
|||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
invitedByUsername?: string;
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
@@ -177,6 +180,7 @@ export interface Comment {
|
|||||||
parentId?: string;
|
parentId?: string;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
authorUsername: string;
|
authorUsername: string;
|
||||||
authorAvatarMime?: string;
|
authorAvatarMime?: string;
|
||||||
@@ -197,6 +201,18 @@ export function isCreateCommentRequest(
|
|||||||
o.parentId === null);
|
o.parentId === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCommentRequest {
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUpdateCommentRequest(
|
||||||
|
obj: unknown,
|
||||||
|
): obj is UpdateCommentRequest {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Playlists
|
* Playlists
|
||||||
*/
|
*/
|
||||||
@@ -205,9 +221,11 @@ export interface Playlist {
|
|||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
slug?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
imageMime?: string;
|
imageMime?: string;
|
||||||
dumpCount?: number;
|
dumpCount?: number;
|
||||||
ownerUsername?: string;
|
ownerUsername?: string;
|
||||||
@@ -384,7 +402,8 @@ export type NotificationType =
|
|||||||
| "user_followed"
|
| "user_followed"
|
||||||
| "user_dump_posted"
|
| "user_dump_posted"
|
||||||
| "playlist_dump_added"
|
| "playlist_dump_added"
|
||||||
| "dump_upvoted";
|
| "dump_upvoted"
|
||||||
|
| "user_mentioned";
|
||||||
|
|
||||||
export interface PlaylistFollowedData {
|
export interface PlaylistFollowedData {
|
||||||
followerId: string;
|
followerId: string;
|
||||||
@@ -419,12 +438,22 @@ export interface DumpUpvotedData {
|
|||||||
dumpTitle: string;
|
dumpTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserMentionedData {
|
||||||
|
mentionerId: string;
|
||||||
|
mentionerUsername: string;
|
||||||
|
contextType: "comment" | "dump" | "playlist";
|
||||||
|
contextId: string;
|
||||||
|
contextTitle: string;
|
||||||
|
dumpId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type NotificationData =
|
export type NotificationData =
|
||||||
| PlaylistFollowedData
|
| PlaylistFollowedData
|
||||||
| UserFollowedData
|
| UserFollowedData
|
||||||
| UserDumpPostedData
|
| UserDumpPostedData
|
||||||
| PlaylistDumpAddedData
|
| PlaylistDumpAddedData
|
||||||
| DumpUpvotedData;
|
| DumpUpvotedData
|
||||||
|
| UserMentionedData;
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type APIResponse,
|
type APIResponse,
|
||||||
type Comment,
|
type Comment,
|
||||||
isCreateCommentRequest,
|
isCreateCommentRequest,
|
||||||
|
isUpdateCommentRequest,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
import { authMiddleware } from "../middleware/auth.ts";
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
import { verifyJWT } from "../lib/jwt.ts";
|
import { verifyJWT } from "../lib/jwt.ts";
|
||||||
@@ -12,11 +13,13 @@ import {
|
|||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
getComments,
|
getComments,
|
||||||
|
updateComment,
|
||||||
} from "../services/comment-service.ts";
|
} from "../services/comment-service.ts";
|
||||||
import { getDump } from "../services/dump-service.ts";
|
import { getDump } from "../services/dump-service.ts";
|
||||||
import {
|
import {
|
||||||
broadcastCommentCreated,
|
broadcastCommentCreated,
|
||||||
broadcastCommentDeleted,
|
broadcastCommentDeleted,
|
||||||
|
broadcastCommentUpdated,
|
||||||
} from "../services/ws-service.ts";
|
} from "../services/ws-service.ts";
|
||||||
|
|
||||||
const router = new Router({ prefix: "/api" });
|
const router = new Router({ prefix: "/api" });
|
||||||
@@ -62,6 +65,29 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
|||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PATCH /api/comments/:commentId — auth required
|
||||||
|
router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
|
||||||
|
const userId = ctx.state.user.userId as string;
|
||||||
|
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
if (!isUpdateCommentRequest(body)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Invalid comment data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { comment, isPrivate } = updateComment(
|
||||||
|
ctx.params.commentId,
|
||||||
|
body.body,
|
||||||
|
userId,
|
||||||
|
isAdmin,
|
||||||
|
);
|
||||||
|
if (!isPrivate) broadcastCommentUpdated(comment);
|
||||||
|
const responseBody: APIResponse<Comment> = { success: true, data: comment };
|
||||||
|
ctx.response.body = responseBody;
|
||||||
|
});
|
||||||
|
|
||||||
// DELETE /api/comments/:commentId — auth required
|
// DELETE /api/comments/:commentId — auth required
|
||||||
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
router.delete("/comments/:commentId", authMiddleware, (ctx) => {
|
||||||
const userId = ctx.state.user.userId as string;
|
const userId = ctx.state.user.userId as string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
createUser,
|
createUser,
|
||||||
getUserById,
|
getUserById,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
|
searchUsers,
|
||||||
} from "../services/user-service.ts";
|
} from "../services/user-service.ts";
|
||||||
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
||||||
import {
|
import {
|
||||||
@@ -131,6 +132,13 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// User search for @mention autocomplete
|
||||||
|
router.get("/search", (ctx) => {
|
||||||
|
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||||
|
const results = searchUsers(q, 8);
|
||||||
|
ctx.response.body = { success: true, data: results };
|
||||||
|
});
|
||||||
|
|
||||||
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
|
// Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
|
||||||
router.get("/by-id/:userId", (ctx) => {
|
router.get("/by-id/:userId", (ctx) => {
|
||||||
const user = getUserById(ctx.params.userId);
|
const user = getUserById(ctx.params.userId);
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
db,
|
db,
|
||||||
isCommentRow,
|
isCommentRow,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
|
import { notifyMentions } from "./notification-service.ts";
|
||||||
|
|
||||||
const SELECT_COLS =
|
const SELECT_COLS =
|
||||||
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.deleted,
|
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted,
|
||||||
u.username as author_username, u.avatar_mime as author_avatar_mime`;
|
u.username as author_username, u.avatar_mime as author_avatar_mime`;
|
||||||
|
|
||||||
function fetchComment(commentId: string): Comment {
|
function fetchComment(commentId: string): Comment {
|
||||||
@@ -59,7 +60,63 @@ export function createComment(
|
|||||||
body.trim(),
|
body.trim(),
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
);
|
);
|
||||||
return fetchComment(id);
|
const comment = fetchComment(id);
|
||||||
|
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||||
|
dumpId,
|
||||||
|
) as { title: string } | undefined;
|
||||||
|
notifyMentions(userId, body, "comment", id, dumpRow?.title ?? "", dumpId);
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateComment(
|
||||||
|
commentId: string,
|
||||||
|
body: string,
|
||||||
|
requestingUserId: string,
|
||||||
|
isAdmin: boolean,
|
||||||
|
): { comment: Comment; 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 existing = fetchComment(commentId);
|
||||||
|
if (existing.deleted) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
400,
|
||||||
|
"Cannot edit a deleted comment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (existing.userId !== requestingUserId && !isAdmin) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.UNAUTHORIZED,
|
||||||
|
401,
|
||||||
|
"Not authorized to edit this comment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare(`UPDATE comments SET body = ?, updated_at = ? WHERE id = ?;`).run(
|
||||||
|
body.trim(),
|
||||||
|
now,
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||||
|
row.dump_id,
|
||||||
|
) as { title: string } | undefined;
|
||||||
|
notifyMentions(
|
||||||
|
requestingUserId,
|
||||||
|
body,
|
||||||
|
"comment",
|
||||||
|
commentId,
|
||||||
|
dumpRow?.title ?? "",
|
||||||
|
row.dump_id,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
comment: fetchComment(commentId),
|
||||||
|
dumpId: row.dump_id,
|
||||||
|
isPrivate: Boolean(row.is_private),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteComment(
|
export function deleteComment(
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
broadcastDumpUpdated,
|
broadcastDumpUpdated,
|
||||||
broadcastNewDump,
|
broadcastNewDump,
|
||||||
} from "./ws-service.ts";
|
} from "./ws-service.ts";
|
||||||
import { notifyUserFollowersNewDump } from "./notification-service.ts";
|
import {
|
||||||
|
notifyMentions,
|
||||||
|
notifyUserFollowersNewDump,
|
||||||
|
} from "./notification-service.ts";
|
||||||
|
import { makeSlug, UUID_RE } from "../lib/slugify.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
|
||||||
@@ -45,13 +49,13 @@ function titleFromUrl(url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BASE_COLS =
|
const BASE_COLS =
|
||||||
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
||||||
|
|
||||||
const SELECT_COLS = `${BASE_COLS},
|
const SELECT_COLS = `${BASE_COLS},
|
||||||
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
|
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
|
||||||
|
|
||||||
const SELECT_COLS_ALIASED =
|
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," +
|
"d.id, d.kind, d.title, d.slug, 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";
|
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
|
||||||
|
|
||||||
export async function createUrlDump(
|
export async function createUrlDump(
|
||||||
@@ -67,14 +71,16 @@ export async function createUrlDump(
|
|||||||
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;
|
const isPrivate = request.isPrivate ?? false;
|
||||||
|
const slug = makeSlug(title, dumpId);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO dumps (id, kind, title, comment, user_id, created_at, url, rich_content, is_private)
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, rich_content, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
dumpId,
|
dumpId,
|
||||||
"url",
|
"url",
|
||||||
title,
|
title,
|
||||||
|
slug,
|
||||||
request.comment ?? null,
|
request.comment ?? null,
|
||||||
userId,
|
userId,
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
@@ -87,6 +93,7 @@ export async function createUrlDump(
|
|||||||
id: dumpId,
|
id: dumpId,
|
||||||
kind: "url",
|
kind: "url",
|
||||||
title,
|
title,
|
||||||
|
slug,
|
||||||
comment: request.comment,
|
comment: request.comment,
|
||||||
userId,
|
userId,
|
||||||
createdAt,
|
createdAt,
|
||||||
@@ -100,6 +107,7 @@ export async function createUrlDump(
|
|||||||
broadcastNewDump(dump);
|
broadcastNewDump(dump);
|
||||||
notifyUserFollowersNewDump(userId, dumpId, title);
|
notifyUserFollowersNewDump(userId, dumpId, title);
|
||||||
}
|
}
|
||||||
|
if (request.comment) notifyMentions(userId, request.comment, "dump", dumpId, title);
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +134,7 @@ export async function createFileDump(
|
|||||||
|
|
||||||
const dumpId = crypto.randomUUID();
|
const dumpId = crypto.randomUUID();
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
|
const slug = makeSlug(file.name, dumpId);
|
||||||
|
|
||||||
await Deno.mkdir(UPLOADS_DIR, { recursive: true });
|
await Deno.mkdir(UPLOADS_DIR, { recursive: true });
|
||||||
const data = new Uint8Array(await file.arrayBuffer());
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
@@ -134,12 +143,13 @@ 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, is_private)
|
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
dumpId,
|
dumpId,
|
||||||
"file",
|
"file",
|
||||||
file.name,
|
file.name,
|
||||||
|
slug,
|
||||||
comment ?? null,
|
comment ?? null,
|
||||||
userId,
|
userId,
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
@@ -158,6 +168,7 @@ export async function createFileDump(
|
|||||||
id: dumpId,
|
id: dumpId,
|
||||||
kind: "file",
|
kind: "file",
|
||||||
title: file.name,
|
title: file.name,
|
||||||
|
slug,
|
||||||
comment,
|
comment,
|
||||||
userId,
|
userId,
|
||||||
createdAt,
|
createdAt,
|
||||||
@@ -172,14 +183,17 @@ export async function createFileDump(
|
|||||||
broadcastNewDump(dump);
|
broadcastNewDump(dump);
|
||||||
notifyUserFollowersNewDump(userId, dumpId, file.name);
|
notifyUserFollowersNewDump(userId, dumpId, file.name);
|
||||||
}
|
}
|
||||||
|
if (comment) notifyMentions(userId, comment, "dump", dumpId, file.name);
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal fetch — no privacy check. Use only when ownership is already enforced.
|
// Internal fetch — no privacy check. Use only when ownership is already enforced.
|
||||||
function fetchDump(dumpId: string): Dump {
|
function fetchDump(idOrSlug: string): Dump {
|
||||||
const row = db.prepare(
|
const row = UUID_RE.test(idOrSlug)
|
||||||
`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`,
|
? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug)
|
||||||
).get(dumpId);
|
: db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE slug = ?;`).get(
|
||||||
|
idOrSlug,
|
||||||
|
);
|
||||||
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");
|
||||||
}
|
}
|
||||||
@@ -234,6 +248,8 @@ export async function updateDump(
|
|||||||
): Promise<Dump> {
|
): Promise<Dump> {
|
||||||
const dump = fetchDump(dumpId);
|
const dump = fetchDump(dumpId);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
// File dumps: only comment and isPrivate are editable
|
// File dumps: only comment and isPrivate are editable
|
||||||
if (dump.kind === "file") {
|
if (dump.kind === "file") {
|
||||||
const updatedDump: Dump = {
|
const updatedDump: Dump = {
|
||||||
@@ -244,10 +260,27 @@ export async function updateDump(
|
|||||||
isPrivate: "isPrivate" in request
|
isPrivate: "isPrivate" in request
|
||||||
? (request.isPrivate ?? false)
|
? (request.isPrivate ?? false)
|
||||||
: dump.isPrivate,
|
: dump.isPrivate,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`)
|
db.prepare(
|
||||||
.run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId);
|
`UPDATE dumps SET comment = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
||||||
if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
).run(
|
||||||
|
updatedDump.comment ?? null,
|
||||||
|
updatedDump.isPrivate ? 1 : 0,
|
||||||
|
now.toISOString(),
|
||||||
|
dumpId,
|
||||||
|
);
|
||||||
|
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||||
|
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||||
|
if (updatedDump.comment) {
|
||||||
|
notifyMentions(
|
||||||
|
dump.userId,
|
||||||
|
updatedDump.comment,
|
||||||
|
"dump",
|
||||||
|
dumpId,
|
||||||
|
updatedDump.title,
|
||||||
|
);
|
||||||
|
}
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +298,11 @@ export async function updateDump(
|
|||||||
title = richContent?.title ?? titleFromUrl(newUrl);
|
title = richContent?.title ?? titleFromUrl(newUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newSlug = makeSlug(title, dumpId);
|
||||||
const updatedDump: Dump = {
|
const updatedDump: Dump = {
|
||||||
...dump,
|
...dump,
|
||||||
title,
|
title,
|
||||||
|
slug: newSlug,
|
||||||
comment: "comment" in request
|
comment: "comment" in request
|
||||||
? (request.comment ?? undefined)
|
? (request.comment ?? undefined)
|
||||||
: dump.comment,
|
: dump.comment,
|
||||||
@@ -276,17 +311,20 @@ export async function updateDump(
|
|||||||
isPrivate: "isPrivate" in request
|
isPrivate: "isPrivate" in request
|
||||||
? (request.isPrivate ?? false)
|
? (request.isPrivate ?? false)
|
||||||
: dump.isPrivate,
|
: dump.isPrivate,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
const row = dumpApiToRow(updatedDump);
|
const row = dumpApiToRow(updatedDump);
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, slug = ?, comment = ?, url = ?, rich_content = ?, is_private = ?, updated_at = ? WHERE id = ?;`,
|
||||||
).run(
|
).run(
|
||||||
row.title,
|
row.title,
|
||||||
|
row.slug,
|
||||||
row.comment,
|
row.comment,
|
||||||
row.url,
|
row.url,
|
||||||
row.rich_content,
|
row.rich_content,
|
||||||
row.is_private,
|
row.is_private,
|
||||||
|
now.toISOString(),
|
||||||
row.id,
|
row.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -294,7 +332,11 @@ export async function updateDump(
|
|||||||
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);
|
if (updatedDump.isPrivate && !dump.isPrivate) broadcastDumpDeleted(dumpId);
|
||||||
|
else if (!updatedDump.isPrivate) broadcastDumpUpdated(updatedDump);
|
||||||
|
if (updatedDump.comment) {
|
||||||
|
notifyMentions(dump.userId, updatedDump.comment, "dump", dumpId, updatedDump.title);
|
||||||
|
}
|
||||||
return updatedDump;
|
return updatedDump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,17 +368,31 @@ export async function replaceFileDump(
|
|||||||
const data = new Uint8Array(await file.arrayBuffer());
|
const data = new Uint8Array(await file.arrayBuffer());
|
||||||
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const newSlug = makeSlug(file.name, dumpId);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE dumps SET title = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, slug = ?, file_name = ?, file_mime = ?, file_size = ?, comment = ?, updated_at = ? WHERE id = ?;`,
|
||||||
).run(file.name, file.name, file.type, file.size, comment ?? null, dumpId);
|
).run(
|
||||||
|
file.name,
|
||||||
|
newSlug,
|
||||||
|
file.name,
|
||||||
|
file.type,
|
||||||
|
file.size,
|
||||||
|
comment ?? null,
|
||||||
|
now.toISOString(),
|
||||||
|
dumpId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (comment) notifyMentions(dump.userId, comment, "dump", dumpId, file.name);
|
||||||
return {
|
return {
|
||||||
...dump,
|
...dump,
|
||||||
title: file.name,
|
title: file.name,
|
||||||
|
slug: newSlug,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileMime: file.type,
|
fileMime: file.type,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
comment,
|
comment,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,19 +432,20 @@ export function getVotedDumpsByUser(
|
|||||||
let totalRow: { count: number } | undefined;
|
let totalRow: { count: number } | undefined;
|
||||||
let rawRows: unknown[];
|
let rawRows: unknown[];
|
||||||
|
|
||||||
if (requestingUserId) {
|
if (requestingUserId === userId) {
|
||||||
|
// Own profile: include private dumps the user themselves voted on and owns.
|
||||||
rawRows = db.prepare(
|
rawRows = db.prepare(
|
||||||
`SELECT ${dumpCols}
|
`SELECT ${dumpCols}
|
||||||
FROM dumps d
|
FROM dumps d
|
||||||
INNER JOIN votes v ON d.id = v.dump_id
|
INNER JOIN votes v ON d.id = v.dump_id
|
||||||
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
|
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?)
|
||||||
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
ORDER BY v.created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
).all(userId, requestingUserId, limit, offset);
|
).all(userId, userId, limit, offset);
|
||||||
totalRow = db.prepare(
|
totalRow = db.prepare(
|
||||||
`SELECT COUNT(*) as count FROM dumps d
|
`SELECT COUNT(*) as count 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 = ? AND (d.is_private = 0 OR d.user_id = ?);`,
|
WHERE v.user_id = ? AND (d.is_private = 0 OR d.user_id = ?);`,
|
||||||
).get(userId, requestingUserId) as { count: number } | undefined;
|
).get(userId, userId) as { count: number } | undefined;
|
||||||
} else {
|
} else {
|
||||||
rawRows = db.prepare(
|
rawRows = db.prepare(
|
||||||
`SELECT ${dumpCols}
|
`SELECT ${dumpCols}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
|||||||
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
|
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
|
||||||
import { sendToUser } from "./ws-service.ts";
|
import { sendToUser } from "./ws-service.ts";
|
||||||
|
|
||||||
|
// Regex: matches @username not already inside a markdown link ([...] or (...)
|
||||||
|
const MENTION_RE = /(?<![[(\\w])@([\w]+)/g;
|
||||||
|
|
||||||
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
|
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
|
||||||
@@ -191,6 +194,45 @@ export function notifyDumpOwnerUpvote(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notifyMentions(
|
||||||
|
mentionerUserId: string,
|
||||||
|
body: string,
|
||||||
|
contextType: "comment" | "dump" | "playlist",
|
||||||
|
contextId: string,
|
||||||
|
contextTitle: string,
|
||||||
|
dumpId?: string,
|
||||||
|
): void {
|
||||||
|
const mentionerRow = db.prepare(
|
||||||
|
`SELECT username FROM users WHERE id = ?;`,
|
||||||
|
).get(mentionerUserId) as { username: string } | undefined;
|
||||||
|
if (!mentionerRow) return;
|
||||||
|
|
||||||
|
const usernames = [...new Set(
|
||||||
|
[...body.matchAll(MENTION_RE)].map((m) => m[1].toLowerCase()),
|
||||||
|
)];
|
||||||
|
|
||||||
|
for (const username of usernames) {
|
||||||
|
const mentionedRow = db.prepare(
|
||||||
|
`SELECT id FROM users WHERE lower(username) = ?;`,
|
||||||
|
).get(username) as { id: string } | undefined;
|
||||||
|
if (!mentionedRow || mentionedRow.id === mentionerUserId) continue;
|
||||||
|
|
||||||
|
createNotification(
|
||||||
|
mentionedRow.id,
|
||||||
|
"user_mentioned",
|
||||||
|
{
|
||||||
|
mentionerId: mentionerUserId,
|
||||||
|
mentionerUsername: mentionerRow.username,
|
||||||
|
contextType,
|
||||||
|
contextId,
|
||||||
|
contextTitle,
|
||||||
|
dumpId,
|
||||||
|
},
|
||||||
|
`mention:${contextType}:${contextId}:${mentionedRow.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function notifyPlaylistFollowersNewDump(
|
export function notifyPlaylistFollowersNewDump(
|
||||||
playlistId: string,
|
playlistId: string,
|
||||||
playlistTitle: string,
|
playlistTitle: string,
|
||||||
|
|||||||
@@ -22,19 +22,23 @@ import {
|
|||||||
broadcastPlaylistDumpsUpdated,
|
broadcastPlaylistDumpsUpdated,
|
||||||
broadcastPlaylistUpdated,
|
broadcastPlaylistUpdated,
|
||||||
} from "./ws-service.ts";
|
} from "./ws-service.ts";
|
||||||
import { notifyPlaylistFollowersNewDump } from "./notification-service.ts";
|
import {
|
||||||
|
notifyMentions,
|
||||||
|
notifyPlaylistFollowersNewDump,
|
||||||
|
} from "./notification-service.ts";
|
||||||
|
import { makeSlug, UUID_RE } from "../lib/slugify.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, is_private";
|
"id, kind, title, slug, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
||||||
|
|
||||||
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
|
const PLAYLIST_SELECT = `p.*, u.username as owner_username,
|
||||||
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
||||||
FROM playlists p LEFT JOIN users u ON u.id = p.user_id`;
|
FROM playlists p LEFT JOIN users u ON u.id = p.user_id`;
|
||||||
|
|
||||||
function getPlaylistById(playlistId: string): Playlist {
|
function getPlaylistById(idOrSlug: string): Playlist {
|
||||||
const row = db.prepare(
|
const row = UUID_RE.test(idOrSlug)
|
||||||
`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`,
|
? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug)
|
||||||
).get(playlistId);
|
: db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.slug = ?;`).get(idOrSlug);
|
||||||
if (!row || !isPlaylistRow(row)) {
|
if (!row || !isPlaylistRow(row)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
|
||||||
}
|
}
|
||||||
@@ -47,13 +51,15 @@ export function createPlaylist(
|
|||||||
): Playlist {
|
): Playlist {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
|
const slug = makeSlug(req.title, id);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO playlists (id, user_id, title, description, is_public, created_at)
|
`INSERT INTO playlists (id, user_id, title, slug, description, is_public, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
req.title,
|
req.title,
|
||||||
|
slug,
|
||||||
req.description ?? null,
|
req.description ?? null,
|
||||||
req.isPublic ? 1 : 0,
|
req.isPublic ? 1 : 0,
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
@@ -62,10 +68,12 @@ export function createPlaylist(
|
|||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
title: req.title,
|
title: req.title,
|
||||||
|
slug,
|
||||||
description: req.description,
|
description: req.description,
|
||||||
isPublic: req.isPublic,
|
isPublic: req.isPublic,
|
||||||
createdAt,
|
createdAt,
|
||||||
};
|
};
|
||||||
|
if (req.description) notifyMentions(userId, req.description, "playlist", id, req.title);
|
||||||
broadcastPlaylistCreated(playlist);
|
broadcastPlaylistCreated(playlist);
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
@@ -91,7 +99,7 @@ export function getPlaylist(
|
|||||||
WHERE pd.playlist_id = ?
|
WHERE pd.playlist_id = ?
|
||||||
AND (d.is_private = 0 OR d.user_id = ?)
|
AND (d.is_private = 0 OR d.user_id = ?)
|
||||||
ORDER BY pd.position ASC;`,
|
ORDER BY pd.position ASC;`,
|
||||||
).all(playlistId, requestingUserId ?? "");
|
).all(playlist.id, 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
|
// Owners always see their own private dumps; strip them for non-owners regardless
|
||||||
@@ -145,16 +153,21 @@ export function updatePlaylist(
|
|||||||
? req.isPublic
|
? req.isPublic
|
||||||
: playlist.isPublic;
|
: playlist.isPublic;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const newSlug = makeSlug(newTitle, playlist.id);
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE playlists SET title = ?, description = ?, is_public = ? WHERE id = ?;`,
|
`UPDATE playlists SET title = ?, slug = ?, description = ?, is_public = ?, updated_at = ? WHERE id = ?;`,
|
||||||
).run(newTitle, newDescription, newIsPublic ? 1 : 0, playlistId);
|
).run(newTitle, newSlug, newDescription, newIsPublic ? 1 : 0, now.toISOString(), playlist.id);
|
||||||
|
|
||||||
const updated: Playlist = {
|
const updated: Playlist = {
|
||||||
...playlist,
|
...playlist,
|
||||||
title: newTitle,
|
title: newTitle,
|
||||||
|
slug: newSlug,
|
||||||
description: newDescription ?? undefined,
|
description: newDescription ?? undefined,
|
||||||
isPublic: newIsPublic,
|
isPublic: newIsPublic,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
if (newDescription) notifyMentions(requestingUserId, newDescription, "playlist", playlist.id, newTitle);
|
||||||
broadcastPlaylistUpdated(updated);
|
broadcastPlaylistUpdated(updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -169,8 +182,8 @@ export function deletePlaylist(
|
|||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlistId);
|
db.prepare(`DELETE FROM playlists WHERE id = ?;`).run(playlist.id);
|
||||||
broadcastPlaylistDeleted(playlistId, playlist.userId, playlist.isPublic);
|
broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setPlaylistImage(
|
export function setPlaylistImage(
|
||||||
@@ -184,9 +197,9 @@ export function setPlaylistImage(
|
|||||||
}
|
}
|
||||||
db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run(
|
db.prepare(`UPDATE playlists SET image_mime = ? WHERE id = ?;`).run(
|
||||||
imageMime,
|
imageMime,
|
||||||
playlistId,
|
playlist.id,
|
||||||
);
|
);
|
||||||
const updated = getPlaylistById(playlistId);
|
const updated = getPlaylistById(playlist.id);
|
||||||
broadcastPlaylistUpdated(updated);
|
broadcastPlaylistUpdated(updated);
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
@@ -204,7 +217,7 @@ export function addDumpToPlaylist(
|
|||||||
|
|
||||||
const minRow = db.prepare(
|
const minRow = db.prepare(
|
||||||
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
`SELECT MIN(position) as min_pos FROM playlist_dumps WHERE playlist_id = ?;`,
|
||||||
).get(playlistId) as { min_pos: number | null } | undefined;
|
).get(playlist.id) as { min_pos: number | null } | undefined;
|
||||||
|
|
||||||
const nextPos = (minRow?.min_pos ?? 1) - 1;
|
const nextPos = (minRow?.min_pos ?? 1) - 1;
|
||||||
const addedAt = new Date().toISOString();
|
const addedAt = new Date().toISOString();
|
||||||
@@ -213,7 +226,7 @@ export function addDumpToPlaylist(
|
|||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
|
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
|
||||||
VALUES (?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?);`,
|
||||||
).run(playlistId, dumpId, nextPos, addedAt);
|
).run(playlist.id, dumpId, nextPos, addedAt);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
if (msg.includes("UNIQUE") || msg.includes("unique")) {
|
if (msg.includes("UNIQUE") || msg.includes("unique")) {
|
||||||
@@ -226,7 +239,7 @@ export function addDumpToPlaylist(
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dumpIds = getCurrentDumpIds(playlistId);
|
const dumpIds = getCurrentDumpIds(playlist.id);
|
||||||
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
||||||
|
|
||||||
if (playlist.isPublic) {
|
if (playlist.isPublic) {
|
||||||
@@ -235,7 +248,7 @@ export function addDumpToPlaylist(
|
|||||||
) as { title: string } | undefined;
|
) as { title: string } | undefined;
|
||||||
if (dumpRow) {
|
if (dumpRow) {
|
||||||
notifyPlaylistFollowersNewDump(
|
notifyPlaylistFollowersNewDump(
|
||||||
playlistId,
|
playlist.id,
|
||||||
playlist.title,
|
playlist.title,
|
||||||
dumpId,
|
dumpId,
|
||||||
dumpRow.title,
|
dumpRow.title,
|
||||||
@@ -257,9 +270,9 @@ export function removeDumpFromPlaylist(
|
|||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`,
|
`DELETE FROM playlist_dumps WHERE playlist_id = ? AND dump_id = ?;`,
|
||||||
).run(playlistId, dumpId);
|
).run(playlist.id, dumpId);
|
||||||
|
|
||||||
const dumpIds = getCurrentDumpIds(playlistId);
|
const dumpIds = getCurrentDumpIds(playlist.id);
|
||||||
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,7 +287,7 @@ export function reorderPlaylist(
|
|||||||
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentIds = getCurrentDumpIds(playlistId);
|
const currentIds = getCurrentDumpIds(playlist.id);
|
||||||
const currentSet = new Set(currentIds);
|
const currentSet = new Set(currentIds);
|
||||||
const newSet = new Set(dumpIds);
|
const newSet = new Set(dumpIds);
|
||||||
|
|
||||||
@@ -293,7 +306,7 @@ export function reorderPlaylist(
|
|||||||
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
|
`UPDATE playlist_dumps SET position = ? WHERE playlist_id = ? AND dump_id = ?;`,
|
||||||
);
|
);
|
||||||
for (let i = 0; i < dumpIds.length; i++) {
|
for (let i = 0; i < dumpIds.length; i++) {
|
||||||
update.run(i, playlistId, dumpIds[i]);
|
update.run(i, playlist.id, dumpIds[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ export function getUserByUsername(username: string): User {
|
|||||||
return userRowToApi(userRow);
|
return userRowToApi(userRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function searchUsers(
|
||||||
|
query: string,
|
||||||
|
limit: number,
|
||||||
|
): { id: string; username: string; avatarMime: string | null }[] {
|
||||||
|
if (!query) return [];
|
||||||
|
const rows = db.prepare(
|
||||||
|
`SELECT id, username, avatar_mime FROM users WHERE username LIKE ? ORDER BY username LIMIT ?;`,
|
||||||
|
).all(`${query}%`, limit) as {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatar_mime: string | null;
|
||||||
|
}[];
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
username: r.username,
|
||||||
|
avatarMime: r.avatar_mime,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function listUsers(): User[] {
|
export function listUsers(): User[] {
|
||||||
const userRows = db.prepare(
|
const userRows = db.prepare(
|
||||||
`${USER_SELECT}`,
|
`${USER_SELECT}`,
|
||||||
@@ -101,20 +120,23 @@ export async function updateUser(
|
|||||||
|
|
||||||
const { password, ...requestFields } = request;
|
const { password, ...requestFields } = request;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
const updatedUser: User = {
|
const updatedUser: User = {
|
||||||
...user,
|
...user,
|
||||||
passwordHash: password ? await hashPassword(password) : user.passwordHash,
|
passwordHash: password ? await hashPassword(password) : user.passwordHash,
|
||||||
...requestFields,
|
...requestFields,
|
||||||
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedUserRow = userApiToRow(updatedUser);
|
const updatedUserRow = userApiToRow(updatedUser);
|
||||||
|
|
||||||
const userResult = db.prepare(
|
const userResult = db.prepare(
|
||||||
`UPDATE users SET username = ?, password_hash = ?, is_admin = ? WHERE id = ?`,
|
`UPDATE users SET username = ?, password_hash = ?, is_admin = ?, updated_at = ? WHERE id = ?`,
|
||||||
).run(
|
).run(
|
||||||
updatedUserRow.username,
|
updatedUserRow.username,
|
||||||
updatedUserRow.password_hash,
|
updatedUserRow.password_hash,
|
||||||
updatedUserRow.is_admin,
|
updatedUserRow.is_admin,
|
||||||
|
now.toISOString(),
|
||||||
updatedUserRow.id,
|
updatedUserRow.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -127,8 +149,8 @@ export async function updateUser(
|
|||||||
|
|
||||||
export function updateUserAvatar(userId: string, mime: string): void {
|
export function updateUserAvatar(userId: string, mime: string): void {
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
`UPDATE users SET avatar_mime = ? WHERE id = ?`,
|
`UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`,
|
||||||
).run(mime, userId);
|
).run(mime, new Date().toISOString(), userId);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "User not found");
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ export function broadcastPlaylistCreated(playlist: Playlist): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastPlaylistUpdated(playlist: Playlist): void {
|
export function broadcastPlaylistUpdated(playlist: Playlist): void {
|
||||||
sendToPlaylistAudience(playlist, { type: "playlist_updated", playlist });
|
// Broadcast to ALL clients so non-owners can react to visibility changes
|
||||||
|
// (e.g. remove a now-private playlist from their feed).
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "playlist_updated", playlist });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastPlaylistDeleted(
|
export function broadcastPlaylistDeleted(
|
||||||
@@ -158,6 +162,12 @@ export function broadcastCommentDeleted(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function broadcastCommentUpdated(comment: Comment): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
send(client.socket, { type: "comment_updated", comment });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ CREATE TABLE dumps (
|
|||||||
comment TEXT,
|
comment TEXT,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT,
|
||||||
url TEXT,
|
url TEXT,
|
||||||
rich_content TEXT,
|
rich_content TEXT,
|
||||||
file_name TEXT,
|
file_name TEXT,
|
||||||
@@ -21,6 +22,7 @@ CREATE TABLE users (
|
|||||||
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,
|
||||||
|
updated_at TEXT,
|
||||||
avatar_mime TEXT,
|
avatar_mime TEXT,
|
||||||
invited_by TEXT REFERENCES users(id)
|
invited_by TEXT REFERENCES users(id)
|
||||||
);
|
);
|
||||||
@@ -41,6 +43,7 @@ CREATE TABLE playlists (
|
|||||||
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,
|
||||||
|
updated_at TEXT,
|
||||||
image_mime TEXT,
|
image_mime TEXT,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -62,6 +65,7 @@ CREATE TABLE comments (
|
|||||||
parent_id TEXT,
|
parent_id TEXT,
|
||||||
body TEXT NOT NULL,
|
body TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT,
|
||||||
deleted INTEGER NOT NULL DEFAULT 0,
|
deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
188
deno.lock
generated
188
deno.lock
generated
@@ -5,7 +5,7 @@
|
|||||||
"jsr:@denosaurs/plug@1": "1.1.0",
|
"jsr:@denosaurs/plug@1": "1.1.0",
|
||||||
"jsr:@oak/commons@1": "1.0.1",
|
"jsr:@oak/commons@1": "1.0.1",
|
||||||
"jsr:@oak/oak@^17.2.0": "17.2.0",
|
"jsr:@oak/oak@^17.2.0": "17.2.0",
|
||||||
"jsr:@panva/jose@^6.2.1": "6.2.1",
|
"jsr:@panva/jose@^6.2.1": "6.2.2",
|
||||||
"jsr:@std/assert@1": "1.0.19",
|
"jsr:@std/assert@1": "1.0.19",
|
||||||
"jsr:@std/bytes@1": "1.0.6",
|
"jsr:@std/bytes@1": "1.0.6",
|
||||||
"jsr:@std/crypto@1": "1.0.5",
|
"jsr:@std/crypto@1": "1.0.5",
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
"jsr:@std/path@1.0": "1.0.9",
|
"jsr:@std/path@1.0": "1.0.9",
|
||||||
"jsr:@std/path@^1.1.4": "1.1.4",
|
"jsr:@std/path@^1.1.4": "1.1.4",
|
||||||
"jsr:@tajpouria/cors@^1.2.1": "1.2.1",
|
"jsr:@tajpouria/cors@^1.2.1": "1.2.1",
|
||||||
"npm:@deno/vite-plugin@^1.0.6": "1.0.6_vite@8.0.0__@types+node@24.12.0",
|
"npm:@deno/vite-plugin@^1.0.6": "1.0.6_vite@8.0.1__@types+node@24.12.0_@types+node@24.12.0",
|
||||||
"npm:@eslint/js@^9.39.4": "9.39.4",
|
"npm:@eslint/js@^9.39.4": "9.39.4",
|
||||||
"npm:@types/node@^24.12.0": "24.12.0",
|
"npm:@types/node@^24.12.0": "24.12.0",
|
||||||
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14",
|
"npm:@types/react-dom@^19.2.3": "19.2.3_@types+react@19.2.14",
|
||||||
"npm:@types/react@^19.2.14": "19.2.14",
|
"npm:@types/react@^19.2.14": "19.2.14",
|
||||||
"npm:@vitejs/plugin-react@^6.0.1": "6.0.1_vite@8.0.0__@types+node@24.12.0",
|
"npm:@vitejs/plugin-react@^6.0.1": "6.0.1_vite@8.0.1__@types+node@24.12.0_@types+node@24.12.0",
|
||||||
"npm:eslint-plugin-react-hooks@^7.0.1": "7.0.1_eslint@9.39.4",
|
"npm:eslint-plugin-react-hooks@^7.0.1": "7.0.1_eslint@9.39.4",
|
||||||
"npm:eslint-plugin-react-refresh@~0.5.2": "0.5.2_eslint@9.39.4",
|
"npm:eslint-plugin-react-refresh@~0.5.2": "0.5.2_eslint@9.39.4",
|
||||||
"npm:eslint@^9.39.4": "9.39.4",
|
"npm:eslint@^9.39.4": "9.39.4",
|
||||||
@@ -36,10 +36,10 @@
|
|||||||
"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: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.1_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.1_@types+node@24.12.0",
|
||||||
"npm:vite@8": "8.0.0_@types+node@24.12.0"
|
"npm:vite@8": "8.0.1_@types+node@24.12.0"
|
||||||
},
|
},
|
||||||
"jsr": {
|
"jsr": {
|
||||||
"@db/sqlite@0.13.0": {
|
"@db/sqlite@0.13.0": {
|
||||||
@@ -81,8 +81,8 @@
|
|||||||
"npm:path-to-regexp"
|
"npm:path-to-regexp"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@panva/jose@6.2.1": {
|
"@panva/jose@6.2.2": {
|
||||||
"integrity": "6725a90a47be84c57a0f889c73bf09c6a209019c816f1f029f9084929fadfcbf"
|
"integrity": "bfe1178a9d2f53effa5ee6c1786b6534a39690b03969472b3ad690600d8898ec"
|
||||||
},
|
},
|
||||||
"@std/assert@1.0.19": {
|
"@std/assert@1.0.19": {
|
||||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e"
|
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e"
|
||||||
@@ -211,15 +211,15 @@
|
|||||||
"@babel/helper-validator-option@7.27.1": {
|
"@babel/helper-validator-option@7.27.1": {
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="
|
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="
|
||||||
},
|
},
|
||||||
"@babel/helpers@7.28.6": {
|
"@babel/helpers@7.29.2": {
|
||||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@babel/template",
|
"@babel/template",
|
||||||
"@babel/types"
|
"@babel/types"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@babel/parser@7.29.0": {
|
"@babel/parser@7.29.2": {
|
||||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@babel/types"
|
"@babel/types"
|
||||||
],
|
],
|
||||||
@@ -252,21 +252,21 @@
|
|||||||
"@babel/helper-validator-identifier"
|
"@babel/helper-validator-identifier"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@deno/vite-plugin@1.0.6_vite@8.0.0__@types+node@24.12.0": {
|
"@deno/vite-plugin@1.0.6_vite@8.0.1__@types+node@24.12.0_@types+node@24.12.0": {
|
||||||
"integrity": "sha512-Sh5XqvFuKAwjARTesi0n6xRpEXm1V0UeqKh+SxIrexCofxOaieNDMqXZD02RiZCg0mrJ43V8eCMuVrDfq6mLmg==",
|
"integrity": "sha512-Sh5XqvFuKAwjARTesi0n6xRpEXm1V0UeqKh+SxIrexCofxOaieNDMqXZD02RiZCg0mrJ43V8eCMuVrDfq6mLmg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"vite"
|
"vite"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@emnapi/core@1.9.0": {
|
"@emnapi/core@1.9.1": {
|
||||||
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
|
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@emnapi/wasi-threads",
|
"@emnapi/wasi-threads",
|
||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@emnapi/runtime@1.9.0": {
|
"@emnapi/runtime@1.9.1": {
|
||||||
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
|
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
@@ -385,103 +385,100 @@
|
|||||||
"@tybys/wasm-util"
|
"@tybys/wasm-util"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@oxc-project/runtime@0.115.0": {
|
"@oxc-project/types@0.120.0": {
|
||||||
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="
|
"integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="
|
||||||
},
|
},
|
||||||
"@oxc-project/types@0.115.0": {
|
"@rolldown/binding-android-arm64@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="
|
"integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
|
||||||
},
|
|
||||||
"@rolldown/binding-android-arm64@1.0.0-rc.9": {
|
|
||||||
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
|
|
||||||
"os": ["android"],
|
"os": ["android"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-darwin-arm64@1.0.0-rc.9": {
|
"@rolldown/binding-darwin-arm64@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
|
"integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-darwin-x64@1.0.0-rc.9": {
|
"@rolldown/binding-darwin-x64@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
|
"integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
|
||||||
"os": ["darwin"],
|
"os": ["darwin"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-freebsd-x64@1.0.0-rc.9": {
|
"@rolldown/binding-freebsd-x64@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
|
"integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
|
||||||
"os": ["freebsd"],
|
"os": ["freebsd"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9": {
|
"@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
|
"integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm"]
|
"cpu": ["arm"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9": {
|
"@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
|
"integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.9": {
|
"@rolldown/binding-linux-arm64-musl@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
|
"integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9": {
|
"@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
|
"integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["ppc64"]
|
"cpu": ["ppc64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9": {
|
"@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
|
"integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["s390x"]
|
"cpu": ["s390x"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.9": {
|
"@rolldown/binding-linux-x64-gnu@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
|
"integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-linux-x64-musl@1.0.0-rc.9": {
|
"@rolldown/binding-linux-x64-musl@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
|
"integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
|
||||||
"os": ["linux"],
|
"os": ["linux"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-openharmony-arm64@1.0.0-rc.9": {
|
"@rolldown/binding-openharmony-arm64@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
|
"integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
|
||||||
"os": ["openharmony"],
|
"os": ["openharmony"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-wasm32-wasi@1.0.0-rc.9": {
|
"@rolldown/binding-wasm32-wasi@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
|
"integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@napi-rs/wasm-runtime"
|
"@napi-rs/wasm-runtime"
|
||||||
],
|
],
|
||||||
"cpu": ["wasm32"]
|
"cpu": ["wasm32"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9": {
|
"@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
|
"integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["arm64"]
|
"cpu": ["arm64"]
|
||||||
},
|
},
|
||||||
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.9": {
|
"@rolldown/binding-win32-x64-msvc@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
|
"integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
|
||||||
"os": ["win32"],
|
"os": ["win32"],
|
||||||
"cpu": ["x64"]
|
"cpu": ["x64"]
|
||||||
},
|
},
|
||||||
|
"@rolldown/pluginutils@1.0.0-rc.10": {
|
||||||
|
"integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg=="
|
||||||
|
},
|
||||||
"@rolldown/pluginutils@1.0.0-rc.7": {
|
"@rolldown/pluginutils@1.0.0-rc.7": {
|
||||||
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="
|
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="
|
||||||
},
|
},
|
||||||
"@rolldown/pluginutils@1.0.0-rc.9": {
|
|
||||||
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="
|
|
||||||
},
|
|
||||||
"@tybys/wasm-util@0.10.1": {
|
"@tybys/wasm-util@0.10.1": {
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"tslib"
|
"tslib"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@types/debug@4.1.12": {
|
"@types/debug@4.1.13": {
|
||||||
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@types/ms"
|
"@types/ms"
|
||||||
]
|
]
|
||||||
@@ -537,8 +534,8 @@
|
|||||||
"@types/unist@3.0.3": {
|
"@types/unist@3.0.3": {
|
||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
"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.1_@typescript-eslint+parser@8.57.1__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-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@eslint-community/regexpp",
|
"@eslint-community/regexpp",
|
||||||
"@typescript-eslint/parser",
|
"@typescript-eslint/parser",
|
||||||
@@ -553,8 +550,8 @@
|
|||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/parser@8.57.0_eslint@9.39.4_typescript@5.9.3": {
|
"@typescript-eslint/parser@8.57.1_eslint@9.39.4_typescript@5.9.3": {
|
||||||
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==",
|
"integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@typescript-eslint/scope-manager",
|
"@typescript-eslint/scope-manager",
|
||||||
"@typescript-eslint/types",
|
"@typescript-eslint/types",
|
||||||
@@ -565,8 +562,8 @@
|
|||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/project-service@8.57.0_typescript@5.9.3": {
|
"@typescript-eslint/project-service@8.57.1_typescript@5.9.3": {
|
||||||
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==",
|
"integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@typescript-eslint/tsconfig-utils",
|
"@typescript-eslint/tsconfig-utils",
|
||||||
"@typescript-eslint/types",
|
"@typescript-eslint/types",
|
||||||
@@ -574,21 +571,21 @@
|
|||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/scope-manager@8.57.0": {
|
"@typescript-eslint/scope-manager@8.57.1": {
|
||||||
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==",
|
"integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@typescript-eslint/types",
|
"@typescript-eslint/types",
|
||||||
"@typescript-eslint/visitor-keys"
|
"@typescript-eslint/visitor-keys"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/tsconfig-utils@8.57.0_typescript@5.9.3": {
|
"@typescript-eslint/tsconfig-utils@8.57.1_typescript@5.9.3": {
|
||||||
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==",
|
"integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/type-utils@8.57.0_eslint@9.39.4_typescript@5.9.3": {
|
"@typescript-eslint/type-utils@8.57.1_eslint@9.39.4_typescript@5.9.3": {
|
||||||
"integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==",
|
"integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@typescript-eslint/types",
|
"@typescript-eslint/types",
|
||||||
"@typescript-eslint/typescript-estree",
|
"@typescript-eslint/typescript-estree",
|
||||||
@@ -599,11 +596,11 @@
|
|||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/types@8.57.0": {
|
"@typescript-eslint/types@8.57.1": {
|
||||||
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="
|
"integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ=="
|
||||||
},
|
},
|
||||||
"@typescript-eslint/typescript-estree@8.57.0_typescript@5.9.3": {
|
"@typescript-eslint/typescript-estree@8.57.1_typescript@5.9.3": {
|
||||||
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==",
|
"integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@typescript-eslint/project-service",
|
"@typescript-eslint/project-service",
|
||||||
"@typescript-eslint/tsconfig-utils",
|
"@typescript-eslint/tsconfig-utils",
|
||||||
@@ -617,8 +614,8 @@
|
|||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/utils@8.57.0_eslint@9.39.4_typescript@5.9.3": {
|
"@typescript-eslint/utils@8.57.1_eslint@9.39.4_typescript@5.9.3": {
|
||||||
"integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==",
|
"integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@eslint-community/eslint-utils",
|
"@eslint-community/eslint-utils",
|
||||||
"@typescript-eslint/scope-manager",
|
"@typescript-eslint/scope-manager",
|
||||||
@@ -628,8 +625,8 @@
|
|||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"@typescript-eslint/visitor-keys@8.57.0": {
|
"@typescript-eslint/visitor-keys@8.57.1": {
|
||||||
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==",
|
"integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@typescript-eslint/types",
|
"@typescript-eslint/types",
|
||||||
"eslint-visitor-keys@5.0.1"
|
"eslint-visitor-keys@5.0.1"
|
||||||
@@ -638,7 +635,7 @@
|
|||||||
"@ungap/structured-clone@1.3.0": {
|
"@ungap/structured-clone@1.3.0": {
|
||||||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="
|
"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.1__@types+node@24.12.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": [
|
||||||
"@rolldown/pluginutils@1.0.0-rc.7",
|
"@rolldown/pluginutils@1.0.0-rc.7",
|
||||||
@@ -682,8 +679,8 @@
|
|||||||
"balanced-match@4.0.4": {
|
"balanced-match@4.0.4": {
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="
|
||||||
},
|
},
|
||||||
"baseline-browser-mapping@2.10.8": {
|
"baseline-browser-mapping@2.10.10": {
|
||||||
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
|
"integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==",
|
||||||
"bin": true
|
"bin": true
|
||||||
},
|
},
|
||||||
"brace-expansion@1.1.12": {
|
"brace-expansion@1.1.12": {
|
||||||
@@ -713,8 +710,8 @@
|
|||||||
"callsites@3.1.0": {
|
"callsites@3.1.0": {
|
||||||
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
|
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
|
||||||
},
|
},
|
||||||
"caniuse-lite@1.0.30001779": {
|
"caniuse-lite@1.0.30001780": {
|
||||||
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="
|
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="
|
||||||
},
|
},
|
||||||
"ccount@2.0.1": {
|
"ccount@2.0.1": {
|
||||||
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="
|
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="
|
||||||
@@ -797,8 +794,8 @@
|
|||||||
"dequal"
|
"dequal"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"electron-to-chromium@1.5.313": {
|
"electron-to-chromium@1.5.321": {
|
||||||
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="
|
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="
|
||||||
},
|
},
|
||||||
"escalade@3.2.0": {
|
"escalade@3.2.0": {
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
|
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="
|
||||||
@@ -952,8 +949,8 @@
|
|||||||
"keyv"
|
"keyv"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"flatted@3.4.1": {
|
"flatted@3.4.2": {
|
||||||
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="
|
||||||
},
|
},
|
||||||
"fsevents@2.3.3": {
|
"fsevents@2.3.3": {
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
@@ -1786,11 +1783,11 @@
|
|||||||
"resolve-from@4.0.0": {
|
"resolve-from@4.0.0": {
|
||||||
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
|
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
|
||||||
},
|
},
|
||||||
"rolldown@1.0.0-rc.9": {
|
"rolldown@1.0.0-rc.10": {
|
||||||
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
|
"integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@oxc-project/types",
|
"@oxc-project/types",
|
||||||
"@rolldown/pluginutils@1.0.0-rc.9"
|
"@rolldown/pluginutils@1.0.0-rc.10"
|
||||||
],
|
],
|
||||||
"optionalDependencies": [
|
"optionalDependencies": [
|
||||||
"@rolldown/binding-android-arm64",
|
"@rolldown/binding-android-arm64",
|
||||||
@@ -1881,8 +1878,8 @@
|
|||||||
"trough@2.2.0": {
|
"trough@2.2.0": {
|
||||||
"integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="
|
"integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="
|
||||||
},
|
},
|
||||||
"ts-api-utils@2.4.0_typescript@5.9.3": {
|
"ts-api-utils@2.5.0_typescript@5.9.3": {
|
||||||
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
|
"integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"typescript"
|
"typescript"
|
||||||
]
|
]
|
||||||
@@ -1896,8 +1893,8 @@
|
|||||||
"prelude-ls"
|
"prelude-ls"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"typescript-eslint@8.57.0_eslint@9.39.4_typescript@5.9.3": {
|
"typescript-eslint@8.57.1_eslint@9.39.4_typescript@5.9.3": {
|
||||||
"integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==",
|
"integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@typescript-eslint/eslint-plugin",
|
"@typescript-eslint/eslint-plugin",
|
||||||
"@typescript-eslint/parser",
|
"@typescript-eslint/parser",
|
||||||
@@ -1988,10 +1985,9 @@
|
|||||||
"vfile-message"
|
"vfile-message"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"vite@8.0.0_@types+node@24.12.0": {
|
"vite@8.0.1_@types+node@24.12.0": {
|
||||||
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
|
"integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"@oxc-project/runtime",
|
|
||||||
"@types/node",
|
"@types/node",
|
||||||
"lightningcss",
|
"lightningcss",
|
||||||
"picomatch",
|
"picomatch",
|
||||||
|
|||||||
278
src/App.css
278
src/App.css
@@ -1,6 +1,6 @@
|
|||||||
/* ── Markdown prose ── */
|
/* ── Markdown prose ── */
|
||||||
.md p {
|
.md p {
|
||||||
margin: 0 0 0.7em;
|
margin: 0 0 0.85em;
|
||||||
}
|
}
|
||||||
.md p:last-child {
|
.md p:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
|
|
||||||
.dump-comment {
|
.dump-comment {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
line-height: 1.6;
|
line-height: 1.72;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
border-left: 3px solid var(--color-accent);
|
border-left: 3px solid var(--color-accent);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -318,6 +318,138 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── FileDropZone ── */
|
||||||
|
.fdz-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz {
|
||||||
|
border: 2px dashed var(--color-border-subtle);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz:hover:not(.fdz--disabled):not(.fdz--filled),
|
||||||
|
.fdz:focus-visible {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz--drag {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz--disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz--filled {
|
||||||
|
cursor: default;
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__upload-icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__browse {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__browse-link {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__limit {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__file-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__file-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__file-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__file-size {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.12s, background 0.12s;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fdz__clear:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Local file / URL preview (DumpCreate) ── */
|
/* ── Local file / URL preview (DumpCreate) ── */
|
||||||
.local-preview-image {
|
.local-preview-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -559,7 +691,8 @@
|
|||||||
|
|
||||||
.file-preview-pdf {
|
.file-preview-pdf {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 420px;
|
height: 80vh;
|
||||||
|
min-height: 600px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 2px solid var(--color-border);
|
border: 2px solid var(--color-border);
|
||||||
display: block;
|
display: block;
|
||||||
@@ -1907,16 +2040,22 @@ body.has-player .fab-new {
|
|||||||
|
|
||||||
.dump-card-meta {
|
.dump-card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.2rem;
|
margin-top: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-card-date {
|
.dump-card-date {
|
||||||
display: block;
|
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dump-edited-label,
|
||||||
|
.playlist-edited-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-card-meta {
|
.playlist-card-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2213,9 +2352,10 @@ body.has-player .fab-new {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.playlist-detail-description {
|
.playlist-detail-description {
|
||||||
|
font-size: 1rem;
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
line-height: 1.5;
|
line-height: 1.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-detail-meta {
|
.playlist-detail-meta {
|
||||||
@@ -2518,11 +2658,32 @@ body.has-player .fab-new {
|
|||||||
.comment-time {
|
.comment-time {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.comment-time:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes comment-highlight {
|
||||||
|
0% { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
|
||||||
|
100% { background: transparent; }
|
||||||
|
}
|
||||||
|
.comment-node--highlight {
|
||||||
|
border-radius: 6px;
|
||||||
|
animation: comment-highlight 2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-edited {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-body {
|
.comment-body {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.6;
|
line-height: 1.65;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2582,6 +2743,20 @@ body.has-player .fab-new {
|
|||||||
border: 1px solid var(--color-border-subtle);
|
border: 1px solid var(--color-border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-top-form-inner {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-top-form-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-reply-textarea {
|
.comment-reply-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -2592,8 +2767,9 @@ body.has-player .fab-new {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
resize: vertical;
|
resize: none;
|
||||||
min-height: 4.5rem;
|
overflow: hidden;
|
||||||
|
min-height: 2.4rem;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2866,15 +3042,12 @@ body.has-player .fab-new {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.notification-item {
|
.notification-item {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.875rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--color-border-subtle);
|
border: 1px solid var(--color-border-subtle);
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
transition: background 0.12s, border-color 0.12s;
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.notification-item:hover {
|
.notification-item:hover {
|
||||||
background: color-mix(
|
background: color-mix(
|
||||||
@@ -2883,6 +3056,15 @@ body.has-player .fab-new {
|
|||||||
var(--color-text) 8%
|
var(--color-text) 8%
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
.notification-item-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.notification-item--unread {
|
.notification-item--unread {
|
||||||
border-left-color: var(--color-accent);
|
border-left-color: var(--color-accent);
|
||||||
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface));
|
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface));
|
||||||
@@ -2945,15 +3127,6 @@ body.has-player .fab-new {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notif-link {
|
|
||||||
color: var(--color-text);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.notif-link:hover {
|
|
||||||
color: var(--color-accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.load-more-btn {
|
.load-more-btn {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 1.5rem auto 0;
|
margin: 1.5rem auto 0;
|
||||||
@@ -2974,3 +3147,64 @@ body.has-player .fab-new {
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Mention autocomplete ── */
|
||||||
|
.mention-textarea-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-textarea-wrap textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.mention-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 200;
|
||||||
|
list-style: none;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.mention-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.mention-dropdown-item:hover,
|
||||||
|
.mention-dropdown-item--selected {
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
.mention-dropdown-username {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.notif-icon--mention {
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tooltip ── */
|
||||||
|
.tooltip {
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
padding: 0.3em 0.65em;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-style: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,13 +69,6 @@ export function AppHeader(
|
|||||||
<button type="button" onClick={() => navigate("/login")}>
|
<button type="button" onClick={() => navigate("/login")}>
|
||||||
Log in
|
Log in
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-primary"
|
|
||||||
onClick={() => navigate("/register")}
|
|
||||||
>
|
|
||||||
Register
|
|
||||||
</button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import type { Comment, RawComment, User } from "../model.ts";
|
|||||||
import { deserializeComment } from "../model.ts";
|
import { deserializeComment } from "../model.ts";
|
||||||
import { Avatar } from "./Avatar.tsx";
|
import { Avatar } from "./Avatar.tsx";
|
||||||
import { Markdown } from "./Markdown.tsx";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
import { TextEditor, type TextEditorHandle } from "./TextEditor.tsx";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
import { ErrorCard } from "./ErrorCard.tsx";
|
import { ErrorCard } from "./ErrorCard.tsx";
|
||||||
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
|
import { ConfirmModal } from "./ConfirmModal.tsx";
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
dumpId: string;
|
dumpId: string;
|
||||||
@@ -15,6 +18,7 @@ interface CommentThreadProps {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
onCommentCreated: (comment: Comment) => void;
|
onCommentCreated: (comment: Comment) => void;
|
||||||
onCommentDeleted: (commentId: string) => void;
|
onCommentDeleted: (commentId: string) => void;
|
||||||
|
onCommentUpdated: (comment: Comment) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
||||||
@@ -27,7 +31,7 @@ function buildTree(comments: Comment[]): Map<string, Comment[]> {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_INDENT_DEPTH = 4;
|
const MAX_INDENT_DEPTH = 6;
|
||||||
|
|
||||||
interface CommentNodeProps {
|
interface CommentNodeProps {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
@@ -38,6 +42,7 @@ interface CommentNodeProps {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
onCommentCreated: (comment: Comment) => void;
|
onCommentCreated: (comment: Comment) => void;
|
||||||
onCommentDeleted: (commentId: string) => void;
|
onCommentDeleted: (commentId: string) => void;
|
||||||
|
onCommentUpdated: (comment: Comment) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CommentNode({
|
function CommentNode({
|
||||||
@@ -49,12 +54,20 @@ function CommentNode({
|
|||||||
token,
|
token,
|
||||||
onCommentCreated,
|
onCommentCreated,
|
||||||
onCommentDeleted,
|
onCommentDeleted,
|
||||||
|
onCommentUpdated,
|
||||||
}: CommentNodeProps) {
|
}: CommentNodeProps) {
|
||||||
const [replyOpen, setReplyOpen] = useState(false);
|
const [replyOpen, setReplyOpen] = useState(false);
|
||||||
const [replyBody, setReplyBody] = useState("");
|
const [replyBody, setReplyBody] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [replyError, setReplyError] = useState<string | null>(null);
|
const [replyError, setReplyError] = useState<string | null>(null);
|
||||||
const replyTextareaRef = useRef<HTMLTextAreaElement>(null);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editBody, setEditBody] = useState("");
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const replyEditorRef = useRef<TextEditorHandle>(null);
|
||||||
|
const editEditorRef = useRef<TextEditorHandle>(null);
|
||||||
|
|
||||||
const children = tree.get(comment.id) ?? [];
|
const children = tree.get(comment.id) ?? [];
|
||||||
|
|
||||||
@@ -98,8 +111,38 @@ function CommentNode({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEditSave(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editBody.trim() || !token) return;
|
||||||
|
setEditSubmitting(true);
|
||||||
|
setEditError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/comments/${comment.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body: editBody }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
onCommentUpdated(deserializeComment(data.data as RawComment));
|
||||||
|
setEditOpen(false);
|
||||||
|
} else {
|
||||||
|
setEditError(data.error?.message ?? "Failed to save edit.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setEditError("Could not reach the server. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setEditSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canDelete = !comment.deleted && !!currentUser &&
|
const canDelete = !comment.deleted && !!currentUser &&
|
||||||
(currentUser.id === comment.userId || currentUser.isAdmin);
|
(currentUser.id === comment.userId || currentUser.isAdmin);
|
||||||
|
const canEdit = !comment.deleted && !!currentUser &&
|
||||||
|
(currentUser.id === comment.userId || currentUser.isAdmin);
|
||||||
|
|
||||||
if (comment.deleted) {
|
if (comment.deleted) {
|
||||||
return (
|
return (
|
||||||
@@ -118,7 +161,7 @@ function CommentNode({
|
|||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<ul
|
<ul
|
||||||
className="comment-replies"
|
className="comment-replies"
|
||||||
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
|
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } : undefined}
|
||||||
>
|
>
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<CommentNode
|
<CommentNode
|
||||||
@@ -131,6 +174,7 @@ function CommentNode({
|
|||||||
token={token}
|
token={token}
|
||||||
onCommentCreated={onCommentCreated}
|
onCommentCreated={onCommentCreated}
|
||||||
onCommentDeleted={onCommentDeleted}
|
onCommentDeleted={onCommentDeleted}
|
||||||
|
onCommentUpdated={onCommentUpdated}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -141,7 +185,7 @@ function CommentNode({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="comment-node">
|
<li className="comment-node">
|
||||||
<div className="comment-node-inner">
|
<div className="comment-node-inner" id={`comment-${comment.id}`}>
|
||||||
<div className="comment-avatar">
|
<div className="comment-avatar">
|
||||||
<Avatar
|
<Avatar
|
||||||
userId={comment.userId}
|
userId={comment.userId}
|
||||||
@@ -158,52 +202,121 @@ function CommentNode({
|
|||||||
>
|
>
|
||||||
{comment.authorUsername}
|
{comment.authorUsername}
|
||||||
</Link>
|
</Link>
|
||||||
<time
|
<Link
|
||||||
|
to={`/dumps/${dumpId}#comment-${comment.id}`}
|
||||||
className="comment-time"
|
className="comment-time"
|
||||||
dateTime={comment.createdAt.toISOString()}
|
|
||||||
title={comment.createdAt.toLocaleString()}
|
|
||||||
>
|
>
|
||||||
{relativeTime(comment.createdAt)}
|
<Tooltip text={comment.createdAt.toLocaleString()}>
|
||||||
</time>
|
<time dateTime={comment.createdAt.toISOString()}>
|
||||||
|
{relativeTime(comment.createdAt)}
|
||||||
|
</time>
|
||||||
|
</Tooltip>
|
||||||
|
</Link>
|
||||||
|
{comment.updatedAt && (
|
||||||
|
<Tooltip text={`Edited ${comment.updatedAt.toLocaleString()}`}>
|
||||||
|
<span className="comment-edited">
|
||||||
|
edited {relativeTime(comment.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Markdown className="comment-body">{comment.body}</Markdown>
|
{editOpen
|
||||||
|
? (
|
||||||
|
<form className="comment-form" onSubmit={handleEditSave}>
|
||||||
|
<TextEditor
|
||||||
|
ref={editEditorRef}
|
||||||
|
className="comment-reply-textarea"
|
||||||
|
value={editBody}
|
||||||
|
onChange={setEditBody}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleEditSave(e);
|
||||||
|
}}
|
||||||
|
autoResize
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
{editError && (
|
||||||
|
<ErrorCard title="Failed to save edit" message={editError} />
|
||||||
|
)}
|
||||||
|
<div className="comment-form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="comment-submit-btn"
|
||||||
|
disabled={editSubmitting || !editBody.trim()}
|
||||||
|
>
|
||||||
|
{editSubmitting ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditBody("");
|
||||||
|
setEditError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
: <Markdown className="comment-body">{comment.body}</Markdown>}
|
||||||
<div className="comment-actions">
|
<div className="comment-actions">
|
||||||
{currentUser && (
|
{currentUser && !editOpen && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="comment-action-btn"
|
className="comment-action-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setReplyOpen((v) => !v);
|
setReplyOpen((v) => !v);
|
||||||
setTimeout(() => replyTextareaRef.current?.focus(), 0);
|
setTimeout(() => replyEditorRef.current?.focus(), 0);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reply
|
Reply
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canDelete && (
|
{canEdit && !editOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setEditBody(comment.body);
|
||||||
|
setEditOpen(true);
|
||||||
|
setTimeout(() => editEditorRef.current?.focus(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete && !editOpen && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="comment-action-btn comment-delete-btn"
|
className="comment-action-btn comment-delete-btn"
|
||||||
onClick={handleDelete}
|
onClick={() => setConfirmDelete(true)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{confirmDelete && (
|
||||||
|
<ConfirmModal
|
||||||
|
message="Delete this comment?"
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={() => { setConfirmDelete(false); handleDelete(); }}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{replyOpen && (
|
{replyOpen && (
|
||||||
<form className="comment-form" onSubmit={handleReply}>
|
<form className="comment-form" onSubmit={handleReply}>
|
||||||
<textarea
|
<TextEditor
|
||||||
ref={replyTextareaRef}
|
ref={replyEditorRef}
|
||||||
className="comment-reply-textarea"
|
className="comment-reply-textarea"
|
||||||
value={replyBody}
|
value={replyBody}
|
||||||
onChange={(e) => setReplyBody(e.target.value)}
|
onChange={setReplyBody}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
|
||||||
e.key === "Enter" && (e.ctrlKey || e.metaKey)
|
|
||||||
) handleReply(e);
|
|
||||||
}}
|
}}
|
||||||
placeholder="Write a reply…"
|
placeholder="Write a reply…"
|
||||||
rows={3}
|
autoResize
|
||||||
|
rows={1}
|
||||||
/>
|
/>
|
||||||
{replyError && (
|
{replyError && (
|
||||||
<ErrorCard title="Failed to post reply" message={replyError} />
|
<ErrorCard title="Failed to post reply" message={replyError} />
|
||||||
@@ -235,7 +348,7 @@ function CommentNode({
|
|||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<ul
|
<ul
|
||||||
className="comment-replies"
|
className="comment-replies"
|
||||||
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
|
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } : undefined}
|
||||||
>
|
>
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<CommentNode
|
<CommentNode
|
||||||
@@ -248,6 +361,7 @@ function CommentNode({
|
|||||||
token={token}
|
token={token}
|
||||||
onCommentCreated={onCommentCreated}
|
onCommentCreated={onCommentCreated}
|
||||||
onCommentDeleted={onCommentDeleted}
|
onCommentDeleted={onCommentDeleted}
|
||||||
|
onCommentUpdated={onCommentUpdated}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -263,6 +377,7 @@ export function CommentThread({
|
|||||||
token,
|
token,
|
||||||
onCommentCreated,
|
onCommentCreated,
|
||||||
onCommentDeleted,
|
onCommentDeleted,
|
||||||
|
onCommentUpdated,
|
||||||
}: CommentThreadProps) {
|
}: CommentThreadProps) {
|
||||||
const [topLevelBody, setTopLevelBody] = useState("");
|
const [topLevelBody, setTopLevelBody] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -309,33 +424,56 @@ export function CommentThread({
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<form
|
<form className="comment-top-form" onSubmit={handleTopLevelSubmit}>
|
||||||
className="comment-form comment-top-form"
|
<div className="comment-top-form-inner">
|
||||||
onSubmit={handleTopLevelSubmit}
|
<div className="comment-avatar">
|
||||||
>
|
<Avatar
|
||||||
<textarea
|
userId={currentUser.id}
|
||||||
className="comment-reply-textarea"
|
username={currentUser.username}
|
||||||
value={topLevelBody}
|
hasAvatar={!!currentUser.avatarMime}
|
||||||
onChange={(e) => setTopLevelBody(e.target.value)}
|
size={28}
|
||||||
onKeyDown={(e) => {
|
/>
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
</div>
|
||||||
handleTopLevelSubmit(e);
|
<div className="comment-top-form-body">
|
||||||
}
|
<TextEditor
|
||||||
}}
|
className="comment-reply-textarea"
|
||||||
placeholder="Add a comment…"
|
value={topLevelBody}
|
||||||
rows={3}
|
onChange={setTopLevelBody}
|
||||||
/>
|
onKeyDown={(e) => {
|
||||||
{topLevelError && (
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
|
||||||
<ErrorCard title="Failed to post comment" message={topLevelError} />
|
}}
|
||||||
)}
|
placeholder="Add a comment…"
|
||||||
<div className="comment-form-actions">
|
autoResize
|
||||||
<button
|
rows={1}
|
||||||
type="submit"
|
/>
|
||||||
className="comment-submit-btn"
|
{topLevelError && (
|
||||||
disabled={submitting || !topLevelBody.trim()}
|
<ErrorCard
|
||||||
>
|
title="Failed to post comment"
|
||||||
{submitting ? "Posting…" : "Post comment"}
|
message={topLevelError}
|
||||||
</button>
|
/>
|
||||||
|
)}
|
||||||
|
<div className="comment-form-actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="comment-submit-btn"
|
||||||
|
disabled={submitting || !topLevelBody.trim()}
|
||||||
|
>
|
||||||
|
{submitting ? "Posting…" : "Post comment"}
|
||||||
|
</button>
|
||||||
|
{topLevelBody.trim() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-action-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setTopLevelBody("");
|
||||||
|
setTopLevelError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -353,6 +491,7 @@ export function CommentThread({
|
|||||||
token={token}
|
token={token}
|
||||||
onCommentCreated={onCommentCreated}
|
onCommentCreated={onCommentCreated}
|
||||||
onCommentDeleted={onCommentDeleted}
|
onCommentDeleted={onCommentDeleted}
|
||||||
|
onCommentUpdated={onCommentUpdated}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import type { Dump } from "../model.ts";
|
import type { Dump } from "../model.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.ts";
|
import { isDumpVisited, isRecent, markDumpVisited } from "../utils/visited.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";
|
import { Markdown } from "./Markdown.tsx";
|
||||||
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
|
|
||||||
interface DumpCardProps {
|
interface DumpCardProps {
|
||||||
dump: Dump;
|
dump: Dump;
|
||||||
@@ -28,7 +30,7 @@ export function DumpCard(
|
|||||||
|
|
||||||
function handleNavigate() {
|
function handleNavigate() {
|
||||||
markDumpVisited(dump.id);
|
markDumpVisited(dump.id);
|
||||||
navigate(`/dumps/${dump.id}`);
|
navigate(dumpUrl(dump));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +52,7 @@ export function DumpCard(
|
|||||||
|
|
||||||
<div className="dump-card-body">
|
<div className="dump-card-body">
|
||||||
<Link
|
<Link
|
||||||
to={`/dumps/${dump.id}`}
|
to={dumpUrl(dump)}
|
||||||
className="dump-card-title"
|
className="dump-card-title"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -66,13 +68,14 @@ export function DumpCard(
|
|||||||
</Markdown>
|
</Markdown>
|
||||||
)}
|
)}
|
||||||
<div className="dump-card-meta">
|
<div className="dump-card-meta">
|
||||||
<time
|
<Tooltip text={dump.createdAt.toLocaleString()}>
|
||||||
className="dump-card-date"
|
<time
|
||||||
dateTime={dump.createdAt.toISOString()}
|
className="dump-card-date"
|
||||||
title={dump.createdAt.toLocaleString()}
|
dateTime={dump.createdAt.toISOString()}
|
||||||
>
|
>
|
||||||
{relativeTime(dump.createdAt)}
|
{relativeTime(dump.createdAt)}
|
||||||
</time>
|
</time>
|
||||||
|
</Tooltip>
|
||||||
{dump.commentCount > 0 && (
|
{dump.commentCount > 0 && (
|
||||||
<span className="dump-card-comment-count">
|
<span className="dump-card-comment-count">
|
||||||
{dump.commentCount}{" "}
|
{dump.commentCount}{" "}
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import { deserializeDump, deserializePlaylistMembership } 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 { formatBytes } from "../utils/format.ts";
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
import RichContentCard from "./RichContentCard.tsx";
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
import type { RichContent } from "../model.ts";
|
import type { RichContent } from "../model.ts";
|
||||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
import { ErrorCard } from "./ErrorCard.tsx";
|
import { ErrorCard } from "./ErrorCard.tsx";
|
||||||
|
import { FileDropZone } from "./FileDropZone.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||||
@@ -57,15 +59,8 @@ function LocalFilePreview({ file }: { file: File }) {
|
|||||||
if (mime.startsWith("audio/")) {
|
if (mime.startsWith("audio/")) {
|
||||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||||
}
|
}
|
||||||
return (
|
// For other types the drop zone chip already shows name + size.
|
||||||
<div className="local-preview-generic">
|
return null;
|
||||||
<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 {
|
interface DumpCreateModalProps {
|
||||||
@@ -383,17 +378,11 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<div className="form-group">
|
<FileDropZone
|
||||||
<label htmlFor="dc-file">File</label>
|
file={file}
|
||||||
<input
|
onChange={setFile}
|
||||||
id="dc-file"
|
disabled={submitting}
|
||||||
type="file"
|
/>
|
||||||
onChange={(e) =>
|
|
||||||
setFile(e.target.files?.[0] ?? null)}
|
|
||||||
disabled={submitting}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{file && <LocalFilePreview file={file} />}
|
{file && <LocalFilePreview file={file} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -459,7 +448,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
{createdDump && (
|
{createdDump && (
|
||||||
<p className="dump-create-success">
|
<p className="dump-create-success">
|
||||||
Dumped!{" "}
|
Dumped!{" "}
|
||||||
<Link to={`/dumps/${createdDump.id}`} onClick={onClose}>
|
<Link to={dumpUrl(createdDump)} onClick={onClose}>
|
||||||
View dump →
|
View dump →
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
145
src/components/FileDropZone.tsx
Normal file
145
src/components/FileDropZone.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
|
||||||
|
function fileIcon(mime: string): string {
|
||||||
|
if (mime.startsWith("image/")) return "🖼";
|
||||||
|
if (mime.startsWith("video/")) return "🎬";
|
||||||
|
if (mime.startsWith("audio/")) return "🎵";
|
||||||
|
if (mime === "application/pdf") return "📄";
|
||||||
|
return "📎";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileDropZoneProps {
|
||||||
|
file: File | null;
|
||||||
|
onChange: (file: File | null) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
hint?: string;
|
||||||
|
showLimit?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileDropZone({
|
||||||
|
file,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
label = "File",
|
||||||
|
hint = "Drop a file here",
|
||||||
|
showLimit = true,
|
||||||
|
}: FileDropZoneProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!disabled) setDragging(true);
|
||||||
|
},
|
||||||
|
[disabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
||||||
|
setDragging(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
if (disabled) return;
|
||||||
|
const dropped = e.dataTransfer.files[0];
|
||||||
|
if (dropped) onChange(dropped);
|
||||||
|
},
|
||||||
|
[disabled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!disabled) inputRef.current?.click();
|
||||||
|
}, [disabled]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(null);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fdz-wrapper">
|
||||||
|
{label && <span className="fdz-label">{label}</span>}
|
||||||
|
<div
|
||||||
|
className={`fdz${dragging ? " fdz--drag" : ""}${disabled ? " fdz--disabled" : ""}${file ? " fdz--filled" : ""}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={file ? undefined : handleClick}
|
||||||
|
role={file ? undefined : "button"}
|
||||||
|
tabIndex={file || disabled ? undefined : 0}
|
||||||
|
onKeyDown={
|
||||||
|
file || disabled
|
||||||
|
? undefined
|
||||||
|
: (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={(e) => onChange(e.target.files?.[0] ?? null)}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file
|
||||||
|
? (
|
||||||
|
<div className="fdz__file">
|
||||||
|
<span className="fdz__file-icon">{fileIcon(file.type)}</span>
|
||||||
|
<div className="fdz__file-meta">
|
||||||
|
<span className="fdz__file-name">{file.name}</span>
|
||||||
|
<span className="fdz__file-size">{formatBytes(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fdz__clear"
|
||||||
|
onClick={handleClear}
|
||||||
|
aria-label="Remove file"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<div className="fdz__empty">
|
||||||
|
<svg
|
||||||
|
className="fdz__upload-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" y1="3" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
<p className="fdz__hint">{hint}</p>
|
||||||
|
<p className="fdz__browse">
|
||||||
|
or <span className="fdz__browse-link">browse files</span>
|
||||||
|
</p>
|
||||||
|
{showLimit && <p className="fdz__limit">Max 50 MB</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Link } from "react-router";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
@@ -9,6 +10,11 @@ interface MarkdownProps {
|
|||||||
|
|
||||||
const REMARK_PLUGINS = [remarkGfm];
|
const REMARK_PLUGINS = [remarkGfm];
|
||||||
|
|
||||||
|
// Convert bare @username (not already inside a markdown link) to clickable links
|
||||||
|
function preprocessMentions(text: string): string {
|
||||||
|
return text.replace(/(?<![[(])@([\w]+)/g, "[@$1](/users/$1)");
|
||||||
|
}
|
||||||
|
|
||||||
export function Markdown(
|
export function Markdown(
|
||||||
{ children, className, inline = false }: MarkdownProps,
|
{ children, className, inline = false }: MarkdownProps,
|
||||||
) {
|
) {
|
||||||
@@ -21,14 +27,19 @@ export function Markdown(
|
|||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={REMARK_PLUGINS}
|
remarkPlugins={REMARK_PLUGINS}
|
||||||
components={{
|
components={{
|
||||||
a: ({ href, children }) => (
|
a: ({ href, children: linkChildren }) => {
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
if (href?.startsWith("/users/")) {
|
||||||
{children}
|
return <Link to={href}>{linkChildren}</Link>;
|
||||||
</a>
|
}
|
||||||
),
|
return (
|
||||||
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
|
{linkChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{preprocessMentions(children)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
38
src/components/MentionDropdown.tsx
Normal file
38
src/components/MentionDropdown.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Avatar } from "./Avatar.tsx";
|
||||||
|
import type { UserResult } from "../hooks/useMentionAutocomplete.ts";
|
||||||
|
|
||||||
|
interface MentionDropdownProps {
|
||||||
|
results: UserResult[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (username: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentionDropdown(
|
||||||
|
{ results, selectedIndex, onSelect }: MentionDropdownProps,
|
||||||
|
) {
|
||||||
|
if (results.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul className="mention-dropdown">
|
||||||
|
{results.map((user, i) => (
|
||||||
|
<li
|
||||||
|
key={user.id}
|
||||||
|
className={`mention-dropdown-item${
|
||||||
|
i === selectedIndex ? " mention-dropdown-item--selected" : ""
|
||||||
|
}`}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // keep textarea focused
|
||||||
|
onSelect(user.username);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
userId={user.id}
|
||||||
|
username={user.username}
|
||||||
|
hasAvatar={!!user.avatarMime}
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
<span className="mention-dropdown-username">@{user.username}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,13 @@ import { Link, useNavigate } from "react-router";
|
|||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Playlist } from "../model.ts";
|
import type { Playlist } from "../model.ts";
|
||||||
import { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
|
import { playlistUrl } from "../utils/urls.ts";
|
||||||
import {
|
import {
|
||||||
isPlaylistVisited,
|
isPlaylistVisited,
|
||||||
isRecent,
|
isRecent,
|
||||||
markPlaylistVisited,
|
markPlaylistVisited,
|
||||||
} from "../utils/visited.ts";
|
} from "../utils/visited.ts";
|
||||||
|
import { Tooltip } from "./Tooltip.tsx";
|
||||||
|
|
||||||
interface PlaylistCardProps {
|
interface PlaylistCardProps {
|
||||||
playlist: Playlist;
|
playlist: Playlist;
|
||||||
@@ -23,7 +25,7 @@ export function PlaylistCard(
|
|||||||
|
|
||||||
function handleNavigate() {
|
function handleNavigate() {
|
||||||
markPlaylistVisited(playlist.id);
|
markPlaylistVisited(playlist.id);
|
||||||
navigate(`/playlists/${playlist.id}`);
|
navigate(playlistUrl(playlist));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +47,7 @@ export function PlaylistCard(
|
|||||||
</div>
|
</div>
|
||||||
<div className="playlist-card-body">
|
<div className="playlist-card-body">
|
||||||
<Link
|
<Link
|
||||||
to={`/playlists/${playlist.id}`}
|
to={playlistUrl(playlist)}
|
||||||
className="playlist-card-title"
|
className="playlist-card-title"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -81,12 +83,11 @@ export function PlaylistCard(
|
|||||||
{playlist.dumpCount === 1 ? "dump" : "dumps"}
|
{playlist.dumpCount === 1 ? "dump" : "dumps"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<time
|
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||||
dateTime={playlist.createdAt.toISOString()}
|
<time dateTime={playlist.createdAt.toISOString()}>
|
||||||
title={playlist.createdAt.toLocaleString()}
|
{relativeTime(playlist.createdAt)}
|
||||||
>
|
</time>
|
||||||
{relativeTime(playlist.createdAt)}
|
</Tooltip>
|
||||||
</time>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
90
src/components/TextEditor.tsx
Normal file
90
src/components/TextEditor.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import { MentionDropdown } from "./MentionDropdown.tsx";
|
||||||
|
import { useMentionAutocomplete } from "../hooks/useMentionAutocomplete.ts";
|
||||||
|
|
||||||
|
export interface TextEditorHandle {
|
||||||
|
focus(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextEditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
autoResize?: boolean;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||||
|
function TextEditor(
|
||||||
|
{
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
rows,
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
autoResize = false,
|
||||||
|
onKeyDown,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focus: () => textareaRef.current?.focus(),
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
mentionOpen,
|
||||||
|
mentionResults,
|
||||||
|
mentionSelectedIndex,
|
||||||
|
handleMentionChange,
|
||||||
|
handleMentionKeyDown,
|
||||||
|
handleMentionSelect,
|
||||||
|
} = useMentionAutocomplete(value, onChange, textareaRef);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!autoResize) return;
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = `${el.scrollHeight}px`;
|
||||||
|
}, [value, autoResize]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mention-textarea-wrap">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={value}
|
||||||
|
onChange={handleMentionChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
handleMentionKeyDown(e);
|
||||||
|
if (!e.defaultPrevented) onKeyDown?.(e);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
|
rows={rows}
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
{mentionOpen && (
|
||||||
|
<MentionDropdown
|
||||||
|
results={mentionResults}
|
||||||
|
selectedIndex={mentionSelectedIndex}
|
||||||
|
onSelect={handleMentionSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
39
src/components/Tooltip.tsx
Normal file
39
src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
text: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ text, children }: TooltipProps) {
|
||||||
|
const [rect, setRect] = useState<DOMRect | null>(null);
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
const show = useCallback(() => {
|
||||||
|
setRect(ref.current?.getBoundingClientRect() ?? null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hide = useCallback(() => setRect(null), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} onMouseEnter={show} onMouseLeave={hide}>
|
||||||
|
{children}
|
||||||
|
{rect &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
className="tooltip"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
left: rect.left + rect.width / 2,
|
||||||
|
top: rect.top,
|
||||||
|
transform: "translate(-50%, calc(-100% - 7px))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export interface PlaylistEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentEvent {
|
export interface CommentEvent {
|
||||||
type: "created" | "deleted";
|
type: "created" | "deleted" | "updated";
|
||||||
dumpId: string;
|
dumpId: string;
|
||||||
comment?: Comment;
|
comment?: Comment;
|
||||||
commentId?: string;
|
commentId?: string;
|
||||||
|
|||||||
@@ -140,6 +140,18 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
case "dump_updated": {
|
case "dump_updated": {
|
||||||
const dump = deserializeDump(msg.dump as RawDump);
|
const dump = deserializeDump(msg.dump as RawDump);
|
||||||
setLastDumpEvent(dump);
|
setLastDumpEvent(dump);
|
||||||
|
// Un-delete if this dump was previously removed from the feed
|
||||||
|
// (e.g. it was made private, and is now public again).
|
||||||
|
setDeletedDumpIds((prev) => {
|
||||||
|
if (!prev.has(dump.id)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(dump.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// Add to live feed if not already present (private→public).
|
||||||
|
setRecentDumps((prev) =>
|
||||||
|
prev.some((d) => d.id === dump.id) ? prev : [dump, ...prev]
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +239,16 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "comment_updated": {
|
||||||
|
const comment = deserializeComment(msg.comment as RawComment);
|
||||||
|
setLastCommentEvent({
|
||||||
|
type: "updated",
|
||||||
|
dumpId: comment.dumpId,
|
||||||
|
comment,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "notification_created": {
|
case "notification_created": {
|
||||||
const notification = deserializeNotification(
|
const notification = deserializeNotification(
|
||||||
msg.notification as RawNotification,
|
msg.notification as RawNotification,
|
||||||
|
|||||||
49
src/hooks/useDumpListSync.ts
Normal file
49
src/hooks/useDumpListSync.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
import type { Dump } from "../model.ts";
|
||||||
|
import { useWS } from "./useWS.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a dump list in sync with real-time WS events:
|
||||||
|
* - deletedDumpIds: filters out dumps that were deleted or privatised.
|
||||||
|
* - lastDumpEvent: updates existing dumps in-place; optionally prepends
|
||||||
|
* new ones when `addFilter` returns true for them.
|
||||||
|
*
|
||||||
|
* @param setDumps Updater that patches the caller's dump array.
|
||||||
|
* @param addFilter Optional predicate: return true to prepend a dump that
|
||||||
|
* isn't already in the list (e.g. became public).
|
||||||
|
*/
|
||||||
|
export function useDumpListSync(
|
||||||
|
setDumps: (fn: (prev: Dump[]) => Dump[]) => void,
|
||||||
|
addFilter?: (dump: Dump) => boolean,
|
||||||
|
): void {
|
||||||
|
const { deletedDumpIds, lastDumpEvent } = useWS();
|
||||||
|
|
||||||
|
// Keep refs up-to-date so closures in effects are never stale.
|
||||||
|
const setDumpsRef = useRef(setDumps);
|
||||||
|
const addFilterRef = useRef(addFilter);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setDumpsRef.current = setDumps;
|
||||||
|
addFilterRef.current = addFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deletedDumpIds.size === 0) return;
|
||||||
|
setDumpsRef.current((prev) => prev.filter((d) => !deletedDumpIds.has(d.id)));
|
||||||
|
}, [deletedDumpIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastDumpEvent) return;
|
||||||
|
setDumpsRef.current((prev) => {
|
||||||
|
const idx = prev.findIndex((d) => d.id === lastDumpEvent.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = lastDumpEvent;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
if (addFilterRef.current?.(lastDumpEvent)) {
|
||||||
|
return [lastDumpEvent, ...prev];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [lastDumpEvent]);
|
||||||
|
}
|
||||||
162
src/hooks/useMentionAutocomplete.ts
Normal file
162
src/hooks/useMentionAutocomplete.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
|
export interface UserResult {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
avatarMime: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MentionState {
|
||||||
|
open: boolean;
|
||||||
|
query: string;
|
||||||
|
start: number;
|
||||||
|
results: UserResult[];
|
||||||
|
selectedIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLOSED: MentionState = {
|
||||||
|
open: false,
|
||||||
|
query: "",
|
||||||
|
start: 0,
|
||||||
|
results: [],
|
||||||
|
selectedIndex: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getMentionQuery(
|
||||||
|
value: string,
|
||||||
|
pos: number,
|
||||||
|
): { query: string; start: number } | null {
|
||||||
|
const textBefore = value.slice(0, pos);
|
||||||
|
// Match @word at end of text before cursor, not preceded by [ ( or word char
|
||||||
|
const match = textBefore.match(/(?<![[(A-Za-z0-9_])@([A-Za-z0-9_]*)$/);
|
||||||
|
if (!match || match[1].length === 0) return null;
|
||||||
|
const start = pos - match[0].length;
|
||||||
|
return { query: match[1], start };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMentionAutocomplete(
|
||||||
|
value: string,
|
||||||
|
onChange: (v: string) => void,
|
||||||
|
textareaRef: RefObject<HTMLTextAreaElement | null>,
|
||||||
|
) {
|
||||||
|
const [state, setState] = useState<MentionState>(CLOSED);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const cursorRef = useRef<number>(0);
|
||||||
|
|
||||||
|
const handleMentionChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
const pos = e.target.selectionStart ?? newValue.length;
|
||||||
|
cursorRef.current = pos;
|
||||||
|
onChange(newValue);
|
||||||
|
|
||||||
|
const mention = getMentionQuery(newValue, pos);
|
||||||
|
if (!mention) {
|
||||||
|
setState(CLOSED);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
open: false,
|
||||||
|
query: mention.query,
|
||||||
|
start: mention.start,
|
||||||
|
selectedIndex: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_URL}/api/users/search?q=${encodeURIComponent(mention.query)}`,
|
||||||
|
);
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.success && body.data.length > 0) {
|
||||||
|
setState((s) =>
|
||||||
|
s.query === mention.query
|
||||||
|
? { ...s, open: true, results: body.data, selectedIndex: 0 }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setState(CLOSED);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setState(CLOSED);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const doSelect = useCallback(
|
||||||
|
(username: string, start: number, cursorPos: number) => {
|
||||||
|
const before = value.slice(0, start);
|
||||||
|
const after = value.slice(cursorPos);
|
||||||
|
onChange(`${before}@${username} ${after}`);
|
||||||
|
setState(CLOSED);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (el) {
|
||||||
|
const newPos = start + username.length + 2; // @ + username + space
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(newPos, newPos);
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
},
|
||||||
|
[value, onChange, textareaRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMentionKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (!state.open || state.results.length === 0) return;
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
selectedIndex: Math.min(s.selectedIndex + 1, s.results.length - 1),
|
||||||
|
}));
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setState((s) => ({
|
||||||
|
...s,
|
||||||
|
selectedIndex: Math.max(s.selectedIndex - 1, 0),
|
||||||
|
}));
|
||||||
|
} else if (
|
||||||
|
(e.key === "Enter" || e.key === "Tab") && !e.ctrlKey && !e.metaKey
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
const user = state.results[state.selectedIndex];
|
||||||
|
if (user) doSelect(user.username, state.start, cursorRef.current);
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setState(CLOSED);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state, doSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMentionSelect = useCallback(
|
||||||
|
(username: string) => {
|
||||||
|
doSelect(username, state.start, cursorRef.current);
|
||||||
|
},
|
||||||
|
[doSelect, state.start],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mentionOpen: state.open,
|
||||||
|
mentionResults: state.results,
|
||||||
|
mentionSelectedIndex: state.selectedIndex,
|
||||||
|
handleMentionChange,
|
||||||
|
handleMentionKeyDown,
|
||||||
|
handleMentionSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
88
src/hooks/usePlaylistListSync.ts
Normal file
88
src/hooks/usePlaylistListSync.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
import type { Playlist } from "../model.ts";
|
||||||
|
import { useWS } from "./useWS.ts";
|
||||||
|
|
||||||
|
interface PlaylistListSyncOptions {
|
||||||
|
/** Keep private playlists visible (caller is the owner). */
|
||||||
|
isOwner?: boolean;
|
||||||
|
/**
|
||||||
|
* Only act on created/updated events for playlists owned by this user.
|
||||||
|
* Leave undefined to act on any playlist already in the list.
|
||||||
|
*/
|
||||||
|
ownerId?: string;
|
||||||
|
/**
|
||||||
|
* Set true for "followed" lists: never prepend newly created playlists
|
||||||
|
* (followed membership is managed separately), but still update/remove.
|
||||||
|
*/
|
||||||
|
noNewEntries?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps a playlist list in sync with real-time WS events:
|
||||||
|
* - lastPlaylistEvent "created" → prepend (unless noNewEntries)
|
||||||
|
* - lastPlaylistEvent "updated" → update in-place, or remove if now private
|
||||||
|
* - lastPlaylistEvent "deleted" → remove
|
||||||
|
* - deletedPlaylistIds growing → filter
|
||||||
|
*/
|
||||||
|
export function usePlaylistListSync(
|
||||||
|
setPlaylists: (fn: (prev: Playlist[]) => Playlist[]) => void,
|
||||||
|
options?: PlaylistListSyncOptions,
|
||||||
|
): void {
|
||||||
|
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||||
|
|
||||||
|
const setPlaylistsRef = useRef(setPlaylists);
|
||||||
|
const optionsRef = useRef(options);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setPlaylistsRef.current = setPlaylists;
|
||||||
|
optionsRef.current = options;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastPlaylistEvent) return;
|
||||||
|
const { isOwner, ownerId, noNewEntries } = optionsRef.current ?? {};
|
||||||
|
const ev = lastPlaylistEvent;
|
||||||
|
|
||||||
|
setPlaylistsRef.current((prev) => {
|
||||||
|
if (ev.type === "created" && ev.playlist) {
|
||||||
|
if (noNewEntries) return prev;
|
||||||
|
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
||||||
|
if (!ev.playlist.isPublic && !isOwner) return prev;
|
||||||
|
if (prev.some((p) => p.id === ev.playlist!.id)) return prev;
|
||||||
|
return [ev.playlist, ...prev];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.type === "updated" && ev.playlist) {
|
||||||
|
const idx = prev.findIndex((p) => p.id === ev.playlist!.id);
|
||||||
|
if (idx === -1) {
|
||||||
|
// Playlist was removed (went private). Re-add if it's now public and
|
||||||
|
// visible to this list. noNewEntries lists (followed) skip this since
|
||||||
|
// we can't verify the follow relationship is still active.
|
||||||
|
if (noNewEntries) return prev;
|
||||||
|
if (ownerId && ev.playlist.userId !== ownerId) return prev;
|
||||||
|
if (!ev.playlist.isPublic && !isOwner) return prev;
|
||||||
|
return [ev.playlist, ...prev];
|
||||||
|
}
|
||||||
|
// Remove if it became private and the viewer can't see private playlists
|
||||||
|
if (!ev.playlist.isPublic && !isOwner) {
|
||||||
|
return prev.filter((p) => p.id !== ev.playlist!.id);
|
||||||
|
}
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = ev.playlist;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.type === "deleted") {
|
||||||
|
return prev.filter((p) => p.id !== ev.playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [lastPlaylistEvent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (deletedPlaylistIds.size === 0) return;
|
||||||
|
setPlaylistsRef.current((prev) =>
|
||||||
|
prev.filter((p) => !deletedPlaylistIds.has(p.id))
|
||||||
|
);
|
||||||
|
}, [deletedPlaylistIds]);
|
||||||
|
}
|
||||||
61
src/model.ts
61
src/model.ts
@@ -23,9 +23,11 @@ export interface Dump {
|
|||||||
id: string;
|
id: string;
|
||||||
kind: "url" | "file";
|
kind: "url" | "file";
|
||||||
title: string;
|
title: string;
|
||||||
|
slug?: string;
|
||||||
comment?: string;
|
comment?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
url?: string;
|
url?: string;
|
||||||
richContent?: RichContent;
|
richContent?: RichContent;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
@@ -45,6 +47,7 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
invitedByUsername?: string;
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
@@ -55,14 +58,15 @@ export interface PublicUser {
|
|||||||
username: string;
|
username: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
invitedByUsername?: string;
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire types — createdAt arrives as an ISO string from API/WS/localStorage
|
// Wire types — createdAt/updatedAt arrive as ISO strings from API/WS/localStorage
|
||||||
type WithStringDate<T extends { createdAt: Date }> = Omit<T, "createdAt"> & {
|
type WithStringDate<T extends { createdAt: Date }> =
|
||||||
createdAt: string;
|
& Omit<T, "createdAt" | "updatedAt">
|
||||||
};
|
& { createdAt: string; updatedAt?: string };
|
||||||
export type RawDump = WithStringDate<Dump>;
|
export type RawDump = WithStringDate<Dump>;
|
||||||
export type RawUser = WithStringDate<User>;
|
export type RawUser = WithStringDate<User>;
|
||||||
export type RawPublicUser = WithStringDate<PublicUser>;
|
export type RawPublicUser = WithStringDate<PublicUser>;
|
||||||
@@ -70,15 +74,27 @@ export type RawAuthResponse = Omit<AuthResponse, "user"> & { user: RawUser };
|
|||||||
|
|
||||||
// Deserializers — convert wire types to domain types at API/WS/localStorage boundaries
|
// Deserializers — convert wire types to domain types at API/WS/localStorage boundaries
|
||||||
export function deserializeDump(raw: RawDump): Dump {
|
export function deserializeDump(raw: RawDump): Dump {
|
||||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
return {
|
||||||
|
...raw,
|
||||||
|
createdAt: new Date(raw.createdAt),
|
||||||
|
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializeUser(raw: RawUser): User {
|
export function deserializeUser(raw: RawUser): User {
|
||||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
return {
|
||||||
|
...raw,
|
||||||
|
createdAt: new Date(raw.createdAt),
|
||||||
|
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializePublicUser(raw: RawPublicUser): PublicUser {
|
export function deserializePublicUser(raw: RawPublicUser): PublicUser {
|
||||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
return {
|
||||||
|
...raw,
|
||||||
|
createdAt: new Date(raw.createdAt),
|
||||||
|
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
|
export function deserializeAuthResponse(raw: RawAuthResponse): AuthResponse {
|
||||||
@@ -117,6 +133,7 @@ export interface Comment {
|
|||||||
parentId?: string;
|
parentId?: string;
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
authorUsername: string;
|
authorUsername: string;
|
||||||
authorAvatarMime?: string;
|
authorAvatarMime?: string;
|
||||||
@@ -125,7 +142,11 @@ export interface Comment {
|
|||||||
export type RawComment = WithStringDate<Comment>;
|
export type RawComment = WithStringDate<Comment>;
|
||||||
|
|
||||||
export function deserializeComment(raw: RawComment): Comment {
|
export function deserializeComment(raw: RawComment): Comment {
|
||||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
return {
|
||||||
|
...raw,
|
||||||
|
createdAt: new Date(raw.createdAt),
|
||||||
|
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,9 +157,11 @@ export interface Playlist {
|
|||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
slug?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
imageMime?: string;
|
imageMime?: string;
|
||||||
dumpCount?: number;
|
dumpCount?: number;
|
||||||
ownerUsername?: string;
|
ownerUsername?: string;
|
||||||
@@ -163,7 +186,11 @@ export type RawPlaylistWithDumps =
|
|||||||
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
|
export type RawPlaylistMembership = { playlist: RawPlaylist; hasDump: boolean };
|
||||||
|
|
||||||
export function deserializePlaylist(raw: RawPlaylist): Playlist {
|
export function deserializePlaylist(raw: RawPlaylist): Playlist {
|
||||||
return { ...raw, createdAt: new Date(raw.createdAt) };
|
return {
|
||||||
|
...raw,
|
||||||
|
createdAt: new Date(raw.createdAt),
|
||||||
|
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deserializePlaylistWithDumps(
|
export function deserializePlaylistWithDumps(
|
||||||
@@ -172,6 +199,7 @@ export function deserializePlaylistWithDumps(
|
|||||||
return {
|
return {
|
||||||
...raw,
|
...raw,
|
||||||
createdAt: new Date(raw.createdAt),
|
createdAt: new Date(raw.createdAt),
|
||||||
|
updatedAt: raw.updatedAt ? new Date(raw.updatedAt) : undefined,
|
||||||
dumps: raw.dumps.map(deserializeDump),
|
dumps: raw.dumps.map(deserializeDump),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -340,7 +368,8 @@ export type NotificationType =
|
|||||||
| "user_followed"
|
| "user_followed"
|
||||||
| "user_dump_posted"
|
| "user_dump_posted"
|
||||||
| "playlist_dump_added"
|
| "playlist_dump_added"
|
||||||
| "dump_upvoted";
|
| "dump_upvoted"
|
||||||
|
| "user_mentioned";
|
||||||
|
|
||||||
export interface PlaylistFollowedData {
|
export interface PlaylistFollowedData {
|
||||||
followerId: string;
|
followerId: string;
|
||||||
@@ -375,12 +404,22 @@ export interface DumpUpvotedData {
|
|||||||
dumpTitle: string;
|
dumpTitle: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserMentionedData {
|
||||||
|
mentionerId: string;
|
||||||
|
mentionerUsername: string;
|
||||||
|
contextType: "comment" | "dump" | "playlist";
|
||||||
|
contextId: string;
|
||||||
|
contextTitle: string;
|
||||||
|
dumpId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type NotificationData =
|
export type NotificationData =
|
||||||
| PlaylistFollowedData
|
| PlaylistFollowedData
|
||||||
| UserFollowedData
|
| UserFollowedData
|
||||||
| UserDumpPostedData
|
| UserDumpPostedData
|
||||||
| PlaylistDumpAddedData
|
| PlaylistDumpAddedData
|
||||||
| DumpUpvotedData;
|
| DumpUpvotedData
|
||||||
|
| UserMentionedData;
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
||||||
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
@@ -22,6 +23,7 @@ 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 { Markdown } from "../components/Markdown.tsx";
|
||||||
import { CommentThread } from "../components/CommentThread.tsx";
|
import { CommentThread } from "../components/CommentThread.tsx";
|
||||||
|
import { Tooltip } from "../components/Tooltip.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
|
||||||
type DumpState =
|
type DumpState =
|
||||||
@@ -115,6 +117,18 @@ export function Dump() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [selectedDump, token]);
|
}, [selectedDump, token]);
|
||||||
|
|
||||||
|
// Scroll to and highlight a comment when navigating to #comment-{id}
|
||||||
|
useEffect(() => {
|
||||||
|
if (!location.hash.startsWith("#comment-")) return;
|
||||||
|
const id = location.hash.slice(1);
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
el.classList.add("comment-node--highlight");
|
||||||
|
const t = setTimeout(() => el.classList.remove("comment-node--highlight"), 2000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [comments, location.hash]);
|
||||||
|
|
||||||
// React to WS comment events
|
// React to WS comment events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
||||||
@@ -135,6 +149,14 @@ export function Dump() {
|
|||||||
: c
|
: c
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
lastCommentEvent.type === "updated" && lastCommentEvent.comment
|
||||||
|
) {
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === lastCommentEvent.comment!.id ? lastCommentEvent.comment! : c
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [lastCommentEvent, selectedDump]);
|
}, [lastCommentEvent, selectedDump]);
|
||||||
|
|
||||||
@@ -213,13 +235,21 @@ export function Dump() {
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
: <span className="dump-op-link">…</span>}
|
: <span className="dump-op-link">…</span>}
|
||||||
<time
|
<Tooltip text={dump.createdAt.toLocaleString()}>
|
||||||
className="dump-card-date"
|
<time
|
||||||
dateTime={dump.createdAt.toISOString()}
|
className="dump-card-date"
|
||||||
title={dump.createdAt.toLocaleString()}
|
dateTime={dump.createdAt.toISOString()}
|
||||||
>
|
>
|
||||||
{relativeTime(dump.createdAt)}
|
{relativeTime(dump.createdAt)}
|
||||||
</time>
|
</time>
|
||||||
|
</Tooltip>
|
||||||
|
{dump.updatedAt && (
|
||||||
|
<Tooltip text={`Edited ${dump.updatedAt.toLocaleString()}`}>
|
||||||
|
<span className="dump-edited-label">
|
||||||
|
edited {relativeTime(dump.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{dump.isPrivate && (
|
{dump.isPrivate && (
|
||||||
<span className="dump-card-private-badge">private</span>
|
<span className="dump-card-private-badge">private</span>
|
||||||
)}
|
)}
|
||||||
@@ -251,7 +281,7 @@ export function Dump() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="dump-actions">
|
<div className="dump-actions">
|
||||||
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
|
{canEdit && <Link to={`${dumpUrl(dump)}/edit`}>Edit</Link>}
|
||||||
<Link to="/">← Back to all dumps</Link>
|
<Link to="/">← Back to all dumps</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -271,6 +301,10 @@ export function Dump() {
|
|||||||
c.id === id ? { ...c, deleted: true, body: "" } : c
|
c.id === id ? { ...c, deleted: true, body: "" } : c
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
onCommentUpdated={(updated) =>
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.map((c) => (c.id === updated.id ? updated : c))
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{playlistModalOpen && (
|
{playlistModalOpen && (
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import { API_URL } from "../config/api.ts";
|
|||||||
import type { CreateUrlDumpRequest, RichContent } from "../model.ts";
|
import type { CreateUrlDumpRequest, RichContent } from "../model.ts";
|
||||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||||
import { formatBytes } from "../utils/format.ts";
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import RichContentCard from "../components/RichContentCard.tsx";
|
import RichContentCard from "../components/RichContentCard.tsx";
|
||||||
import { MediaPlayer } from "../components/MediaPlayer.tsx";
|
import { MediaPlayer } from "../components/MediaPlayer.tsx";
|
||||||
|
import { TextEditor } from "../components/TextEditor.tsx";
|
||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
|
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||||
@@ -46,15 +49,8 @@ function LocalFilePreview({ file }: { file: File }) {
|
|||||||
if (mime.startsWith("audio/")) {
|
if (mime.startsWith("audio/")) {
|
||||||
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
|
||||||
}
|
}
|
||||||
return (
|
// For other types the drop zone chip already shows name + size.
|
||||||
<div className="local-preview-generic">
|
return null;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DumpCreate() {
|
export function DumpCreate() {
|
||||||
@@ -144,7 +140,7 @@ export function DumpCreate() {
|
|||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
navigate(`/dumps/${apiResponse.data.id}`);
|
navigate(dumpUrl(apiResponse.data));
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState({
|
||||||
status: "error",
|
status: "error",
|
||||||
@@ -266,26 +262,21 @@ export function DumpCreate() {
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<>
|
<>
|
||||||
<div key="file-field" className="form-group">
|
<FileDropZone
|
||||||
<label htmlFor="file">File</label>
|
file={file}
|
||||||
<input
|
onChange={setFile}
|
||||||
id="file"
|
disabled={submitting}
|
||||||
type="file"
|
/>
|
||||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
||||||
disabled={submitting}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{file && <LocalFilePreview file={file} />}
|
{file && <LocalFilePreview file={file} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="comment">Why are you dumping this?</label>
|
<label htmlFor="comment">Why are you dumping this?</label>
|
||||||
<textarea
|
<TextEditor
|
||||||
id="comment"
|
id="comment"
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={setComment}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
placeholder="Tell the community what makes this worth their time..."
|
placeholder="Tell the community what makes this worth their time..."
|
||||||
rows={3}
|
rows={3}
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import type { Dump, UpdateDumpRequest } from "../model.ts";
|
|||||||
import { deserializeDump } from "../model.ts";
|
import { deserializeDump } from "../model.ts";
|
||||||
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||||
import { formatBytes } from "../utils/format.ts";
|
import { formatBytes } from "../utils/format.ts";
|
||||||
|
import { dumpUrl } from "../utils/urls.ts";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||||
import RichContentCard from "../components/RichContentCard.tsx";
|
import RichContentCard from "../components/RichContentCard.tsx";
|
||||||
import FilePreview from "../components/FilePreview.tsx";
|
import FilePreview from "../components/FilePreview.tsx";
|
||||||
|
import { TextEditor } from "../components/TextEditor.tsx";
|
||||||
|
import { FileDropZone } from "../components/FileDropZone.tsx";
|
||||||
|
|
||||||
type DumpEditState =
|
type DumpEditState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -101,7 +104,7 @@ export function DumpEdit() {
|
|||||||
const updatedDump: Dump = deserializeDump(apiResponse.data);
|
const updatedDump: Dump = deserializeDump(apiResponse.data);
|
||||||
setState({ status: "loaded", dump: updatedDump });
|
setState({ status: "loaded", dump: updatedDump });
|
||||||
setNewFile(null);
|
setNewFile(null);
|
||||||
navigate(`/dumps/${updatedDump.id}`, { state: { dump: updatedDump } });
|
navigate(dumpUrl(updatedDump), { state: { dump: updatedDump } });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefreshMetadata = async () => {
|
const handleRefreshMetadata = async () => {
|
||||||
@@ -236,26 +239,22 @@ export function DumpEdit() {
|
|||||||
<strong>{dump.fileName}</strong>
|
<strong>{dump.fileName}</strong>
|
||||||
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
|
||||||
</p>
|
</p>
|
||||||
<label htmlFor="replace-file">Replace file</label>
|
<FileDropZone
|
||||||
<input
|
file={newFile}
|
||||||
id="replace-file"
|
onChange={setNewFile}
|
||||||
type="file"
|
label="Replace file"
|
||||||
onChange={(e) => setNewFile(e.target.files?.[0] ?? null)}
|
hint="Drop a replacement here"
|
||||||
|
showLimit={false}
|
||||||
/>
|
/>
|
||||||
{newFile && (
|
|
||||||
<p className="file-input-info">
|
|
||||||
{newFile.name} — {formatBytes(newFile.size)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="comment">Why are you dumping this?</label>
|
<label htmlFor="comment">Why are you dumping this?</label>
|
||||||
<textarea
|
<TextEditor
|
||||||
id="comment"
|
id="comment"
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => setComment(e.currentTarget.value)}
|
onChange={setComment}
|
||||||
placeholder="Tell the community what makes this worth their time..."
|
placeholder="Tell the community what makes this worth their time..."
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
@@ -287,7 +286,7 @@ export function DumpEdit() {
|
|||||||
Delete dump
|
Delete dump
|
||||||
</button>
|
</button>
|
||||||
<div className="form-actions-right">
|
<div className="form-actions-right">
|
||||||
<Link to={`/dumps/${dump.id}`} className="form-cancel">
|
<Link to={dumpUrl(dump)} className="form-cancel">
|
||||||
Cancel
|
Cancel
|
||||||
</Link>
|
</Link>
|
||||||
<button type="submit" className="btn-primary">Save</button>
|
<button type="submit" className="btn-primary">Save</button>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { friendlyFetchError } from "../utils/apiError.ts";
|
|||||||
import { useFeedCache } from "../hooks/useFeedCache.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 { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -172,6 +173,24 @@ export function Index() {
|
|||||||
DumpsState
|
DumpsState
|
||||||
>({ status: "loading" });
|
>({ status: "loading" });
|
||||||
|
|
||||||
|
const setFollowedUsersDumpsItems = useCallback(
|
||||||
|
(fn: (prev: Dump[]) => Dump[]) =>
|
||||||
|
setFollowedUsersDumps((s) =>
|
||||||
|
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
useDumpListSync(setFollowedUsersDumpsItems);
|
||||||
|
|
||||||
|
const setFollowedPlaylistsDumpsItems = useCallback(
|
||||||
|
(fn: (prev: Dump[]) => Dump[]) =>
|
||||||
|
setFollowedPlaylistsDumps((s) =>
|
||||||
|
s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) }
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
useDumpListSync(setFollowedPlaylistsDumpsItems);
|
||||||
|
|
||||||
const [tab, setTab] = useState<FeedTab>("hot");
|
const [tab, setTab] = useState<FeedTab>("hot");
|
||||||
const [followedSection, setFollowedSection] = useState<FollowedSection>(
|
const [followedSection, setFollowedSection] = useState<FollowedSection>(
|
||||||
"users",
|
"users",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
|
import { Tooltip } from "../components/Tooltip.tsx";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import type {
|
import type {
|
||||||
DumpUpvotedData,
|
DumpUpvotedData,
|
||||||
@@ -15,6 +16,7 @@ import type {
|
|||||||
RawNotification,
|
RawNotification,
|
||||||
UserDumpPostedData,
|
UserDumpPostedData,
|
||||||
UserFollowedData,
|
UserFollowedData,
|
||||||
|
UserMentionedData,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { deserializeNotification } from "../model.ts";
|
import { deserializeNotification } from "../model.ts";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
@@ -33,7 +35,7 @@ type State =
|
|||||||
loadingMore: boolean;
|
loadingMore: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
|
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist" | "mention";
|
||||||
|
|
||||||
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -47,16 +49,31 @@ function notifIconKind(type: Notification["type"]): NotifIconKind {
|
|||||||
return "dump";
|
return "dump";
|
||||||
case "playlist_dump_added":
|
case "playlist_dump_added":
|
||||||
return "playlist";
|
return "playlist";
|
||||||
|
case "user_mentioned":
|
||||||
|
return "mention";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UpvoteSvg = () => (
|
||||||
|
<svg viewBox="0 0 10 10" width="11" height="11" fill="currentColor">
|
||||||
|
<polygon points="5,1 9,9 1,9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FollowSvg = () => (
|
||||||
|
<svg viewBox="0 0 10 10" width="10" height="10" fill="currentColor">
|
||||||
|
<polygon points="2,1 9,5 2,9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
function NotifIcon({ type }: { type: Notification["type"] }) {
|
function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||||
const kind = notifIconKind(type);
|
const kind = notifIconKind(type);
|
||||||
const glyphs: Record<NotifIconKind, string> = {
|
const glyphs: Record<NotifIconKind, React.ReactNode> = {
|
||||||
upvote: "▲",
|
upvote: <UpvoteSvg />,
|
||||||
follow: "►",
|
follow: <FollowSvg />,
|
||||||
dump: "🚚",
|
dump: "🚚",
|
||||||
playlist: "📜",
|
playlist: "📜",
|
||||||
|
mention: "@",
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<span className={`notif-icon notif-icon--${kind}`}>
|
<span className={`notif-icon notif-icon--${kind}`}>
|
||||||
@@ -65,78 +82,56 @@ function NotifIcon({ type }: { type: Notification["type"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notificationLink(n: Notification): string {
|
||||||
|
const data = n.data as NotificationData;
|
||||||
|
switch (n.type) {
|
||||||
|
case "user_followed":
|
||||||
|
return `/users/${(data as UserFollowedData).followerUsername}`;
|
||||||
|
case "playlist_followed":
|
||||||
|
return `/playlists/${(data as PlaylistFollowedData).playlistId}`;
|
||||||
|
case "user_dump_posted":
|
||||||
|
return `/dumps/${(data as UserDumpPostedData).dumpId}`;
|
||||||
|
case "playlist_dump_added":
|
||||||
|
return `/dumps/${(data as PlaylistDumpAddedData).dumpId}`;
|
||||||
|
case "dump_upvoted":
|
||||||
|
return `/dumps/${(data as DumpUpvotedData).dumpId}`;
|
||||||
|
case "user_mentioned": {
|
||||||
|
const d = data as UserMentionedData;
|
||||||
|
if (d.contextType === "comment") return `/dumps/${d.dumpId}#comment-${d.contextId}`;
|
||||||
|
if (d.contextType === "dump") return `/dumps/${d.contextId}`;
|
||||||
|
return `/playlists/${d.contextId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function notificationContent(n: Notification): React.ReactNode {
|
function notificationContent(n: Notification): React.ReactNode {
|
||||||
const data = n.data as NotificationData;
|
const data = n.data as NotificationData;
|
||||||
switch (n.type) {
|
switch (n.type) {
|
||||||
case "user_followed": {
|
case "user_followed": {
|
||||||
const d = data as UserFollowedData;
|
const d = data as UserFollowedData;
|
||||||
return (
|
return <><strong>{d.followerUsername}</strong>{" started following you"}</>;
|
||||||
<>
|
|
||||||
<Link to={`/users/${d.followerUsername}`} className="notif-link">
|
|
||||||
{d.followerUsername}
|
|
||||||
</Link>
|
|
||||||
{" started following you"}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
case "playlist_followed": {
|
case "playlist_followed": {
|
||||||
const d = data as PlaylistFollowedData;
|
const d = data as PlaylistFollowedData;
|
||||||
return (
|
return <><strong>{d.followerUsername}</strong>{" followed your playlist "}<strong>{d.playlistTitle}</strong></>;
|
||||||
<>
|
|
||||||
<Link to={`/users/${d.followerUsername}`} className="notif-link">
|
|
||||||
{d.followerUsername}
|
|
||||||
</Link>
|
|
||||||
{" followed your playlist "}
|
|
||||||
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
|
|
||||||
{d.playlistTitle}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
case "user_dump_posted": {
|
case "user_dump_posted": {
|
||||||
const d = data as UserDumpPostedData;
|
const d = data as UserDumpPostedData;
|
||||||
return (
|
return <><strong>{d.dumperUsername}</strong>{" posted "}<strong>{d.dumpTitle}</strong></>;
|
||||||
<>
|
|
||||||
<Link to={`/users/${d.dumperUsername}`} className="notif-link">
|
|
||||||
{d.dumperUsername}
|
|
||||||
</Link>
|
|
||||||
{" posted "}
|
|
||||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
|
||||||
{d.dumpTitle}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
case "playlist_dump_added": {
|
case "playlist_dump_added": {
|
||||||
const d = data as PlaylistDumpAddedData;
|
const d = data as PlaylistDumpAddedData;
|
||||||
return (
|
return <><strong>{d.dumpTitle}</strong>{" was added to "}<strong>{d.playlistTitle}</strong></>;
|
||||||
<>
|
|
||||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
|
||||||
{d.dumpTitle}
|
|
||||||
</Link>
|
|
||||||
{" was added to "}
|
|
||||||
<Link to={`/playlists/${d.playlistId}`} className="notif-link">
|
|
||||||
{d.playlistTitle}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
case "dump_upvoted": {
|
case "dump_upvoted": {
|
||||||
const d = data as DumpUpvotedData;
|
const d = data as DumpUpvotedData;
|
||||||
return (
|
return <><strong>{d.voterUsername}</strong>{" upvoted "}<strong>{d.dumpTitle}</strong></>;
|
||||||
<>
|
}
|
||||||
<Link to={`/users/${d.voterUsername}`} className="notif-link">
|
case "user_mentioned": {
|
||||||
{d.voterUsername}
|
const d = data as UserMentionedData;
|
||||||
</Link>
|
const where = d.contextTitle || (d.contextType === "comment" ? "a comment" : "a post");
|
||||||
{" upvoted "}
|
return <><strong>{d.mentionerUsername}</strong>{" mentioned you in "}<strong>{where}</strong></>;
|
||||||
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
|
||||||
{d.dumpTitle}
|
|
||||||
</Link>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
default:
|
|
||||||
return "New notification";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,21 +303,28 @@ export function Notifications() {
|
|||||||
!n.read ? " notification-item--unread" : ""
|
!n.read ? " notification-item--unread" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<NotifIcon type={n.type} />
|
<Link
|
||||||
<div className="notification-body">
|
to={notificationLink(n)}
|
||||||
<span className="notification-content">
|
className="notification-item-link"
|
||||||
{notificationContent(n)}
|
>
|
||||||
</span>
|
<NotifIcon type={n.type} />
|
||||||
<time
|
<div className="notification-body">
|
||||||
className="notification-time"
|
<span className="notification-content">
|
||||||
dateTime={n.createdAt.toISOString()}
|
{notificationContent(n)}
|
||||||
>
|
</span>
|
||||||
{timeAgo(n.createdAt)}
|
<Tooltip text={n.createdAt.toLocaleString()}>
|
||||||
</time>
|
<time
|
||||||
</div>
|
className="notification-time"
|
||||||
{!n.read && (
|
dateTime={n.createdAt.toISOString()}
|
||||||
<span className="notif-dot" aria-hidden="true" />
|
>
|
||||||
)}
|
{timeAgo(n.createdAt)}
|
||||||
|
</time>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
{!n.read && (
|
||||||
|
<span className="notif-dot" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
|
import type {
|
||||||
import { deserializePlaylistWithDumps } from "../model.ts";
|
PlaylistWithDumps,
|
||||||
|
RawDump,
|
||||||
|
RawPlaylist,
|
||||||
|
RawPlaylistWithDumps,
|
||||||
|
} from "../model.ts";
|
||||||
|
import {
|
||||||
|
deserializeDump,
|
||||||
|
deserializePlaylist,
|
||||||
|
deserializePlaylistWithDumps,
|
||||||
|
} from "../model.ts";
|
||||||
|
import { playlistUrl } from "../utils/urls.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 { relativeTime } from "../utils/relativeTime.ts";
|
import { relativeTime } from "../utils/relativeTime.ts";
|
||||||
@@ -12,8 +22,10 @@ import { PageError } from "../components/PageError.tsx";
|
|||||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||||
import { ImagePicker } from "../components/ImagePicker.tsx";
|
import { ImagePicker } from "../components/ImagePicker.tsx";
|
||||||
import { Markdown } from "../components/Markdown.tsx";
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
|
import { TextEditor } from "../components/TextEditor.tsx";
|
||||||
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
|
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
|
||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
|
import { Tooltip } from "../components/Tooltip.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
@@ -31,10 +43,13 @@ export function PlaylistDetail() {
|
|||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
deletedDumpIds,
|
deletedDumpIds,
|
||||||
|
lastDumpEvent,
|
||||||
lastPlaylistEvent,
|
lastPlaylistEvent,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||||
|
// Stable UUID for WS comparisons — avoids re-running effects on every state change
|
||||||
|
const playlistUUID = state.status === "loaded" ? state.playlist.id : null;
|
||||||
|
|
||||||
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
|
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
|
||||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||||
@@ -45,7 +60,9 @@ export function PlaylistDetail() {
|
|||||||
>({});
|
>({});
|
||||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||||
|
|
||||||
const [dragSrcIndex, setDragSrcIndex] = useState<number | null>(null);
|
// dragSrcRef: mutable ref so handleDragOver always sees the current source index
|
||||||
|
// without stale closure issues (state would only update on next render).
|
||||||
|
const dragSrcRef = useRef<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
@@ -58,9 +75,18 @@ export function PlaylistDetail() {
|
|||||||
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);
|
||||||
|
|
||||||
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
|
// Mirrors activeDumpIds for use in effects without adding it as a dep.
|
||||||
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
|
// Updated on every render via useLayoutEffect so it's always current.
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>(null);
|
const activeDumpIdsRef = useRef(activeDumpIds);
|
||||||
|
// knownDumpIds: all dump IDs that belong to this playlist (for re-adding when dumps become public again)
|
||||||
|
const knownDumpIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
// Authoritative dump order from the server (fetchPlaylist + dumps_updated events).
|
||||||
|
// Used to re-insert dumps at their correct position after private→public transitions.
|
||||||
|
const dumpOrderRef = useRef<string[]>([]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
activeDumpIdsRef.current = activeDumpIds;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
@@ -87,8 +113,10 @@ export function PlaylistDetail() {
|
|||||||
);
|
);
|
||||||
setState({ status: "loaded", playlist: pl });
|
setState({ status: "loaded", playlist: pl });
|
||||||
const ids = new Set(pl.dumps.map((d) => d.id));
|
const ids = new Set(pl.dumps.map((d) => d.id));
|
||||||
|
const order = pl.dumps.map((d) => d.id);
|
||||||
setActiveDumpIds(ids);
|
setActiveDumpIds(ids);
|
||||||
prevActiveDumpIdsRef.current = ids;
|
dumpOrderRef.current = order;
|
||||||
|
for (const id of ids) knownDumpIdsRef.current.add(id);
|
||||||
setFading({});
|
setFading({});
|
||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
cancels.current.clear();
|
cancels.current.clear();
|
||||||
@@ -172,13 +200,17 @@ export function PlaylistDetail() {
|
|||||||
|
|
||||||
// WS: playlist metadata updated or deleted
|
// WS: playlist metadata updated or deleted
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!lastPlaylistEvent || !playlistId) return;
|
if (!lastPlaylistEvent || !playlistUUID) return;
|
||||||
const ev = lastPlaylistEvent;
|
const ev = lastPlaylistEvent;
|
||||||
if (ev.playlistId !== playlistId) return;
|
// Compare against the resolved UUID, not the URL param (which may be a slug)
|
||||||
|
if (ev.playlistId !== playlistUUID) return;
|
||||||
|
|
||||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||||
const newIds = new Set(ev.dumpIds);
|
const newIds = new Set(ev.dumpIds);
|
||||||
const prev = prevActiveDumpIdsRef.current ?? new Set<string>();
|
for (const id of newIds) knownDumpIdsRef.current.add(id);
|
||||||
|
// Use the ref so we always diff against the current activeDumpIds,
|
||||||
|
// including changes from deletedDumpIds / lastDumpEvent effects.
|
||||||
|
const prev = activeDumpIdsRef.current;
|
||||||
|
|
||||||
// Removed: were active, not in new set → fade out
|
// Removed: were active, not in new set → fade out
|
||||||
for (const id of prev) {
|
for (const id of prev) {
|
||||||
@@ -192,46 +224,65 @@ export function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-added while fading → cancel fade, restore to active
|
// Newly added IDs: cancel any fade, mark active, fetch dump data individually.
|
||||||
|
// We never call fetchPlaylist here — that would reset state to "loading", cycle
|
||||||
|
// playlistUUID, and re-trigger this effect in a loop.
|
||||||
for (const id of newIds) {
|
for (const id of newIds) {
|
||||||
if (!prev.has(id)) {
|
if (!prev.has(id)) {
|
||||||
if (cancels.current.has(id)) {
|
cancels.current.get(id)?.();
|
||||||
cancels.current.get(id)!();
|
|
||||||
}
|
|
||||||
// If this is a brand-new dump we haven't seen, re-fetch
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
const known = s.playlist.dumps.some((d) => d.id === id);
|
|
||||||
if (!known) {
|
|
||||||
// Trigger a re-fetch asynchronously
|
|
||||||
setTimeout(fetchPlaylist, 0);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
setActiveDumpIds((s) => new Set([...s, id]));
|
setActiveDumpIds((s) => new Set([...s, id]));
|
||||||
|
// Capture ev.dumpIds so we can insert the new dump at its correct position.
|
||||||
|
const orderedIds = ev.dumpIds!;
|
||||||
|
fetch(`${API_URL}/api/dumps/${id}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
.then((r) => r.ok ? r.json() : null)
|
||||||
|
.then((body) => {
|
||||||
|
if (!body?.success) return;
|
||||||
|
const dump = deserializeDump(body.data as RawDump);
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded") return s;
|
||||||
|
if (s.playlist.dumps.some((d) => d.id === dump.id)) return s;
|
||||||
|
// Insert at the correct server-ordered position.
|
||||||
|
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||||
|
dumpMap.set(dump.id, dump);
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
playlist: {
|
||||||
|
...s.playlist,
|
||||||
|
dumps: [
|
||||||
|
...orderedIds
|
||||||
|
.filter((oid) => dumpMap.has(oid))
|
||||||
|
.map((oid) => dumpMap.get(oid)!),
|
||||||
|
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reorder active dumps to match the new server order,
|
// Apply the server-authoritative order: active dumps in ev.dumpIds order,
|
||||||
// keeping fading dumps at their current visual positions.
|
// fading dumps (not in newIds) appended at the end.
|
||||||
setState((prev) => {
|
setState((s) => {
|
||||||
if (prev.status !== "loaded") return prev;
|
if (s.status !== "loaded") return s;
|
||||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
const dumpMap = new Map(s.playlist.dumps.map((d) => [d.id, d]));
|
||||||
const activeQueue = ev.dumpIds!
|
|
||||||
.filter((id) => dumpMap.has(id))
|
|
||||||
.map((id) => dumpMap.get(id)!);
|
|
||||||
let qi = 0;
|
|
||||||
const result = prev.playlist.dumps
|
|
||||||
.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,
|
...s,
|
||||||
playlist: { ...prev.playlist, dumps: result },
|
playlist: {
|
||||||
|
...s.playlist,
|
||||||
|
dumps: [
|
||||||
|
...ev.dumpIds!
|
||||||
|
.filter((id) => dumpMap.has(id))
|
||||||
|
.map((id) => dumpMap.get(id)!),
|
||||||
|
...s.playlist.dumps.filter((d) => !newIds.has(d.id)),
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
dumpOrderRef.current = ev.dumpIds!;
|
||||||
prevActiveDumpIdsRef.current = newIds;
|
|
||||||
} else if (ev.type === "updated" && ev.playlist) {
|
} else if (ev.type === "updated" && ev.playlist) {
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.status !== "loaded") return prev;
|
if (prev.status !== "loaded") return prev;
|
||||||
@@ -249,7 +300,7 @@ export function PlaylistDetail() {
|
|||||||
} else if (ev.type === "deleted") {
|
} else if (ev.type === "deleted") {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
}, [lastPlaylistEvent, playlistId]);
|
}, [lastPlaylistEvent, playlistUUID]);
|
||||||
|
|
||||||
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -269,36 +320,85 @@ export function PlaylistDetail() {
|
|||||||
});
|
});
|
||||||
}, [deletedDumpIds]);
|
}, [deletedDumpIds]);
|
||||||
|
|
||||||
const handleDragStart = (index: number) => setDragSrcIndex(index);
|
// Update dump metadata in-place; re-add if it was in this playlist but hidden (private→public)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastDumpEvent) return;
|
||||||
|
const dump = lastDumpEvent;
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.status !== "loaded") return prev;
|
||||||
|
const idx = prev.playlist.dumps.findIndex((d) => d.id === dump.id);
|
||||||
|
if (idx !== -1) {
|
||||||
|
// Update in-place
|
||||||
|
const dumps = [...prev.playlist.dumps];
|
||||||
|
dumps[idx] = dump;
|
||||||
|
return { ...prev, playlist: { ...prev.playlist, dumps } };
|
||||||
|
}
|
||||||
|
// Re-add if this dump belongs to the playlist and is now public,
|
||||||
|
// inserting at its correct server-ordered position.
|
||||||
|
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||||
|
const order = dumpOrderRef.current;
|
||||||
|
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||||
|
dumpMap.set(dump.id, dump);
|
||||||
|
const reinserted = order.length > 0
|
||||||
|
? [
|
||||||
|
...order.filter((id) => dumpMap.has(id)).map((id) => dumpMap.get(id)!),
|
||||||
|
...prev.playlist.dumps.filter((d) => !new Set(order).has(d.id)),
|
||||||
|
]
|
||||||
|
: [...prev.playlist.dumps, dump];
|
||||||
|
return { ...prev, playlist: { ...prev.playlist, dumps: reinserted } };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
// Restore to activeDumpIds if re-added
|
||||||
|
if (!dump.isPrivate && knownDumpIdsRef.current.has(dump.id)) {
|
||||||
|
setActiveDumpIds((prev) => {
|
||||||
|
if (prev.has(dump.id)) return prev;
|
||||||
|
return new Set([...prev, dump.id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [lastDumpEvent]);
|
||||||
|
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
dragSrcRef.current = index;
|
||||||
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (dragSrcIndex === null || dragSrcIndex === index) return;
|
const src = dragSrcRef.current;
|
||||||
|
if (src === null || src === index) return;
|
||||||
|
// Only swap once the pointer has crossed the card's midpoint.
|
||||||
|
// Without this, entering a card immediately re-triggers the swap in the
|
||||||
|
// opposite direction (the two items keep bouncing back and forth).
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const mid = rect.top + rect.height / 2;
|
||||||
|
if (src < index && e.clientY < mid) return; // dragging downward, not past mid yet
|
||||||
|
if (src > index && e.clientY > mid) return; // dragging upward, not past mid yet
|
||||||
|
// Update visual order in state. Use activeDumpIdsRef so the updater never
|
||||||
|
// reads a stale closure — activeDumpIds can't change mid-drag but this is
|
||||||
|
// the correct pattern for setState updaters.
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (prev.status !== "loaded") return prev;
|
if (prev.status !== "loaded") return prev;
|
||||||
// Only reorder among active dumps
|
const ids = activeDumpIdsRef.current;
|
||||||
const activeDumps = prev.playlist.dumps.filter((d) =>
|
const activeDumps = prev.playlist.dumps.filter((d) => ids.has(d.id));
|
||||||
activeDumpIds.has(d.id)
|
const fadingDumps = prev.playlist.dumps.filter((d) => !ids.has(d.id));
|
||||||
);
|
|
||||||
const fadingDumps = prev.playlist.dumps.filter((d) =>
|
|
||||||
!activeDumpIds.has(d.id)
|
|
||||||
);
|
|
||||||
const reordered = [...activeDumps];
|
const reordered = [...activeDumps];
|
||||||
const [moved] = reordered.splice(dragSrcIndex, 1);
|
const [moved] = reordered.splice(src, 1);
|
||||||
reordered.splice(index, 0, moved);
|
reordered.splice(index, 0, moved);
|
||||||
setDragSrcIndex(index);
|
|
||||||
setDragOverIndex(index);
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
// Update the ref and highlight index outside the updater (no side effects inside updaters).
|
||||||
|
dragSrcRef.current = index;
|
||||||
|
setDragOverIndex(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = async () => {
|
const handleDragEnd = async () => {
|
||||||
if (state.status !== "loaded" || !playlistId) return;
|
const src = dragSrcRef.current;
|
||||||
setDragSrcIndex(null);
|
dragSrcRef.current = null;
|
||||||
setDragOverIndex(null);
|
setDragOverIndex(null);
|
||||||
|
if (src === null || state.status !== "loaded" || !playlistId) return;
|
||||||
const activeDumps = state.playlist.dumps.filter((d) =>
|
const activeDumps = state.playlist.dumps.filter((d) =>
|
||||||
activeDumpIds.has(d.id)
|
activeDumpIds.has(d.id)
|
||||||
);
|
);
|
||||||
@@ -341,13 +441,6 @@ 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);
|
||||||
@@ -364,15 +457,25 @@ export function PlaylistDetail() {
|
|||||||
setEditSaving(true);
|
setEditSaving(true);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
try {
|
try {
|
||||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
const updateRes = await authFetch(
|
||||||
method: "PUT",
|
`${API_URL}/api/playlists/${playlistId}`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({
|
method: "PUT",
|
||||||
title: editTitle,
|
headers: { "Content-Type": "application/json" },
|
||||||
description: editDescription || undefined,
|
body: JSON.stringify({
|
||||||
isPublic: editIsPublic,
|
title: editTitle,
|
||||||
}),
|
description: editDescription || undefined,
|
||||||
});
|
isPublic: editIsPublic,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const updateJson = await updateRes.json() as {
|
||||||
|
success: boolean;
|
||||||
|
data: RawPlaylist;
|
||||||
|
};
|
||||||
|
const updatedPlaylist = updateJson.success
|
||||||
|
? deserializePlaylist(updateJson.data)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
@@ -384,7 +487,11 @@ export function PlaylistDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setEditOpen(false);
|
setEditOpen(false);
|
||||||
fetchPlaylist();
|
if (updatedPlaylist) {
|
||||||
|
navigate(playlistUrl(updatedPlaylist), { replace: true });
|
||||||
|
} else {
|
||||||
|
fetchPlaylist();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setEditError(friendlyFetchError(err));
|
setEditError(friendlyFetchError(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -519,12 +626,12 @@ export function PlaylistDetail() {
|
|||||||
|
|
||||||
{editOpen
|
{editOpen
|
||||||
? (
|
? (
|
||||||
<textarea
|
<TextEditor
|
||||||
ref={descriptionRef}
|
|
||||||
className="playlist-edit-textarea"
|
className="playlist-edit-textarea"
|
||||||
value={editDescription}
|
value={editDescription}
|
||||||
onChange={(e) => setEditDescription(e.target.value)}
|
onChange={setEditDescription}
|
||||||
placeholder="Description (optional)"
|
placeholder="Description (optional)"
|
||||||
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -571,12 +678,18 @@ export function PlaylistDetail() {
|
|||||||
@{playlist.ownerUsername}
|
@{playlist.ownerUsername}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<time
|
<Tooltip text={playlist.createdAt.toLocaleString()}>
|
||||||
dateTime={playlist.createdAt.toISOString()}
|
<time dateTime={playlist.createdAt.toISOString()}>
|
||||||
title={playlist.createdAt.toLocaleString()}
|
{relativeTime(playlist.createdAt)}
|
||||||
>
|
</time>
|
||||||
{relativeTime(playlist.createdAt)}
|
</Tooltip>
|
||||||
</time>
|
{playlist.updatedAt && (
|
||||||
|
<Tooltip text={`Edited ${playlist.updatedAt.toLocaleString()}`}>
|
||||||
|
<span className="playlist-edited-label">
|
||||||
|
edited {relativeTime(playlist.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -590,7 +703,10 @@ export function PlaylistDetail() {
|
|||||||
{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>
|
||||||
: (
|
: (
|
||||||
<div className="playlist-dump-list">
|
<div
|
||||||
|
className="playlist-dump-list"
|
||||||
|
onDragOver={isOwner ? (e) => e.preventDefault() : undefined}
|
||||||
|
>
|
||||||
{visibleDumps.map((dump) => {
|
{visibleDumps.map((dump) => {
|
||||||
const isActive = activeDumpIds.has(dump.id);
|
const isActive = activeDumpIds.has(dump.id);
|
||||||
const phase = fading[dump.id];
|
const phase = fading[dump.id];
|
||||||
@@ -617,7 +733,7 @@ export function PlaylistDetail() {
|
|||||||
onDragOver={isOwner && isActive
|
onDragOver={isOwner && isActive
|
||||||
? (e) => handleDragOver(e, activeIndex)
|
? (e) => handleDragOver(e, activeIndex)
|
||||||
: undefined}
|
: undefined}
|
||||||
onDragEnd={isOwner && isActive ? handleDragEnd : undefined}
|
onDragEnd={isOwner ? handleDragEnd : undefined}
|
||||||
>
|
>
|
||||||
{isOwner && isActive && (
|
{isOwner && isActive && (
|
||||||
<span className="drag-handle" aria-hidden>⠿</span>
|
<span className="drag-handle" aria-hidden>⠿</span>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
|||||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -49,6 +50,19 @@ export function UserDumps() {
|
|||||||
const [state, setState] = useState<State>({ status: "loading" });
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
|
const isOwnProfile = me?.id === profileUserId;
|
||||||
|
|
||||||
|
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
|
setState((s) => s.status !== "loaded" ? s : { ...s, dumps: fn(s.dumps) });
|
||||||
|
}, []);
|
||||||
|
const addFilter = useCallback((dump: Dump): boolean => {
|
||||||
|
if (!profileUserId) return false;
|
||||||
|
if (dump.userId !== profileUserId) return false;
|
||||||
|
return isOwnProfile || !dump.isPrivate;
|
||||||
|
}, [profileUserId, isOwnProfile]);
|
||||||
|
useDumpListSync(setDumps, addFilter);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
@@ -197,7 +211,6 @@ export function UserDumps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { profileUser, dumps, hasMore, loadingMore } = state;
|
const { profileUser, dumps, hasMore, loadingMore } = state;
|
||||||
const isOwnProfile = me?.username === profileUser.username;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function UserLogin() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="auth-card-footer">
|
<p className="auth-card-footer">
|
||||||
No account? <Link to="/register">Register</Link>
|
This is a mirage.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -55,7 +55,6 @@ function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
|||||||
export function UserPlaylists() {
|
export function UserPlaylists() {
|
||||||
const { username } = useParams();
|
const { username } = useParams();
|
||||||
const { user: me, authFetch, token } = useAuth();
|
const { user: me, authFetch, token } = useAuth();
|
||||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
|
||||||
|
|
||||||
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
||||||
Playlist
|
Playlist
|
||||||
@@ -73,6 +72,28 @@ export function UserPlaylists() {
|
|||||||
const [state, setState] = useState<State>({ status: "loading" });
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
|
const isOwnProfile = me?.id === profileUserId;
|
||||||
|
|
||||||
|
const setCreated = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||||
|
setState((s) =>
|
||||||
|
s.status !== "loaded" ? s : { ...s, created: { ...s.created, items: fn(s.created.items) } }
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
usePlaylistListSync(setCreated, {
|
||||||
|
isOwner: isOwnProfile,
|
||||||
|
ownerId: profileUserId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setFollowed = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||||
|
setState((s) =>
|
||||||
|
s.status !== "loaded"
|
||||||
|
? s
|
||||||
|
: { ...s, followed: { ...s.followed, items: fn(s.followed.items) } }
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
usePlaylistListSync(setFollowed, { noNewEntries: true });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
@@ -246,77 +267,6 @@ export function UserPlaylists() {
|
|||||||
!state.followed.loadingMore,
|
!state.followed.loadingMore,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Real-time WS playlist updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
|
||||||
const ev = lastPlaylistEvent;
|
|
||||||
const isOwnProfile = me?.username === state.profileUser.username;
|
|
||||||
|
|
||||||
if (ev.type === "created" && ev.playlist?.userId === state.profileUser.id) {
|
|
||||||
if (ev.playlist.isPublic || isOwnProfile) {
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
if (s.created.items.some((p) => p.id === ev.playlist!.id)) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
created: {
|
|
||||||
...s.created,
|
|
||||||
items: [ev.playlist!, ...s.created.items],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (ev.type === "updated") {
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
const updatedCreated = ev.playlist?.userId === state.profileUser.id
|
|
||||||
? s.created.items
|
|
||||||
.map((p) => p.id === ev.playlist!.id ? ev.playlist! : p)
|
|
||||||
.filter((p) => p.isPublic || isOwnProfile)
|
|
||||||
: s.created.items;
|
|
||||||
const updatedFollowed = s.followed.items.map((p) =>
|
|
||||||
p.id === ev.playlist?.id ? ev.playlist! : p
|
|
||||||
).filter((p) => p.isPublic);
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
created: { ...s.created, items: updatedCreated },
|
|
||||||
followed: { ...s.followed, items: updatedFollowed },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} else if (ev.type === "deleted") {
|
|
||||||
setState((s) =>
|
|
||||||
s.status !== "loaded" ? s : {
|
|
||||||
...s,
|
|
||||||
created: {
|
|
||||||
...s.created,
|
|
||||||
items: s.created.items.filter((p) => p.id !== ev.playlistId),
|
|
||||||
},
|
|
||||||
followed: {
|
|
||||||
...s.followed,
|
|
||||||
items: s.followed.items.filter((p) => p.id !== ev.playlistId),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [lastPlaylistEvent, me]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!deletedPlaylistIds.size || state.status !== "loaded") return;
|
|
||||||
setState((s) =>
|
|
||||||
s.status !== "loaded" ? s : {
|
|
||||||
...s,
|
|
||||||
created: {
|
|
||||||
...s.created,
|
|
||||||
items: s.created.items.filter((p) => !deletedPlaylistIds.has(p.id)),
|
|
||||||
},
|
|
||||||
followed: {
|
|
||||||
...s.followed,
|
|
||||||
items: s.followed.items.filter((p) => !deletedPlaylistIds.has(p.id)),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}, [deletedPlaylistIds]);
|
|
||||||
|
|
||||||
// Scroll save
|
// Scroll save
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.status !== "loaded") return;
|
if (state.status !== "loaded") return;
|
||||||
@@ -395,7 +345,6 @@ export function UserPlaylists() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { profileUser, created, followed } = state;
|
const { profileUser, created, followed } = state;
|
||||||
const isOwnProfile = me?.username === profileUser.username;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
@@ -19,6 +19,8 @@ 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 { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
|
import { usePlaylistListSync } from "../hooks/usePlaylistListSync.ts";
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||||
import { deserializePlaylist } from "../model.ts";
|
import { deserializePlaylist } from "../model.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
@@ -114,10 +116,9 @@ export function UserPublicProfile() {
|
|||||||
voteCounts,
|
voteCounts,
|
||||||
myVotes,
|
myVotes,
|
||||||
lastVoteEvent,
|
lastVoteEvent,
|
||||||
|
lastDumpEvent,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
lastPlaylistEvent,
|
|
||||||
deletedPlaylistIds,
|
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
|
const { cached: cachedDumps, saveState: saveDumps } = useFeedCache<Dump>(
|
||||||
@@ -136,11 +137,104 @@ export function UserPublicProfile() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
||||||
|
const isOwnProfile = me?.id === profileUserId;
|
||||||
|
|
||||||
|
const removedDumpPositionsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
|
||||||
|
const setDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded") return s;
|
||||||
|
const prev = s.dumps.items;
|
||||||
|
const next = fn(prev);
|
||||||
|
if (next.length < prev.length) {
|
||||||
|
const nextIds = new Set(next.map((d) => d.id));
|
||||||
|
prev.forEach((d, idx) => {
|
||||||
|
if (!nextIds.has(d.id)) removedDumpPositionsRef.current.set(d.id, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ...s, dumps: { ...s.dumps, items: next } };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
// No addFilter — insertion at correct position is handled by the effect below.
|
||||||
|
useDumpListSync(setDumps);
|
||||||
|
|
||||||
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
const [profileVotedIds, setProfileVotedIds] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Tracks the list index of each dump at the moment it was removed from the
|
||||||
|
// votes list, so we can re-insert it at the correct position when it becomes
|
||||||
|
// public again (instead of always prepending at position 0).
|
||||||
|
const removedVotePositionsRef = useRef<Map<string, number>>(new Map());
|
||||||
|
// Dump IDs removed due to vote withdrawal — must not be re-inserted on
|
||||||
|
// a future dump_updated event (that would only be for private→public transitions).
|
||||||
|
const withdrawnVoteIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const setVotes = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded") return s;
|
||||||
|
const prev = s.votes.items;
|
||||||
|
const next = fn(prev);
|
||||||
|
if (next.length < prev.length) {
|
||||||
|
const nextIds = new Set(next.map((d) => d.id));
|
||||||
|
prev.forEach((d, idx) => {
|
||||||
|
if (!nextIds.has(d.id)) removedVotePositionsRef.current.set(d.id, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ...s, votes: { ...s.votes, items: next } };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
useDumpListSync(setVotes);
|
||||||
|
|
||||||
|
// Re-insert a vote-list dump at its original position after private→public.
|
||||||
|
// Skip dumps whose vote was explicitly withdrawn (those were removed intentionally).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
||||||
|
const dump = lastDumpEvent;
|
||||||
|
if (withdrawnVoteIdsRef.current.has(dump.id)) return;
|
||||||
|
const savedIdx = removedVotePositionsRef.current.get(dump.id);
|
||||||
|
if (savedIdx === undefined) return;
|
||||||
|
removedVotePositionsRef.current.delete(dump.id);
|
||||||
|
setVotes((prev) => {
|
||||||
|
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [lastDumpEvent, setVotes]);
|
||||||
|
|
||||||
|
// Re-insert a dumps-column dump at its original position after private→public.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastDumpEvent || lastDumpEvent.isPrivate) return;
|
||||||
|
const dump = lastDumpEvent;
|
||||||
|
if (dump.userId !== profileUserId) return;
|
||||||
|
const savedIdx = removedDumpPositionsRef.current.get(dump.id);
|
||||||
|
if (savedIdx === undefined) return;
|
||||||
|
removedDumpPositionsRef.current.delete(dump.id);
|
||||||
|
setDumps((prev) => {
|
||||||
|
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
next.splice(Math.min(savedIdx, next.length), 0, dump);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [lastDumpEvent, profileUserId, setDumps]);
|
||||||
|
|
||||||
|
const setPlaylists = useCallback((fn: (prev: Playlist[]) => Playlist[]) => {
|
||||||
|
setState((s) =>
|
||||||
|
s.status !== "loaded"
|
||||||
|
? s
|
||||||
|
: { ...s, playlists: { ...s.playlists, items: fn(s.playlists.items) } }
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
usePlaylistListSync(setPlaylists, {
|
||||||
|
isOwner: isOwnProfile,
|
||||||
|
ownerId: profileUserId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
@@ -260,8 +354,6 @@ export function UserPublicProfile() {
|
|||||||
})();
|
})();
|
||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
const profileUserId = state.status === "loaded" ? state.user.id : null;
|
|
||||||
|
|
||||||
// Own profile: keep profileVotedIds in sync with myVotes
|
// Own profile: keep profileVotedIds in sync with myVotes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!profileUserId || me?.id !== profileUserId) return;
|
if (!profileUserId || me?.id !== profileUserId) return;
|
||||||
@@ -301,7 +393,10 @@ export function UserPublicProfile() {
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
withdrawnVoteIdsRef.current.add(dumpId);
|
||||||
|
setVotes((prev) => prev.filter((d) => d.id !== dumpId));
|
||||||
} else {
|
} else {
|
||||||
|
withdrawnVoteIdsRef.current.delete(dumpId);
|
||||||
if (!isOwnProfile) {
|
if (!isOwnProfile) {
|
||||||
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
setProfileVotedIds((prev) => new Set([...prev, dumpId]));
|
||||||
}
|
}
|
||||||
@@ -327,65 +422,6 @@ export function UserPublicProfile() {
|
|||||||
}
|
}
|
||||||
}, [lastVoteEvent, me, profileUserId]);
|
}, [lastVoteEvent, me, profileUserId]);
|
||||||
|
|
||||||
// Real-time playlist updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
|
||||||
const profileUserId = state.user.id;
|
|
||||||
const isOwnProfile = me?.id === profileUserId;
|
|
||||||
const ev = lastPlaylistEvent;
|
|
||||||
|
|
||||||
if (ev.type === "created" && ev.playlist?.userId === profileUserId) {
|
|
||||||
if (ev.playlist.isPublic || isOwnProfile) {
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
playlists: {
|
|
||||||
...s.playlists,
|
|
||||||
items: [ev.playlist!, ...s.playlists.items],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (ev.type === "updated" && ev.playlist?.userId === profileUserId) {
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
playlists: {
|
|
||||||
...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") {
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
playlists: {
|
|
||||||
...s.playlists,
|
|
||||||
items: s.playlists.items.filter((p) => p.id !== ev.playlistId),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [lastPlaylistEvent, me]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
const filtered = s.playlists.items.filter((p) =>
|
|
||||||
!deletedPlaylistIds.has(p.id)
|
|
||||||
);
|
|
||||||
if (filtered.length === s.playlists.items.length) return s;
|
|
||||||
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
|
||||||
});
|
|
||||||
}, [deletedPlaylistIds]);
|
|
||||||
|
|
||||||
// Save scroll position + loaded state to sessionStorage on scroll
|
// Save scroll position + loaded state to sessionStorage on scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -506,7 +542,6 @@ export function UserPublicProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { user: profileUser, dumps, votes, playlists } = state;
|
const { user: profileUser, dumps, votes, playlists } = state;
|
||||||
const isOwnProfile = me?.username === profileUser.username;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
|||||||
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
import { useDumpListSync } from "../hooks/useDumpListSync.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -46,6 +47,12 @@ export function UserUpvoted() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [state, setState] = useState<State>({ status: "loading" });
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
|
||||||
|
const setVotesDumps = useCallback((fn: (prev: Dump[]) => Dump[]) => {
|
||||||
|
setState((s) => s.status !== "loaded" ? s : { ...s, votes: fn(s.votes) });
|
||||||
|
}, []);
|
||||||
|
useDumpListSync(setVotesDumps);
|
||||||
|
|
||||||
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||||
const [fading, setFading] = useState<
|
const [fading, setFading] = useState<
|
||||||
Record<string, "cooldown" | "dismissing">
|
Record<string, "cooldown" | "dismissing">
|
||||||
|
|||||||
7
src/utils/urls.ts
Normal file
7
src/utils/urls.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function dumpUrl(dump: { id: string; slug?: string }): string {
|
||||||
|
return `/dumps/${dump.slug ?? dump.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playlistUrl(playlist: { id: string; slug?: string }): string {
|
||||||
|
return `/playlists/${playlist.slug ?? playlist.id}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user