diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 8729d57..6e025f5 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -1,11 +1,38 @@ import { randomBytes, scrypt } from "node:crypto"; 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_KEY = new TextEncoder().encode(JWT_SECRET); +// ── Invite tokens ───────────────────────────────────────────────────────────── + +export async function createInviteToken(inviterId: string): Promise { + 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 { + try { + const { payload } = await jwtVerify(token, JWT_KEY); + if (!isInvitePayload(payload)) return null; + return payload as InvitePayload; + } catch { + return null; + } +} + export async function createJWT( payload: Omit, ): Promise { diff --git a/api/main.ts b/api/main.ts index a6aaa70..3438cf5 100644 --- a/api/main.ts +++ b/api/main.ts @@ -9,6 +9,9 @@ import wsRouter from "./routes/ws.ts"; import previewRouter from "./routes/preview.ts"; import playlistsRouter from "./routes/playlists.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 { errorMiddleware } from "./middleware/error.ts"; @@ -50,6 +53,18 @@ app.use( commentsRouter.routes(), commentsRouter.allowedMethods(), ); +app.use( + followsRouter.routes(), + followsRouter.allowedMethods(), +); +app.use( + notificationsRouter.routes(), + notificationsRouter.allowedMethods(), +); +app.use( + invitesRouter.routes(), + invitesRouter.allowedMethods(), +); app.use(routeStaticFilesFrom([ `${Deno.cwd()}/dist`, `${Deno.cwd()}/public`, diff --git a/api/model/db.ts b/api/model/db.ts index 8bb64f8..9eaa1c2 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -2,6 +2,8 @@ import { DatabaseSync, type SQLOutputValue } from "node:sqlite"; import { type Comment, Dump, + type Notification, + type NotificationType, type Playlist, type RichContent, type User, @@ -10,31 +12,10 @@ import { export const db = new DatabaseSync("api/sql/gerbeur.db"); db.exec("PRAGMA foreign_keys = ON;"); -// Migration: add is_private column if it doesn't exist yet -try { - db.exec(`ALTER TABLE dumps ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0;`); -} catch { /* column already exists */ } - -// Migration: create comments table if it doesn't exist yet -try { - db.exec(`CREATE TABLE IF NOT EXISTS comments ( - id TEXT PRIMARY KEY, - dump_id TEXT NOT NULL REFERENCES dumps(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE, - body TEXT NOT NULL, - created_at TEXT NOT NULL, - deleted INTEGER NOT NULL DEFAULT 0 - );`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_comments_dump ON comments(dump_id, created_at);`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_votes_user ON votes(user_id);`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_dumps_dump ON playlist_dumps(dump_id);`); -} catch { /* already exists */ } - -// Migration: add deleted column to comments if it doesn't exist yet -try { - db.exec(`ALTER TABLE comments ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0;`); -} catch { /* column already exists */ } +// Purge expired unused invites on startup +db.prepare( + `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`, +).run(); /** * Database Row Types @@ -53,7 +34,7 @@ export interface DumpRow { file_mime: string | null; file_size: number | null; vote_count: number; - comment_count?: number; + comment_count: number; is_private: number; [key: string]: SQLOutputValue; // Index signature } @@ -65,6 +46,9 @@ export interface UserRow { is_admin: number; created_at: string; 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 } @@ -127,7 +111,7 @@ export function dumpRowToApi(row: DumpRow): Dump { fileMime: row.file_mime ?? undefined, fileSize: row.file_size ?? undefined, voteCount: row.vote_count, - commentCount: row.comment_count ?? 0, + commentCount: row.comment_count, isPrivate: Boolean(row.is_private), }; } @@ -146,6 +130,7 @@ export function dumpApiToRow(dump: Dump): DumpRow { file_mime: dump.fileMime ?? null, file_size: dump.fileSize ?? null, vote_count: dump.voteCount, + comment_count: dump.commentCount, is_private: dump.isPrivate ? 1 : 0, }; } @@ -158,6 +143,9 @@ export function userRowToApi(row: UserRow): User { isAdmin: Boolean(row.is_admin), createdAt: new Date(row.created_at), 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, created_at: user.createdAt.toISOString(), avatar_mime: user.avatarMime ?? null, + invited_by: null, + invited_by_username: null, }; } @@ -185,7 +175,9 @@ export interface CommentRow { [key: string]: SQLOutputValue; } -export function isCommentRow(obj: Record): obj is CommentRow { +export function isCommentRow( + obj: Record, +): obj is CommentRow { return !!obj && typeof obj === "object" && typeof obj.id === "string" && typeof obj.dump_id === "string" && @@ -195,7 +187,8 @@ export function isCommentRow(obj: Record): obj is Commen typeof obj.created_at === "string" && typeof obj.deleted === "number" && typeof obj.author_username === "string" && - (typeof obj.author_avatar_mime === "string" || obj.author_avatar_mime === null); + (typeof obj.author_avatar_mime === "string" || + obj.author_avatar_mime === null); } export function commentRowToApi(row: CommentRow): Comment { @@ -243,5 +236,86 @@ export function playlistRowToApi(row: PlaylistRow): Playlist { createdAt: new Date(row.created_at), imageMime: row.image_mime ?? 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, +): 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, +): 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, +): 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"); +} diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index b5203da..cd7d918 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -41,6 +41,7 @@ export interface User { isAdmin: boolean; createdAt: Date; avatarMime?: string; + invitedByUsername?: string; } export interface LoginUserRequest { @@ -51,6 +52,7 @@ export interface LoginUserRequest { export interface RegisterUserRequest { username: string; password: string; + inviteToken: string; } export interface UpdateUserRequest { @@ -70,7 +72,8 @@ export function isRegisterUserRequest( ): obj is RegisterUserRequest { return !!obj && typeof obj === "object" && "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 { @@ -101,6 +104,19 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload { "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).purpose === "invite" && + "inviterId" in obj && + typeof (obj as Record).inviterId === "string"; +} + /** * API */ @@ -171,11 +187,14 @@ export interface CreateCommentRequest { 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; const o = obj as Record; 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; imageMime?: string; dumpCount?: number; + ownerUsername?: string; } export interface PlaylistWithDumps extends Playlist { @@ -345,3 +365,72 @@ export interface PingMessage { export interface PongMessage { 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; +} diff --git a/api/routes/comments.ts b/api/routes/comments.ts index f496632..3ee4e5a 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -31,14 +31,16 @@ router.get("/dumps/:dumpId/comments", async (ctx) => { } const dump = getDump(ctx.params.dumpId, requestingUserId); const comments = getComments(dump.id); - const responseBody: APIResponse = { success: true, data: comments }; + const responseBody: APIResponse = { + success: true, + data: comments, + }; ctx.response.body = responseBody; }); // POST /api/dumps/:dumpId/comments — auth required router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { const userId = ctx.state.user.userId as string; - const isAdmin = (ctx.state.user.isAdmin ?? false) as boolean; const dump = getDump(ctx.params.dumpId, userId); const body = await ctx.request.body.json(); if (!isCreateCommentRequest(body)) { diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 9e6ac80..540e80a 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -93,8 +93,17 @@ router.get("/", async (ctx) => { const payload = await verifyJWT(authHeader.substring(7)); if (payload) requestingUserId = payload.userId; } - const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); - const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); + const page = Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1, + ); + const limit = Math.min( + Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20, + ), + 100, + ); const { items, total } = listDumps(page, limit, requestingUserId); const responseBody: APIResponse> = { success: true, diff --git a/api/routes/follows.ts b/api/routes/follows.ts new file mode 100644 index 0000000..a99642e --- /dev/null +++ b/api/routes/follows.ts @@ -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 = { 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 = { + items, + total, + hasMore: page * limit < total, + }; + const body: APIResponse> = { 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 = { + items, + total, + hasMore: page * limit < total, + }; + const body: APIResponse> = { 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; diff --git a/api/routes/invites.ts b/api/routes/invites.ts new file mode 100644 index 0000000..1732856 --- /dev/null +++ b/api/routes/invites.ts @@ -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; diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts new file mode 100644 index 0000000..e2ab6bd --- /dev/null +++ b/api/routes/notifications.ts @@ -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, + }; +}); + +// 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; diff --git a/api/routes/users.ts b/api/routes/users.ts index 2cafd25..852d551 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -15,52 +15,48 @@ import { getUserById, getUserByUsername, } from "../services/user-service.ts"; +import { redeemInvite, validateInvite } from "../services/invite-service.ts"; import { getDumpsByUser, getVotedDumpsByUser, } from "../services/dump-service.ts"; import { listPlaylistsByUser } from "../services/playlist-service.ts"; +import { getFollowedPlaylistsByUser } from "../services/follow-service.ts"; // Users router 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) => { - try { - const body = await ctx.request.body.json(); - - if (!isRegisterUserRequest(body)) { - throw new APIException( - APIErrorCode.VALIDATION_ERROR, - 400, - "Invalid request", - ); - } - - const user = await createUser(body); - const token = await createJWT({ - userId: user.id, - username: user.username, - isAdmin: user.isAdmin, - }); - - ctx.response.status = 201; - ctx.response.body = { - success: true, - data: { - token, - user, - }, - }; - } catch (err) { - console.error(err); + const body = await ctx.request.body.json(); + if (!isRegisterUserRequest(body)) { throw new APIException( - APIErrorCode.SERVER_ERROR, - 500, - "Failed to register user", + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid request", ); } + + // Validate invite — throws 404/409 if bad + 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, + username: user.username, + isAdmin: user.isAdmin, + }); + + ctx.response.status = 201; + ctx.response.body = { + success: true, + data: { token: authToken, user }, + }; }); // Login @@ -142,6 +138,31 @@ router.get("/by-id/:userId", (ctx) => { 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, + }; +}); + // Playlists by user (optional auth: include private only if requester === owner) router.get("/:username/playlists", async (ctx) => { const user = getUserByUsername(ctx.params.username); @@ -151,12 +172,30 @@ router.get("/:username/playlists", async (ctx) => { const payload = await verifyJWT(authHeader.substring(7)); if (payload) requestingUserId = payload.userId; } - const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); - const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); - const { items, total } = listPlaylistsByUser(user.id, requestingUserId, page, limit); + const page = Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1, + ); + const limit = Math.min( + Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20, + ), + 100, + ); + const { items, total } = listPlaylistsByUser( + user.id, + requestingUserId, + page, + limit, + ); ctx.response.body = { success: true, - data: { items, total, hasMore: page * limit < total } satisfies PaginatedData, + data: { + items, + total, + hasMore: page * limit < total, + } satisfies PaginatedData, }; }); @@ -176,13 +215,26 @@ router.get("/:username/dumps", async (ctx) => { const payload = await verifyJWT(authHeader.substring(7)); if (payload) requestingUserId = payload.userId; } - const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); - const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); + const page = Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1, + ); + const limit = Math.min( + Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20, + ), + 100, + ); const includePrivate = requestingUserId === user.id; const { items, total } = getDumpsByUser(user.id, page, limit, includePrivate); ctx.response.body = { success: true, - data: { items, total, hasMore: page * limit < total } satisfies PaginatedData, + data: { + items, + total, + hasMore: page * limit < total, + } satisfies PaginatedData, }; }); @@ -195,12 +247,30 @@ router.get("/:username/votes", async (ctx) => { const payload = await verifyJWT(authHeader.substring(7)); if (payload) requestingUserId = payload.userId; } - const page = Math.max(1, parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1); - const limit = Math.min(Math.max(1, parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20), 100); - const { items, total } = getVotedDumpsByUser(user.id, page, limit, requestingUserId); + const page = Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("page") ?? "1") || 1, + ); + const limit = Math.min( + Math.max( + 1, + parseInt(ctx.request.url.searchParams.get("limit") ?? "20") || 20, + ), + 100, + ); + const { items, total } = getVotedDumpsByUser( + user.id, + page, + limit, + requestingUserId, + ); ctx.response.body = { success: true, - data: { items, total, hasMore: page * limit < total } satisfies PaginatedData, + data: { + items, + total, + hasMore: page * limit < total, + } satisfies PaginatedData, }; }); diff --git a/api/routes/ws.ts b/api/routes/ws.ts index 978a945..33d8258 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -13,6 +13,7 @@ import { getUserVotes, removeVote, } from "../services/vote-service.ts"; +import { getUnreadCount } from "../services/notification-service.ts"; import { getUserById } from "../services/user-service.ts"; import { APIException } from "../model/interfaces.ts"; @@ -61,10 +62,14 @@ router.get("/ws", async (ctx) => { try { const myVotes = authPayload ? getUserVotes(authPayload.userId) : []; + const unreadNotificationCount = authPayload + ? getUnreadCount(authPayload.userId) + : 0; socket.send(JSON.stringify({ type: "welcome", users: getOnlineUsers(), myVotes, + unreadNotificationCount, })); } catch (err) { console.error("[ws] welcome send failed:", err); diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index baac5e2..8cbc97d 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -3,9 +3,10 @@ import { APIException, type Comment, } from "../model/interfaces.ts"; +import { type SQLOutputValue } from "node:sqlite"; import { - commentRowToApi, type CommentRow, + commentRowToApi, db, isCommentRow, } from "../model/db.ts"; @@ -18,7 +19,7 @@ function fetchComment(commentId: string): Comment { const row = db.prepare( `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, ).get(commentId); - if (!row || !isCommentRow(row as Record)) { + if (!row || !isCommentRow(row as Record)) { throw new APIException(APIErrorCode.NOT_FOUND, 404, "Comment not found"); } return commentRowToApi(row as CommentRow); @@ -50,7 +51,14 @@ export function createComment( const createdAt = new Date(); db.prepare( `INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`, - ).run(id, dumpId, userId, parentId ?? null, body.trim(), createdAt.toISOString()); + ).run( + id, + dumpId, + userId, + parentId ?? null, + body.trim(), + createdAt.toISOString(), + ); return fetchComment(id); } @@ -73,6 +81,8 @@ export function deleteComment( "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) }; } diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 6791bdc..b8d61cd 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -12,6 +12,7 @@ import { broadcastDumpUpdated, broadcastNewDump, } from "./ws-service.ts"; +import { notifyUserFollowersNewDump } from "./notification-service.ts"; const UPLOADS_DIR = "api/uploads"; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB @@ -95,7 +96,10 @@ export async function createUrlDump( commentCount: 0, isPrivate, }; - if (!isPrivate) broadcastNewDump(dump); + if (!isPrivate) { + broadcastNewDump(dump); + notifyUserFollowersNewDump(userId, dumpId, title); + } return dump; } @@ -164,7 +168,10 @@ export async function createFileDump( commentCount: 0, isPrivate, }; - if (!isPrivate) broadcastNewDump(dump); + if (!isPrivate) { + broadcastNewDump(dump); + notifyUserFollowersNewDump(userId, dumpId, file.name); + } return dump; } @@ -211,7 +218,11 @@ export function listDumps( ).get() as { count: number } | undefined; 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 }; @@ -230,7 +241,9 @@ export async function updateDump( comment: "comment" in request ? (request.comment ?? undefined) : 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 = ?;`) .run(updatedDump.comment ?? null, updatedDump.isPrivate ? 1 : 0, dumpId); @@ -260,13 +273,22 @@ export async function updateDump( : dump.comment, url: newUrl, richContent, - isPrivate: "isPrivate" in request ? (request.isPrivate ?? false) : dump.isPrivate, + isPrivate: "isPrivate" in request + ? (request.isPrivate ?? false) + : dump.isPrivate, }; const row = dumpApiToRow(updatedDump); const result = db.prepare( `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) { 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};`, ).get(userId) as { count: number } | undefined; 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 }; } @@ -380,7 +406,11 @@ export function getVotedDumpsByUser( const rows = rawRows as Parameters[0][]; 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 }; } diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts new file mode 100644 index 0000000..cfde2e8 --- /dev/null +++ b/api/services/follow-service.ts @@ -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[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[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[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[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[0][]; + + if (!rawRows.every(isPlaylistRow)) { + throw new APIException( + APIErrorCode.SERVER_ERROR, + 500, + "Malformed playlist data", + ); + } + return { items: rawRows.map(playlistRowToApi), total: totalRow?.count ?? 0 }; +} diff --git a/api/services/invite-service.ts b/api/services/invite-service.ts new file mode 100644 index 0000000..c28ac37 --- /dev/null +++ b/api/services/invite-service.ts @@ -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 { + 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 { + 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); +} diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts new file mode 100644 index 0000000..d296ea8 --- /dev/null +++ b/api/services/notification-service.ts @@ -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[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}`, + ); + } +} diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index a035dbc..67077cb 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -22,14 +22,19 @@ import { broadcastPlaylistDumpsUpdated, broadcastPlaylistUpdated, } from "./ws-service.ts"; +import { notifyPlaylistFollowersNewDump } from "./notification-service.ts"; 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"; +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 { - const row = db.prepare(`SELECT * FROM playlists WHERE id = ?;`).get( - playlistId, - ); + const row = db.prepare( + `SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`, + ).get(playlistId); if (!row || !isPlaylistRow(row)) { 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); // Owners always see their own private dumps; strip them for non-owners regardless - const visibleDumps = isOwner - ? dumps - : dumps.filter((d) => !d.isPrivate); + const visibleDumps = isOwner ? dumps : dumps.filter((d) => !d.isPrivate); 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 = ? AND is_public = 1;`; const sql = isOwner - ? `SELECT p.*, (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count - FROM playlists p WHERE p.user_id = ? ORDER BY p.created_at DESC 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 ?;`; + ? `SELECT ${PLAYLIST_SELECT} 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 ?;`; const totalRow = db.prepare(countSql).get(userId) as | { count: number } @@ -227,6 +228,20 @@ export function addDumpToPlaylist( const dumpIds = getCurrentDumpIds(playlistId); 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( diff --git a/api/services/providers/soundcloud.ts b/api/services/providers/soundcloud.ts index aa2ef02..478fb3b 100644 --- a/api/services/providers/soundcloud.ts +++ b/api/services/providers/soundcloud.ts @@ -30,7 +30,9 @@ export const soundcloudProvider: RichContentProvider = { title: extractOgTag(html, "title"), description: extractOgTag(html, "description"), 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`, }; }, }; diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts index 1e000af..3cc6170 100644 --- a/api/services/providers/youtube.ts +++ b/api/services/providers/youtube.ts @@ -12,7 +12,9 @@ function extractVideoId(url: string): string | null { if (u.pathname === "/watch" || u.pathname.startsWith("/watch?")) { 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; } } diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 48b8aa4..d39e8c1 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -9,8 +9,15 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.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( request: RegisterUserRequest, + inviterId: string | null, ): Promise { const userId = crypto.randomUUID(); const createdAt = new Date(); @@ -30,14 +37,15 @@ export async function createUser( const passwordHash = await hashPassword(request.password); db.prepare( - `INSERT INTO users (id, username, password_hash, is_admin, created_at) - VALUES (?, ?, ?, ?, ?);`, + `INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by) + VALUES (?, ?, ?, ?, ?, ?);`, ).run( userId, request.username, passwordHash, 0, createdAt.toISOString(), + inviterId, ); return { @@ -51,8 +59,7 @@ export async function createUser( export function getUserById(userId: string): User { const userRow = db.prepare( - `SELECT id, username, password_hash, is_admin, created_at, avatar_mime - FROM users WHERE id = ?`, + `${USER_SELECT} WHERE u.id = ?`, ).get(userId); if (!userRow || !isUserRow(userRow)) { @@ -64,8 +71,7 @@ export function getUserById(userId: string): User { export function getUserByUsername(username: string): User { const userRow = db.prepare( - `SELECT id, username, password_hash, is_admin, created_at, avatar_mime - FROM users WHERE username = ?`, + `${USER_SELECT} WHERE u.username = ?`, ).get(username); if (!userRow || !isUserRow(userRow)) { @@ -77,7 +83,7 @@ export function getUserByUsername(username: string): User { export function listUsers(): User[] { const userRows = db.prepare( - `SELECT id, username, password_hash, is_admin, created_at, avatar_mime FROM users`, + `${USER_SELECT}`, ).all(); if (!userRows || !userRows.every(isUserRow)) { diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index f8eaa34..5a5cbd3 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -1,5 +1,6 @@ import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { db } from "../model/db.ts"; +import { notifyDumpOwnerUpvote } from "./notification-service.ts"; export function castVote(dumpId: string, userId: string): number { try { @@ -14,6 +15,7 @@ export function castVote(dumpId: string, userId: string): number { `SELECT vote_count FROM dumps WHERE id = ?;`, ).get(dumpId) as { vote_count: number } | undefined; db.exec("COMMIT;"); + notifyDumpOwnerUpvote(userId, dumpId); return row?.vote_count ?? 0; } catch (err) { db.exec("ROLLBACK;"); diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 614c66a..dfd2ef7 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -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 { 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 { const users = getOnlineUsers(); 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) { send(client.socket, { type: "comment_deleted", commentId, dumpId }); } diff --git a/api/sql/schema.sql b/api/sql/schema.sql index c9b4f63..a5014fe 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -21,7 +21,8 @@ CREATE TABLE users ( password_hash TEXT NOT NULL, is_admin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, - avatar_mime TEXT + avatar_mime TEXT, + invited_by TEXT REFERENCES users(id) ); CREATE TABLE votes ( @@ -33,7 +34,6 @@ CREATE TABLE votes ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); --- v2: playlists CREATE TABLE playlists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -55,7 +55,6 @@ CREATE TABLE playlist_dumps ( FOREIGN KEY (dump_id) REFERENCES dumps(id) ON DELETE CASCADE ); --- v3: comments CREATE TABLE comments ( id TEXT PRIMARY KEY, 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_dump ON playlist_dumps(dump_id); 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; diff --git a/src/App.css b/src/App.css index d75109a..c09aca0 100644 --- a/src/App.css +++ b/src/App.css @@ -13,8 +13,12 @@ .md a:hover { text-decoration: none; } -.md strong { font-weight: 700; } -.md em { font-style: italic; } +.md strong { + font-weight: 700; +} +.md em { + font-style: italic; +} .md code { font-family: monospace; background: var(--color-bg); @@ -40,15 +44,16 @@ padding-left: 1.5em; margin: 0.4em 0; } -.md li { margin: 0.15em 0; } +.md li { + margin: 0.15em 0; +} .md blockquote { border-left: 3px solid var(--color-border); margin: 0.5em 0; padding: 0.2em 0.75em; opacity: 0.75; } -.md h1, .md h2, .md h3, -.md h4, .md h5, .md h6 { +.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { margin: 0.6em 0 0.2em; font-weight: 700; line-height: 1.25; @@ -62,9 +67,13 @@ .md--inline blockquote { margin: 0; } -.md--inline li { margin: 0; } +.md--inline li { + margin: 0; +} .md--inline ul, -.md--inline ol { padding-left: 1.2em; } +.md--inline ol { + padding-left: 1.2em; +} /* ── Dump detail page ── */ .dump-detail { @@ -101,15 +110,6 @@ justify-self: center; } -.dump-header-info { - display: flex; - flex-direction: column; - gap: 0.3rem; - flex: 1; - min-width: 0; -} - - .dump-title { margin: 0; font-size: 1.5rem; @@ -631,32 +631,6 @@ background: var(--color-soundcloud); } -.rich-content-embed { - width: 100%; - display: block; - border: 2px solid var(--color-border); - border-radius: 10px; - overflow: hidden; - margin-top: 0.75rem; -} -.rich-content-embed iframe { - width: 100%; - border: none; - display: block; -} -.embed-youtube { - aspect-ratio: 16/9; -} -.embed-youtube iframe { - height: 100%; -} -.embed-soundcloud { - height: 166px; -} -.embed-bandcamp { - height: 120px; -} - /* ── Global persistent player ── */ .global-player { position: fixed; @@ -862,14 +836,6 @@ body.has-player .fab-new { opacity: 0.6; } -/* ── Online users ── */ -.online-users { - display: flex; - flex-wrap: wrap; - gap: 0.4rem; - margin-bottom: 1rem; -} - .avatar-img { object-fit: cover; border: 2px solid var(--color-surface); @@ -1087,19 +1053,6 @@ body.has-player .fab-new { } /* ── 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 { margin: 0; font-size: 1.25rem; @@ -1110,6 +1063,132 @@ body.has-player .fab-new { 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 { padding: 0.3rem 0.9rem; border: 1.5px solid var(--color-border); @@ -1126,23 +1205,6 @@ body.has-player .fab-new { 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 { color: var(--color-danger); margin: 0; @@ -1601,10 +1663,6 @@ body.has-player .fab-new { gap: 0.4rem; } -.feed-header { - padding: 0.5rem 0 0; -} - .feed-sort-btn { padding: 0.25rem 0.8rem; border-radius: 6px; @@ -1751,6 +1809,20 @@ body.has-player .fab-new { 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 ── */ .dump-card-comment, .playlist-card-description { @@ -1833,6 +1905,14 @@ body.has-player .fab-new { .playlist-card-count { 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-btn { @@ -2060,8 +2140,20 @@ body.has-player .fab-new { 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 { - margin: 0 0 0.25rem; + margin: 0; font-size: 1.5rem; font-weight: 700; word-break: break-word; @@ -2080,6 +2172,15 @@ body.has-player .fab-new { font-size: 0.82rem; 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-detail-content { @@ -2090,14 +2191,6 @@ body.has-player .fab-new { gap: 0.4rem; } -.playlist-header-actions { - flex-shrink: 0; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 0.4rem; -} - .playlist-edit-btn { background: none; border: 1px solid var(--color-border-subtle); @@ -2276,73 +2369,6 @@ body.has-player .fab-new { opacity: 0.75; } -/* ── Public/Private toggle ── */ -.toggle-row { - display: flex; - align-items: center; - gap: 0.6rem; - cursor: pointer; - margin-bottom: 0.5rem; -} - -.toggle-label { - font-size: 0.9rem; - color: var(--color-text); - user-select: none; -} - -.toggle-hint { - font-size: 0.8rem; - color: var(--color-text-muted); - user-select: none; -} - -.toggle-switch { - position: relative; - display: inline-flex; - align-items: center; - width: 2.4rem; - height: 1.3rem; - flex-shrink: 0; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; - position: absolute; -} - -.toggle-thumb { - position: absolute; - inset: 0; - border-radius: 999px; - background: var(--color-text-muted); - transition: background 0.2s; - cursor: pointer; -} - -.toggle-thumb::after { - content: ""; - position: absolute; - left: 0.15rem; - top: 50%; - transform: translateY(-50%); - width: 1rem; - height: 1rem; - border-radius: 50%; - background: #fff; - transition: left 0.2s; -} - -.toggle-switch input:checked + .toggle-thumb { - background: var(--color-accent); -} - -.toggle-switch input:checked + .toggle-thumb::after { - left: calc(100% - 1.15rem); -} - /* ── Dump card comment count ── */ .dump-card-comment-count { font-size: 0.72rem; @@ -2401,7 +2427,11 @@ body.has-player .fab-new { } .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 { @@ -2477,7 +2507,8 @@ body.has-player .fab-new { padding-left: 1.25rem; margin-left: 1.1rem; margin-top: 0.35rem; - border-left: 2px solid color-mix(in srgb, var(--color-accent) 30%, transparent); + border-left: 2px solid + color-mix(in srgb, var(--color-accent) 30%, transparent); display: flex; flex-direction: column; gap: 0.35rem; @@ -2516,7 +2547,8 @@ body.has-player .fab-new { .comment-reply-textarea:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 20%, transparent); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--color-accent) 20%, transparent); } .comment-form-actions { @@ -2574,3 +2606,324 @@ body.has-player .fab-new { margin: 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; +} diff --git a/src/App.tsx b/src/App.tsx index af25489..fa95e82 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,12 +8,16 @@ import { DumpEdit } from "./pages/DumpEdit.tsx"; import { UserLogin } from "./pages/UserLogin.tsx"; import { UserPublicProfile } from "./pages/UserPublicProfile.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 { MyPlaylists } from "./pages/MyPlaylists.tsx"; +import { Notifications } from "./pages/Notifications.tsx"; import { AuthProvider } from "./contexts/AuthProvider.tsx"; import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; import { WSProvider } from "./contexts/WSProvider.tsx"; +import { FollowProvider } from "./contexts/FollowProvider.tsx"; import { useAuth } from "./hooks/useAuth.ts"; import { GlobalPlayer } from "./components/GlobalPlayer.tsx"; @@ -23,46 +27,54 @@ function AppRoutes() { const { token } = useAuth(); return ( - - - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - - - - } - /> - } /> - - + + + + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + } /> + } + /> + } /> + + + + } + /> + + + ); } diff --git a/src/components/AddToPlaylistModal.tsx b/src/components/AddToPlaylistModal.tsx index 0f0cf79..5bddaba 100644 --- a/src/components/AddToPlaylistModal.tsx +++ b/src/components/AddToPlaylistModal.tsx @@ -2,10 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { API_URL } from "../config/api.ts"; import { useAuth } from "../hooks/useAuth.ts"; -import type { - PlaylistMembership, - RawPlaylistMembership, -} from "../model.ts"; +import type { PlaylistMembership, RawPlaylistMembership } from "../model.ts"; import { deserializePlaylistMembership } from "../model.ts"; import { PlaylistCreateForm } from "./PlaylistCreateForm.tsx"; diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 54b1751..8972eae 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -1,14 +1,14 @@ import { type ReactNode, useEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; import { Link, useNavigate } from "react-router"; import { useAuth } from "../hooks/useAuth.ts"; import { DumpCreateModal } from "./DumpCreateModal.tsx"; +import { NotificationBell } from "./NotificationBell.tsx"; export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) { const { user } = useAuth(); const navigate = useNavigate(); const headerRef = useRef(null); - const [showFab, setShowFab] = useState(false); + const [_showFab, setShowFab] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); useEffect(() => { @@ -28,7 +28,9 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) { ref={headerRef} className={`app-header${centerSlot ? " app-header--has-center" : ""}`} > - 🚚 gerbeur + + 🚚 gerbeur + {centerSlot &&
{centerSlot}
} @@ -42,9 +44,13 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) { > {user.username} - + Playlists +