v3: follows, notifications, invite-only registration, unread markers
This commit is contained in:
@@ -1,11 +1,38 @@
|
|||||||
import { randomBytes, scrypt } from "node:crypto";
|
import { randomBytes, scrypt } from "node:crypto";
|
||||||
import { jwtVerify, SignJWT } from "@panva/jose";
|
import { jwtVerify, SignJWT } from "@panva/jose";
|
||||||
|
|
||||||
import { type AuthPayload, isAuthPayload } from "../model/interfaces.ts";
|
import {
|
||||||
|
type AuthPayload,
|
||||||
|
InvitePayload,
|
||||||
|
isAuthPayload,
|
||||||
|
isInvitePayload,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
const JWT_SECRET = "FIXME-gerbeur-dev-env";
|
const JWT_SECRET = "FIXME-gerbeur-dev-env";
|
||||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||||
|
|
||||||
|
// ── Invite tokens ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function createInviteToken(inviterId: string): Promise<string> {
|
||||||
|
return await new SignJWT({ purpose: "invite", inviterId })
|
||||||
|
.setProtectedHeader({ alg: "HS256" })
|
||||||
|
.setJti(crypto.randomUUID())
|
||||||
|
.setExpirationTime("7d")
|
||||||
|
.sign(JWT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyInviteToken(
|
||||||
|
token: string,
|
||||||
|
): Promise<InvitePayload | null> {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(token, JWT_KEY);
|
||||||
|
if (!isInvitePayload(payload)) return null;
|
||||||
|
return payload as InvitePayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createJWT(
|
export async function createJWT(
|
||||||
payload: Omit<AuthPayload, "exp">,
|
payload: Omit<AuthPayload, "exp">,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
|||||||
15
api/main.ts
15
api/main.ts
@@ -9,6 +9,9 @@ import wsRouter from "./routes/ws.ts";
|
|||||||
import previewRouter from "./routes/preview.ts";
|
import previewRouter from "./routes/preview.ts";
|
||||||
import playlistsRouter from "./routes/playlists.ts";
|
import playlistsRouter from "./routes/playlists.ts";
|
||||||
import commentsRouter from "./routes/comments.ts";
|
import commentsRouter from "./routes/comments.ts";
|
||||||
|
import followsRouter from "./routes/follows.ts";
|
||||||
|
import notificationsRouter from "./routes/notifications.ts";
|
||||||
|
import invitesRouter from "./routes/invites.ts";
|
||||||
|
|
||||||
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
import { BASE_URL, HOSTNAME, PORT } from "./config.ts";
|
||||||
import { errorMiddleware } from "./middleware/error.ts";
|
import { errorMiddleware } from "./middleware/error.ts";
|
||||||
@@ -50,6 +53,18 @@ app.use(
|
|||||||
commentsRouter.routes(),
|
commentsRouter.routes(),
|
||||||
commentsRouter.allowedMethods(),
|
commentsRouter.allowedMethods(),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
followsRouter.routes(),
|
||||||
|
followsRouter.allowedMethods(),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
notificationsRouter.routes(),
|
||||||
|
notificationsRouter.allowedMethods(),
|
||||||
|
);
|
||||||
|
app.use(
|
||||||
|
invitesRouter.routes(),
|
||||||
|
invitesRouter.allowedMethods(),
|
||||||
|
);
|
||||||
app.use(routeStaticFilesFrom([
|
app.use(routeStaticFilesFrom([
|
||||||
`${Deno.cwd()}/dist`,
|
`${Deno.cwd()}/dist`,
|
||||||
`${Deno.cwd()}/public`,
|
`${Deno.cwd()}/public`,
|
||||||
|
|||||||
132
api/model/db.ts
132
api/model/db.ts
@@ -2,6 +2,8 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite";
|
|||||||
import {
|
import {
|
||||||
type Comment,
|
type Comment,
|
||||||
Dump,
|
Dump,
|
||||||
|
type Notification,
|
||||||
|
type NotificationType,
|
||||||
type Playlist,
|
type Playlist,
|
||||||
type RichContent,
|
type RichContent,
|
||||||
type User,
|
type User,
|
||||||
@@ -10,31 +12,10 @@ import {
|
|||||||
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
export const db = new DatabaseSync("api/sql/gerbeur.db");
|
||||||
db.exec("PRAGMA foreign_keys = ON;");
|
db.exec("PRAGMA foreign_keys = ON;");
|
||||||
|
|
||||||
// Migration: add is_private column if it doesn't exist yet
|
// Purge expired unused invites on startup
|
||||||
try {
|
db.prepare(
|
||||||
db.exec(`ALTER TABLE dumps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;`);
|
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`,
|
||||||
} catch { /* column already exists */ }
|
).run();
|
||||||
|
|
||||||
// Migration: create comments table if it doesn't exist yet
|
|
||||||
try {
|
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS comments (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
|
|
||||||
body TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
deleted INTEGER NOT NULL DEFAULT 0
|
|
||||||
);`);
|
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_dump ON comments(dump_id, created_at);`);
|
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);`);
|
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_dumps_dump ON playlist_dumps(dump_id);`);
|
|
||||||
} catch { /* already exists */ }
|
|
||||||
|
|
||||||
// Migration: add deleted column to comments if it doesn't exist yet
|
|
||||||
try {
|
|
||||||
db.exec(`ALTER TABLE comments ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;`);
|
|
||||||
} catch { /* column already exists */ }
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database Row Types
|
* Database Row Types
|
||||||
@@ -53,7 +34,7 @@ export interface DumpRow {
|
|||||||
file_mime: string | null;
|
file_mime: string | null;
|
||||||
file_size: number | null;
|
file_size: number | null;
|
||||||
vote_count: number;
|
vote_count: number;
|
||||||
comment_count?: number;
|
comment_count: number;
|
||||||
is_private: number;
|
is_private: number;
|
||||||
[key: string]: SQLOutputValue; // Index signature
|
[key: string]: SQLOutputValue; // Index signature
|
||||||
}
|
}
|
||||||
@@ -65,6 +46,9 @@ export interface UserRow {
|
|||||||
is_admin: number;
|
is_admin: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
avatar_mime: string | null;
|
avatar_mime: string | null;
|
||||||
|
invited_by: string | null;
|
||||||
|
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
|
||||||
|
invited_by_username: string | null;
|
||||||
[key: string]: SQLOutputValue; // Index signature
|
[key: string]: SQLOutputValue; // Index signature
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +111,7 @@ export function dumpRowToApi(row: DumpRow): Dump {
|
|||||||
fileMime: row.file_mime ?? undefined,
|
fileMime: row.file_mime ?? undefined,
|
||||||
fileSize: row.file_size ?? undefined,
|
fileSize: row.file_size ?? undefined,
|
||||||
voteCount: row.vote_count,
|
voteCount: row.vote_count,
|
||||||
commentCount: row.comment_count ?? 0,
|
commentCount: row.comment_count,
|
||||||
isPrivate: Boolean(row.is_private),
|
isPrivate: Boolean(row.is_private),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -146,6 +130,7 @@ export function dumpApiToRow(dump: Dump): DumpRow {
|
|||||||
file_mime: dump.fileMime ?? null,
|
file_mime: dump.fileMime ?? null,
|
||||||
file_size: dump.fileSize ?? null,
|
file_size: dump.fileSize ?? null,
|
||||||
vote_count: dump.voteCount,
|
vote_count: dump.voteCount,
|
||||||
|
comment_count: dump.commentCount,
|
||||||
is_private: dump.isPrivate ? 1 : 0,
|
is_private: dump.isPrivate ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -158,6 +143,9 @@ export function userRowToApi(row: UserRow): User {
|
|||||||
isAdmin: Boolean(row.is_admin),
|
isAdmin: Boolean(row.is_admin),
|
||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
avatarMime: row.avatar_mime ?? undefined,
|
avatarMime: row.avatar_mime ?? undefined,
|
||||||
|
invitedByUsername: typeof row.invited_by_username === "string"
|
||||||
|
? row.invited_by_username
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +157,8 @@ export function userApiToRow(user: User): UserRow {
|
|||||||
is_admin: user.isAdmin ? 1 : 0,
|
is_admin: user.isAdmin ? 1 : 0,
|
||||||
created_at: user.createdAt.toISOString(),
|
created_at: user.createdAt.toISOString(),
|
||||||
avatar_mime: user.avatarMime ?? null,
|
avatar_mime: user.avatarMime ?? null,
|
||||||
|
invited_by: null,
|
||||||
|
invited_by_username: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +175,9 @@ export interface CommentRow {
|
|||||||
[key: string]: SQLOutputValue;
|
[key: string]: SQLOutputValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCommentRow(obj: Record<string, SQLOutputValue>): obj is CommentRow {
|
export function isCommentRow(
|
||||||
|
obj: Record<string, SQLOutputValue>,
|
||||||
|
): obj is CommentRow {
|
||||||
return !!obj && typeof obj === "object" &&
|
return !!obj && typeof obj === "object" &&
|
||||||
typeof obj.id === "string" &&
|
typeof obj.id === "string" &&
|
||||||
typeof obj.dump_id === "string" &&
|
typeof obj.dump_id === "string" &&
|
||||||
@@ -195,7 +187,8 @@ export function isCommentRow(obj: Record<string, SQLOutputValue>): obj is Commen
|
|||||||
typeof obj.created_at === "string" &&
|
typeof obj.created_at === "string" &&
|
||||||
typeof obj.deleted === "number" &&
|
typeof obj.deleted === "number" &&
|
||||||
typeof obj.author_username === "string" &&
|
typeof obj.author_username === "string" &&
|
||||||
(typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null);
|
(typeof obj.author_avatar_mime === "string" ||
|
||||||
|
obj.author_avatar_mime === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commentRowToApi(row: CommentRow): Comment {
|
export function commentRowToApi(row: CommentRow): Comment {
|
||||||
@@ -243,5 +236,86 @@ export function playlistRowToApi(row: PlaylistRow): Playlist {
|
|||||||
createdAt: new Date(row.created_at),
|
createdAt: new Date(row.created_at),
|
||||||
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"
|
||||||
|
? row.owner_username
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FollowRow {
|
||||||
|
id: string;
|
||||||
|
follower_id: string;
|
||||||
|
followed_user_id: string | null;
|
||||||
|
followed_playlist_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
[key: string]: SQLOutputValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFollowRow(
|
||||||
|
obj: Record<string, SQLOutputValue>,
|
||||||
|
): obj is FollowRow {
|
||||||
|
return !!obj &&
|
||||||
|
typeof obj.id === "string" &&
|
||||||
|
typeof obj.follower_id === "string" &&
|
||||||
|
typeof obj.created_at === "string" &&
|
||||||
|
(obj.followed_user_id === null ||
|
||||||
|
typeof obj.followed_user_id === "string") &&
|
||||||
|
(obj.followed_playlist_id === null ||
|
||||||
|
typeof obj.followed_playlist_id === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notifications ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NotificationRow {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
type: string;
|
||||||
|
data: string;
|
||||||
|
read: number;
|
||||||
|
created_at: string;
|
||||||
|
source_key: string | null;
|
||||||
|
[key: string]: SQLOutputValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNotificationRow(
|
||||||
|
obj: Record<string, SQLOutputValue>,
|
||||||
|
): obj is NotificationRow {
|
||||||
|
return !!obj && typeof obj === "object" &&
|
||||||
|
typeof obj.id === "string" &&
|
||||||
|
typeof obj.user_id === "string" &&
|
||||||
|
typeof obj.type === "string" &&
|
||||||
|
typeof obj.data === "string" &&
|
||||||
|
typeof obj.read === "number" &&
|
||||||
|
typeof obj.created_at === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationRowToApi(row: NotificationRow): Notification {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
userId: row.user_id,
|
||||||
|
type: row.type as NotificationType,
|
||||||
|
data: JSON.parse(row.data),
|
||||||
|
read: Boolean(row.read),
|
||||||
|
createdAt: new Date(row.created_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invites ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface InviteRow {
|
||||||
|
token: string;
|
||||||
|
inviter_id: string;
|
||||||
|
used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
[key: string]: SQLOutputValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInviteRow(
|
||||||
|
obj: Record<string, SQLOutputValue>,
|
||||||
|
): obj is InviteRow {
|
||||||
|
return !!obj && typeof obj === "object" &&
|
||||||
|
typeof obj.token === "string" &&
|
||||||
|
typeof obj.inviter_id === "string" &&
|
||||||
|
typeof obj.created_at === "string" &&
|
||||||
|
(obj.used_at === null || typeof obj.used_at === "string");
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export interface User {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginUserRequest {
|
export interface LoginUserRequest {
|
||||||
@@ -51,6 +52,7 @@ export interface LoginUserRequest {
|
|||||||
export interface RegisterUserRequest {
|
export interface RegisterUserRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
inviteToken: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
export interface UpdateUserRequest {
|
||||||
@@ -70,7 +72,8 @@ export function isRegisterUserRequest(
|
|||||||
): obj is RegisterUserRequest {
|
): obj is RegisterUserRequest {
|
||||||
return !!obj && typeof obj === "object" &&
|
return !!obj && typeof obj === "object" &&
|
||||||
"username" in obj && typeof obj.username === "string" &&
|
"username" in obj && typeof obj.username === "string" &&
|
||||||
"password" in obj && typeof obj.password === "string";
|
"password" in obj && typeof obj.password === "string" &&
|
||||||
|
"inviteToken" in obj && typeof obj.inviteToken === "string";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest {
|
||||||
@@ -101,6 +104,19 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
|
|||||||
"exp" in obj && typeof obj.exp === "number";
|
"exp" in obj && typeof obj.exp === "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InvitePayload {
|
||||||
|
purpose: "invite";
|
||||||
|
inviterId: string;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isInvitePayload(obj: unknown): obj is InvitePayload {
|
||||||
|
return !!obj && typeof obj === "object" &&
|
||||||
|
"purpose" in obj && (obj as Record<string, unknown>).purpose === "invite" &&
|
||||||
|
"inviterId" in obj &&
|
||||||
|
typeof (obj as Record<string, unknown>).inviterId === "string";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API
|
* API
|
||||||
*/
|
*/
|
||||||
@@ -171,11 +187,14 @@ export interface CreateCommentRequest {
|
|||||||
parentId?: string;
|
parentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCreateCommentRequest(obj: unknown): obj is CreateCommentRequest {
|
export function isCreateCommentRequest(
|
||||||
|
obj: unknown,
|
||||||
|
): obj is CreateCommentRequest {
|
||||||
if (!obj || typeof obj !== "object") return false;
|
if (!obj || typeof obj !== "object") return false;
|
||||||
const o = obj as Record<string, unknown>;
|
const o = obj as Record<string, unknown>;
|
||||||
return typeof o.body === "string" && (o.body as string).trim().length > 0 &&
|
return typeof o.body === "string" && (o.body as string).trim().length > 0 &&
|
||||||
(!("parentId" in o) || typeof o.parentId === "string" || o.parentId === null);
|
(!("parentId" in o) || typeof o.parentId === "string" ||
|
||||||
|
o.parentId === null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,6 +210,7 @@ export interface Playlist {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
imageMime?: string;
|
imageMime?: string;
|
||||||
dumpCount?: number;
|
dumpCount?: number;
|
||||||
|
ownerUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistWithDumps extends Playlist {
|
export interface PlaylistWithDumps extends Playlist {
|
||||||
@@ -345,3 +365,72 @@ export interface PingMessage {
|
|||||||
export interface PongMessage {
|
export interface PongMessage {
|
||||||
type: "pong";
|
type: "pong";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FollowStatus {
|
||||||
|
followedUserIds: string[];
|
||||||
|
followedPlaylistIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| "playlist_followed"
|
||||||
|
| "user_followed"
|
||||||
|
| "user_dump_posted"
|
||||||
|
| "playlist_dump_added"
|
||||||
|
| "dump_upvoted";
|
||||||
|
|
||||||
|
export interface PlaylistFollowedData {
|
||||||
|
followerId: string;
|
||||||
|
followerUsername: string;
|
||||||
|
playlistId: string;
|
||||||
|
playlistTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserFollowedData {
|
||||||
|
followerId: string;
|
||||||
|
followerUsername: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDumpPostedData {
|
||||||
|
dumperId: string;
|
||||||
|
dumperUsername: string;
|
||||||
|
dumpId: string;
|
||||||
|
dumpTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistDumpAddedData {
|
||||||
|
dumpId: string;
|
||||||
|
dumpTitle: string;
|
||||||
|
playlistId: string;
|
||||||
|
playlistTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DumpUpvotedData {
|
||||||
|
voterId: string;
|
||||||
|
voterUsername: string;
|
||||||
|
dumpId: string;
|
||||||
|
dumpTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationData =
|
||||||
|
| PlaylistFollowedData
|
||||||
|
| UserFollowedData
|
||||||
|
| UserDumpPostedData
|
||||||
|
| PlaylistDumpAddedData
|
||||||
|
| DumpUpvotedData;
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
data: NotificationData;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,14 +31,16 @@ router.get("/dumps/:dumpId/comments", async (ctx) => {
|
|||||||
}
|
}
|
||||||
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
const dump = getDump(ctx.params.dumpId, requestingUserId);
|
||||||
const comments = getComments(dump.id);
|
const comments = getComments(dump.id);
|
||||||
const responseBody: APIResponse<Comment[]> = { success: true, data: comments };
|
const responseBody: APIResponse<Comment[]> = {
|
||||||
|
success: true,
|
||||||
|
data: comments,
|
||||||
|
};
|
||||||
ctx.response.body = responseBody;
|
ctx.response.body = responseBody;
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/dumps/:dumpId/comments — auth required
|
// POST /api/dumps/:dumpId/comments — auth required
|
||||||
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => {
|
||||||
const userId = ctx.state.user.userId as string;
|
const userId = ctx.state.user.userId as string;
|
||||||
const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean;
|
|
||||||
const dump = getDump(ctx.params.dumpId, userId);
|
const dump = getDump(ctx.params.dumpId, userId);
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
if (!isCreateCommentRequest(body)) {
|
if (!isCreateCommentRequest(body)) {
|
||||||
|
|||||||
@@ -93,8 +93,17 @@ router.get("/", async (ctx) => {
|
|||||||
const payload = await verifyJWT(authHeader.substring(7));
|
const payload = await verifyJWT(authHeader.substring(7));
|
||||||
if (payload) requestingUserId = payload.userId;
|
if (payload) requestingUserId = payload.userId;
|
||||||
}
|
}
|
||||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
const page = Math.max(
|
||||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
const { items, total } = listDumps(page, limit, requestingUserId);
|
const { items, total } = listDumps(page, limit, requestingUserId);
|
||||||
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
const responseBody: APIResponse<PaginatedData<Dump>> = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
108
api/routes/follows.ts
Normal file
108
api/routes/follows.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import {
|
||||||
|
type APIResponse,
|
||||||
|
type Dump,
|
||||||
|
type FollowStatus,
|
||||||
|
type PaginatedData,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
import {
|
||||||
|
followPlaylist,
|
||||||
|
followUser,
|
||||||
|
getFollowedPlaylistsDumpFeed,
|
||||||
|
getFollowedUsersDumpFeed,
|
||||||
|
getFollowStatus,
|
||||||
|
unfollowPlaylist,
|
||||||
|
unfollowUser,
|
||||||
|
} from "../services/follow-service.ts";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/follows" });
|
||||||
|
|
||||||
|
// Static routes first to prevent Oak matching "status"/"feed" as a :param
|
||||||
|
|
||||||
|
// GET /api/follows/status
|
||||||
|
router.get("/status", authMiddleware, (ctx) => {
|
||||||
|
const status = getFollowStatus(ctx.state.user.userId as string);
|
||||||
|
const body: APIResponse<FollowStatus> = { success: true, data: status };
|
||||||
|
ctx.response.body = body;
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/follows/feed/users?page=&limit=
|
||||||
|
router.get("/feed/users", authMiddleware, (ctx) => {
|
||||||
|
const page = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const { items, total } = getFollowedUsersDumpFeed(
|
||||||
|
ctx.state.user.userId as string,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
const data: PaginatedData<Dump> = {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
hasMore: page * limit < total,
|
||||||
|
};
|
||||||
|
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||||
|
ctx.response.body = body;
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/follows/feed/playlists?page=&limit=
|
||||||
|
router.get("/feed/playlists", authMiddleware, (ctx) => {
|
||||||
|
const page = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const { items, total } = getFollowedPlaylistsDumpFeed(
|
||||||
|
ctx.state.user.userId as string,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
const data: PaginatedData<Dump> = {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
hasMore: page * limit < total,
|
||||||
|
};
|
||||||
|
const body: APIResponse<PaginatedData<Dump>> = { success: true, data };
|
||||||
|
ctx.response.body = body;
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/follows/users/:userId
|
||||||
|
router.post("/users/:userId", authMiddleware, (ctx) => {
|
||||||
|
followUser(ctx.state.user.userId as string, ctx.params.userId);
|
||||||
|
ctx.response.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/follows/users/:userId
|
||||||
|
router.delete("/users/:userId", authMiddleware, (ctx) => {
|
||||||
|
unfollowUser(ctx.state.user.userId as string, ctx.params.userId);
|
||||||
|
ctx.response.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/follows/playlists/:playlistId
|
||||||
|
router.post("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||||
|
followPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
|
||||||
|
ctx.response.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/follows/playlists/:playlistId
|
||||||
|
router.delete("/playlists/:playlistId", authMiddleware, (ctx) => {
|
||||||
|
unfollowPlaylist(ctx.state.user.userId as string, ctx.params.playlistId);
|
||||||
|
ctx.response.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
32
api/routes/invites.ts
Normal file
32
api/routes/invites.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import { createInvite, validateInvite } from "../services/invite-service.ts";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/invites" });
|
||||||
|
|
||||||
|
// Create a new invite link (any authenticated user)
|
||||||
|
router.post("/", authMiddleware, async (ctx: AuthContext) => {
|
||||||
|
if (!ctx.state.user) {
|
||||||
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
|
}
|
||||||
|
const token = await createInvite(ctx.state.user.userId);
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { success: true, data: { token } };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate an invite token (used by the register page before showing the form)
|
||||||
|
router.get("/:token", async (ctx) => {
|
||||||
|
try {
|
||||||
|
await validateInvite(ctx.params.token);
|
||||||
|
ctx.response.body = { success: true };
|
||||||
|
} catch {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"Invalid or expired invite",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
67
api/routes/notifications.ts
Normal file
67
api/routes/notifications.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import {
|
||||||
|
APIErrorCode,
|
||||||
|
APIException,
|
||||||
|
type AuthPayload,
|
||||||
|
type PaginatedData,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import {
|
||||||
|
getNotificationsForUser,
|
||||||
|
markAllRead,
|
||||||
|
markOneRead,
|
||||||
|
} from "../services/notification-service.ts";
|
||||||
|
|
||||||
|
const router = new Router({ prefix: "/api/notifications" });
|
||||||
|
|
||||||
|
// GET /api/notifications?page=N&limit=N
|
||||||
|
router.get("/", authMiddleware, (ctx: AuthContext) => {
|
||||||
|
if (!ctx.state.user) {
|
||||||
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
|
}
|
||||||
|
const page = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const { items, total } = getNotificationsForUser(
|
||||||
|
ctx.state.user.userId,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
hasMore: page * limit < total,
|
||||||
|
} satisfies PaginatedData<typeof items[number]>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/read-all
|
||||||
|
router.post("/read-all", authMiddleware, (ctx: AuthContext) => {
|
||||||
|
if (!ctx.state.user) {
|
||||||
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
|
}
|
||||||
|
markAllRead(ctx.state.user.userId);
|
||||||
|
ctx.response.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/notifications/:id/read
|
||||||
|
router.patch("/:id/read", authMiddleware, (ctx) => {
|
||||||
|
const user = ctx.state.user as AuthPayload;
|
||||||
|
if (!user) {
|
||||||
|
throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated");
|
||||||
|
}
|
||||||
|
markOneRead(ctx.params.id, user.userId);
|
||||||
|
ctx.response.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -15,18 +15,19 @@ import {
|
|||||||
getUserById,
|
getUserById,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
} from "../services/user-service.ts";
|
} from "../services/user-service.ts";
|
||||||
|
import { redeemInvite, validateInvite } from "../services/invite-service.ts";
|
||||||
import {
|
import {
|
||||||
getDumpsByUser,
|
getDumpsByUser,
|
||||||
getVotedDumpsByUser,
|
getVotedDumpsByUser,
|
||||||
} from "../services/dump-service.ts";
|
} from "../services/dump-service.ts";
|
||||||
import { listPlaylistsByUser } from "../services/playlist-service.ts";
|
import { listPlaylistsByUser } from "../services/playlist-service.ts";
|
||||||
|
import { getFollowedPlaylistsByUser } from "../services/follow-service.ts";
|
||||||
|
|
||||||
// Users router
|
// Users router
|
||||||
const router = new Router({ prefix: "/api/users" });
|
const router = new Router({ prefix: "/api/users" });
|
||||||
|
|
||||||
// Register a new user
|
// Register a new user (requires a valid invite token)
|
||||||
router.post("/register", async (ctx) => {
|
router.post("/register", async (ctx) => {
|
||||||
try {
|
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
if (!isRegisterUserRequest(body)) {
|
if (!isRegisterUserRequest(body)) {
|
||||||
@@ -37,8 +38,15 @@ router.post("/register", async (ctx) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await createUser(body);
|
// Validate invite — throws 404/409 if bad
|
||||||
const token = await createJWT({
|
const inviterId = await validateInvite(body.inviteToken);
|
||||||
|
|
||||||
|
const user = await createUser(body, inviterId);
|
||||||
|
|
||||||
|
// Mark invite as used only after the user row is committed
|
||||||
|
redeemInvite(body.inviteToken);
|
||||||
|
|
||||||
|
const authToken = await createJWT({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
isAdmin: user.isAdmin,
|
isAdmin: user.isAdmin,
|
||||||
@@ -47,20 +55,8 @@ router.post("/register", async (ctx) => {
|
|||||||
ctx.response.status = 201;
|
ctx.response.status = 201;
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: { token: authToken, user },
|
||||||
token,
|
|
||||||
user,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
|
|
||||||
throw new APIException(
|
|
||||||
APIErrorCode.SERVER_ERROR,
|
|
||||||
500,
|
|
||||||
"Failed to register user",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login
|
// Login
|
||||||
@@ -142,6 +138,31 @@ router.get("/by-id/:userId", (ctx) => {
|
|||||||
ctx.response.body = { success: true, data: publicUser };
|
ctx.response.body = { success: true, data: publicUser };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Followed playlists for a user (public only)
|
||||||
|
router.get("/:username/followed-playlists", (ctx) => {
|
||||||
|
const user = getUserByUsername(ctx.params.username);
|
||||||
|
const page = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const { items, total } = getFollowedPlaylistsByUser(user.id, page, limit);
|
||||||
|
ctx.response.body = {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
hasMore: page * limit < total,
|
||||||
|
} satisfies PaginatedData<typeof items[number]>,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Playlists by user (optional auth: include private only if requester === owner)
|
// Playlists by user (optional auth: include private only if requester === owner)
|
||||||
router.get("/:username/playlists", async (ctx) => {
|
router.get("/:username/playlists", async (ctx) => {
|
||||||
const user = getUserByUsername(ctx.params.username);
|
const user = getUserByUsername(ctx.params.username);
|
||||||
@@ -151,12 +172,30 @@ router.get("/:username/playlists", async (ctx) => {
|
|||||||
const payload = await verifyJWT(authHeader.substring(7));
|
const payload = await verifyJWT(authHeader.substring(7));
|
||||||
if (payload) requestingUserId = payload.userId;
|
if (payload) requestingUserId = payload.userId;
|
||||||
}
|
}
|
||||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
const page = Math.max(
|
||||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
1,
|
||||||
const { items, total } = listPlaylistsByUser(user.id, requestingUserId, page, limit);
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const { items, total } = listPlaylistsByUser(
|
||||||
|
user.id,
|
||||||
|
requestingUserId,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
data: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
hasMore: page * limit < total,
|
||||||
|
} satisfies PaginatedData<typeof items[number]>,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,13 +215,26 @@ router.get("/:username/dumps", async (ctx) => {
|
|||||||
const payload = await verifyJWT(authHeader.substring(7));
|
const payload = await verifyJWT(authHeader.substring(7));
|
||||||
if (payload) requestingUserId = payload.userId;
|
if (payload) requestingUserId = payload.userId;
|
||||||
}
|
}
|
||||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
const page = Math.max(
|
||||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
const includePrivate = requestingUserId === user.id;
|
const includePrivate = requestingUserId === user.id;
|
||||||
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate);
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
data: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
hasMore: page * limit < total,
|
||||||
|
} satisfies PaginatedData<typeof items[number]>,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,12 +247,30 @@ router.get("/:username/votes", async (ctx) => {
|
|||||||
const payload = await verifyJWT(authHeader.substring(7));
|
const payload = await verifyJWT(authHeader.substring(7));
|
||||||
if (payload) requestingUserId = payload.userId;
|
if (payload) requestingUserId = payload.userId;
|
||||||
}
|
}
|
||||||
const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1);
|
const page = Math.max(
|
||||||
const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100);
|
1,
|
||||||
const { items, total } = getVotedDumpsByUser(user.id, page, limit, requestingUserId);
|
parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1,
|
||||||
|
);
|
||||||
|
const limit = Math.min(
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20,
|
||||||
|
),
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const { items, total } = getVotedDumpsByUser(
|
||||||
|
user.id,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
requestingUserId,
|
||||||
|
);
|
||||||
ctx.response.body = {
|
ctx.response.body = {
|
||||||
success: true,
|
success: true,
|
||||||
data: { items, total, hasMore: page * limit < total } satisfies PaginatedData<typeof items[number]>,
|
data: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
hasMore: page * limit < total,
|
||||||
|
} satisfies PaginatedData<typeof items[number]>,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getUserVotes,
|
getUserVotes,
|
||||||
removeVote,
|
removeVote,
|
||||||
} from "../services/vote-service.ts";
|
} from "../services/vote-service.ts";
|
||||||
|
import { getUnreadCount } from "../services/notification-service.ts";
|
||||||
import { getUserById } from "../services/user-service.ts";
|
import { getUserById } from "../services/user-service.ts";
|
||||||
import { APIException } from "../model/interfaces.ts";
|
import { APIException } from "../model/interfaces.ts";
|
||||||
|
|
||||||
@@ -61,10 +62,14 @@ router.get("/ws", async (ctx) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
const myVotes = authPayload ? getUserVotes(authPayload.userId) : [];
|
||||||
|
const unreadNotificationCount = authPayload
|
||||||
|
? getUnreadCount(authPayload.userId)
|
||||||
|
: 0;
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: "welcome",
|
type: "welcome",
|
||||||
users: getOnlineUsers(),
|
users: getOnlineUsers(),
|
||||||
myVotes,
|
myVotes,
|
||||||
|
unreadNotificationCount,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ws] welcome send failed:", err);
|
console.error("[ws] welcome send failed:", err);
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import {
|
|||||||
APIException,
|
APIException,
|
||||||
type Comment,
|
type Comment,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
import { type SQLOutputValue } from "node:sqlite";
|
||||||
import {
|
import {
|
||||||
commentRowToApi,
|
|
||||||
type CommentRow,
|
type CommentRow,
|
||||||
|
commentRowToApi,
|
||||||
db,
|
db,
|
||||||
isCommentRow,
|
isCommentRow,
|
||||||
} from "../model/db.ts";
|
} from "../model/db.ts";
|
||||||
@@ -18,7 +19,7 @@ function fetchComment(commentId: string): Comment {
|
|||||||
const row = db.prepare(
|
const row = db.prepare(
|
||||||
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
|
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
|
||||||
).get(commentId);
|
).get(commentId);
|
||||||
if (!row || !isCommentRow(row as Record<string, unknown>)) {
|
if (!row || !isCommentRow(row as Record<string, SQLOutputValue>)) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found");
|
||||||
}
|
}
|
||||||
return commentRowToApi(row as CommentRow);
|
return commentRowToApi(row as CommentRow);
|
||||||
@@ -50,7 +51,14 @@ export function createComment(
|
|||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
|
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`,
|
||||||
).run(id, dumpId, userId, parentId ?? null, body.trim(), createdAt.toISOString());
|
).run(
|
||||||
|
id,
|
||||||
|
dumpId,
|
||||||
|
userId,
|
||||||
|
parentId ?? null,
|
||||||
|
body.trim(),
|
||||||
|
createdAt.toISOString(),
|
||||||
|
);
|
||||||
return fetchComment(id);
|
return fetchComment(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +81,8 @@ export function deleteComment(
|
|||||||
"Not authorized to delete this comment",
|
"Not authorized to delete this comment",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(commentId);
|
db.prepare(`UPDATE comments SET deleted = 1, body = '' WHERE id = ?;`).run(
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
|
return { dumpId: row.dump_id, isPrivate: Boolean(row.is_private) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
broadcastDumpUpdated,
|
broadcastDumpUpdated,
|
||||||
broadcastNewDump,
|
broadcastNewDump,
|
||||||
} from "./ws-service.ts";
|
} from "./ws-service.ts";
|
||||||
|
import { notifyUserFollowersNewDump } from "./notification-service.ts";
|
||||||
|
|
||||||
const UPLOADS_DIR = "api/uploads";
|
const UPLOADS_DIR = "api/uploads";
|
||||||
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
@@ -95,7 +96,10 @@ export async function createUrlDump(
|
|||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
};
|
};
|
||||||
if (!isPrivate) broadcastNewDump(dump);
|
if (!isPrivate) {
|
||||||
|
broadcastNewDump(dump);
|
||||||
|
notifyUserFollowersNewDump(userId, dumpId, title);
|
||||||
|
}
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +168,10 @@ export async function createFileDump(
|
|||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
};
|
};
|
||||||
if (!isPrivate) broadcastNewDump(dump);
|
if (!isPrivate) {
|
||||||
|
broadcastNewDump(dump);
|
||||||
|
notifyUserFollowersNewDump(userId, dumpId, file.name);
|
||||||
|
}
|
||||||
return dump;
|
return dump;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +218,11 @@ export function listDumps(
|
|||||||
).get() as { count: number } | undefined;
|
).get() as { count: number } | undefined;
|
||||||
|
|
||||||
if (!rows || !rows.every(isDumpRow)) {
|
if (!rows || !rows.every(isDumpRow)) {
|
||||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed dump data",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||||
@@ -230,7 +241,9 @@ export async function updateDump(
|
|||||||
comment: "comment" in request
|
comment: "comment" in request
|
||||||
? (request.comment ?? undefined)
|
? (request.comment ?? undefined)
|
||||||
: dump.comment,
|
: dump.comment,
|
||||||
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
|
isPrivate: "isPrivate" in request
|
||||||
|
? (request.isPrivate ?? false)
|
||||||
|
: dump.isPrivate,
|
||||||
};
|
};
|
||||||
db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`)
|
db.prepare(`UPDATE dumps SET comment = ?, is_private = ? WHERE id = ?;`)
|
||||||
.run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId);
|
.run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId);
|
||||||
@@ -260,13 +273,22 @@ export async function updateDump(
|
|||||||
: dump.comment,
|
: dump.comment,
|
||||||
url: newUrl,
|
url: newUrl,
|
||||||
richContent,
|
richContent,
|
||||||
isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate,
|
isPrivate: "isPrivate" in request
|
||||||
|
? (request.isPrivate ?? false)
|
||||||
|
: dump.isPrivate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const row = dumpApiToRow(updatedDump);
|
const row = dumpApiToRow(updatedDump);
|
||||||
const result = db.prepare(
|
const result = db.prepare(
|
||||||
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`,
|
`UPDATE dumps SET title = ?, comment = ?, url = ?, rich_content = ?, is_private = ? WHERE id = ?;`,
|
||||||
).run(row.title, row.comment, row.url, row.rich_content, row.is_private, row.id);
|
).run(
|
||||||
|
row.title,
|
||||||
|
row.comment,
|
||||||
|
row.url,
|
||||||
|
row.rich_content,
|
||||||
|
row.is_private,
|
||||||
|
row.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (result.changes === 0) {
|
if (result.changes === 0) {
|
||||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Dump not found");
|
||||||
@@ -333,7 +355,11 @@ export function getDumpsByUser(
|
|||||||
`SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`,
|
`SELECT COUNT(*) as count FROM dumps WHERE user_id = ?${privacyFilter};`,
|
||||||
).get(userId) as { count: number } | undefined;
|
).get(userId) as { count: number } | undefined;
|
||||||
if (!rows.every(isDumpRow)) {
|
if (!rows.every(isDumpRow)) {
|
||||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed dump data",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||||
}
|
}
|
||||||
@@ -380,7 +406,11 @@ export function getVotedDumpsByUser(
|
|||||||
|
|
||||||
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
|
const rows = rawRows as Parameters<typeof isDumpRow>[0][];
|
||||||
if (!rows.every(isDumpRow)) {
|
if (!rows.every(isDumpRow)) {
|
||||||
throw new APIException(APIErrorCode.SERVER_ERROR, 500, "Malformed dump data");
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed dump data",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
return { items: rows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
259
api/services/follow-service.ts
Normal file
259
api/services/follow-service.ts
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
APIErrorCode,
|
||||||
|
APIException,
|
||||||
|
type Dump,
|
||||||
|
type FollowStatus,
|
||||||
|
type Playlist,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
import {
|
||||||
|
notifyPlaylistOwnerNewFollower,
|
||||||
|
notifyUserNewFollower,
|
||||||
|
} from "./notification-service.ts";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
dumpRowToApi,
|
||||||
|
isDumpRow,
|
||||||
|
isFollowRow,
|
||||||
|
isPlaylistRow,
|
||||||
|
playlistRowToApi,
|
||||||
|
} from "../model/db.ts";
|
||||||
|
|
||||||
|
// Mirrors dump-service SELECT_COLS_ALIASED — kept local to avoid circular imports
|
||||||
|
const SELECT_COLS_ALIASED =
|
||||||
|
"d.id, d.kind, d.title, d.comment, d.user_id, d.created_at, d.url, d.rich_content, " +
|
||||||
|
"d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private," +
|
||||||
|
" (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count";
|
||||||
|
|
||||||
|
// ── Follow / unfollow a user ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function followUser(followerId: string, followedUserId: string): void {
|
||||||
|
if (followerId === followedUserId) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.BAD_REQUEST,
|
||||||
|
400,
|
||||||
|
"Cannot follow yourself",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let isNew = true;
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO follows (id, follower_id, followed_user_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?);`,
|
||||||
|
).run(
|
||||||
|
crypto.randomUUID(),
|
||||||
|
followerId,
|
||||||
|
followedUserId,
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.toLowerCase().includes("unique")) {
|
||||||
|
isNew = false;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isNew) notifyUserNewFollower(followerId, followedUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unfollowUser(followerId: string, followedUserId: string): void {
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`,
|
||||||
|
).run(followerId, followedUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Follow / unfollow a playlist ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function followPlaylist(followerId: string, playlistId: string): void {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT id, is_public FROM playlists WHERE id = ?;`,
|
||||||
|
).get(playlistId) as { id: string; is_public: number } | undefined;
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found");
|
||||||
|
}
|
||||||
|
if (!row.is_public) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.UNAUTHORIZED,
|
||||||
|
403,
|
||||||
|
"Cannot follow a private playlist",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let isNew = true;
|
||||||
|
try {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO follows (id, follower_id, followed_playlist_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?);`,
|
||||||
|
).run(
|
||||||
|
crypto.randomUUID(),
|
||||||
|
followerId,
|
||||||
|
playlistId,
|
||||||
|
new Date().toISOString(),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (msg.toLowerCase().includes("unique")) {
|
||||||
|
isNew = false;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unfollowPlaylist(followerId: string, playlistId: string): void {
|
||||||
|
db.prepare(
|
||||||
|
`DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`,
|
||||||
|
).run(followerId, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Follow status ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getFollowStatus(followerId: string): FollowStatus {
|
||||||
|
const rawUserRows = db.prepare(
|
||||||
|
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
||||||
|
FROM follows WHERE follower_id = ? AND followed_user_id IS NOT NULL;`,
|
||||||
|
).all(followerId) as Parameters<typeof isFollowRow>[0][];
|
||||||
|
|
||||||
|
const rawPlaylistRows = db.prepare(
|
||||||
|
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
|
||||||
|
FROM follows WHERE follower_id = ? AND followed_playlist_id IS NOT NULL;`,
|
||||||
|
).all(followerId) as Parameters<typeof isFollowRow>[0][];
|
||||||
|
|
||||||
|
if (!rawUserRows.every(isFollowRow) || !rawPlaylistRows.every(isFollowRow)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed follow data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
followedUserIds: rawUserRows.map((r) => r.followed_user_id!),
|
||||||
|
followedPlaylistIds: rawPlaylistRows.map((r) => r.followed_playlist_id!),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Followed-users feed ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getFollowedUsersDumpFeed(
|
||||||
|
followerId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): { items: Dump[]; total: number } {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const rawRows = db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS_ALIASED}
|
||||||
|
FROM dumps d
|
||||||
|
INNER JOIN follows f ON f.followed_user_id = d.user_id
|
||||||
|
WHERE f.follower_id = ?
|
||||||
|
AND d.is_private = 0
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?;`,
|
||||||
|
).all(followerId, limit, offset);
|
||||||
|
|
||||||
|
const totalRow = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM dumps d
|
||||||
|
INNER JOIN follows f ON f.followed_user_id = d.user_id
|
||||||
|
WHERE f.follower_id = ?
|
||||||
|
AND d.is_private = 0;`,
|
||||||
|
).get(followerId) as { count: number } | undefined;
|
||||||
|
|
||||||
|
const userFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
|
||||||
|
if (!userFeedRows.every(isDumpRow)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed dump data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { items: userFeedRows.map(dumpRowToApi), total: totalRow?.count ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Followed-playlists dump feed ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getFollowedPlaylistsDumpFeed(
|
||||||
|
followerId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): { items: Dump[]; total: number } {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const rawRows = db.prepare(
|
||||||
|
`SELECT ${SELECT_COLS_ALIASED}
|
||||||
|
FROM dumps d
|
||||||
|
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
|
||||||
|
INNER JOIN playlists p ON p.id = pd.playlist_id
|
||||||
|
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
||||||
|
WHERE f.follower_id = ?
|
||||||
|
AND p.is_public = 1
|
||||||
|
AND d.is_private = 0
|
||||||
|
GROUP BY d.id
|
||||||
|
ORDER BY MAX(pd.added_at) DESC
|
||||||
|
LIMIT ? OFFSET ?;`,
|
||||||
|
).all(followerId, limit, offset);
|
||||||
|
|
||||||
|
const totalRow = db.prepare(
|
||||||
|
`SELECT COUNT(DISTINCT d.id) as count
|
||||||
|
FROM dumps d
|
||||||
|
INNER JOIN playlist_dumps pd ON pd.dump_id = d.id
|
||||||
|
INNER JOIN playlists p ON p.id = pd.playlist_id
|
||||||
|
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
||||||
|
WHERE f.follower_id = ?
|
||||||
|
AND p.is_public = 1
|
||||||
|
AND d.is_private = 0;`,
|
||||||
|
).get(followerId) as { count: number } | undefined;
|
||||||
|
|
||||||
|
const playlistFeedRows = rawRows as Parameters<typeof isDumpRow>[0][];
|
||||||
|
if (!playlistFeedRows.every(isDumpRow)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed dump data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: playlistFeedRows.map(dumpRowToApi),
|
||||||
|
total: totalRow?.count ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Followed playlists (as playlist objects) ──────────────────────────────────
|
||||||
|
|
||||||
|
export function getFollowedPlaylistsByUser(
|
||||||
|
userId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): { items: Playlist[]; total: number } {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const totalRow = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM follows f
|
||||||
|
WHERE f.follower_id = ? AND f.followed_playlist_id IS NOT NULL;`,
|
||||||
|
).get(userId) as { count: number } | undefined;
|
||||||
|
|
||||||
|
const rawRows = db.prepare(
|
||||||
|
`SELECT p.*, u.username as owner_username,
|
||||||
|
(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
|
||||||
|
INNER JOIN follows f ON f.followed_playlist_id = p.id
|
||||||
|
WHERE f.follower_id = ?
|
||||||
|
AND p.is_public = 1
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?;`,
|
||||||
|
).all(userId, limit, offset) as Parameters<typeof isPlaylistRow>[0][];
|
||||||
|
|
||||||
|
if (!rawRows.every(isPlaylistRow)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed playlist data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 };
|
||||||
|
}
|
||||||
53
api/services/invite-service.ts
Normal file
53
api/services/invite-service.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { db, isInviteRow } from "../model/db.ts";
|
||||||
|
import { createInviteToken, verifyInviteToken } from "../lib/jwt.ts";
|
||||||
|
|
||||||
|
export async function createInvite(inviterId: string): Promise<string> {
|
||||||
|
const token = await createInviteToken(inviterId);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
|
||||||
|
).run(token, inviterId, new Date().toISOString());
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the JWT signature + expiry and checks the token exists and has not
|
||||||
|
* been used. Returns the inviterId on success; throws APIException otherwise.
|
||||||
|
*/
|
||||||
|
export async function validateInvite(token: string): Promise<string> {
|
||||||
|
const payload = await verifyInviteToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.NOT_FOUND,
|
||||||
|
404,
|
||||||
|
"Invalid or expired invite",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT token, inviter_id, used_at, created_at FROM invites WHERE token = ?;`,
|
||||||
|
).get(token);
|
||||||
|
|
||||||
|
if (!row || !isInviteRow(row)) {
|
||||||
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Invite not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.used_at !== null) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.VALIDATION_ERROR,
|
||||||
|
409,
|
||||||
|
"Invite already used",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.inviterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the token as used. Call this only after the user has been created.
|
||||||
|
*/
|
||||||
|
export function redeemInvite(token: string): void {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE invites SET used_at = ? WHERE token = ?;`,
|
||||||
|
).run(new Date().toISOString(), token);
|
||||||
|
}
|
||||||
212
api/services/notification-service.ts
Normal file
212
api/services/notification-service.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type {
|
||||||
|
Notification,
|
||||||
|
NotificationData,
|
||||||
|
NotificationType,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
|
import { db, isNotificationRow, notificationRowToApi } from "../model/db.ts";
|
||||||
|
import { sendToUser } from "./ws-service.ts";
|
||||||
|
|
||||||
|
// ── Core CRUD ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op.
|
||||||
|
function createNotification(
|
||||||
|
userId: string,
|
||||||
|
type: NotificationType,
|
||||||
|
data: NotificationData,
|
||||||
|
sourceKey: string | null = null,
|
||||||
|
): void {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const dataJson = JSON.stringify(data);
|
||||||
|
|
||||||
|
let changes: number;
|
||||||
|
|
||||||
|
if (sourceKey) {
|
||||||
|
// INSERT OR IGNORE: idempotent — same (user_id, source_key) pair is a no-op
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?, ?);`,
|
||||||
|
).run(id, userId, type, dataJson, createdAt, sourceKey);
|
||||||
|
changes = result.changes as number;
|
||||||
|
} else {
|
||||||
|
const result = db.prepare(
|
||||||
|
`INSERT INTO notifications (id, user_id, type, data, read, created_at, source_key)
|
||||||
|
VALUES (?, ?, ?, ?, 0, ?, NULL);`,
|
||||||
|
).run(id, userId, type, dataJson, createdAt);
|
||||||
|
changes = result.changes as number;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes > 0) {
|
||||||
|
sendToUser(userId, {
|
||||||
|
type: "notification_created",
|
||||||
|
notification: { id, userId, type, data, read: false, createdAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNotificationsForUser(
|
||||||
|
userId: string,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): { items: Notification[]; total: number } {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const rawRows = db.prepare(
|
||||||
|
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
|
||||||
|
).all(userId, limit, offset) as Parameters<typeof isNotificationRow>[0][];
|
||||||
|
|
||||||
|
const totalRow = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ?;`,
|
||||||
|
).get(userId) as { count: number } | undefined;
|
||||||
|
|
||||||
|
if (!rawRows.every(isNotificationRow)) {
|
||||||
|
throw new APIException(
|
||||||
|
APIErrorCode.SERVER_ERROR,
|
||||||
|
500,
|
||||||
|
"Malformed notification data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items: rawRows.map(notificationRowToApi),
|
||||||
|
total: totalRow?.count ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUnreadCount(userId: string): number {
|
||||||
|
const row = db.prepare(
|
||||||
|
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`,
|
||||||
|
).get(userId) as { count: number } | undefined;
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markAllRead(userId: string): void {
|
||||||
|
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markOneRead(notificationId: string, userId: string): void {
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`,
|
||||||
|
).run(notificationId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trigger helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function notifyUserNewFollower(
|
||||||
|
followerId: string,
|
||||||
|
followedUserId: string,
|
||||||
|
): void {
|
||||||
|
const followerRow = db.prepare(
|
||||||
|
`SELECT username FROM users WHERE id = ?;`,
|
||||||
|
).get(followerId) as { username: string } | undefined;
|
||||||
|
|
||||||
|
if (!followerRow) return;
|
||||||
|
|
||||||
|
createNotification(
|
||||||
|
followedUserId,
|
||||||
|
"user_followed",
|
||||||
|
{ followerId, followerUsername: followerRow.username },
|
||||||
|
`user-followed:${followedUserId}:${followerId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyPlaylistOwnerNewFollower(
|
||||||
|
followerId: string,
|
||||||
|
playlistId: string,
|
||||||
|
): void {
|
||||||
|
const followerRow = db.prepare(
|
||||||
|
`SELECT username FROM users WHERE id = ?;`,
|
||||||
|
).get(followerId) as { username: string } | undefined;
|
||||||
|
|
||||||
|
const playlistRow = db.prepare(
|
||||||
|
`SELECT title, user_id FROM playlists WHERE id = ?;`,
|
||||||
|
).get(playlistId) as { title: string; user_id: string } | undefined;
|
||||||
|
|
||||||
|
if (!followerRow || !playlistRow) return;
|
||||||
|
if (followerId === playlistRow.user_id) return;
|
||||||
|
|
||||||
|
createNotification(
|
||||||
|
playlistRow.user_id,
|
||||||
|
"playlist_followed",
|
||||||
|
{
|
||||||
|
followerId,
|
||||||
|
followerUsername: followerRow.username,
|
||||||
|
playlistId,
|
||||||
|
playlistTitle: playlistRow.title,
|
||||||
|
},
|
||||||
|
`followed:${playlistId}:${followerId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyUserFollowersNewDump(
|
||||||
|
dumperId: string,
|
||||||
|
dumpId: string,
|
||||||
|
dumpTitle: string,
|
||||||
|
): void {
|
||||||
|
const posterRow = db.prepare(
|
||||||
|
`SELECT username FROM users WHERE id = ?;`,
|
||||||
|
).get(dumperId) as { username: string } | undefined;
|
||||||
|
if (!posterRow) return;
|
||||||
|
|
||||||
|
const followerRows = db.prepare(
|
||||||
|
`SELECT follower_id FROM follows WHERE followed_user_id = ?;`,
|
||||||
|
).all(dumperId) as { follower_id: string }[];
|
||||||
|
|
||||||
|
for (const row of followerRows) {
|
||||||
|
createNotification(
|
||||||
|
row.follower_id,
|
||||||
|
"user_dump_posted",
|
||||||
|
{ dumperId, dumperUsername: posterRow.username, dumpId, dumpTitle },
|
||||||
|
`dump:${dumpId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyDumpOwnerUpvote(
|
||||||
|
voterId: string,
|
||||||
|
dumpId: string,
|
||||||
|
): void {
|
||||||
|
const voterRow = db.prepare(
|
||||||
|
`SELECT username FROM users WHERE id = ?;`,
|
||||||
|
).get(voterId) as { username: string } | undefined;
|
||||||
|
|
||||||
|
const dumpRow = db.prepare(
|
||||||
|
`SELECT title, user_id FROM dumps WHERE id = ?;`,
|
||||||
|
).get(dumpId) as { title: string; user_id: string } | undefined;
|
||||||
|
|
||||||
|
if (!voterRow || !dumpRow) return;
|
||||||
|
if (voterId === dumpRow.user_id) return; // no self-notification
|
||||||
|
|
||||||
|
createNotification(
|
||||||
|
dumpRow.user_id,
|
||||||
|
"dump_upvoted",
|
||||||
|
{
|
||||||
|
voterId,
|
||||||
|
voterUsername: voterRow.username,
|
||||||
|
dumpId,
|
||||||
|
dumpTitle: dumpRow.title,
|
||||||
|
},
|
||||||
|
`upvote:${dumpId}:${voterId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyPlaylistFollowersNewDump(
|
||||||
|
playlistId: string,
|
||||||
|
playlistTitle: string,
|
||||||
|
dumpId: string,
|
||||||
|
dumpTitle: string,
|
||||||
|
): void {
|
||||||
|
const followerRows = db.prepare(
|
||||||
|
`SELECT follower_id FROM follows WHERE followed_playlist_id = ?;`,
|
||||||
|
).all(playlistId) as { follower_id: string }[];
|
||||||
|
|
||||||
|
for (const row of followerRows) {
|
||||||
|
createNotification(
|
||||||
|
row.follower_id,
|
||||||
|
"playlist_dump_added",
|
||||||
|
{ dumpId, dumpTitle, playlistId, playlistTitle },
|
||||||
|
`pdump:${playlistId}:${dumpId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,14 +22,19 @@ import {
|
|||||||
broadcastPlaylistDumpsUpdated,
|
broadcastPlaylistDumpsUpdated,
|
||||||
broadcastPlaylistUpdated,
|
broadcastPlaylistUpdated,
|
||||||
} from "./ws-service.ts";
|
} from "./ws-service.ts";
|
||||||
|
import { notifyPlaylistFollowersNewDump } from "./notification-service.ts";
|
||||||
|
|
||||||
const DUMP_SELECT_COLS =
|
const DUMP_SELECT_COLS =
|
||||||
"id, kind, title, comment, user_id, created_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private";
|
"id, kind, title, 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,
|
||||||
|
(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`;
|
||||||
|
|
||||||
function getPlaylistById(playlistId: string): Playlist {
|
function getPlaylistById(playlistId: string): Playlist {
|
||||||
const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get(
|
const row = db.prepare(
|
||||||
playlistId,
|
`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`,
|
||||||
);
|
).get(playlistId);
|
||||||
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");
|
||||||
}
|
}
|
||||||
@@ -90,9 +95,7 @@ export function getPlaylist(
|
|||||||
|
|
||||||
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
|
||||||
const visibleDumps = isOwner
|
const visibleDumps = isOwner ? dumps : dumps.filter((d) => !d.isPrivate);
|
||||||
? dumps
|
|
||||||
: dumps.filter((d) => !d.isPrivate);
|
|
||||||
|
|
||||||
return { ...playlist, dumps: visibleDumps };
|
return { ...playlist, dumps: visibleDumps };
|
||||||
}
|
}
|
||||||
@@ -110,10 +113,8 @@ export function listPlaylistsByUser(
|
|||||||
? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;`
|
? `SELECT COUNT(*) as count FROM playlists WHERE user_id = ?;`
|
||||||
: `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`;
|
: `SELECT COUNT(*) as count FROM playlists WHERE user_id = ? AND is_public = 1;`;
|
||||||
const sql = isOwner
|
const sql = isOwner
|
||||||
? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
? `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
|
||||||
FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`
|
: `SELECT ${PLAYLIST_SELECT} WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
|
||||||
: `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
|
|
||||||
FROM playlists p WHERE p.user_id = ? AND p.is_public = 1 ORDER BY p.created_at DESC LIMIT ? OFFSET ?;`;
|
|
||||||
|
|
||||||
const totalRow = db.prepare(countSql).get(userId) as
|
const totalRow = db.prepare(countSql).get(userId) as
|
||||||
| { count: number }
|
| { count: number }
|
||||||
@@ -227,6 +228,20 @@ export function addDumpToPlaylist(
|
|||||||
|
|
||||||
const dumpIds = getCurrentDumpIds(playlistId);
|
const dumpIds = getCurrentDumpIds(playlistId);
|
||||||
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
broadcastPlaylistDumpsUpdated(playlist, dumpIds);
|
||||||
|
|
||||||
|
if (playlist.isPublic) {
|
||||||
|
const dumpRow = db.prepare(`SELECT title FROM dumps WHERE id = ?;`).get(
|
||||||
|
dumpId,
|
||||||
|
) as { title: string } | undefined;
|
||||||
|
if (dumpRow) {
|
||||||
|
notifyPlaylistFollowersNewDump(
|
||||||
|
playlistId,
|
||||||
|
playlist.title,
|
||||||
|
dumpId,
|
||||||
|
dumpRow.title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeDumpFromPlaylist(
|
export function removeDumpFromPlaylist(
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ export const soundcloudProvider: RichContentProvider = {
|
|||||||
title: extractOgTag(html, "title"),
|
title: extractOgTag(html, "title"),
|
||||||
description: extractOgTag(html, "description"),
|
description: extractOgTag(html, "description"),
|
||||||
thumbnailUrl: extractOgTag(html, "image"),
|
thumbnailUrl: extractOgTag(html, "image"),
|
||||||
embedUrl: `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&visual=true&auto_play=false`,
|
embedUrl: `https://w.soundcloud.com/player/?url=${
|
||||||
|
encodeURIComponent(url)
|
||||||
|
}&visual=true&auto_play=false`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ function extractVideoId(url: string): string | null {
|
|||||||
if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) {
|
if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) {
|
||||||
return u.searchParams.get("v");
|
return u.searchParams.get("v");
|
||||||
}
|
}
|
||||||
if (u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")) {
|
if (
|
||||||
|
u.pathname.startsWith("/embed/") || u.pathname.startsWith("/shorts/")
|
||||||
|
) {
|
||||||
return u.pathname.split("/")[2] || null;
|
return u.pathname.split("/")[2] || null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,15 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
|||||||
|
|
||||||
import { hashPassword } from "../lib/jwt.ts";
|
import { hashPassword } from "../lib/jwt.ts";
|
||||||
|
|
||||||
|
const USER_SELECT =
|
||||||
|
`SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.avatar_mime, u.invited_by,
|
||||||
|
i.username as invited_by_username
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN users i ON i.id = u.invited_by`;
|
||||||
|
|
||||||
export async function createUser(
|
export async function createUser(
|
||||||
request: RegisterUserRequest,
|
request: RegisterUserRequest,
|
||||||
|
inviterId: string | null,
|
||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const userId = crypto.randomUUID();
|
const userId = crypto.randomUUID();
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
@@ -30,14 +37,15 @@ export async function createUser(
|
|||||||
const passwordHash = await hashPassword(request.password);
|
const passwordHash = await hashPassword(request.password);
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO users (id, username, password_hash, is_admin, created_at)
|
`INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by)
|
||||||
VALUES (?, ?, ?, ?, ?);`,
|
VALUES (?, ?, ?, ?, ?, ?);`,
|
||||||
).run(
|
).run(
|
||||||
userId,
|
userId,
|
||||||
request.username,
|
request.username,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
0,
|
0,
|
||||||
createdAt.toISOString(),
|
createdAt.toISOString(),
|
||||||
|
inviterId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -51,8 +59,7 @@ export async function createUser(
|
|||||||
|
|
||||||
export function getUserById(userId: string): User {
|
export function getUserById(userId: string): User {
|
||||||
const userRow = db.prepare(
|
const userRow = db.prepare(
|
||||||
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
|
`${USER_SELECT} WHERE u.id = ?`,
|
||||||
FROM users WHERE id = ?`,
|
|
||||||
).get(userId);
|
).get(userId);
|
||||||
|
|
||||||
if (!userRow || !isUserRow(userRow)) {
|
if (!userRow || !isUserRow(userRow)) {
|
||||||
@@ -64,8 +71,7 @@ export function getUserById(userId: string): User {
|
|||||||
|
|
||||||
export function getUserByUsername(username: string): User {
|
export function getUserByUsername(username: string): User {
|
||||||
const userRow = db.prepare(
|
const userRow = db.prepare(
|
||||||
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime
|
`${USER_SELECT} WHERE u.username = ?`,
|
||||||
FROM users WHERE username = ?`,
|
|
||||||
).get(username);
|
).get(username);
|
||||||
|
|
||||||
if (!userRow || !isUserRow(userRow)) {
|
if (!userRow || !isUserRow(userRow)) {
|
||||||
@@ -77,7 +83,7 @@ export function getUserByUsername(username: string): User {
|
|||||||
|
|
||||||
export function listUsers(): User[] {
|
export function listUsers(): User[] {
|
||||||
const userRows = db.prepare(
|
const userRows = db.prepare(
|
||||||
`SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users`,
|
`${USER_SELECT}`,
|
||||||
).all();
|
).all();
|
||||||
|
|
||||||
if (!userRows || !userRows.every(isUserRow)) {
|
if (!userRows || !userRows.every(isUserRow)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||||
import { db } from "../model/db.ts";
|
import { db } from "../model/db.ts";
|
||||||
|
import { notifyDumpOwnerUpvote } from "./notification-service.ts";
|
||||||
|
|
||||||
export function castVote(dumpId: string, userId: string): number {
|
export function castVote(dumpId: string, userId: string): number {
|
||||||
try {
|
try {
|
||||||
@@ -14,6 +15,7 @@ export function castVote(dumpId: string, userId: string): number {
|
|||||||
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
`SELECT vote_count FROM dumps WHERE id = ?;`,
|
||||||
).get(dumpId) as { vote_count: number } | undefined;
|
).get(dumpId) as { vote_count: number } | undefined;
|
||||||
db.exec("COMMIT;");
|
db.exec("COMMIT;");
|
||||||
|
notifyDumpOwnerUpvote(userId, dumpId);
|
||||||
return row?.vote_count ?? 0;
|
return row?.vote_count ?? 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
db.exec("ROLLBACK;");
|
db.exec("ROLLBACK;");
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import type { Comment, Dump, OnlineUser, Playlist } from "../model/interfaces.ts";
|
import type {
|
||||||
|
Comment,
|
||||||
|
Dump,
|
||||||
|
OnlineUser,
|
||||||
|
Playlist,
|
||||||
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
export interface WsClient {
|
export interface WsClient {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
@@ -46,6 +51,14 @@ function send(socket: WebSocket, data: unknown): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sendToUser(userId: string, data: unknown): void {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.userId === userId) {
|
||||||
|
send(client.socket, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function broadcastPresence(): void {
|
export function broadcastPresence(): void {
|
||||||
const users = getOnlineUsers();
|
const users = getOnlineUsers();
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
@@ -136,7 +149,10 @@ export function broadcastCommentCreated(comment: Comment): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function broadcastCommentDeleted(commentId: string, dumpId: string): void {
|
export function broadcastCommentDeleted(
|
||||||
|
commentId: string,
|
||||||
|
dumpId: string,
|
||||||
|
): void {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
send(client.socket, { type: "comment_deleted", commentId, dumpId });
|
send(client.socket, { type: "comment_deleted", commentId, dumpId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ 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,
|
||||||
avatar_mime TEXT
|
avatar_mime TEXT,
|
||||||
|
invited_by TEXT REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE votes (
|
CREATE TABLE votes (
|
||||||
@@ -33,7 +34,6 @@ CREATE TABLE votes (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- v2: playlists
|
|
||||||
CREATE TABLE playlists (
|
CREATE TABLE playlists (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
@@ -55,7 +55,6 @@ CREATE TABLE playlist_dumps (
|
|||||||
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
-- v3: comments
|
|
||||||
CREATE TABLE comments (
|
CREATE TABLE comments (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
dump_id TEXT NOT NULL,
|
dump_id TEXT NOT NULL,
|
||||||
@@ -75,3 +74,52 @@ CREATE INDEX idx_playlists_user ON playlists(user_id);
|
|||||||
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
CREATE INDEX idx_playlist_dumps_order ON playlist_dumps(playlist_id, position);
|
||||||
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
CREATE INDEX idx_playlist_dumps_dump ON playlist_dumps(dump_id);
|
||||||
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
CREATE INDEX idx_comments_dump ON comments(dump_id, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE follows (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
follower_id TEXT NOT NULL,
|
||||||
|
followed_user_id TEXT,
|
||||||
|
followed_playlist_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (followed_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (followed_playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
CHECK (
|
||||||
|
(followed_user_id IS NOT NULL AND followed_playlist_id IS NULL)
|
||||||
|
OR
|
||||||
|
(followed_user_id IS NULL AND followed_playlist_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_follows_user
|
||||||
|
ON follows(follower_id, followed_user_id)
|
||||||
|
WHERE followed_user_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_follows_playlist
|
||||||
|
ON follows(follower_id, followed_playlist_id)
|
||||||
|
WHERE followed_playlist_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_follows_follower ON follows(follower_id);
|
||||||
|
|
||||||
|
CREATE TABLE invites (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
inviter_id TEXT NOT NULL,
|
||||||
|
used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (inviter_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
source_key TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_notifications_user ON notifications(user_id, created_at);
|
||||||
|
CREATE UNIQUE INDEX idx_notifications_dedup
|
||||||
|
ON notifications(user_id, source_key)
|
||||||
|
WHERE source_key IS NOT NULL;
|
||||||
|
|||||||
679
src/App.css
679
src/App.css
@@ -13,8 +13,12 @@
|
|||||||
.md a:hover {
|
.md a:hover {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.md strong { font-weight: 700; }
|
.md strong {
|
||||||
.md em { font-style: italic; }
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.md em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
.md code {
|
.md code {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -40,15 +44,16 @@
|
|||||||
padding-left: 1.5em;
|
padding-left: 1.5em;
|
||||||
margin: 0.4em 0;
|
margin: 0.4em 0;
|
||||||
}
|
}
|
||||||
.md li { margin: 0.15em 0; }
|
.md li {
|
||||||
|
margin: 0.15em 0;
|
||||||
|
}
|
||||||
.md blockquote {
|
.md blockquote {
|
||||||
border-left: 3px solid var(--color-border);
|
border-left: 3px solid var(--color-border);
|
||||||
margin: 0.5em 0;
|
margin: 0.5em 0;
|
||||||
padding: 0.2em 0.75em;
|
padding: 0.2em 0.75em;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
.md h1, .md h2, .md h3,
|
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 {
|
||||||
.md h4, .md h5, .md h6 {
|
|
||||||
margin: 0.6em 0 0.2em;
|
margin: 0.6em 0 0.2em;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
@@ -62,9 +67,13 @@
|
|||||||
.md--inline blockquote {
|
.md--inline blockquote {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.md--inline li { margin: 0; }
|
.md--inline li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
.md--inline ul,
|
.md--inline ul,
|
||||||
.md--inline ol { padding-left: 1.2em; }
|
.md--inline ol {
|
||||||
|
padding-left: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Dump detail page ── */
|
/* ── Dump detail page ── */
|
||||||
.dump-detail {
|
.dump-detail {
|
||||||
@@ -101,15 +110,6 @@
|
|||||||
justify-self: center;
|
justify-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dump-header-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.3rem;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.dump-title {
|
.dump-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
@@ -631,32 +631,6 @@
|
|||||||
background: var(--color-soundcloud);
|
background: var(--color-soundcloud);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-content-embed {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
border: 2px solid var(--color-border);
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
.rich-content-embed iframe {
|
|
||||||
width: 100%;
|
|
||||||
border: none;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.embed-youtube {
|
|
||||||
aspect-ratio: 16/9;
|
|
||||||
}
|
|
||||||
.embed-youtube iframe {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.embed-soundcloud {
|
|
||||||
height: 166px;
|
|
||||||
}
|
|
||||||
.embed-bandcamp {
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Global persistent player ── */
|
/* ── Global persistent player ── */
|
||||||
.global-player {
|
.global-player {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -862,14 +836,6 @@ body.has-player .fab-new {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Online users ── */
|
|
||||||
.online-users {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-img {
|
.avatar-img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 2px solid var(--color-surface);
|
border: 2px solid var(--color-surface);
|
||||||
@@ -1087,19 +1053,6 @@ body.has-player .fab-new {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Profile (own) page ── */
|
/* ── Profile (own) page ── */
|
||||||
.profile-avatar-section {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar-upload {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-username {
|
.profile-username {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
@@ -1110,6 +1063,132 @@ body.has-player .fab-new {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-header .follow-btn {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-invited-by {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0.15rem 0 0.4rem;
|
||||||
|
}
|
||||||
|
.profile-invited-by--founding {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.profile-invited-by-link {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.profile-invited-by-link:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-own-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.profile-own-actions .logout-btn {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.invite-generate {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-btn {
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border: 1.5px solid var(--color-accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.invite-btn:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.3rem 0.5rem 0.3rem 0.75rem;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
.invite-url {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.invite-copy-btn {
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
.invite-copy-btn:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Profile sub-pages (dumps / upvoted / playlists) ── */
|
||||||
|
.profile-subpage-back {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-subpage-back:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-subpage-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-subpage-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Profile "View all" link ── */
|
||||||
|
.profile-view-all {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-view-all:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
padding: 0.3rem 0.9rem;
|
padding: 0.3rem 0.9rem;
|
||||||
border: 1.5px solid var(--color-border);
|
border: 1.5px solid var(--color-border);
|
||||||
@@ -1126,23 +1205,6 @@ body.has-player .fab-new {
|
|||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-upload-label {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
border: 2px solid var(--color-accent);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-accent);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-upload-label:hover {
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: var(--color-on-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1601,10 +1663,6 @@ body.has-player .fab-new {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-header {
|
|
||||||
padding: 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feed-sort-btn {
|
.feed-sort-btn {
|
||||||
padding: 0.25rem 0.8rem;
|
padding: 0.25rem 0.8rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -1751,6 +1809,20 @@ body.has-player .fab-new {
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Unread dot ── */
|
||||||
|
.unread-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
vertical-align: baseline;
|
||||||
|
position: relative;
|
||||||
|
top: -0.15em;
|
||||||
|
margin-right: 0.4em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Shared card description / comment ── */
|
/* ── Shared card description / comment ── */
|
||||||
.dump-card-comment,
|
.dump-card-comment,
|
||||||
.playlist-card-description {
|
.playlist-card-description {
|
||||||
@@ -1833,6 +1905,14 @@ body.has-player .fab-new {
|
|||||||
.playlist-card-count {
|
.playlist-card-count {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
.playlist-card-owner {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.playlist-card-owner:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Playlist card delete button ── */
|
/* ── Playlist card delete button ── */
|
||||||
.playlist-card-delete-btn {
|
.playlist-card-delete-btn {
|
||||||
@@ -2060,8 +2140,20 @@ body.has-player .fab-new {
|
|||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-detail-title-row .playlist-edit-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-detail-title {
|
.playlist-detail-title {
|
||||||
margin: 0 0 0.25rem;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -2080,6 +2172,15 @@ body.has-player .fab-new {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
.playlist-detail-owner {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.playlist-detail-owner:hover {
|
||||||
|
color: var(--color-accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Playlist header inline edit ── */
|
/* ── Playlist header inline edit ── */
|
||||||
.playlist-detail-content {
|
.playlist-detail-content {
|
||||||
@@ -2090,14 +2191,6 @@ body.has-player .fab-new {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-header-actions {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-edit-btn {
|
.playlist-edit-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border-subtle);
|
border: 1px solid var(--color-border-subtle);
|
||||||
@@ -2276,73 +2369,6 @@ body.has-player .fab-new {
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Public/Private toggle ── */
|
|
||||||
.toggle-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-hint {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 2.4rem;
|
|
||||||
height: 1.3rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-thumb {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--color-text-muted);
|
|
||||||
transition: background 0.2s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-thumb::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0.15rem;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
transition: left 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-thumb {
|
|
||||||
background: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch input:checked + .toggle-thumb::after {
|
|
||||||
left: calc(100% - 1.15rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Dump card comment count ── */
|
/* ── Dump card comment count ── */
|
||||||
.dump-card-comment-count {
|
.dump-card-comment-count {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -2401,7 +2427,11 @@ body.has-player .fab-new {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.comment-node-inner:hover {
|
.comment-node-inner:hover {
|
||||||
background: color-mix(in srgb, var(--color-surface) 80%, var(--color-accent) 20%);
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 80%,
|
||||||
|
var(--color-accent) 20%
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-avatar {
|
.comment-avatar {
|
||||||
@@ -2477,7 +2507,8 @@ body.has-player .fab-new {
|
|||||||
padding-left: 1.25rem;
|
padding-left: 1.25rem;
|
||||||
margin-left: 1.1rem;
|
margin-left: 1.1rem;
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
border-left: 2px solid color-mix(in srgb, var(--color-accent) 30%, transparent);
|
border-left: 2px solid
|
||||||
|
color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -2516,7 +2547,8 @@ body.has-player .fab-new {
|
|||||||
.comment-reply-textarea:focus {
|
.comment-reply-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-accent);
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 20%, transparent);
|
box-shadow: 0 0 0 3px
|
||||||
|
color-mix(in srgb, var(--color-accent) 20%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-form-actions {
|
.comment-form-actions {
|
||||||
@@ -2574,3 +2606,324 @@ body.has-player .fab-new {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Follow button ── */
|
||||||
|
.follow-btn {
|
||||||
|
padding: 0.25rem 0.9rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--color-accent);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-btn:hover {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-btn--following {
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.follow-btn--following:hover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Followed feed layout ── */
|
||||||
|
.followed-feed {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.followed-sub-nav {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.followed-feed .dump-feed {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.followed-feed .index-status {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notification bell ── */
|
||||||
|
@keyframes bell-ring {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
transform: rotate(-20deg);
|
||||||
|
}
|
||||||
|
28% {
|
||||||
|
transform: rotate(20deg);
|
||||||
|
}
|
||||||
|
42% {
|
||||||
|
transform: rotate(-14deg);
|
||||||
|
}
|
||||||
|
56% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: rotate(-6deg);
|
||||||
|
}
|
||||||
|
84% {
|
||||||
|
transform: rotate(3deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-bell {
|
||||||
|
position: relative;
|
||||||
|
background: var(--color-header-user-bg);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.35rem 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.notification-bell:hover {
|
||||||
|
background: var(--color-header-user-bg-hover);
|
||||||
|
}
|
||||||
|
.notification-bell-icon {
|
||||||
|
display: inline-block;
|
||||||
|
transform-origin: 50% 10%;
|
||||||
|
}
|
||||||
|
.notification-bell--ringing .notification-bell-icon {
|
||||||
|
animation: bell-ring 0.65s cubic-bezier(0.36, 0.07, 0.19, 0.97);
|
||||||
|
}
|
||||||
|
.notification-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -3px;
|
||||||
|
background: var(--color-danger);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 3px;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notifications page ── */
|
||||||
|
.notifications-page {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem 3rem;
|
||||||
|
}
|
||||||
|
.notifications-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
}
|
||||||
|
.notifications-title {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.notifications-title-bell {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.notifications-unread-pill {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
|
||||||
|
color: var(--color-danger);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-danger) 35%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.2rem 0.65rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.notifications-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
.notifications-empty-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.notifications-empty p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
.notifications-empty-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 0.5rem auto 0 !important;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notif-group {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.notif-group-label {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 0.6rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.notif-group-label::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--color-border-subtle);
|
||||||
|
}
|
||||||
|
.notification-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.notification-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.875rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.notification-item:hover {
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--color-surface) 80%,
|
||||||
|
var(--color-text) 8%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.notification-item--unread {
|
||||||
|
border-left-color: var(--color-accent);
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 9%, var(--color-surface));
|
||||||
|
}
|
||||||
|
.notification-item--unread:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-accent) 14%, var(--color-surface));
|
||||||
|
}
|
||||||
|
.notif-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-accent);
|
||||||
|
box-shadow: 0 0 5px color-mix(in srgb, var(--color-accent) 70%, transparent);
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.notif-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notif-icon--upvote {
|
||||||
|
background: color-mix(in srgb, #f59e0b 30%, transparent);
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.notif-icon--follow {
|
||||||
|
background: color-mix(in srgb, #8b5cf6 30%, transparent);
|
||||||
|
color: #8b5cf6;
|
||||||
|
}
|
||||||
|
.notif-icon--dump {
|
||||||
|
background: color-mix(in srgb, #3b82f6 30%, transparent);
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
.notif-icon--playlist {
|
||||||
|
background: color-mix(in srgb, #10b981 30%, transparent);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.notification-content {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.notification-time {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
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 {
|
||||||
|
display: block;
|
||||||
|
margin: 1.5rem auto 0;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.load-more-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
.load-more-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -8,12 +8,16 @@ import { DumpEdit } from "./pages/DumpEdit.tsx";
|
|||||||
import { UserLogin } from "./pages/UserLogin.tsx";
|
import { UserLogin } from "./pages/UserLogin.tsx";
|
||||||
import { UserPublicProfile } from "./pages/UserPublicProfile.tsx";
|
import { UserPublicProfile } from "./pages/UserPublicProfile.tsx";
|
||||||
import { UserRegister } from "./pages/UserRegister.tsx";
|
import { UserRegister } from "./pages/UserRegister.tsx";
|
||||||
|
import { UserDumps } from "./pages/UserDumps.tsx";
|
||||||
|
import { UserUpvoted } from "./pages/UserUpvoted.tsx";
|
||||||
|
import { UserPlaylists } from "./pages/UserPlaylists.tsx";
|
||||||
import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
|
import { PlaylistDetail } from "./pages/PlaylistDetail.tsx";
|
||||||
import { MyPlaylists } from "./pages/MyPlaylists.tsx";
|
import { Notifications } from "./pages/Notifications.tsx";
|
||||||
|
|
||||||
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
import { AuthProvider } from "./contexts/AuthProvider.tsx";
|
||||||
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
import { PlayerProvider } from "./contexts/PlayerProvider.tsx";
|
||||||
import { WSProvider } from "./contexts/WSProvider.tsx";
|
import { WSProvider } from "./contexts/WSProvider.tsx";
|
||||||
|
import { FollowProvider } from "./contexts/FollowProvider.tsx";
|
||||||
import { useAuth } from "./hooks/useAuth.ts";
|
import { useAuth } from "./hooks/useAuth.ts";
|
||||||
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
import { GlobalPlayer } from "./components/GlobalPlayer.tsx";
|
||||||
|
|
||||||
@@ -23,6 +27,7 @@ function AppRoutes() {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
return (
|
return (
|
||||||
<WSProvider token={token}>
|
<WSProvider token={token}>
|
||||||
|
<FollowProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
@@ -52,17 +57,24 @@ function AppRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/users/:username" element={<UserPublicProfile />} />
|
<Route path="/users/:username" element={<UserPublicProfile />} />
|
||||||
|
<Route path="/users/:username/dumps" element={<UserDumps />} />
|
||||||
|
<Route path="/users/:username/upvoted" element={<UserUpvoted />} />
|
||||||
<Route
|
<Route
|
||||||
path="/playlists"
|
path="/users/:username/playlists"
|
||||||
|
element={<UserPlaylists />}
|
||||||
|
/>
|
||||||
|
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
||||||
|
<Route
|
||||||
|
path="/notifications"
|
||||||
element={
|
element={
|
||||||
<RestrictedLoggedIn>
|
<RestrictedLoggedIn>
|
||||||
<MyPlaylists />
|
<Notifications />
|
||||||
</RestrictedLoggedIn>
|
</RestrictedLoggedIn>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/playlists/:playlistId" element={<PlaylistDetail />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</FollowProvider>
|
||||||
</WSProvider>
|
</WSProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import type {
|
import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts";
|
||||||
PlaylistMembership,
|
|
||||||
RawPlaylistMembership,
|
|
||||||
} from "../model.ts";
|
|
||||||
import { deserializePlaylistMembership } from "../model.ts";
|
import { deserializePlaylistMembership } from "../model.ts";
|
||||||
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx";
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate } from "react-router";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
import { DumpCreateModal } from "./DumpCreateModal.tsx";
|
||||||
|
import { NotificationBell } from "./NotificationBell.tsx";
|
||||||
|
|
||||||
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const headerRef = useRef<HTMLElement>(null);
|
const headerRef = useRef<HTMLElement>(null);
|
||||||
const [showFab, setShowFab] = useState(false);
|
const [_showFab, setShowFab] = useState(false);
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,7 +28,9 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
|||||||
ref={headerRef}
|
ref={headerRef}
|
||||||
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
className={`app-header${centerSlot ? " app-header--has-center" : ""}`}
|
||||||
>
|
>
|
||||||
<Link to="/" className="app-header-brand">🚚 gerbeur</Link>
|
<Link to="/" state={{ tab: "hot" }} className="app-header-brand">
|
||||||
|
🚚 gerbeur
|
||||||
|
</Link>
|
||||||
|
|
||||||
{centerSlot && <div className="app-header-center">{centerSlot}</div>}
|
{centerSlot && <div className="app-header-center">{centerSlot}</div>}
|
||||||
|
|
||||||
@@ -42,9 +44,13 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
|||||||
>
|
>
|
||||||
{user.username}
|
{user.username}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/playlists" className="app-header-user">
|
<Link
|
||||||
|
to={`/users/${user.username}/playlists`}
|
||||||
|
className="app-header-user"
|
||||||
|
>
|
||||||
Playlists
|
Playlists
|
||||||
</Link>
|
</Link>
|
||||||
|
<NotificationBell />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -71,7 +77,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* {user && createPortal(
|
{
|
||||||
|
/* {user && createPortal(
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
className={`fab-new${showFab ? " fab-new--visible" : ""}`}
|
||||||
@@ -81,7 +88,8 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
|
|||||||
+ New
|
+ New
|
||||||
</button>,
|
</button>,
|
||||||
document.body,
|
document.body,
|
||||||
)} */}
|
)} */
|
||||||
|
}
|
||||||
|
|
||||||
{createModalOpen && (
|
{createModalOpen && (
|
||||||
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ function CommentNode({
|
|||||||
<li className="comment-node">
|
<li className="comment-node">
|
||||||
<div className="comment-node-inner comment-node-inner--deleted">
|
<div className="comment-node-inner comment-node-inner--deleted">
|
||||||
<div className="comment-avatar comment-avatar--deleted">
|
<div className="comment-avatar comment-avatar--deleted">
|
||||||
<div className="comment-avatar-placeholder" style={{ width: 28, height: 28 }} />
|
<div
|
||||||
|
className="comment-avatar-placeholder"
|
||||||
|
style={{ width: 28, height: 28 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="comment-content">
|
<div className="comment-content">
|
||||||
<p className="comment-deleted-placeholder">[deleted]</p>
|
<p className="comment-deleted-placeholder">[deleted]</p>
|
||||||
@@ -194,14 +197,14 @@ function CommentNode({
|
|||||||
value={replyBody}
|
value={replyBody}
|
||||||
onChange={(e) => setReplyBody(e.target.value)}
|
onChange={(e) => setReplyBody(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReply(e);
|
if (
|
||||||
|
e.key === "Enter" && (e.ctrlKey || e.metaKey)
|
||||||
|
) handleReply(e);
|
||||||
}}
|
}}
|
||||||
placeholder="Write a reply…"
|
placeholder="Write a reply…"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
{replyError && (
|
{replyError && <p className="comment-form-error">{replyError}</p>}
|
||||||
<p className="comment-form-error">{replyError}</p>
|
|
||||||
)}
|
|
||||||
<div className="comment-form-actions">
|
<div className="comment-form-actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -229,9 +232,7 @@ function CommentNode({
|
|||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<ul
|
<ul
|
||||||
className="comment-replies"
|
className="comment-replies"
|
||||||
style={depth >= MAX_INDENT_DEPTH
|
style={depth >= MAX_INDENT_DEPTH ? { paddingLeft: 0 } : undefined}
|
||||||
? { paddingLeft: 0 }
|
|
||||||
: undefined}
|
|
||||||
>
|
>
|
||||||
{children.map((child) => (
|
{children.map((child) => (
|
||||||
<CommentNode
|
<CommentNode
|
||||||
@@ -305,13 +306,18 @@ export function CommentThread({
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{currentUser && (
|
{currentUser && (
|
||||||
<form className="comment-form comment-top-form" onSubmit={handleTopLevelSubmit}>
|
<form
|
||||||
|
className="comment-form comment-top-form"
|
||||||
|
onSubmit={handleTopLevelSubmit}
|
||||||
|
>
|
||||||
<textarea
|
<textarea
|
||||||
className="comment-reply-textarea"
|
className="comment-reply-textarea"
|
||||||
value={topLevelBody}
|
value={topLevelBody}
|
||||||
onChange={(e) => setTopLevelBody(e.target.value)}
|
onChange={(e) => setTopLevelBody(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleTopLevelSubmit(e);
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
handleTopLevelSubmit(e);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Add a comment…"
|
placeholder="Add a comment…"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { 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";
|
||||||
@@ -22,12 +23,19 @@ export function DumpCard(
|
|||||||
DumpCardProps,
|
DumpCardProps,
|
||||||
) {
|
) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const unread = !isOwner && isRecent(dump.createdAt) &&
|
||||||
|
!isDumpVisited(dump.id);
|
||||||
|
|
||||||
|
function handleNavigate() {
|
||||||
|
markDumpVisited(dump.id);
|
||||||
|
navigate(`/dumps/${dump.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={`dump-card${className ? ` ${className}` : ""}`}>
|
<li className={`dump-card${className ? ` ${className}` : ""}`}>
|
||||||
<div
|
<div
|
||||||
className="dump-card-inner"
|
className="dump-card-inner"
|
||||||
onClick={() => navigate(`/dumps/${dump.id}`)}
|
onClick={handleNavigate}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="dump-card-preview"
|
className="dump-card-preview"
|
||||||
@@ -44,12 +52,18 @@ export function DumpCard(
|
|||||||
<Link
|
<Link
|
||||||
to={`/dumps/${dump.id}`}
|
to={`/dumps/${dump.id}`}
|
||||||
className="dump-card-title"
|
className="dump-card-title"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
markDumpVisited(dump.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{unread && <span className="unread-dot" aria-hidden="true" />}
|
||||||
{dump.title}
|
{dump.title}
|
||||||
</Link>
|
</Link>
|
||||||
{dump.comment && (
|
{dump.comment && (
|
||||||
<Markdown className="dump-card-comment" inline>{dump.comment}</Markdown>
|
<Markdown className="dump-card-comment" inline>
|
||||||
|
{dump.comment}
|
||||||
|
</Markdown>
|
||||||
)}
|
)}
|
||||||
<div className="dump-card-meta">
|
<div className="dump-card-meta">
|
||||||
<time
|
<time
|
||||||
@@ -61,7 +75,8 @@ export function DumpCard(
|
|||||||
</time>
|
</time>
|
||||||
{dump.commentCount > 0 && (
|
{dump.commentCount > 0 && (
|
||||||
<span className="dump-card-comment-count">
|
<span className="dump-card-comment-count">
|
||||||
{dump.commentCount} {dump.commentCount === 1 ? "comment" : "comments"}
|
{dump.commentCount}{" "}
|
||||||
|
{dump.commentCount === 1 ? "comment" : "comments"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{dump.isPrivate && isOwner && (
|
{dump.isPrivate && isOwner && (
|
||||||
|
|||||||
@@ -10,11 +10,9 @@ import type {
|
|||||||
RawDump,
|
RawDump,
|
||||||
RawPlaylistMembership,
|
RawPlaylistMembership,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import {
|
import { deserializeDump, deserializePlaylistMembership } from "../model.ts";
|
||||||
deserializeDump,
|
|
||||||
deserializePlaylistMembership,
|
|
||||||
} from "../model.ts";
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { formatBytes } from "../utils/format.ts";
|
import { formatBytes } from "../utils/format.ts";
|
||||||
import RichContentCard from "./RichContentCard.tsx";
|
import RichContentCard from "./RichContentCard.tsx";
|
||||||
import { MediaPlayer } from "./MediaPlayer.tsx";
|
import { MediaPlayer } from "./MediaPlayer.tsx";
|
||||||
@@ -74,6 +72,7 @@ interface DumpCreateModalProps {
|
|||||||
|
|
||||||
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
||||||
const { authFetch } = useAuth();
|
const { authFetch } = useAuth();
|
||||||
|
const { injectDump } = useWS();
|
||||||
const backdropRef = useRef<HTMLDivElement>(null);
|
const backdropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [phase, setPhase] = useState<Phase>("create");
|
const [phase, setPhase] = useState<Phase>("create");
|
||||||
@@ -225,6 +224,7 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
const dump = deserializeDump(apiResponse.data as RawDump);
|
const dump = deserializeDump(apiResponse.data as RawDump);
|
||||||
|
injectDump(dump);
|
||||||
setCreatedDump(dump);
|
setCreatedDump(dump);
|
||||||
setPhase("playlist");
|
setPhase("playlist");
|
||||||
setPlaylistsLoading(true);
|
setPlaylistsLoading(true);
|
||||||
@@ -281,7 +281,6 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const submitting = submitState.status === "submitting";
|
const submitting = submitState.status === "submitting";
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
@@ -376,7 +375,9 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
)}
|
)}
|
||||||
{urlPreview.status === "done" &&
|
{urlPreview.status === "done" &&
|
||||||
urlPreview.richContent && (
|
urlPreview.richContent && (
|
||||||
<RichContentCard richContent={urlPreview.richContent} />
|
<RichContentCard
|
||||||
|
richContent={urlPreview.richContent}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -411,21 +412,24 @@ export function DumpCreateModal({ onClose }: DumpCreateModalProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="toggle-row">
|
<div className="dump-mode-toggle">
|
||||||
<span className="toggle-label">Public</span>
|
<button
|
||||||
<span className="toggle-switch">
|
type="button"
|
||||||
<input
|
className={!isPrivate ? "active" : ""}
|
||||||
type="checkbox"
|
|
||||||
checked={!isPrivate}
|
|
||||||
onChange={(e) => setIsPrivate(!e.target.checked)}
|
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
/>
|
onClick={() => setIsPrivate(false)}
|
||||||
<span className="toggle-thumb" />
|
>
|
||||||
</span>
|
Public
|
||||||
{isPrivate && (
|
</button>
|
||||||
<span className="toggle-hint">Only visible to you</span>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</label>
|
className={isPrivate ? "active" : ""}
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => setIsPrivate(true)}
|
||||||
|
>
|
||||||
|
Private
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<div className="form-actions-right">
|
<div className="form-actions-right">
|
||||||
|
|||||||
65
src/components/FollowButton.tsx
Normal file
65
src/components/FollowButton.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useFollows } from "../hooks/useFollows.ts";
|
||||||
|
|
||||||
|
interface FollowUserButtonProps {
|
||||||
|
targetUserId: string;
|
||||||
|
targetUsername: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FollowPlaylistButtonProps {
|
||||||
|
targetPlaylistId: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUserButton(
|
||||||
|
{ targetUserId, targetUsername }: FollowUserButtonProps,
|
||||||
|
) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { followedUserIds, followUser, unfollowUser, isLoaded } = useFollows();
|
||||||
|
|
||||||
|
if (!user || user.id === targetUserId) return null;
|
||||||
|
|
||||||
|
const isFollowing = followedUserIds.has(targetUserId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||||
|
disabled={!isLoaded}
|
||||||
|
onClick={() =>
|
||||||
|
isFollowing ? unfollowUser(targetUserId) : followUser(targetUserId)}
|
||||||
|
aria-label={isFollowing
|
||||||
|
? `Unfollow ${targetUsername}`
|
||||||
|
: `Follow ${targetUsername}`}
|
||||||
|
>
|
||||||
|
{isFollowing ? "Following" : "Follow"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowPlaylistButton(
|
||||||
|
{ targetPlaylistId, isPublic }: FollowPlaylistButtonProps,
|
||||||
|
) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { followedPlaylistIds, followPlaylist, unfollowPlaylist, isLoaded } =
|
||||||
|
useFollows();
|
||||||
|
|
||||||
|
if (!user || !isPublic) return null;
|
||||||
|
|
||||||
|
const isFollowing = followedPlaylistIds.has(targetPlaylistId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`follow-btn${isFollowing ? " follow-btn--following" : ""}`}
|
||||||
|
disabled={!isLoaded}
|
||||||
|
onClick={() =>
|
||||||
|
isFollowing
|
||||||
|
? unfollowPlaylist(targetPlaylistId)
|
||||||
|
: followPlaylist(targetPlaylistId)}
|
||||||
|
aria-label={isFollowing ? "Unfollow playlist" : "Follow playlist"}
|
||||||
|
>
|
||||||
|
{isFollowing ? "Following" : "Follow"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,10 @@ export function GlobalPlayer() {
|
|||||||
document.body.classList.add("has-player");
|
document.body.classList.add("has-player");
|
||||||
|
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
document.body.style.setProperty("--player-height", `${el.offsetHeight}px`);
|
document.body.style.setProperty(
|
||||||
|
"--player-height",
|
||||||
|
`${el.offsetHeight}px`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -37,13 +40,24 @@ export function GlobalPlayer() {
|
|||||||
if (!current) return null;
|
if (!current) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`global-player global-player--${current.type}${reduced ? " global-player--reduced" : ""}`} ref={ref}>
|
<div
|
||||||
|
className={`global-player global-player--${current.type}${
|
||||||
|
reduced ? " global-player--reduced" : ""
|
||||||
|
}`}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
<div className="global-player-header">
|
<div className="global-player-header">
|
||||||
<span className="global-player-title">{current.title ?? current.embedUrl}</span>
|
<span className="global-player-title">
|
||||||
<button className="btn btn--ghost" onClick={() => setReduced((r) => !r)}>
|
{current.title ?? current.embedUrl}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--ghost"
|
||||||
|
onClick={() => setReduced((r) => !r)}
|
||||||
|
>
|
||||||
{reduced ? "▲" : "▼"}
|
{reduced ? "▲" : "▼"}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn--ghost" onClick={stop}>
|
<button type="button" className="btn btn--ghost" onClick={stop}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,9 +9,15 @@ interface MarkdownProps {
|
|||||||
|
|
||||||
const REMARK_PLUGINS = [remarkGfm];
|
const REMARK_PLUGINS = [remarkGfm];
|
||||||
|
|
||||||
export function Markdown({ children, className, inline = false }: MarkdownProps) {
|
export function Markdown(
|
||||||
|
{ children, className, inline = false }: MarkdownProps,
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<div className={`md${className ? ` ${className}` : ""}${inline ? " md--inline" : ""}`}>
|
<div
|
||||||
|
className={`md${className ? ` ${className}` : ""}${
|
||||||
|
inline ? " md--inline" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={REMARK_PLUGINS}
|
remarkPlugins={REMARK_PLUGINS}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
50
src/components/NotificationBell.tsx
Normal file
50
src/components/NotificationBell.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const { unreadNotificationCount, lastNotification } = useWS();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [ringing, setRinging] = useState(false);
|
||||||
|
const animatingRef = useRef(false);
|
||||||
|
// Initialised to the ID already in context — so the first effect run never
|
||||||
|
// animates, regardless of whether lastNotification is null or stale.
|
||||||
|
const lastSeenIdRef = useRef<string | null>(lastNotification?.id ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastNotification) return;
|
||||||
|
if (lastNotification.id === lastSeenIdRef.current) return;
|
||||||
|
lastSeenIdRef.current = lastNotification.id;
|
||||||
|
|
||||||
|
if (animatingRef.current) return;
|
||||||
|
animatingRef.current = true;
|
||||||
|
setRinging(true);
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setRinging(false);
|
||||||
|
animatingRef.current = false;
|
||||||
|
}, 700);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [lastNotification]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`notification-bell${
|
||||||
|
ringing ? " notification-bell--ringing" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => navigate("/notifications")}
|
||||||
|
aria-label={`Notifications${
|
||||||
|
unreadNotificationCount > 0
|
||||||
|
? ` (${unreadNotificationCount} unread)`
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="notification-bell-icon">🔔</span>
|
||||||
|
{unreadNotificationCount > 0 && (
|
||||||
|
<span className="notification-badge">
|
||||||
|
{unreadNotificationCount > 99 ? "99+" : unreadNotificationCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,19 +2,35 @@ 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 {
|
||||||
|
isPlaylistVisited,
|
||||||
|
isRecent,
|
||||||
|
markPlaylistVisited,
|
||||||
|
} from "../utils/visited.ts";
|
||||||
|
|
||||||
interface PlaylistCardProps {
|
interface PlaylistCardProps {
|
||||||
playlist: Playlist;
|
playlist: Playlist;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
isOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
|
export function PlaylistCard(
|
||||||
|
{ playlist, onDelete, isOwner }: PlaylistCardProps,
|
||||||
|
) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const unread = !isOwner && isRecent(playlist.createdAt) &&
|
||||||
|
!isPlaylistVisited(playlist.id);
|
||||||
|
|
||||||
|
function handleNavigate() {
|
||||||
|
markPlaylistVisited(playlist.id);
|
||||||
|
navigate(`/playlists/${playlist.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="playlist-card">
|
<li className="playlist-card">
|
||||||
<div
|
<div
|
||||||
className="playlist-card-inner"
|
className="playlist-card-inner"
|
||||||
onClick={() => navigate(`/playlists/${playlist.id}`)}
|
onClick={handleNavigate}
|
||||||
>
|
>
|
||||||
<div className="playlist-card-preview">
|
<div className="playlist-card-preview">
|
||||||
{playlist.imageMime
|
{playlist.imageMime
|
||||||
@@ -31,8 +47,12 @@ export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
|
|||||||
<Link
|
<Link
|
||||||
to={`/playlists/${playlist.id}`}
|
to={`/playlists/${playlist.id}`}
|
||||||
className="playlist-card-title"
|
className="playlist-card-title"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
markPlaylistVisited(playlist.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{unread && <span className="unread-dot" aria-hidden="true" />}
|
||||||
{playlist.title}
|
{playlist.title}
|
||||||
</Link>
|
</Link>
|
||||||
{playlist.description && (
|
{playlist.description && (
|
||||||
@@ -46,6 +66,15 @@ export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
|
|||||||
>
|
>
|
||||||
{playlist.isPublic ? "public" : "private"}
|
{playlist.isPublic ? "public" : "private"}
|
||||||
</span>
|
</span>
|
||||||
|
{playlist.ownerUsername && !isOwner && (
|
||||||
|
<Link
|
||||||
|
to={`/users/${playlist.ownerUsername}`}
|
||||||
|
className="playlist-card-owner"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
@{playlist.ownerUsername}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{playlist.dumpCount !== undefined && (
|
{playlist.dumpCount !== undefined && (
|
||||||
<span className="playlist-card-count">
|
<span className="playlist-card-count">
|
||||||
{playlist.dumpCount}{" "}
|
{playlist.dumpCount}{" "}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function PlaylistCreateForm(
|
|||||||
<div className="form-actions-right">
|
<div className="form-actions-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-secondary"
|
className="form-cancel"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
21
src/contexts/FollowContext.ts
Normal file
21
src/contexts/FollowContext.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export interface FollowContextValue {
|
||||||
|
followedUserIds: Set<string>;
|
||||||
|
followedPlaylistIds: Set<string>;
|
||||||
|
followUser: (userId: string) => Promise<void>;
|
||||||
|
unfollowUser: (userId: string) => Promise<void>;
|
||||||
|
followPlaylist: (playlistId: string) => Promise<void>;
|
||||||
|
unfollowPlaylist: (playlistId: string) => Promise<void>;
|
||||||
|
isLoaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FollowContext = createContext<FollowContextValue>({
|
||||||
|
followedUserIds: new Set(),
|
||||||
|
followedPlaylistIds: new Set(),
|
||||||
|
followUser: async () => {},
|
||||||
|
unfollowUser: async () => {},
|
||||||
|
followPlaylist: async () => {},
|
||||||
|
unfollowPlaylist: async () => {},
|
||||||
|
isLoaded: false,
|
||||||
|
});
|
||||||
125
src/contexts/FollowProvider.tsx
Normal file
125
src/contexts/FollowProvider.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||||
|
import { FollowContext, type FollowContextValue } from "./FollowContext.ts";
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import type { FollowStatus } from "../model.ts";
|
||||||
|
|
||||||
|
export function FollowProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { token, authFetch } = useAuth();
|
||||||
|
const [followedUserIds, setFollowedUserIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [followedPlaylistIds, setFollowedPlaylistIds] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setFollowedUserIds(new Set());
|
||||||
|
setFollowedPlaylistIds(new Set());
|
||||||
|
setIsLoaded(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
fetch(`${API_URL}/api/follows/status`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (cancelled || !body.success) return;
|
||||||
|
const status = body.data as FollowStatus;
|
||||||
|
setFollowedUserIds(new Set(status.followedUserIds));
|
||||||
|
setFollowedPlaylistIds(new Set(status.followedPlaylistIds));
|
||||||
|
setIsLoaded(true);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setIsLoaded(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const followUser = useCallback(async (userId: string) => {
|
||||||
|
setFollowedUserIds((prev) => new Set([...prev, userId]));
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
} catch {
|
||||||
|
setFollowedUserIds((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.delete(userId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [authFetch]);
|
||||||
|
|
||||||
|
const unfollowUser = useCallback(async (userId: string) => {
|
||||||
|
setFollowedUserIds((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.delete(userId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${API_URL}/api/follows/users/${userId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
} catch {
|
||||||
|
setFollowedUserIds((prev) => new Set([...prev, userId]));
|
||||||
|
}
|
||||||
|
}, [authFetch]);
|
||||||
|
|
||||||
|
const followPlaylist = useCallback(async (playlistId: string) => {
|
||||||
|
setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${API_URL}/api/follows/playlists/${playlistId}`,
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
} catch {
|
||||||
|
setFollowedPlaylistIds((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.delete(playlistId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [authFetch]);
|
||||||
|
|
||||||
|
const unfollowPlaylist = useCallback(async (playlistId: string) => {
|
||||||
|
setFollowedPlaylistIds((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.delete(playlistId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
`${API_URL}/api/follows/playlists/${playlistId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
} catch {
|
||||||
|
setFollowedPlaylistIds((prev) => new Set([...prev, playlistId]));
|
||||||
|
}
|
||||||
|
}, [authFetch]);
|
||||||
|
|
||||||
|
const value: FollowContextValue = {
|
||||||
|
followedUserIds,
|
||||||
|
followedPlaylistIds,
|
||||||
|
followUser,
|
||||||
|
unfollowUser,
|
||||||
|
followPlaylist,
|
||||||
|
unfollowPlaylist,
|
||||||
|
isLoaded,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FollowContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</FollowContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
import type { Comment, Dump, OnlineUser, Playlist } from "../model.ts";
|
import type {
|
||||||
|
Comment,
|
||||||
|
Dump,
|
||||||
|
Notification,
|
||||||
|
OnlineUser,
|
||||||
|
Playlist,
|
||||||
|
} from "../model.ts";
|
||||||
|
|
||||||
export interface VoteEvent {
|
export interface VoteEvent {
|
||||||
dumpId: string;
|
dumpId: string;
|
||||||
@@ -33,8 +39,12 @@ export interface WSContextValue {
|
|||||||
lastPlaylistEvent: PlaylistEvent | null;
|
lastPlaylistEvent: PlaylistEvent | null;
|
||||||
deletedPlaylistIds: Set<string>;
|
deletedPlaylistIds: Set<string>;
|
||||||
lastCommentEvent: CommentEvent | null;
|
lastCommentEvent: CommentEvent | null;
|
||||||
|
unreadNotificationCount: number;
|
||||||
|
lastNotification: Notification | null;
|
||||||
castVote: (dumpId: string) => void;
|
castVote: (dumpId: string) => void;
|
||||||
removeVote: (dumpId: string) => void;
|
removeVote: (dumpId: string) => void;
|
||||||
|
injectDump: (dump: Dump) => void;
|
||||||
|
clearUnreadNotifications: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WSContext = createContext<WSContextValue>({
|
export const WSContext = createContext<WSContextValue>({
|
||||||
@@ -48,6 +58,10 @@ export const WSContext = createContext<WSContextValue>({
|
|||||||
lastPlaylistEvent: null,
|
lastPlaylistEvent: null,
|
||||||
deletedPlaylistIds: new Set(),
|
deletedPlaylistIds: new Set(),
|
||||||
lastCommentEvent: null,
|
lastCommentEvent: null,
|
||||||
|
unreadNotificationCount: 0,
|
||||||
|
lastNotification: null,
|
||||||
castVote: () => {},
|
castVote: () => {},
|
||||||
removeVote: () => {},
|
removeVote: () => {},
|
||||||
|
injectDump: () => {},
|
||||||
|
clearUnreadNotifications: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ import {
|
|||||||
import { WS_URL } from "../config/api.ts";
|
import { WS_URL } from "../config/api.ts";
|
||||||
import type {
|
import type {
|
||||||
Dump,
|
Dump,
|
||||||
|
Notification,
|
||||||
OnlineUser,
|
OnlineUser,
|
||||||
RawComment,
|
RawComment,
|
||||||
RawDump,
|
RawDump,
|
||||||
|
RawNotification,
|
||||||
RawPlaylist,
|
RawPlaylist,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
import {
|
import {
|
||||||
deserializeComment,
|
deserializeComment,
|
||||||
deserializeDump,
|
deserializeDump,
|
||||||
|
deserializeNotification,
|
||||||
deserializePlaylist,
|
deserializePlaylist,
|
||||||
} from "../model.ts";
|
} from "../model.ts";
|
||||||
|
|
||||||
@@ -52,6 +55,10 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
const [lastCommentEvent, setLastCommentEvent] = useState<CommentEvent | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
|
||||||
|
const [lastNotification, setLastNotification] = useState<Notification | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
// Refs to avoid stale closures in event handlers
|
// Refs to avoid stale closures in event handlers
|
||||||
const voteCountsRef = useRef(voteCounts);
|
const voteCountsRef = useRef(voteCounts);
|
||||||
@@ -100,6 +107,9 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
const votes = msg.myVotes as string[];
|
const votes = msg.myVotes as string[];
|
||||||
setOnlineUsers(users);
|
setOnlineUsers(users);
|
||||||
setMyVotes(new Set(votes));
|
setMyVotes(new Set(votes));
|
||||||
|
setUnreadNotificationCount(
|
||||||
|
(msg.unreadNotificationCount as number) ?? 0,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,6 +227,15 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "notification_created": {
|
||||||
|
const notification = deserializeNotification(
|
||||||
|
msg.notification as RawNotification,
|
||||||
|
);
|
||||||
|
setLastNotification(notification);
|
||||||
|
setUnreadNotificationCount((prev) => prev + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
// On error, revert any pending optimistic update for the affected dump
|
// On error, revert any pending optimistic update for the affected dump
|
||||||
// (the revert timeout will handle it)
|
// (the revert timeout will handle it)
|
||||||
@@ -309,6 +328,17 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
|
socketRef.current?.send(JSON.stringify({ type: "vote_remove", dumpId }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const injectDump = useCallback((dump: Dump) => {
|
||||||
|
setRecentDumps((prev) => {
|
||||||
|
if (prev.some((d) => d.id === dump.id)) return prev;
|
||||||
|
return [dump, ...prev];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearUnreadNotifications = useCallback(() => {
|
||||||
|
setUnreadNotificationCount(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const value: WSContextValue = {
|
const value: WSContextValue = {
|
||||||
onlineUsers,
|
onlineUsers,
|
||||||
voteCounts,
|
voteCounts,
|
||||||
@@ -320,8 +350,12 @@ export function WSProvider({ children, token }: WSProviderProps) {
|
|||||||
lastPlaylistEvent,
|
lastPlaylistEvent,
|
||||||
deletedPlaylistIds,
|
deletedPlaylistIds,
|
||||||
lastCommentEvent,
|
lastCommentEvent,
|
||||||
|
unreadNotificationCount,
|
||||||
|
lastNotification,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
|
injectDump,
|
||||||
|
clearUnreadNotifications,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useNavigationType } from "react-router";
|
import { useNavigationType } from "react-router";
|
||||||
|
|
||||||
const TTL = 10 * 60 * 1000; // 10 minutes
|
const TTL = 10 * 60 * 1000; // 10 minutes
|
||||||
@@ -13,7 +13,12 @@ interface FeedCacheEntry<T> {
|
|||||||
|
|
||||||
export interface FeedCacheResult<T> {
|
export interface FeedCacheResult<T> {
|
||||||
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
cached: Omit<FeedCacheEntry<T>, "savedAt"> | null;
|
||||||
saveState: (items: T[], page: number, hasMore: boolean, scrollY: number) => void;
|
saveState: (
|
||||||
|
items: T[],
|
||||||
|
page: number,
|
||||||
|
hasMore: boolean,
|
||||||
|
scrollY: number,
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFeedCache<T>(
|
export function useFeedCache<T>(
|
||||||
@@ -22,9 +27,8 @@ export function useFeedCache<T>(
|
|||||||
): FeedCacheResult<T> {
|
): FeedCacheResult<T> {
|
||||||
const navType = useNavigationType();
|
const navType = useNavigationType();
|
||||||
|
|
||||||
// Read ONCE on mount. Empty deps is intentional — nav type and cache are only
|
// useState initializer runs exactly once on mount — correct for a cache read.
|
||||||
// relevant at the moment the component first mounts.
|
const [cached] = useState<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
||||||
const cached = useMemo<Omit<FeedCacheEntry<T>, "savedAt"> | null>(() => {
|
|
||||||
if (navType !== "POP") return null;
|
if (navType !== "POP") return null;
|
||||||
try {
|
try {
|
||||||
const raw = sessionStorage.getItem(key);
|
const raw = sessionStorage.getItem(key);
|
||||||
@@ -35,15 +39,20 @@ export function useFeedCache<T>(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
});
|
||||||
}, []);
|
|
||||||
|
|
||||||
const saveState = useCallback(
|
const saveState = useCallback(
|
||||||
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
(items: T[], page: number, hasMore: boolean, scrollY: number) => {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(
|
sessionStorage.setItem(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({ items, page, hasMore, scrollY, savedAt: Date.now() }),
|
JSON.stringify({
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
hasMore,
|
||||||
|
scrollY,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// QuotaExceededError or SecurityError — degrade silently
|
// QuotaExceededError or SecurityError — degrade silently
|
||||||
|
|||||||
4
src/hooks/useFollows.ts
Normal file
4
src/hooks/useFollows.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import { FollowContext } from "../contexts/FollowContext.ts";
|
||||||
|
|
||||||
|
export const useFollows = () => useContext(FollowContext);
|
||||||
78
src/model.ts
78
src/model.ts
@@ -46,6 +46,7 @@ export interface User {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public user profile (no passwordHash)
|
// Public user profile (no passwordHash)
|
||||||
@@ -55,6 +56,7 @@ export interface PublicUser {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire types — createdAt arrives as an ISO string from API/WS/localStorage
|
// Wire types — createdAt arrives as an ISO string from API/WS/localStorage
|
||||||
@@ -139,6 +141,7 @@ export interface Playlist {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
imageMime?: string;
|
imageMime?: string;
|
||||||
dumpCount?: number;
|
dumpCount?: number;
|
||||||
|
ownerUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlaylistWithDumps extends Playlist {
|
export interface PlaylistWithDumps extends Playlist {
|
||||||
@@ -318,3 +321,78 @@ export interface ActionResultFailure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ActionResult = ActionResultSuccess | ActionResultFailure;
|
export type ActionResult = ActionResultSuccess | ActionResultFailure;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follows
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FollowStatus {
|
||||||
|
followedUserIds: string[];
|
||||||
|
followedPlaylistIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| "playlist_followed"
|
||||||
|
| "user_followed"
|
||||||
|
| "user_dump_posted"
|
||||||
|
| "playlist_dump_added"
|
||||||
|
| "dump_upvoted";
|
||||||
|
|
||||||
|
export interface PlaylistFollowedData {
|
||||||
|
followerId: string;
|
||||||
|
followerUsername: string;
|
||||||
|
playlistId: string;
|
||||||
|
playlistTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserFollowedData {
|
||||||
|
followerId: string;
|
||||||
|
followerUsername: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDumpPostedData {
|
||||||
|
dumperId: string;
|
||||||
|
dumperUsername: string;
|
||||||
|
dumpId: string;
|
||||||
|
dumpTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistDumpAddedData {
|
||||||
|
dumpId: string;
|
||||||
|
dumpTitle: string;
|
||||||
|
playlistId: string;
|
||||||
|
playlistTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DumpUpvotedData {
|
||||||
|
voterId: string;
|
||||||
|
voterUsername: string;
|
||||||
|
dumpId: string;
|
||||||
|
dumpTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationData =
|
||||||
|
| PlaylistFollowedData
|
||||||
|
| UserFollowedData
|
||||||
|
| UserDumpPostedData
|
||||||
|
| PlaylistDumpAddedData
|
||||||
|
| DumpUpvotedData;
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
data: NotificationData;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RawNotification = WithStringDate<Notification>;
|
||||||
|
|
||||||
|
export function deserializeNotification(raw: RawNotification): Notification {
|
||||||
|
return { ...raw, createdAt: new Date(raw.createdAt) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ export function Dump() {
|
|||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
|
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
const { voteCounts, myVotes, castVote, removeVote, lastDumpEvent, lastCommentEvent } = useWS();
|
const {
|
||||||
|
voteCounts,
|
||||||
|
myVotes,
|
||||||
|
castVote,
|
||||||
|
removeVote,
|
||||||
|
lastDumpEvent,
|
||||||
|
lastCommentEvent,
|
||||||
|
} = useWS();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedDump) return;
|
if (!selectedDump) return;
|
||||||
@@ -114,7 +121,9 @@ export function Dump() {
|
|||||||
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
if (!lastCommentEvent || lastCommentEvent.dumpId !== selectedDump) return;
|
||||||
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
if (lastCommentEvent.type === "created" && lastCommentEvent.comment) {
|
||||||
setComments((prev) => {
|
setComments((prev) => {
|
||||||
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) return prev;
|
if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
return [...prev, lastCommentEvent.comment!];
|
return [...prev, lastCommentEvent.comment!];
|
||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ export function DumpEdit() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const body: UpdateDumpRequest = state.dump.kind === "url"
|
const body: UpdateDumpRequest = state.dump.kind === "url"
|
||||||
? { url: url.trim() || undefined, comment: comment.trim() || undefined, isPrivate }
|
? {
|
||||||
|
url: url.trim() || undefined,
|
||||||
|
comment: comment.trim() || undefined,
|
||||||
|
isPrivate,
|
||||||
|
}
|
||||||
: { comment: comment.trim() || undefined, isPrivate };
|
: { comment: comment.trim() || undefined, isPrivate };
|
||||||
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
@@ -263,20 +267,22 @@ export function DumpEdit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="toggle-row">
|
<div className="dump-mode-toggle">
|
||||||
<span className="toggle-label">Public</span>
|
<button
|
||||||
<span className="toggle-switch">
|
type="button"
|
||||||
<input
|
className={!isPrivate ? "active" : ""}
|
||||||
type="checkbox"
|
onClick={() => setIsPrivate(false)}
|
||||||
checked={!isPrivate}
|
>
|
||||||
onChange={(e) => setIsPrivate(!e.target.checked)}
|
Public
|
||||||
/>
|
</button>
|
||||||
<span className="toggle-thumb" />
|
<button
|
||||||
</span>
|
type="button"
|
||||||
{isPrivate && (
|
className={isPrivate ? "active" : ""}
|
||||||
<span className="toggle-hint">Only visible to you</span>
|
onClick={() => setIsPrivate(true)}
|
||||||
)}
|
>
|
||||||
</label>
|
Private
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
|
|
||||||
import { Avatar } from "../components/Avatar.tsx";
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
@@ -7,10 +13,15 @@ import { AppHeader } from "../components/AppHeader.tsx";
|
|||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
|
|
||||||
import { deserializeDump, type Dump, type PaginatedData, type RawDump } from "../model.ts";
|
import {
|
||||||
|
deserializeDump,
|
||||||
|
type Dump,
|
||||||
|
type PaginatedData,
|
||||||
|
type RawDump,
|
||||||
|
type User,
|
||||||
|
} from "../model.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 { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
@@ -24,15 +35,88 @@ const hydrateDump = (raw: Dump): Dump =>
|
|||||||
type DumpsState =
|
type DumpsState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; error: string }
|
| { status: "error"; error: string }
|
||||||
| { status: "loaded"; dumps: Dump[]; hasMore: boolean; page: number; loadingMore: boolean };
|
| {
|
||||||
|
status: "loaded";
|
||||||
|
dumps: Dump[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
loadingMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type SortMode = "new" | "hot";
|
type FeedTab = "hot" | "new" | "followed";
|
||||||
|
type FollowedSection = "users" | "playlists";
|
||||||
|
|
||||||
function hotScore(dump: Dump): number {
|
function hotScore(dump: Dump): number {
|
||||||
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
const ageHours = (Date.now() - dump.createdAt.getTime()) / 3_600_000;
|
||||||
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
return (dump.voteCount + 1) / Math.pow(ageHours + 2, 1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── FollowedSubFeed ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface FollowedSubFeedProps {
|
||||||
|
state: DumpsState;
|
||||||
|
voteCounts: Record<string, number>;
|
||||||
|
myVotes: Set<string>;
|
||||||
|
user: User | null;
|
||||||
|
castVote: (id: string) => void;
|
||||||
|
removeVote: (id: string) => void;
|
||||||
|
deletedDumpIds: Set<string>;
|
||||||
|
emptyMessage: string;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FollowedSubFeed({
|
||||||
|
state,
|
||||||
|
voteCounts,
|
||||||
|
myVotes,
|
||||||
|
user,
|
||||||
|
castVote,
|
||||||
|
removeVote,
|
||||||
|
deletedDumpIds,
|
||||||
|
emptyMessage,
|
||||||
|
onLoadMore,
|
||||||
|
}: FollowedSubFeedProps) {
|
||||||
|
const hasMore = state.status === "loaded" && state.hasMore &&
|
||||||
|
!state.loadingMore;
|
||||||
|
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore);
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return <p className="index-status">Loading…</p>;
|
||||||
|
}
|
||||||
|
if (state.status === "error") {
|
||||||
|
return <p className="index-status index-status--error">{state.error}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = state.dumps.filter((d) => !deletedDumpIds.has(d.id));
|
||||||
|
|
||||||
|
if (visible.length === 0) {
|
||||||
|
return <p className="index-status">{emptyMessage}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{visible.map((dump) => (
|
||||||
|
<DumpCard
|
||||||
|
key={dump.id}
|
||||||
|
dump={dump}
|
||||||
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||||
|
voted={myVotes.has(dump.id)}
|
||||||
|
canVote={!!user}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
isOwner={user?.id === dump.userId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{state.loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function Index() {
|
export function Index() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
const justDeletedId = (location.state as { deletedDumpId?: string } | null)
|
||||||
@@ -49,22 +133,70 @@ export function Index() {
|
|||||||
removeVote,
|
removeVote,
|
||||||
} = useWS();
|
} = useWS();
|
||||||
|
|
||||||
const { cached, saveState } = useFeedCache<Dump>(`feed:index:${user?.id ?? "guest"}`, hydrateDump);
|
// Main feed
|
||||||
|
const { cached, saveState } = useFeedCache<Dump>(
|
||||||
|
`feed:index:${user?.id ?? "guest"}`,
|
||||||
|
hydrateDump,
|
||||||
|
);
|
||||||
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
|
const [dumpsState, setDumpsState] = useState<DumpsState>(() =>
|
||||||
cached
|
cached
|
||||||
? { status: "loaded", dumps: cached.items, hasMore: cached.hasMore, page: cached.page, loadingMore: false }
|
? {
|
||||||
|
status: "loaded",
|
||||||
|
dumps: cached.items,
|
||||||
|
hasMore: cached.hasMore,
|
||||||
|
page: cached.page,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
: { status: "loading" }
|
: { status: "loading" }
|
||||||
);
|
);
|
||||||
const [sort, setSort] = useState<SortMode>("hot");
|
const mainFetchDone = useRef(false);
|
||||||
|
|
||||||
|
// Followed feeds
|
||||||
|
const { cached: cachedFollowedUsers, saveState: saveFollowedUsers } =
|
||||||
|
useFeedCache<Dump>(
|
||||||
|
`feed:followed-users:${user?.id ?? "guest"}`,
|
||||||
|
hydrateDump,
|
||||||
|
);
|
||||||
|
const { cached: cachedFollowedPlaylists, saveState: saveFollowedPlaylists } =
|
||||||
|
useFeedCache<Dump>(
|
||||||
|
`feed:followed-playlists:${user?.id ?? "guest"}`,
|
||||||
|
hydrateDump,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [followedUsersDumps, setFollowedUsersDumps] = useState<DumpsState>({
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
const [followedPlaylistsDumps, setFollowedPlaylistsDumps] = useState<
|
||||||
|
DumpsState
|
||||||
|
>({ status: "loading" });
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<FeedTab>("hot");
|
||||||
|
const [followedSection, setFollowedSection] = useState<FollowedSection>(
|
||||||
|
"users",
|
||||||
|
);
|
||||||
|
|
||||||
|
// When the logo is clicked it navigates to / with state { tab: "hot" }, producing
|
||||||
|
// a new location.key even if already on /. React to that to reset the active tab.
|
||||||
|
useEffect(() => {
|
||||||
|
const st = location.state as { tab?: string } | null;
|
||||||
|
if (st?.tab === "hot" || st?.tab === "new" || st?.tab === "followed") {
|
||||||
|
setTab(st.tab as FeedTab);
|
||||||
|
}
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
// ── Main feed fetch ──
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cached) return; // restored from cache, skip fetch
|
if (mainFetchDone.current || cached) return;
|
||||||
|
mainFetchDone.current = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`, {
|
const res = await fetch(
|
||||||
|
`${API_URL}/api/dumps/?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
{
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
},
|
||||||
|
);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
@@ -82,13 +214,96 @@ export function Index() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [cached, token]);
|
||||||
}, []);
|
|
||||||
|
// ── Followed feeds fetch (lazy, on first tab open) ──
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== "followed" || !user || !token) return;
|
||||||
|
|
||||||
|
if (followedUsersDumps.status === "loading") {
|
||||||
|
if (cachedFollowedUsers) {
|
||||||
|
setFollowedUsersDumps({
|
||||||
|
status: "loaded",
|
||||||
|
dumps: cachedFollowedUsers.items,
|
||||||
|
hasMore: cachedFollowedUsers.hasMore,
|
||||||
|
page: cachedFollowedUsers.page,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fetch(`${API_URL}/api/follows/feed/users?page=1&limit=${PAGE_SIZE}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setFollowedUsersDumps({
|
||||||
|
status: "loaded",
|
||||||
|
dumps: items.map(deserializeDump),
|
||||||
|
hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setFollowedUsersDumps({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (followedPlaylistsDumps.status === "loading") {
|
||||||
|
if (cachedFollowedPlaylists) {
|
||||||
|
setFollowedPlaylistsDumps({
|
||||||
|
status: "loaded",
|
||||||
|
dumps: cachedFollowedPlaylists.items,
|
||||||
|
hasMore: cachedFollowedPlaylists.hasMore,
|
||||||
|
page: cachedFollowedPlaylists.page,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/follows/feed/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setFollowedPlaylistsDumps({
|
||||||
|
status: "loaded",
|
||||||
|
dumps: items.map(deserializeDump),
|
||||||
|
hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setFollowedPlaylistsDumps({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
tab,
|
||||||
|
user?.id,
|
||||||
|
token,
|
||||||
|
cachedFollowedUsers,
|
||||||
|
cachedFollowedPlaylists,
|
||||||
|
followedUsersDumps.status,
|
||||||
|
followedPlaylistsDumps.status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ── Load-more callbacks ──
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
dumpsState.status !== "loaded" ||
|
dumpsState.status !== "loaded" || !dumpsState.hasMore ||
|
||||||
!dumpsState.hasMore ||
|
|
||||||
dumpsState.loadingMore
|
dumpsState.loadingMore
|
||||||
) return;
|
) return;
|
||||||
const nextPage = dumpsState.page + 1;
|
const nextPage = dumpsState.page + 1;
|
||||||
@@ -120,12 +335,92 @@ export function Index() {
|
|||||||
);
|
);
|
||||||
}, [dumpsState, token]);
|
}, [dumpsState, token]);
|
||||||
|
|
||||||
|
const loadMoreFollowedUsers = useCallback(() => {
|
||||||
|
if (
|
||||||
|
followedUsersDumps.status !== "loaded" ||
|
||||||
|
!followedUsersDumps.hasMore ||
|
||||||
|
followedUsersDumps.loadingMore ||
|
||||||
|
!token
|
||||||
|
) return;
|
||||||
|
const nextPage = followedUsersDumps.page + 1;
|
||||||
|
setFollowedUsersDumps((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||||
|
);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/follows/feed/users?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setFollowedUsersDumps((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setFollowedUsersDumps((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [followedUsersDumps, token]);
|
||||||
|
|
||||||
|
const loadMoreFollowedPlaylists = useCallback(() => {
|
||||||
|
if (
|
||||||
|
followedPlaylistsDumps.status !== "loaded" ||
|
||||||
|
!followedPlaylistsDumps.hasMore ||
|
||||||
|
followedPlaylistsDumps.loadingMore ||
|
||||||
|
!token
|
||||||
|
) return;
|
||||||
|
const nextPage = followedPlaylistsDumps.page + 1;
|
||||||
|
setFollowedPlaylistsDumps((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: true } : s
|
||||||
|
);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/follows/feed/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setFollowedPlaylistsDumps((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setFollowedPlaylistsDumps((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [followedPlaylistsDumps, token]);
|
||||||
|
|
||||||
|
// ── Scroll save effects ──
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll(
|
const sentinelRef = useInfiniteScroll(
|
||||||
loadMore,
|
loadMore,
|
||||||
dumpsState.status === "loaded" && dumpsState.hasMore && !dumpsState.loadingMore,
|
dumpsState.status === "loaded" && dumpsState.hasMore &&
|
||||||
|
!dumpsState.loadingMore,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save scroll position + loaded state to sessionStorage on scroll
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dumpsState.status !== "loaded") return;
|
if (dumpsState.status !== "loaded") return;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
@@ -133,25 +428,80 @@ export function Index() {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
if (dumpsState.status === "loaded") {
|
if (dumpsState.status === "loaded") {
|
||||||
saveState(dumpsState.dumps, dumpsState.page, dumpsState.hasMore, window.scrollY);
|
saveState(
|
||||||
|
dumpsState.dumps,
|
||||||
|
dumpsState.page,
|
||||||
|
dumpsState.hasMore,
|
||||||
|
globalThis.scrollY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
}, [dumpsState, saveState]);
|
}, [dumpsState, saveState]);
|
||||||
|
|
||||||
// Restore scroll position after cache restoration
|
useEffect(() => {
|
||||||
|
if (followedUsersDumps.status !== "loaded") return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (followedUsersDumps.status === "loaded") {
|
||||||
|
saveFollowedUsers(
|
||||||
|
followedUsersDumps.dumps,
|
||||||
|
followedUsersDumps.page,
|
||||||
|
followedUsersDumps.hasMore,
|
||||||
|
globalThis.scrollY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [followedUsersDumps, saveFollowedUsers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (followedPlaylistsDumps.status !== "loaded") return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (followedPlaylistsDumps.status === "loaded") {
|
||||||
|
saveFollowedPlaylists(
|
||||||
|
followedPlaylistsDumps.dumps,
|
||||||
|
followedPlaylistsDumps.page,
|
||||||
|
followedPlaylistsDumps.hasMore,
|
||||||
|
globalThis.scrollY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [followedPlaylistsDumps, saveFollowedPlaylists]);
|
||||||
|
|
||||||
|
// ── Scroll restoration ──
|
||||||
|
|
||||||
const scrollRestored = useRef(false);
|
const scrollRestored = useRef(false);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (cached?.scrollY == null || scrollRestored.current) return;
|
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||||
if (dumpsState.status === "loaded") {
|
if (dumpsState.status === "loaded") {
|
||||||
window.scrollTo(0, cached.scrollY);
|
globalThis.scrollTo(0, cached.scrollY);
|
||||||
scrollRestored.current = true;
|
scrollRestored.current = true;
|
||||||
}
|
}
|
||||||
// cached is stable (read once), safe to omit
|
}, [dumpsState.status, cached]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [dumpsState.status]);
|
// ── Derived values ──
|
||||||
|
|
||||||
const loading = dumpsState.status === "loading";
|
const loading = dumpsState.status === "loading";
|
||||||
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
const error = dumpsState.status === "error" ? dumpsState.error : null;
|
||||||
@@ -163,11 +513,13 @@ export function Index() {
|
|||||||
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
.filter((d) => !deletedDumpIds.has(d.id) && d.id !== justDeletedId);
|
||||||
|
|
||||||
const sortedDumps = [...combined].sort(
|
const sortedDumps = [...combined].sort(
|
||||||
sort === "hot"
|
tab === "new"
|
||||||
? (a, b) => hotScore(b) - hotScore(a)
|
? (a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
||||||
: (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
|
: (a, b) => hotScore(b) - hotScore(a),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Render ──
|
||||||
|
|
||||||
const presenceRow = (
|
const presenceRow = (
|
||||||
<div className="index-presence">
|
<div className="index-presence">
|
||||||
{onlineUsers.map((u) => (
|
{onlineUsers.map((u) => (
|
||||||
@@ -188,22 +540,31 @@ export function Index() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortButtons = !loading && !error && combined.length > 0 && (
|
const tabBar = (
|
||||||
<div className="feed-sort">
|
<div className="feed-sort">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`feed-sort-btn${sort === "hot" ? " active" : ""}`}
|
className={`feed-sort-btn${tab === "hot" ? " active" : ""}`}
|
||||||
onClick={() => setSort("hot")}
|
onClick={() => setTab("hot")}
|
||||||
>
|
>
|
||||||
Hot
|
Hot
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`feed-sort-btn${sort === "new" ? " active" : ""}`}
|
className={`feed-sort-btn${tab === "new" ? " active" : ""}`}
|
||||||
onClick={() => setSort("new")}
|
onClick={() => setTab("new")}
|
||||||
>
|
>
|
||||||
New
|
New
|
||||||
</button>
|
</button>
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`feed-sort-btn${tab === "followed" ? " active" : ""}`}
|
||||||
|
onClick={() => setTab("followed")}
|
||||||
|
>
|
||||||
|
Followed
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -213,17 +574,20 @@ export function Index() {
|
|||||||
centerSlot={
|
centerSlot={
|
||||||
<div className="header-center-slot">
|
<div className="header-center-slot">
|
||||||
{presenceRow}
|
{presenceRow}
|
||||||
{sortButtons}
|
{tabBar}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Shown only on narrow viewports */}
|
{/* Shown only on narrow viewports */}
|
||||||
<div className="index-below-header">
|
<div className="index-below-header">
|
||||||
{sortButtons}
|
{tabBar}
|
||||||
{presenceRow}
|
{presenceRow}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hot / New feed */}
|
||||||
|
{tab !== "followed" && (
|
||||||
|
<>
|
||||||
{loading && <p className="index-status">Loading…</p>}
|
{loading && <p className="index-status">Loading…</p>}
|
||||||
{error && <p className="index-status index-status--error">{error}</p>}
|
{error && <p className="index-status index-status--error">{error}</p>}
|
||||||
|
|
||||||
@@ -250,6 +614,62 @@ export function Index() {
|
|||||||
|
|
||||||
<div ref={sentinelRef} />
|
<div ref={sentinelRef} />
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Followed feed */}
|
||||||
|
{tab === "followed" && user && (
|
||||||
|
<div className="followed-feed">
|
||||||
|
<div className="feed-sort followed-sub-nav">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`feed-sort-btn${
|
||||||
|
followedSection === "users" ? " active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setFollowedSection("users")}
|
||||||
|
>
|
||||||
|
From people
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`feed-sort-btn${
|
||||||
|
followedSection === "playlists" ? " active" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setFollowedSection("playlists")}
|
||||||
|
>
|
||||||
|
From playlists
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{followedSection === "users" && (
|
||||||
|
<FollowedSubFeed
|
||||||
|
state={followedUsersDumps}
|
||||||
|
voteCounts={voteCounts}
|
||||||
|
myVotes={myVotes}
|
||||||
|
user={user}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
deletedDumpIds={deletedDumpIds}
|
||||||
|
emptyMessage="Follow some users to see their dumps here."
|
||||||
|
onLoadMore={loadMoreFollowedUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{followedSection === "playlists" && (
|
||||||
|
<FollowedSubFeed
|
||||||
|
state={followedPlaylistsDumps}
|
||||||
|
voteCounts={voteCounts}
|
||||||
|
myVotes={myVotes}
|
||||||
|
user={user}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
deletedDumpIds={deletedDumpIds}
|
||||||
|
emptyMessage="Follow some public playlists to see their dumps here."
|
||||||
|
onLoadMore={loadMoreFollowedPlaylists}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import { API_URL } from "../config/api.ts";
|
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
|
||||||
import { deserializePlaylist, type PaginatedData } from "../model.ts";
|
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
|
||||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
|
||||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
|
||||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
|
||||||
|
|
||||||
type State =
|
|
||||||
| { status: "loading" }
|
|
||||||
| { status: "error"; error: string }
|
|
||||||
| { status: "loaded"; playlists: Playlist[]; hasMore: boolean; page: number; loadingMore: boolean };
|
|
||||||
|
|
||||||
export function MyPlaylists() {
|
|
||||||
const { user, authFetch, token } = useAuth();
|
|
||||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
|
||||||
const [state, setState] = useState<State>({ status: "loading" });
|
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return;
|
|
||||||
fetch(`${API_URL}/api/users/${user.username}/playlists?page=1&limit=${PAGE_SIZE}`, {
|
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
|
||||||
})
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
if (!body.success) throw new Error("Failed to load");
|
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
|
||||||
setState({
|
|
||||||
status: "loaded",
|
|
||||||
playlists: items.map(deserializePlaylist),
|
|
||||||
hasMore,
|
|
||||||
page: 1,
|
|
||||||
loadingMore: false,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch((err) =>
|
|
||||||
setState({
|
|
||||||
status: "error",
|
|
||||||
error: err instanceof Error ? err.message : "Failed to load playlists",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [user?.username]);
|
|
||||||
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
if (state.status !== "loaded" || !state.hasMore || state.loadingMore || !user) return;
|
|
||||||
const nextPage = state.page + 1;
|
|
||||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
|
||||||
fetch(
|
|
||||||
`${API_URL}/api/users/${user.username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
|
||||||
{ headers: { Authorization: `Bearer ${token}` } },
|
|
||||||
)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
playlists: [...s.playlists, ...items.map(deserializePlaylist)],
|
|
||||||
hasMore,
|
|
||||||
page: nextPage,
|
|
||||||
loadingMore: false,
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s)
|
|
||||||
);
|
|
||||||
}, [state, user, token]);
|
|
||||||
|
|
||||||
const sentinelRef = useInfiniteScroll(
|
|
||||||
loadMore,
|
|
||||||
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Real-time WS updates
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lastPlaylistEvent || !user) return;
|
|
||||||
const ev = lastPlaylistEvent;
|
|
||||||
|
|
||||||
if (ev.type === "created" && ev.playlist?.userId === user.id) {
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
|
|
||||||
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
|
|
||||||
});
|
|
||||||
} else if (ev.type === "updated" && ev.playlist?.userId === user.id) {
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
playlists: s.playlists.map((p) =>
|
|
||||||
p.id === ev.playlist!.id ? ev.playlist! : p
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
} else if (ev.type === "deleted") {
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [lastPlaylistEvent, user]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!deletedPlaylistIds.size) return;
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
playlists: s.playlists.filter((p) => !deletedPlaylistIds.has(p.id)),
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
}, [deletedPlaylistIds]);
|
|
||||||
|
|
||||||
const handleDelete = async (playlistId: string) => {
|
|
||||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? { ...s, playlists: s.playlists.filter((p) => p.id !== playlistId) }
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell>
|
|
||||||
<div className="my-playlists-header">
|
|
||||||
<h1 className="my-playlists-title">My Playlists</h1>
|
|
||||||
<NewPlaylistForm
|
|
||||||
toggleClassName="btn-primary"
|
|
||||||
onCreated={(p) =>
|
|
||||||
setState((s) => {
|
|
||||||
if (s.status !== "loaded") return s;
|
|
||||||
if (s.playlists.some((pl) => pl.id === p.id)) return s;
|
|
||||||
return { ...s, playlists: [p, ...s.playlists] };
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{state.status === "loading" && <p className="page-loading">Loading…</p>}
|
|
||||||
{state.status === "error" && <p className="form-error">{state.error}</p>}
|
|
||||||
{state.status === "loaded" && (
|
|
||||||
state.playlists.length === 0
|
|
||||||
? <p className="empty-state">No playlists yet. Create one!</p>
|
|
||||||
: (
|
|
||||||
<ul className="dump-feed">
|
|
||||||
{state.playlists.map((p) => (
|
|
||||||
<PlaylistCard
|
|
||||||
key={p.id}
|
|
||||||
playlist={p}
|
|
||||||
onDelete={() => setConfirmDeleteId(p.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div ref={sentinelRef} />
|
|
||||||
{state.status === "loaded" && state.loadingMore && (
|
|
||||||
<p className="feed-loading-more">Loading more…</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{confirmDeleteId && (
|
|
||||||
<ConfirmModal
|
|
||||||
message="Delete this playlist? This cannot be undone."
|
|
||||||
confirmLabel="Delete playlist"
|
|
||||||
onConfirm={() => {
|
|
||||||
handleDelete(confirmDeleteId);
|
|
||||||
setConfirmDeleteId(null);
|
|
||||||
}}
|
|
||||||
onCancel={() => setConfirmDeleteId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PageShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
340
src/pages/Notifications.tsx
Normal file
340
src/pages/Notifications.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
import type {
|
||||||
|
DumpUpvotedData,
|
||||||
|
Notification,
|
||||||
|
NotificationData,
|
||||||
|
PaginatedData,
|
||||||
|
PlaylistDumpAddedData,
|
||||||
|
PlaylistFollowedData,
|
||||||
|
RawNotification,
|
||||||
|
UserDumpPostedData,
|
||||||
|
UserFollowedData,
|
||||||
|
} from "../model.ts";
|
||||||
|
import { deserializeNotification } from "../model.ts";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "error"; error: string }
|
||||||
|
| {
|
||||||
|
status: "loaded";
|
||||||
|
items: Notification[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
loadingMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotifIconKind = "upvote" | "follow" | "dump" | "playlist";
|
||||||
|
|
||||||
|
function notifIconKind(type: Notification["type"]): NotifIconKind {
|
||||||
|
switch (type) {
|
||||||
|
case "dump_upvoted":
|
||||||
|
return "upvote";
|
||||||
|
case "playlist_followed":
|
||||||
|
return "follow";
|
||||||
|
case "user_followed":
|
||||||
|
return "follow";
|
||||||
|
case "user_dump_posted":
|
||||||
|
return "dump";
|
||||||
|
case "playlist_dump_added":
|
||||||
|
return "playlist";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotifIcon({ type }: { type: Notification["type"] }) {
|
||||||
|
const kind = notifIconKind(type);
|
||||||
|
const glyphs: Record<NotifIconKind, string> = {
|
||||||
|
upvote: "▲",
|
||||||
|
follow: "►",
|
||||||
|
dump: "🚚",
|
||||||
|
playlist: "📜",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={`notif-icon notif-icon--${kind}`}>
|
||||||
|
{glyphs[kind]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationContent(n: Notification): React.ReactNode {
|
||||||
|
const data = n.data as NotificationData;
|
||||||
|
switch (n.type) {
|
||||||
|
case "user_followed": {
|
||||||
|
const d = data as UserFollowedData;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to={`/users/${d.followerUsername}`} className="notif-link">
|
||||||
|
{d.followerUsername}
|
||||||
|
</Link>
|
||||||
|
{" started following you"}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "playlist_followed": {
|
||||||
|
const d = data as PlaylistFollowedData;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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": {
|
||||||
|
const d = data as UserDumpPostedData;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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": {
|
||||||
|
const d = data as PlaylistDumpAddedData;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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": {
|
||||||
|
const d = data as DumpUpvotedData;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link to={`/users/${d.voterUsername}`} className="notif-link">
|
||||||
|
{d.voterUsername}
|
||||||
|
</Link>
|
||||||
|
{" upvoted "}
|
||||||
|
<Link to={`/dumps/${d.dumpId}`} className="notif-link">
|
||||||
|
{d.dumpTitle}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "New notification";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(date: Date): string {
|
||||||
|
const secs = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
|
if (secs < 60) return "just now";
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.floor(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.floor(hrs / 24);
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(d: Date): number {
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByDate(
|
||||||
|
items: Notification[],
|
||||||
|
): { label: string; items: Notification[] }[] {
|
||||||
|
const todayTs = startOfDay(new Date());
|
||||||
|
const yesterdayTs = todayTs - 86_400_000;
|
||||||
|
|
||||||
|
const buckets: Record<string, Notification[]> = {};
|
||||||
|
for (const n of items) {
|
||||||
|
const ts = startOfDay(n.createdAt);
|
||||||
|
const key = ts >= todayTs
|
||||||
|
? "Today"
|
||||||
|
: ts >= yesterdayTs
|
||||||
|
? "Yesterday"
|
||||||
|
: "Earlier";
|
||||||
|
(buckets[key] ??= []).push(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (["Today", "Yesterday", "Earlier"] as const)
|
||||||
|
.filter((k) => buckets[k]?.length)
|
||||||
|
.map((label) => ({ label, items: buckets[label] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Notifications() {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const { clearUnreadNotifications, lastNotification } = useWS();
|
||||||
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. Fetch with original read state so unread items are highlighted
|
||||||
|
// 2. Only after displaying, mark all read on the server
|
||||||
|
authFetch(`${API_URL}/api/notifications?page=1&limit=${PAGE_SIZE}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!body.success) throw new Error("Failed to load");
|
||||||
|
const data = body.data as PaginatedData<RawNotification>;
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
items: data.items.map(deserializeNotification),
|
||||||
|
hasMore: data.hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
// Mark read server-side after we've shown the unread state
|
||||||
|
return authFetch(`${API_URL}/api/notifications/read-all`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
clearUnreadNotifications();
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? { ...s, items: s.items.map((n) => ({ ...n, read: true })) }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err instanceof Error && err.message === "Failed to load") {
|
||||||
|
setState({ status: "error", error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastNotification) return;
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded") return s;
|
||||||
|
if (s.items.some((n) => n.id === lastNotification.id)) return s;
|
||||||
|
// Keep as unread so it gets highlighted when it arrives
|
||||||
|
return { ...s, items: [lastNotification, ...s.items] };
|
||||||
|
});
|
||||||
|
}, [lastNotification]);
|
||||||
|
|
||||||
|
const loadMore = () => {
|
||||||
|
if (state.status !== "loaded" || !state.hasMore || state.loadingMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextPage = state.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
|
authFetch(
|
||||||
|
`${API_URL}/api/notifications?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const data = body.data as PaginatedData<RawNotification>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
items: [...s.items, ...data.items.map(deserializeNotification)],
|
||||||
|
hasMore: data.hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalUnread = state.status === "loaded"
|
||||||
|
? state.items.filter((n) => !n.read).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="notifications-page">
|
||||||
|
<div className="notifications-header">
|
||||||
|
<h1 className="notifications-title">
|
||||||
|
<span className="notifications-title-bell">🔔</span>
|
||||||
|
Notifications
|
||||||
|
</h1>
|
||||||
|
{state.status === "loaded" && totalUnread > 0 && (
|
||||||
|
<span className="notifications-unread-pill">
|
||||||
|
{totalUnread} new
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.status === "loading" && <p className="page-loading">Loading…</p>}
|
||||||
|
{state.status === "error" && <p className="form-error">{state.error}
|
||||||
|
</p>}
|
||||||
|
|
||||||
|
{state.status === "loaded" && state.items.length === 0 && (
|
||||||
|
<div className="notifications-empty">
|
||||||
|
<span className="notifications-empty-icon">🔕</span>
|
||||||
|
<p>Nothing here yet.</p>
|
||||||
|
<p className="notifications-empty-hint">
|
||||||
|
You'll be notified when someone follows your playlists, upvotes
|
||||||
|
your dumps, or posts new content.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "loaded" && state.items.length > 0 &&
|
||||||
|
groupByDate(state.items).map(({ label, items }) => (
|
||||||
|
<section key={label} className="notif-group">
|
||||||
|
<h2 className="notif-group-label">{label}</h2>
|
||||||
|
<ul className="notification-list">
|
||||||
|
{items.map((n) => (
|
||||||
|
<li
|
||||||
|
key={n.id}
|
||||||
|
className={`notification-item${
|
||||||
|
!n.read ? " notification-item--unread" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<NotifIcon type={n.type} />
|
||||||
|
<div className="notification-body">
|
||||||
|
<span className="notification-content">
|
||||||
|
{notificationContent(n)}
|
||||||
|
</span>
|
||||||
|
<time
|
||||||
|
className="notification-time"
|
||||||
|
dateTime={n.createdAt.toISOString()}
|
||||||
|
>
|
||||||
|
{timeAgo(n.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
{!n.read && (
|
||||||
|
<span className="notif-dot" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{state.status === "loaded" && state.hasMore && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="load-more-btn"
|
||||||
|
onClick={loadMore}
|
||||||
|
disabled={state.loadingMore}
|
||||||
|
>
|
||||||
|
{state.loadingMore ? "Loading…" : "Load more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { 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 { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
|
||||||
import { deserializePlaylistWithDumps } from "../model.ts";
|
import { deserializePlaylistWithDumps } from "../model.ts";
|
||||||
@@ -12,6 +12,7 @@ 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 { FollowPlaylistButton } from "../components/FollowButton.tsx";
|
||||||
|
|
||||||
type LoadState =
|
type LoadState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
@@ -356,7 +357,6 @@ export function PlaylistDetail() {
|
|||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleEditSave = async () => {
|
const handleEditSave = async () => {
|
||||||
if (!playlistId || state.status !== "loaded") return;
|
if (!playlistId || state.status !== "loaded") return;
|
||||||
setEditSaving(true);
|
setEditSaving(true);
|
||||||
@@ -392,7 +392,9 @@ export function PlaylistDetail() {
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!playlistId) return;
|
if (!playlistId) return;
|
||||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE" });
|
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
navigate("/");
|
navigate("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -460,6 +462,7 @@ export function PlaylistDetail() {
|
|||||||
<div className="playlist-detail-content">
|
<div className="playlist-detail-content">
|
||||||
{editOpen
|
{editOpen
|
||||||
? (
|
? (
|
||||||
|
<div className="playlist-detail-title-row">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="playlist-edit-input"
|
className="playlist-edit-input"
|
||||||
@@ -467,8 +470,50 @@ export function PlaylistDetail() {
|
|||||||
onChange={(e) => setEditTitle(e.target.value)}
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={editSaving}
|
||||||
|
onClick={handleEditSave}
|
||||||
|
>
|
||||||
|
{editSaving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="form-cancel"
|
||||||
|
onClick={() => setEditOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-danger"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
: <h1 className="playlist-detail-title">{playlist.title}</h1>}
|
: (
|
||||||
|
<div className="playlist-detail-title-row">
|
||||||
|
<h1 className="playlist-detail-title">{playlist.title}</h1>
|
||||||
|
{!isOwner && (
|
||||||
|
<FollowPlaylistButton
|
||||||
|
targetPlaylistId={playlist.id}
|
||||||
|
isPublic={playlist.isPublic}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isOwner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="playlist-edit-btn"
|
||||||
|
onClick={openEdit}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editOpen
|
{editOpen
|
||||||
? (
|
? (
|
||||||
@@ -516,6 +561,14 @@ export function PlaylistDetail() {
|
|||||||
>
|
>
|
||||||
{playlist.isPublic ? "public" : "private"}
|
{playlist.isPublic ? "public" : "private"}
|
||||||
</span>
|
</span>
|
||||||
|
{playlist.ownerUsername && (
|
||||||
|
<Link
|
||||||
|
to={`/users/${playlist.ownerUsername}`}
|
||||||
|
className="playlist-detail-owner"
|
||||||
|
>
|
||||||
|
@{playlist.ownerUsername}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<time
|
<time
|
||||||
dateTime={playlist.createdAt.toISOString()}
|
dateTime={playlist.createdAt.toISOString()}
|
||||||
title={playlist.createdAt.toLocaleString()}
|
title={playlist.createdAt.toLocaleString()}
|
||||||
@@ -527,47 +580,6 @@ export function PlaylistDetail() {
|
|||||||
</div>
|
</div>
|
||||||
{editError && <p className="form-error">{editError}</p>}
|
{editError && <p className="form-error">{editError}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isOwner && (
|
|
||||||
<div className="playlist-header-actions">
|
|
||||||
{editOpen
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-primary"
|
|
||||||
disabled={editSaving}
|
|
||||||
onClick={handleEditSave}
|
|
||||||
>
|
|
||||||
{editSaving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-secondary"
|
|
||||||
onClick={() => setEditOpen(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn-danger"
|
|
||||||
onClick={() => setConfirmDelete(true)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
: (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="playlist-edit-btn"
|
|
||||||
onClick={openEdit}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
260
src/pages/UserDumps.tsx
Normal file
260
src/pages/UserDumps.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||||
|
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
|
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
import { PageError } from "../components/PageError.tsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const hydrateDump = (raw: Dump): Dump =>
|
||||||
|
deserializeDump(raw as unknown as RawDump);
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "error"; error: string }
|
||||||
|
| {
|
||||||
|
status: "loaded";
|
||||||
|
profileUser: PublicUser;
|
||||||
|
dumps: Dump[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
loadingMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserDumps() {
|
||||||
|
const { username } = useParams();
|
||||||
|
const { user: me, token } = useAuth();
|
||||||
|
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||||
|
const { cached, saveState } = useFeedCache<Dump>(
|
||||||
|
`feed:user-dumps-full:${username ?? ""}`,
|
||||||
|
hydrateDump,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
fetch(`${API_URL}/api/users/${username}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!body.success) throw new Error("User not found");
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(body.data),
|
||||||
|
dumps: cached.items,
|
||||||
|
hasMore: cached.hasMore,
|
||||||
|
page: cached.page,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders: HeadersInit = token
|
||||||
|
? { Authorization: `Bearer ${token}` }
|
||||||
|
: {};
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API_URL}/api/users/${username}`),
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: authHeaders },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([userRes, dumpsRes]) =>
|
||||||
|
Promise.all([userRes.json(), dumpsRes.json()])
|
||||||
|
)
|
||||||
|
.then(([userBody, dumpsBody]) => {
|
||||||
|
if (!userBody.success) throw new Error("User not found");
|
||||||
|
const { items, hasMore } = dumpsBody.success
|
||||||
|
? dumpsBody.data as PaginatedData<RawDump>
|
||||||
|
: { items: [], hasMore: false };
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(userBody.data),
|
||||||
|
dumps: items.map(deserializeDump),
|
||||||
|
hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (
|
||||||
|
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||||
|
!username
|
||||||
|
) return;
|
||||||
|
const nextPage = state.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
dumps: [...s.dumps, ...items.map(deserializeDump)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [state, username, token]);
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll(
|
||||||
|
loadMore,
|
||||||
|
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
saveState(state.dumps, state.page, state.hasMore, globalThis.scrollY);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [state, saveState]);
|
||||||
|
|
||||||
|
const scrollRestored = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||||
|
if (state.status === "loaded") {
|
||||||
|
globalThis.scrollTo(0, cached.scrollY);
|
||||||
|
scrollRestored.current = true;
|
||||||
|
}
|
||||||
|
}, [state.status, cached]);
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<p className="page-loading">Loading…</p>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<PageError
|
||||||
|
message={state.error}
|
||||||
|
actions={
|
||||||
|
<Link to={`/users/${username}`} className="logout-btn">
|
||||||
|
← Back to profile
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profileUser, dumps, hasMore, loadingMore } = state;
|
||||||
|
const isOwnProfile = me?.username === profileUser.username;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="profile-subpage-header">
|
||||||
|
<Link
|
||||||
|
to={`/users/${username}`}
|
||||||
|
className="profile-subpage-back"
|
||||||
|
>
|
||||||
|
← {profileUser.username}
|
||||||
|
</Link>
|
||||||
|
<div className="profile-subpage-title-row">
|
||||||
|
<Avatar
|
||||||
|
userId={profileUser.id}
|
||||||
|
username={profileUser.username}
|
||||||
|
hasAvatar={!!profileUser.avatarMime}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<h1 className="profile-subpage-title">Dumps</h1>
|
||||||
|
{isOwnProfile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="new-playlist-toggle"
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
>
|
||||||
|
+ New dump
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createModalOpen && (
|
||||||
|
<DumpCreateModal onClose={() => setCreateModalOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dumps.length === 0
|
||||||
|
? <p className="empty-state">Nothing here yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{dumps.map((dump) => (
|
||||||
|
<DumpCard
|
||||||
|
key={dump.id}
|
||||||
|
dump={dump}
|
||||||
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||||
|
voted={myVotes.has(dump.id)}
|
||||||
|
canVote={!!me}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
isOwner={isOwnProfile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
|
{!hasMore && dumps.length > 0 && (
|
||||||
|
<p className="index-status">All {dumps.length} dumps loaded.</p>
|
||||||
|
)}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
494
src/pages/UserPlaylists.tsx
Normal file
494
src/pages/UserPlaylists.tsx
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type {
|
||||||
|
PaginatedData,
|
||||||
|
Playlist,
|
||||||
|
PublicUser,
|
||||||
|
RawPlaylist,
|
||||||
|
} from "../model.ts";
|
||||||
|
import { deserializePlaylist, deserializePublicUser } from "../model.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
|
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||||
|
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||||
|
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
import { PageError } from "../components/PageError.tsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const hydratePlaylist = (raw: Playlist): Playlist =>
|
||||||
|
deserializePlaylist(raw as unknown as RawPlaylist);
|
||||||
|
|
||||||
|
interface PlaylistFeed {
|
||||||
|
items: Playlist[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
loadingMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "error"; error: string }
|
||||||
|
| {
|
||||||
|
status: "loaded";
|
||||||
|
profileUser: PublicUser;
|
||||||
|
created: PlaylistFeed;
|
||||||
|
followed: PlaylistFeed;
|
||||||
|
};
|
||||||
|
|
||||||
|
function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed {
|
||||||
|
return { items, hasMore, page: 1, loadingMore: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserPlaylists() {
|
||||||
|
const { username } = useParams();
|
||||||
|
const { user: me, authFetch, token } = useAuth();
|
||||||
|
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||||
|
|
||||||
|
const { cached: cachedCreated, saveState: saveCreated } = useFeedCache<
|
||||||
|
Playlist
|
||||||
|
>(
|
||||||
|
`feed:user-playlists:${username ?? ""}`,
|
||||||
|
hydratePlaylist,
|
||||||
|
);
|
||||||
|
const { cached: cachedFollowed, saveState: saveFollowed } = useFeedCache<
|
||||||
|
Playlist
|
||||||
|
>(
|
||||||
|
`feed:user-followed-playlists:${username ?? ""}`,
|
||||||
|
hydratePlaylist,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
|
||||||
|
const authHeaders: HeadersInit = token
|
||||||
|
? { Authorization: `Bearer ${token}` }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
if (cachedCreated && cachedFollowed) {
|
||||||
|
fetch(`${API_URL}/api/users/${username}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!body.success) throw new Error("User not found");
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(body.data),
|
||||||
|
created: {
|
||||||
|
items: cachedCreated.items,
|
||||||
|
hasMore: cachedCreated.hasMore,
|
||||||
|
page: cachedCreated.page,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
followed: {
|
||||||
|
items: cachedFollowed.items,
|
||||||
|
hasMore: cachedFollowed.hasMore,
|
||||||
|
page: cachedFollowed.page,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API_URL}/api/users/${username}`),
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: authHeaders },
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([userRes, createdRes, followedRes]) =>
|
||||||
|
Promise.all([userRes.json(), createdRes.json(), followedRes.json()])
|
||||||
|
)
|
||||||
|
.then(([userBody, createdBody, followedBody]) => {
|
||||||
|
if (!userBody.success) throw new Error("User not found");
|
||||||
|
const createdData = createdBody.success
|
||||||
|
? createdBody.data as PaginatedData<RawPlaylist>
|
||||||
|
: { items: [], hasMore: false };
|
||||||
|
const followedData = followedBody.success
|
||||||
|
? followedBody.data as PaginatedData<RawPlaylist>
|
||||||
|
: { items: [], hasMore: false };
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(userBody.data),
|
||||||
|
created: initialFeed(
|
||||||
|
createdData.items.map(deserializePlaylist),
|
||||||
|
createdData.hasMore,
|
||||||
|
),
|
||||||
|
followed: initialFeed(
|
||||||
|
followedData.items.map(deserializePlaylist),
|
||||||
|
followedData.hasMore,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
const loadMoreCreated = useCallback(() => {
|
||||||
|
if (
|
||||||
|
state.status !== "loaded" || !state.created.hasMore ||
|
||||||
|
state.created.loadingMore || !username
|
||||||
|
) return;
|
||||||
|
const nextPage = state.created.page + 1;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? { ...s, created: { ...s.created, loadingMore: true } }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
created: {
|
||||||
|
items: [...s.created.items, ...items.map(deserializePlaylist)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? { ...s, created: { ...s.created, loadingMore: false } }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [state, username, token]);
|
||||||
|
|
||||||
|
const loadMoreFollowed = useCallback(() => {
|
||||||
|
if (
|
||||||
|
state.status !== "loaded" || !state.followed.hasMore ||
|
||||||
|
state.followed.loadingMore || !username
|
||||||
|
) return;
|
||||||
|
const nextPage = state.followed.page + 1;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? { ...s, followed: { ...s.followed, loadingMore: true } }
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
followed: {
|
||||||
|
items: [...s.followed.items, ...items.map(deserializePlaylist)],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? { ...s, followed: { ...s.followed, loadingMore: false } }
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [state, username]);
|
||||||
|
|
||||||
|
const createdSentinelRef = useInfiniteScroll(
|
||||||
|
loadMoreCreated,
|
||||||
|
state.status === "loaded" && state.created.hasMore &&
|
||||||
|
!state.created.loadingMore,
|
||||||
|
);
|
||||||
|
const followedSentinelRef = useInfiniteScroll(
|
||||||
|
loadMoreFollowed,
|
||||||
|
state.status === "loaded" && state.followed.hasMore &&
|
||||||
|
!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
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
const y = globalThis.scrollY;
|
||||||
|
saveCreated(
|
||||||
|
state.created.items,
|
||||||
|
state.created.page,
|
||||||
|
state.created.hasMore,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
saveFollowed(
|
||||||
|
state.followed.items,
|
||||||
|
state.followed.page,
|
||||||
|
state.followed.hasMore,
|
||||||
|
y,
|
||||||
|
);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [state, saveCreated, saveFollowed]);
|
||||||
|
|
||||||
|
const scrollRestored = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cachedCreated?.scrollY == null || scrollRestored.current) return;
|
||||||
|
if (state.status === "loaded") {
|
||||||
|
globalThis.scrollTo(0, cachedCreated.scrollY);
|
||||||
|
scrollRestored.current = true;
|
||||||
|
}
|
||||||
|
}, [state.status, cachedCreated]);
|
||||||
|
|
||||||
|
const handleDelete = async (playlistId: string) => {
|
||||||
|
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
created: {
|
||||||
|
...s.created,
|
||||||
|
items: s.created.items.filter((p) => p.id !== playlistId),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<p className="page-loading">Loading…</p>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<PageError
|
||||||
|
message={state.error}
|
||||||
|
actions={
|
||||||
|
<Link to={`/users/${username}`} className="logout-btn">
|
||||||
|
← Back to profile
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profileUser, created, followed } = state;
|
||||||
|
const isOwnProfile = me?.username === profileUser.username;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="profile-subpage-header">
|
||||||
|
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||||
|
← {profileUser.username}
|
||||||
|
</Link>
|
||||||
|
<div className="profile-subpage-title-row">
|
||||||
|
<Avatar
|
||||||
|
userId={profileUser.id}
|
||||||
|
username={profileUser.username}
|
||||||
|
hasAvatar={!!profileUser.avatarMime}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<h1 className="profile-subpage-title">Playlists</h1>
|
||||||
|
{isOwnProfile && (
|
||||||
|
<NewPlaylistForm
|
||||||
|
toggleClassName="btn-primary"
|
||||||
|
onCreated={(p) =>
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded") return s;
|
||||||
|
if (s.created.items.some((pl) => pl.id === p.id)) return s;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
created: { ...s.created, items: [p, ...s.created.items] },
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="profile-section">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<h2 className="profile-section-title">
|
||||||
|
Created ({created.items.length}
|
||||||
|
{created.hasMore ? "+" : ""})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{created.items.length === 0
|
||||||
|
? <p className="empty-state">No playlists yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{created.items.map((p) => (
|
||||||
|
<PlaylistCard
|
||||||
|
key={p.id}
|
||||||
|
playlist={p}
|
||||||
|
isOwner={isOwnProfile}
|
||||||
|
onDelete={isOwnProfile
|
||||||
|
? () => setConfirmDeleteId(p.id)
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div ref={createdSentinelRef} />
|
||||||
|
{created.loadingMore && (
|
||||||
|
<p className="feed-loading-more">Loading more…</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="profile-section">
|
||||||
|
<div className="profile-section-header">
|
||||||
|
<h2 className="profile-section-title">
|
||||||
|
Followed ({followed.items.length}
|
||||||
|
{followed.hasMore ? "+" : ""})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{followed.items.length === 0
|
||||||
|
? <p className="empty-state">No followed playlists yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{followed.items.map((p) => (
|
||||||
|
<PlaylistCard key={p.id} playlist={p} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div ref={followedSentinelRef} />
|
||||||
|
{followed.loadingMore && (
|
||||||
|
<p className="feed-loading-more">Loading more…</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{confirmDeleteId && (
|
||||||
|
<ConfirmModal
|
||||||
|
message="Delete this playlist? This cannot be undone."
|
||||||
|
confirmLabel="Delete playlist"
|
||||||
|
onConfirm={() => {
|
||||||
|
handleDelete(confirmDeleteId);
|
||||||
|
setConfirmDeleteId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDeleteId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { Link, useNavigate, useParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
import type { Dump, PaginatedData, PublicUser } from "../model.ts";
|
||||||
@@ -19,15 +19,66 @@ import { PageShell } from "../components/PageShell.tsx";
|
|||||||
import { PageError } from "../components/PageError.tsx";
|
import { PageError } from "../components/PageError.tsx";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { useWS } from "../hooks/useWS.ts";
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
|
||||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||||
import { deserializePlaylist } from "../model.ts";
|
import { deserializePlaylist } from "../model.ts";
|
||||||
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
||||||
|
import { FollowUserButton } from "../components/FollowButton.tsx";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const hydrateDump = (raw: Dump): Dump => deserializeDump(raw as unknown as RawDump);
|
function InviteButton() {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const [inviteUrl, setInviteUrl] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`${API_URL}/api/invites`, { method: "POST" });
|
||||||
|
const body = await res.json();
|
||||||
|
if (body.success) {
|
||||||
|
const url =
|
||||||
|
`${globalThis.location.origin}/register?token=${body.data.token}`;
|
||||||
|
setInviteUrl(url);
|
||||||
|
} else {
|
||||||
|
setError("Failed to generate invite");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Failed to generate invite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
if (!inviteUrl) return;
|
||||||
|
await navigator.clipboard.writeText(inviteUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inviteUrl) {
|
||||||
|
return (
|
||||||
|
<div className="invite-result">
|
||||||
|
<span className="invite-url">{inviteUrl}</span>
|
||||||
|
<button type="button" className="invite-copy-btn" onClick={copy}>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="invite-generate">
|
||||||
|
<button type="button" className="invite-btn" onClick={generate}>
|
||||||
|
+ Invite someone
|
||||||
|
</button>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hydrateDump = (raw: Dump): Dump =>
|
||||||
|
deserializeDump(raw as unknown as RawDump);
|
||||||
const hydratePlaylist = (raw: Playlist): Playlist =>
|
const hydratePlaylist = (raw: Playlist): Playlist =>
|
||||||
deserializePlaylist(raw as unknown as RawPlaylist);
|
deserializePlaylist(raw as unknown as RawPlaylist);
|
||||||
|
|
||||||
@@ -75,7 +126,9 @@ export function UserPublicProfile() {
|
|||||||
`feed:profile-votes:${username ?? ""}`,
|
`feed:profile-votes:${username ?? ""}`,
|
||||||
hydrateDump,
|
hydrateDump,
|
||||||
);
|
);
|
||||||
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<Playlist>(
|
const { cached: cachedPlaylists, saveState: savePlaylists } = useFeedCache<
|
||||||
|
Playlist
|
||||||
|
>(
|
||||||
`feed:profile-playlists:${username ?? ""}`,
|
`feed:profile-playlists:${username ?? ""}`,
|
||||||
hydratePlaylist,
|
hydratePlaylist,
|
||||||
);
|
);
|
||||||
@@ -104,31 +157,64 @@ export function UserPublicProfile() {
|
|||||||
setState({
|
setState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
user: deserializePublicUser(body.data),
|
user: deserializePublicUser(body.data),
|
||||||
dumps: { items: cachedDumps.items, hasMore: cachedDumps.hasMore, page: cachedDumps.page, loadingMore: false },
|
dumps: {
|
||||||
votes: { items: cachedVotes.items, hasMore: cachedVotes.hasMore, page: cachedVotes.page, loadingMore: false },
|
items: cachedDumps.items,
|
||||||
playlists: { items: cachedPlaylists.items, hasMore: cachedPlaylists.hasMore, page: cachedPlaylists.page, loadingMore: false },
|
hasMore: cachedDumps.hasMore,
|
||||||
|
page: cachedDumps.page,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
votes: {
|
||||||
|
items: cachedVotes.items,
|
||||||
|
hasMore: cachedVotes.hasMore,
|
||||||
|
page: cachedVotes.page,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
|
playlists: {
|
||||||
|
items: cachedPlaylists.items,
|
||||||
|
hasMore: cachedPlaylists.hasMore,
|
||||||
|
page: cachedPlaylists.page,
|
||||||
|
loadingMore: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
|
setProfileVotedIds(new Set(cachedVotes.items.map((d) => d.id)));
|
||||||
})
|
})
|
||||||
.catch((err) =>
|
.catch((err) =>
|
||||||
setState({ status: "error", error: err instanceof Error ? err.message : "Failed to load profile" })
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to load profile",
|
||||||
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const authHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
const authHeaders: HeadersInit = token
|
||||||
|
? { Authorization: `Bearer ${token}` }
|
||||||
|
: {};
|
||||||
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
|
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
|
||||||
fetch(`${API_URL}/api/users/${username}`),
|
fetch(`${API_URL}/api/users/${username}`),
|
||||||
fetch(`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
fetch(
|
||||||
fetch(`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
`${API_URL}/api/users/${username}/dumps?page=1&limit=${PAGE_SIZE}`,
|
||||||
fetch(`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }),
|
{ headers: authHeaders },
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: authHeaders },
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: authHeaders },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!userRes.ok) {
|
if (!userRes.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
userRes.status === 404 ? "User not found" : `HTTP ${userRes.status}`,
|
userRes.status === 404
|
||||||
|
? "User not found"
|
||||||
|
: `HTTP ${userRes.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +240,10 @@ export function UserPublicProfile() {
|
|||||||
setState({
|
setState({
|
||||||
status: "loaded",
|
status: "loaded",
|
||||||
user: deserializePublicUser(userBody.data),
|
user: deserializePublicUser(userBody.data),
|
||||||
dumps: initialList(dumpsData.items.map(deserializeDump), dumpsData.hasMore),
|
dumps: initialList(
|
||||||
|
dumpsData.items.map(deserializeDump),
|
||||||
|
dumpsData.hasMore,
|
||||||
|
),
|
||||||
votes: initialList(voteItems, votesData.hasMore),
|
votes: initialList(voteItems, votesData.hasMore),
|
||||||
playlists: initialList(
|
playlists: initialList(
|
||||||
playlistsData.items.map(deserializePlaylist),
|
playlistsData.items.map(deserializePlaylist),
|
||||||
@@ -189,7 +278,10 @@ export function UserPublicProfile() {
|
|||||||
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
|
myVotes.has(d.id) && !prev.has(d.id) && !voteIds.has(d.id)
|
||||||
);
|
);
|
||||||
if (toAdd.length === 0) return s;
|
if (toAdd.length === 0) return s;
|
||||||
return { ...s, votes: { ...s.votes, items: [...toAdd, ...s.votes.items] } };
|
return {
|
||||||
|
...s,
|
||||||
|
votes: { ...s.votes, items: [...toAdd, ...s.votes.items] },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
prevMyVotesRef.current = new Set(myVotes);
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
}, [myVotes, me, profileUserId]);
|
}, [myVotes, me, profileUserId]);
|
||||||
@@ -219,10 +311,16 @@ export function UserPublicProfile() {
|
|||||||
if (!body.success) return;
|
if (!body.success) return;
|
||||||
const dump = deserializeDump(body.data);
|
const dump = deserializeDump(body.data);
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded" || s.votes.items.some((d) => d.id === dumpId)) {
|
if (
|
||||||
|
s.status !== "loaded" ||
|
||||||
|
s.votes.items.some((d) => d.id === dumpId)
|
||||||
|
) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
return { ...s, votes: { ...s.votes, items: [dump, ...s.votes.items] } };
|
return {
|
||||||
|
...s,
|
||||||
|
votes: { ...s.votes, items: [dump, ...s.votes.items] },
|
||||||
|
};
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -243,7 +341,10 @@ export function UserPublicProfile() {
|
|||||||
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
if (s.playlists.items.some((p) => p.id === ev.playlist!.id)) return s;
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
playlists: { ...s.playlists, items: [ev.playlist!, ...s.playlists.items] },
|
playlists: {
|
||||||
|
...s.playlists,
|
||||||
|
items: [ev.playlist!, ...s.playlists.items],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -278,7 +379,9 @@ export function UserPublicProfile() {
|
|||||||
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
||||||
setState((s) => {
|
setState((s) => {
|
||||||
if (s.status !== "loaded") return s;
|
if (s.status !== "loaded") return s;
|
||||||
const filtered = s.playlists.items.filter((p) => !deletedPlaylistIds.has(p.id));
|
const filtered = s.playlists.items.filter((p) =>
|
||||||
|
!deletedPlaylistIds.has(p.id)
|
||||||
|
);
|
||||||
if (filtered.length === s.playlists.items.length) return s;
|
if (filtered.length === s.playlists.items.length) return s;
|
||||||
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
return { ...s, playlists: { ...s.playlists, items: filtered } };
|
||||||
});
|
});
|
||||||
@@ -292,14 +395,22 @@ export function UserPublicProfile() {
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
if (state.status !== "loaded") return;
|
if (state.status !== "loaded") return;
|
||||||
const y = window.scrollY;
|
const y = globalThis.scrollY;
|
||||||
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
|
saveDumps(state.dumps.items, state.dumps.page, state.dumps.hasMore, y);
|
||||||
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
|
saveVotes(state.votes.items, state.votes.page, state.votes.hasMore, y);
|
||||||
savePlaylists(state.playlists.items, state.playlists.page, state.playlists.hasMore, y);
|
savePlaylists(
|
||||||
|
state.playlists.items,
|
||||||
|
state.playlists.page,
|
||||||
|
state.playlists.hasMore,
|
||||||
|
y,
|
||||||
|
);
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
return () => { window.removeEventListener("scroll", onScroll); clearTimeout(timer); };
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
}, [state, saveDumps, saveVotes, savePlaylists]);
|
}, [state, saveDumps, saveVotes, savePlaylists]);
|
||||||
|
|
||||||
// Restore scroll position after cache restoration
|
// Restore scroll position after cache restoration
|
||||||
@@ -307,94 +418,10 @@ export function UserPublicProfile() {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
|
if (cachedDumps?.scrollY == null || scrollRestored.current) return;
|
||||||
if (state.status === "loaded") {
|
if (state.status === "loaded") {
|
||||||
window.scrollTo(0, cachedDumps.scrollY);
|
globalThis.scrollTo(0, cachedDumps.scrollY);
|
||||||
scrollRestored.current = true;
|
scrollRestored.current = true;
|
||||||
}
|
}
|
||||||
// cachedDumps is stable (read once), safe to omit
|
}, [state.status, cachedDumps]);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [state.status]);
|
|
||||||
|
|
||||||
const loadMoreDumps = useCallback(() => {
|
|
||||||
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore || !username) return;
|
|
||||||
const nextPage = state.dumps.page + 1;
|
|
||||||
setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: true } } : s);
|
|
||||||
fetch(`${API_URL}/api/users/${username}/dumps?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
})
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
dumps: {
|
|
||||||
items: [...s.dumps.items, ...items.map(deserializeDump)],
|
|
||||||
hasMore,
|
|
||||||
page: nextPage,
|
|
||||||
loadingMore: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, dumps: { ...s.dumps, loadingMore: false } } : s));
|
|
||||||
}, [state, username, token]);
|
|
||||||
|
|
||||||
const loadMoreVotes = useCallback(() => {
|
|
||||||
if (state.status !== "loaded" || !state.votes.hasMore || state.votes.loadingMore || !username) return;
|
|
||||||
const nextPage = state.votes.page + 1;
|
|
||||||
setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: true } } : s);
|
|
||||||
fetch(`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`, {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
||||||
})
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
votes: {
|
|
||||||
items: [...s.votes.items, ...items.map(deserializeDump)],
|
|
||||||
hasMore,
|
|
||||||
page: nextPage,
|
|
||||||
loadingMore: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, votes: { ...s.votes, loadingMore: false } } : s));
|
|
||||||
}, [state, username, token]);
|
|
||||||
|
|
||||||
const loadMorePlaylists = useCallback(() => {
|
|
||||||
if (state.status !== "loaded" || !state.playlists.hasMore || state.playlists.loadingMore || !username) return;
|
|
||||||
const nextPage = state.playlists.page + 1;
|
|
||||||
setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: true } } : s);
|
|
||||||
fetch(
|
|
||||||
`${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
|
||||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
|
||||||
)
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((body) => {
|
|
||||||
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
|
||||||
setState((s) =>
|
|
||||||
s.status === "loaded"
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
playlists: {
|
|
||||||
items: [...s.playlists.items, ...items.map(deserializePlaylist)],
|
|
||||||
hasMore,
|
|
||||||
page: nextPage,
|
|
||||||
loadingMore: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => setState((s) => s.status === "loaded" ? { ...s, playlists: { ...s.playlists, loadingMore: false } } : s));
|
|
||||||
}, [state, username, token]);
|
|
||||||
|
|
||||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -432,7 +459,10 @@ export function UserPublicProfile() {
|
|||||||
|
|
||||||
setState((prev) =>
|
setState((prev) =>
|
||||||
prev.status === "loaded"
|
prev.status === "loaded"
|
||||||
? { ...prev, user: { ...prev.user, avatarMime: body.data?.avatarMime } }
|
? {
|
||||||
|
...prev,
|
||||||
|
user: { ...prev.user, avatarMime: body.data?.avatarMime },
|
||||||
|
}
|
||||||
: prev
|
: prev
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -504,11 +534,37 @@ export function UserPublicProfile() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="profile-username">{profileUser.username}</h1>
|
<h1 className="profile-username">{profileUser.username}</h1>
|
||||||
|
{profileUser.invitedByUsername
|
||||||
|
? (
|
||||||
|
<p className="profile-invited-by">
|
||||||
|
invited by{" "}
|
||||||
|
<Link
|
||||||
|
to={`/users/${profileUser.invitedByUsername}`}
|
||||||
|
className="profile-invited-by-link"
|
||||||
|
>
|
||||||
|
@{profileUser.invitedByUsername}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<p className="profile-invited-by profile-invited-by--founding">
|
||||||
|
O.G.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{avatarError && <p className="form-error">{avatarError}</p>}
|
{avatarError && <p className="form-error">{avatarError}</p>}
|
||||||
|
{!isOwnProfile && (
|
||||||
|
<FollowUserButton
|
||||||
|
targetUserId={profileUser.id}
|
||||||
|
targetUsername={profileUser.username}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
|
<div className="profile-own-actions">
|
||||||
|
<InviteButton />
|
||||||
<button type="button" className="logout-btn" onClick={logout}>
|
<button type="button" className="logout-btn" onClick={logout}>
|
||||||
Log out
|
Log out
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,9 +579,7 @@ export function UserPublicProfile() {
|
|||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
isOwnProfile={isOwnProfile}
|
isOwnProfile={isOwnProfile}
|
||||||
hasMore={dumps.hasMore}
|
viewAllHref={`/users/${profileUser.username}/dumps`}
|
||||||
loadingMore={dumps.loadingMore}
|
|
||||||
onLoadMore={loadMoreDumps}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UpvotedDumpList
|
<UpvotedDumpList
|
||||||
@@ -537,16 +591,15 @@ export function UserPublicProfile() {
|
|||||||
canVote={!!me}
|
canVote={!!me}
|
||||||
castVote={castVote}
|
castVote={castVote}
|
||||||
removeVote={removeVote}
|
removeVote={removeVote}
|
||||||
hasMore={votes.hasMore}
|
viewAllHref={`/users/${profileUser.username}/upvoted`}
|
||||||
loadingMore={votes.loadingMore}
|
|
||||||
onLoadMore={loadMoreVotes}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="profile-section" id="playlists">
|
<section className="profile-section" id="playlists">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
<h2 className="profile-section-title">
|
<h2 className="profile-section-title">
|
||||||
Playlists ({playlists.items.length}{playlists.hasMore ? "+" : ""})
|
Playlists ({playlists.items.length}
|
||||||
|
{playlists.hasMore ? "+" : ""})
|
||||||
</h2>
|
</h2>
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
<NewPlaylistForm
|
<NewPlaylistForm
|
||||||
@@ -556,7 +609,10 @@ export function UserPublicProfile() {
|
|||||||
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
|
if (s.playlists.items.some((pl) => pl.id === p.id)) return s;
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
playlists: { ...s.playlists, items: [p, ...s.playlists.items] },
|
playlists: {
|
||||||
|
...s.playlists,
|
||||||
|
items: [p, ...s.playlists.items],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
@@ -567,38 +623,23 @@ export function UserPublicProfile() {
|
|||||||
: (
|
: (
|
||||||
<ul className="dump-feed">
|
<ul className="dump-feed">
|
||||||
{playlists.items.map((p) => (
|
{playlists.items.map((p) => (
|
||||||
<PlaylistCard key={p.id} playlist={p} />
|
<PlaylistCard key={p.id} playlist={p} isOwner={isOwnProfile} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<PlaylistSentinel
|
{playlists.items.length > 0 && (
|
||||||
hasMore={playlists.hasMore}
|
<Link
|
||||||
loadingMore={playlists.loadingMore}
|
to={`/users/${profileUser.username}/playlists`}
|
||||||
onLoadMore={loadMorePlaylists}
|
className="profile-view-all"
|
||||||
/>
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sentinel wrapper (keeps hooks at top level) ──────────────────────────────
|
|
||||||
|
|
||||||
function PlaylistSentinel(
|
|
||||||
{ hasMore, loadingMore, onLoadMore }: {
|
|
||||||
hasMore: boolean;
|
|
||||||
loadingMore: boolean;
|
|
||||||
onLoadMore: () => void;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div ref={sentinelRef} />
|
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Plain dump list ──────────────────────────────────────────────────────────
|
// ── Plain dump list ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DumpList(
|
function DumpList(
|
||||||
@@ -611,9 +652,7 @@ function DumpList(
|
|||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
isOwnProfile,
|
isOwnProfile,
|
||||||
hasMore,
|
viewAllHref,
|
||||||
loadingMore,
|
|
||||||
onLoadMore,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
dumps: Dump[];
|
dumps: Dump[];
|
||||||
@@ -623,13 +662,10 @@ function DumpList(
|
|||||||
castVote: (id: string) => void;
|
castVote: (id: string) => void;
|
||||||
removeVote: (id: string) => void;
|
removeVote: (id: string) => void;
|
||||||
isOwnProfile?: boolean;
|
isOwnProfile?: boolean;
|
||||||
hasMore: boolean;
|
viewAllHref: string;
|
||||||
loadingMore: boolean;
|
|
||||||
onLoadMore: () => void;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
|
||||||
return (
|
return (
|
||||||
<section className="profile-section">
|
<section className="profile-section">
|
||||||
<div className="profile-section-header">
|
<div className="profile-section-header">
|
||||||
@@ -665,8 +701,9 @@ function DumpList(
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div ref={sentinelRef} />
|
{dumps.length > 0 && (
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -683,9 +720,7 @@ function UpvotedDumpList(
|
|||||||
canVote,
|
canVote,
|
||||||
castVote,
|
castVote,
|
||||||
removeVote,
|
removeVote,
|
||||||
hasMore,
|
viewAllHref,
|
||||||
loadingMore,
|
|
||||||
onLoadMore,
|
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
dumps: Dump[];
|
dumps: Dump[];
|
||||||
@@ -695,15 +730,14 @@ function UpvotedDumpList(
|
|||||||
canVote: boolean;
|
canVote: boolean;
|
||||||
castVote: (id: string) => void;
|
castVote: (id: string) => void;
|
||||||
removeVote: (id: string) => void;
|
removeVote: (id: string) => void;
|
||||||
hasMore: boolean;
|
viewAllHref: string;
|
||||||
loadingMore: boolean;
|
|
||||||
onLoadMore: () => void;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const [fading, setFading] = useState<Record<string, "cooldown" | "dismissing">>({});
|
const [fading, setFading] = useState<
|
||||||
|
Record<string, "cooldown" | "dismissing">
|
||||||
|
>({});
|
||||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||||
const prevVotedIds = useRef<Set<string> | null>(null);
|
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||||
const sentinelRef = useInfiniteScroll(onLoadMore, hasMore && !loadingMore);
|
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
cancels.current.forEach((c) => c());
|
cancels.current.forEach((c) => c());
|
||||||
@@ -809,8 +843,9 @@ function UpvotedDumpList(
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<div ref={sentinelRef} />
|
{visibleDumps.length > 0 && (
|
||||||
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
<Link to={viewAllHref} className="profile-view-all">View all →</Link>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { SubmitEvent } from "react";
|
import type { SubmitEvent } from "react";
|
||||||
import { Link, useNavigate } from "react-router";
|
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { API_URL } from "../config/api.ts";
|
import { API_URL } from "../config/api.ts";
|
||||||
import { deserializeAuthResponse } from "../model.ts";
|
import { deserializeAuthResponse } from "../model.ts";
|
||||||
import { useAuth } from "../hooks/useAuth.ts";
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
import { PageShell } from "../components/PageShell.tsx";
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
|
||||||
type UserRegisterState =
|
type TokenState =
|
||||||
|
| { status: "checking" }
|
||||||
|
| { status: "invalid" }
|
||||||
|
| { status: "valid" };
|
||||||
|
|
||||||
|
type FormState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
| { status: "submitting" }
|
| { status: "submitting" }
|
||||||
| { status: "error"; error: string };
|
| { status: "error"; error: string };
|
||||||
@@ -15,13 +20,29 @@ type UserRegisterState =
|
|||||||
export function UserRegister() {
|
export function UserRegister() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const token = searchParams.get("token") ?? "";
|
||||||
|
|
||||||
const [state, setState] = useState<UserRegisterState>({ status: "idle" });
|
const [tokenState, setTokenState] = useState<TokenState>({
|
||||||
|
status: "checking",
|
||||||
|
});
|
||||||
|
const [formState, setFormState] = useState<FormState>({ status: "idle" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setTokenState({ status: "invalid" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`${API_URL}/api/invites/${encodeURIComponent(token)}`)
|
||||||
|
.then((r) => {
|
||||||
|
setTokenState(r.ok ? { status: "valid" } : { status: "invalid" });
|
||||||
|
})
|
||||||
|
.catch(() => setTokenState({ status: "invalid" }));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
setFormState({ status: "submitting" });
|
||||||
setState({ status: "submitting" });
|
|
||||||
|
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
const username = formData.get("username");
|
const username = formData.get("username");
|
||||||
@@ -31,34 +52,56 @@ export function UserRegister() {
|
|||||||
const res = await fetch(`${API_URL}/api/users/register`, {
|
const res = await fetch(`${API_URL}/api/users/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password, inviteToken: token }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
|
|
||||||
const apiResponse = await res.json();
|
const apiResponse = await res.json();
|
||||||
|
|
||||||
if (apiResponse.success) {
|
if (apiResponse.success) {
|
||||||
login(deserializeAuthResponse(apiResponse.data));
|
login(deserializeAuthResponse(apiResponse.data));
|
||||||
navigate("/");
|
navigate("/");
|
||||||
} else {
|
} else {
|
||||||
setState({ status: "error", error: apiResponse.error.message });
|
setFormState({
|
||||||
|
status: "error",
|
||||||
|
error: apiResponse.error?.message ?? "Registration failed.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setState({
|
setFormState({
|
||||||
status: "error",
|
status: "error",
|
||||||
error: err instanceof Error ? err.message : "Registration failed.",
|
error: err instanceof Error ? err.message : "Registration failed.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (tokenState.status === "checking") {
|
||||||
|
return (
|
||||||
|
<PageShell centered>
|
||||||
|
<p className="page-loading">Checking invite…</p>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tokenState.status === "invalid") {
|
||||||
|
return (
|
||||||
|
<PageShell centered>
|
||||||
|
<div className="auth-card">
|
||||||
|
<h1 className="auth-card-title">Invalid invite</h1>
|
||||||
|
<p className="auth-card-footer">
|
||||||
|
This invite link is missing, expired, or already used.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell centered>
|
<PageShell centered>
|
||||||
<div className="auth-card">
|
<div className="auth-card">
|
||||||
<h1 className="auth-card-title">Register</h1>
|
<h1 className="auth-card-title">Register</h1>
|
||||||
|
|
||||||
{state.status === "error" && (
|
{formState.status === "error" && (
|
||||||
<div className="error-banner">{state.error}</div>
|
<div className="error-banner">{formState.error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
@@ -67,7 +110,7 @@ export function UserRegister() {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={formState.status === "submitting"}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -75,14 +118,14 @@ export function UserRegister() {
|
|||||||
type="password"
|
type="password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
required
|
required
|
||||||
disabled={state.status === "submitting"}
|
disabled={formState.status === "submitting"}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={state.status === "submitting"}
|
disabled={formState.status === "submitting"}
|
||||||
>
|
>
|
||||||
{state.status === "submitting" ? "Registering…" : "Register"}
|
{formState.status === "submitting" ? "Registering…" : "Register"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
392
src/pages/UserUpvoted.tsx
Normal file
392
src/pages/UserUpvoted.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Link, useParams } from "react-router";
|
||||||
|
|
||||||
|
import { API_URL } from "../config/api.ts";
|
||||||
|
import type { Dump, PaginatedData, PublicUser, RawDump } from "../model.ts";
|
||||||
|
import { deserializeDump, deserializePublicUser } from "../model.ts";
|
||||||
|
import { useAuth } from "../hooks/useAuth.ts";
|
||||||
|
import { useWS } from "../hooks/useWS.ts";
|
||||||
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts";
|
||||||
|
import { useFeedCache } from "../hooks/useFeedCache.ts";
|
||||||
|
import { Avatar } from "../components/Avatar.tsx";
|
||||||
|
import { DumpCard } from "../components/DumpCard.tsx";
|
||||||
|
import { PageShell } from "../components/PageShell.tsx";
|
||||||
|
import { PageError } from "../components/PageError.tsx";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const hydrateDump = (raw: Dump): Dump =>
|
||||||
|
deserializeDump(raw as unknown as RawDump);
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "error"; error: string }
|
||||||
|
| {
|
||||||
|
status: "loaded";
|
||||||
|
profileUser: PublicUser;
|
||||||
|
votes: Dump[];
|
||||||
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
loadingMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserUpvoted() {
|
||||||
|
const { username } = useParams();
|
||||||
|
const { user: me, token } = useAuth();
|
||||||
|
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
||||||
|
const { cached, saveState } = useFeedCache<Dump>(
|
||||||
|
`feed:user-upvoted-full:${username ?? ""}`,
|
||||||
|
hydrateDump,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
||||||
|
const [fading, setFading] = useState<
|
||||||
|
Record<string, "cooldown" | "dismissing">
|
||||||
|
>({});
|
||||||
|
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||||
|
const prevVotedIds = useRef<Set<string> | null>(null);
|
||||||
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
cancels.current.forEach((c) => c());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!username) return;
|
||||||
|
setState({ status: "loading" });
|
||||||
|
setVotedIds(new Set());
|
||||||
|
prevVotedIds.current = null;
|
||||||
|
prevMyVotesRef.current = null;
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
fetch(`${API_URL}/api/users/${username}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!body.success) throw new Error("User not found");
|
||||||
|
const voteIds = new Set(cached.items.map((d) => d.id));
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(body.data),
|
||||||
|
votes: cached.items,
|
||||||
|
hasMore: cached.hasMore,
|
||||||
|
page: cached.page,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
setVotedIds(voteIds);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeaders: HeadersInit = token
|
||||||
|
? { Authorization: `Bearer ${token}` }
|
||||||
|
: {};
|
||||||
|
Promise.all([
|
||||||
|
fetch(`${API_URL}/api/users/${username}`),
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/votes?page=1&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: authHeaders },
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([userRes, votesRes]) =>
|
||||||
|
Promise.all([userRes.json(), votesRes.json()])
|
||||||
|
)
|
||||||
|
.then(([userBody, votesBody]) => {
|
||||||
|
if (!userBody.success) throw new Error("User not found");
|
||||||
|
const { items, hasMore } = votesBody.success
|
||||||
|
? votesBody.data as PaginatedData<RawDump>
|
||||||
|
: { items: [], hasMore: false };
|
||||||
|
const voteItems = items.map(deserializeDump);
|
||||||
|
setState({
|
||||||
|
status: "loaded",
|
||||||
|
profileUser: deserializePublicUser(userBody.data),
|
||||||
|
votes: voteItems,
|
||||||
|
hasMore,
|
||||||
|
page: 1,
|
||||||
|
loadingMore: false,
|
||||||
|
});
|
||||||
|
setVotedIds(new Set(voteItems.map((d) => d.id)));
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setState({
|
||||||
|
status: "error",
|
||||||
|
error: err instanceof Error ? err.message : "Failed to load",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [username]);
|
||||||
|
|
||||||
|
const profileUserId = state.status === "loaded" ? state.profileUser.id : null;
|
||||||
|
|
||||||
|
// Own profile: keep votedIds in sync with myVotes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profileUserId || me?.id !== profileUserId) return;
|
||||||
|
setVotedIds(new Set(myVotes));
|
||||||
|
if (prevMyVotesRef.current === null) {
|
||||||
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = prevMyVotesRef.current;
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded") return s;
|
||||||
|
const voteIdSet = new Set(s.votes.map((d) => d.id));
|
||||||
|
const toAdd = [...myVotes].filter((id) =>
|
||||||
|
!prev.has(id) && !voteIdSet.has(id)
|
||||||
|
);
|
||||||
|
if (toAdd.length === 0) return s;
|
||||||
|
// Newly voted items will arrive via lastVoteEvent fetch below
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
prevMyVotesRef.current = new Set(myVotes);
|
||||||
|
}, [myVotes, me, profileUserId]);
|
||||||
|
|
||||||
|
// WS vote events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastVoteEvent || !profileUserId) return;
|
||||||
|
const { dumpId, voterId, action } = lastVoteEvent;
|
||||||
|
if (voterId !== profileUserId) return;
|
||||||
|
|
||||||
|
if (action === "remove") {
|
||||||
|
setVotedIds((prev) => {
|
||||||
|
const n = new Set(prev);
|
||||||
|
n.delete(dumpId);
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setVotedIds((prev) => new Set([...prev, dumpId]));
|
||||||
|
fetch(`${API_URL}/api/dumps/${dumpId}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
if (!body.success) return;
|
||||||
|
const dump = deserializeDump(body.data);
|
||||||
|
setState((s) => {
|
||||||
|
if (s.status !== "loaded" || s.votes.some((d) => d.id === dumpId)) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
return { ...s, votes: [dump, ...s.votes] };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}, [lastVoteEvent, profileUserId]);
|
||||||
|
|
||||||
|
// Fade animation when items leave votedIds
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevVotedIds.current === null) {
|
||||||
|
prevVotedIds.current = new Set(votedIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = prevVotedIds.current;
|
||||||
|
|
||||||
|
for (const id of prev) {
|
||||||
|
if (!votedIds.has(id) && !cancels.current.has(id)) {
|
||||||
|
let dead = false;
|
||||||
|
let kill = () => {};
|
||||||
|
kill = () => {
|
||||||
|
dead = true;
|
||||||
|
setFading((f) => {
|
||||||
|
const n = { ...f };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
cancels.current.delete(id);
|
||||||
|
};
|
||||||
|
cancels.current.set(id, () => kill());
|
||||||
|
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
||||||
|
|
||||||
|
const t1 = setTimeout(() => {
|
||||||
|
if (dead) return;
|
||||||
|
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
||||||
|
const t2 = setTimeout(() => {
|
||||||
|
if (!dead) kill();
|
||||||
|
}, 350);
|
||||||
|
kill = () => {
|
||||||
|
dead = true;
|
||||||
|
clearTimeout(t2);
|
||||||
|
setFading((f) => {
|
||||||
|
const n = { ...f };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
cancels.current.delete(id);
|
||||||
|
};
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
kill = () => {
|
||||||
|
dead = true;
|
||||||
|
clearTimeout(t1);
|
||||||
|
setFading((f) => {
|
||||||
|
const n = { ...f };
|
||||||
|
delete n[id];
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
cancels.current.delete(id);
|
||||||
|
};
|
||||||
|
cancels.current.set(id, () => kill());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of votedIds) {
|
||||||
|
if (!prev.has(id) && cancels.current.has(id)) {
|
||||||
|
cancels.current.get(id)!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
prevVotedIds.current = new Set(votedIds);
|
||||||
|
}, [votedIds]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (
|
||||||
|
state.status !== "loaded" || !state.hasMore || state.loadingMore ||
|
||||||
|
!username
|
||||||
|
) return;
|
||||||
|
const nextPage = state.page + 1;
|
||||||
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
||||||
|
fetch(
|
||||||
|
`${API_URL}/api/users/${username}/votes?page=${nextPage}&limit=${PAGE_SIZE}`,
|
||||||
|
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||||
|
)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((body) => {
|
||||||
|
const { items, hasMore } = body.data as PaginatedData<RawDump>;
|
||||||
|
const newItems = items.map(deserializeDump);
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded"
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
votes: [...s.votes, ...newItems],
|
||||||
|
hasMore,
|
||||||
|
page: nextPage,
|
||||||
|
loadingMore: false,
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
);
|
||||||
|
setVotedIds((prev) => new Set([...prev, ...newItems.map((d) => d.id)]));
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setState((s) =>
|
||||||
|
s.status === "loaded" ? { ...s, loadingMore: false } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [state, username, token]);
|
||||||
|
|
||||||
|
const sentinelRef = useInfiniteScroll(
|
||||||
|
loadMore,
|
||||||
|
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const onScroll = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
if (state.status !== "loaded") return;
|
||||||
|
saveState(state.votes, state.page, state.hasMore, globalThis.scrollY);
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
globalThis.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
globalThis.removeEventListener("scroll", onScroll);
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [state, saveState]);
|
||||||
|
|
||||||
|
const scrollRestored = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (cached?.scrollY == null || scrollRestored.current) return;
|
||||||
|
if (state.status === "loaded") {
|
||||||
|
globalThis.scrollTo(0, cached.scrollY);
|
||||||
|
scrollRestored.current = true;
|
||||||
|
}
|
||||||
|
}, [state.status, cached]);
|
||||||
|
|
||||||
|
if (state.status === "loading") {
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<p className="page-loading">Loading…</p>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status === "error") {
|
||||||
|
return (
|
||||||
|
<PageError
|
||||||
|
message={state.error}
|
||||||
|
actions={
|
||||||
|
<Link to={`/users/${username}`} className="logout-btn">
|
||||||
|
← Back to profile
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { profileUser, votes, hasMore, loadingMore } = state;
|
||||||
|
const visibleDumps = votes.filter((d) =>
|
||||||
|
votedIds.has(d.id) || d.id in fading
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="profile-subpage-header">
|
||||||
|
<Link to={`/users/${username}`} className="profile-subpage-back">
|
||||||
|
← {profileUser.username}
|
||||||
|
</Link>
|
||||||
|
<div className="profile-subpage-title-row">
|
||||||
|
<Avatar
|
||||||
|
userId={profileUser.id}
|
||||||
|
username={profileUser.username}
|
||||||
|
hasAvatar={!!profileUser.avatarMime}
|
||||||
|
size={36}
|
||||||
|
/>
|
||||||
|
<h1 className="profile-subpage-title">Upvoted</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{visibleDumps.length === 0
|
||||||
|
? <p className="empty-state">Nothing here yet.</p>
|
||||||
|
: (
|
||||||
|
<ul className="dump-feed">
|
||||||
|
{visibleDumps.map((dump) => {
|
||||||
|
const phase = fading[dump.id];
|
||||||
|
const extraCls = phase === "cooldown"
|
||||||
|
? "dump-card--fading"
|
||||||
|
: phase === "dismissing"
|
||||||
|
? "dump-card--dismissing"
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<DumpCard
|
||||||
|
key={dump.id}
|
||||||
|
dump={dump}
|
||||||
|
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||||
|
voted={myVotes.has(dump.id)}
|
||||||
|
canVote={!!me}
|
||||||
|
castVote={castVote}
|
||||||
|
removeVote={removeVote}
|
||||||
|
className={extraCls}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} />
|
||||||
|
{loadingMore && <p className="feed-loading-more">Loading more…</p>}
|
||||||
|
{!hasMore && visibleDumps.length > 0 && (
|
||||||
|
<p className="index-status">All {votes.length} upvoted dumps loaded.</p>
|
||||||
|
)}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/utils/visited.ts
Normal file
43
src/utils/visited.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const DUMP_KEY = "visited_dumps";
|
||||||
|
const PLAYLIST_KEY = "visited_playlists";
|
||||||
|
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||||
|
|
||||||
|
function loadSet(key: string): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? new Set(JSON.parse(raw) as string[]) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSet(key: string, set: Set<string>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify([...set]));
|
||||||
|
} catch { /* quota exceeded — ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDumpVisited(id: string): boolean {
|
||||||
|
return loadSet(DUMP_KEY).has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlaylistVisited(id: string): boolean {
|
||||||
|
return loadSet(PLAYLIST_KEY).has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markDumpVisited(id: string): void {
|
||||||
|
const set = loadSet(DUMP_KEY);
|
||||||
|
set.add(id);
|
||||||
|
saveSet(DUMP_KEY, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPlaylistVisited(id: string): void {
|
||||||
|
const set = loadSet(PLAYLIST_KEY);
|
||||||
|
set.add(id);
|
||||||
|
saveSet(PLAYLIST_KEY, set);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only items newer than MAX_AGE_MS are eligible to show the unread dot. */
|
||||||
|
export function isRecent(createdAt: Date): boolean {
|
||||||
|
return Date.now() - createdAt.getTime() < MAX_AGE_MS;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user