v3: follows, notifications, invite-only registration, unread markers

This commit is contained in:
khannurien
2026-03-21 18:42:47 +00:00
parent 7c098e7c4c
commit 608c6bc6a8
55 changed files with 4743 additions and 884 deletions

View File

@@ -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> {

View File

@@ -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`,

View File

@@ -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");
}

View File

@@ -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;
}

View File

@@ -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)) {

View File

@@ -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
View 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
View 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;

View 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;

View File

@@ -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]>,
}; };
}); });

View File

@@ -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);

View File

@@ -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) };
} }

View File

@@ -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 };
} }

View 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 };
}

View 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);
}

View 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}`,
);
}
}

View File

@@ -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(

View File

@@ -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`,
}; };
}, },
}; };

View File

@@ -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;
} }
} }

View File

@@ -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)) {

View File

@@ -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;");

View File

@@ -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 });
} }

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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>
); );
} }

View File

@@ -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";

View File

@@ -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)} />

View File

@@ -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}

View File

@@ -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 && (

View File

@@ -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">

View 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>
);
}

View File

@@ -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>

View File

@@ -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={{

View 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>
);
}

View File

@@ -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}{" "}

View File

@@ -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

View 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,
});

View 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>
);
}

View File

@@ -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: () => {},
}); });

View File

@@ -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 (

View File

@@ -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
View File

@@ -0,0 +1,4 @@
import { useContext } from "react";
import { FollowContext } from "../contexts/FollowContext.ts";
export const useFollows = () => useContext(FollowContext);

View File

@@ -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) };
}

View File

@@ -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 (

View File

@@ -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

View File

@@ -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>
); );
} }

View File

@@ -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
View 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>
);
}

View File

@@ -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
View 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
View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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
View 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
View 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;
}