From cbb3505139f3bd9f54262574dcfb356d144684ef Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 29 Mar 2026 11:56:31 +0000 Subject: [PATCH] v3: search engine, responsive header with compact user menu --- .env.example | 7 + README.md | 2 + api/config.ts | 58 +++++++ api/lib/jwt.ts | 6 +- api/lib/pagination.ts | 9 +- api/lib/upload.ts | 33 ++-- api/main.ts | 5 + api/middleware/og.ts | 5 +- api/model/db.ts | 15 +- api/model/interfaces.ts | 16 +- api/routes/search.ts | 47 ++++++ api/services/dump-service.ts | 64 +++++--- api/services/email-service.ts | 93 +++++++++++ api/services/playlist-service.ts | 20 +++ api/services/providers/youtube.ts | 117 ++++++++++++-- api/services/user-service.ts | 9 ++ api/sql/init.ts | 7 +- deno.json | 3 +- deno.lock | 8 +- src/App.css | 255 ++++++++++++++++++++++++++++- src/App.tsx | 2 + src/components/AppHeader.tsx | 34 ++-- src/components/FeedTabBar.tsx | 53 +++++++ src/components/PageShell.tsx | 6 +- src/components/PresenceRow.tsx | 30 ++++ src/components/SearchBar.tsx | 75 +++++++++ src/components/UserMenu.tsx | 64 ++++++++ src/config/api.ts | 3 +- src/index.css | 1 + src/pages/Index.tsx | 81 ++-------- src/pages/Search.tsx | 256 ++++++++++++++++++++++++++++++ 31 files changed, 1206 insertions(+), 178 deletions(-) create mode 100644 api/routes/search.ts create mode 100644 api/services/email-service.ts create mode 100644 src/components/FeedTabBar.tsx create mode 100644 src/components/PresenceRow.tsx create mode 100644 src/components/SearchBar.tsx create mode 100644 src/components/UserMenu.tsx create mode 100644 src/pages/Search.tsx diff --git a/.env.example b/.env.example index 04c17da..2fe6c67 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,13 @@ GERBEUR_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Secret key used to sign JWTs. Generate with: openssl rand -hex 32 GERBEUR_JWT_SECRET= +# SMTP connection URL used by the email service (notifications, invites, etc.) +# Example: smtps://username:password@smtp.example.com:465 +GERBEUR_SMTPS_URL= + +# Site name used in OG meta tags +GERBEUR_SITE_NAME=gerbeur + # ── Frontend (Vite) ─────────────────────────────────────────────────────────── # These must be prefixed with VITE_ to be exposed to the client bundle. # They tell the frontend where the API server is reachable from the browser. diff --git a/README.md b/README.md index fca9d16..b82c533 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ See [`.env.example`](.env.example) for the full list with descriptions. Key vari | Variable | Description | Default | | ------------------------- | ----------------------------------------------------------------------------------------------------- | ----------------------- | | `GERBEUR_JWT_SECRET` | JWT signing secret — **required**, generate with `openssl rand -hex 32` | — | +| `GERBEUR_SMTPS_URL` | SMTPS connection URL used by the email service (`smtps://user:pass@host:465`) | unset | +| `GERBEUR_SITE_NAME` | Site name used in OG meta tags | `gerbeur` | | `GERBEUR_PORT` | API server port | `8000` | | `GERBEUR_ALLOWED_ORIGINS` | Comma-separated list of extra allowed frontend origins; the server's own `BASE_URL` is always allowed | `http://localhost:3000` | | `VITE_API_HOSTNAME` | Override API hostname in the frontend bundle (see [Production](#production)) | unset | diff --git a/api/config.ts b/api/config.ts index b733036..af7f9cc 100644 --- a/api/config.ts +++ b/api/config.ts @@ -1,11 +1,69 @@ export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http"; export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost"; export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000; +export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || ""; +export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || ""; // GERBEUR_LISTEN_HOST controls the network interface Oak binds to. // Defaults to 0.0.0.0 so Docker port-forwarding works out of the box. // Set to 127.0.0.1 to restrict to loopback only. export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0"; export const BASE_URL = `${PROTOCOL}://${HOSTNAME}:${PORT}`; +export const DB_PATH = "api/sql/gerbeur.db"; + +// Upload/files +export const UPLOADS_DIR = "api/uploads"; +export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; +export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; +export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; +export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; + +export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB +export const ALLOWED_IMAGE_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]); + +export const DUMP_MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50 MB +export const DUMP_ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"]; +export const DUMP_ALLOWED_MIME_TYPES = new Set([ + "application/pdf", + "application/json", + "application/zip", + "application/x-zip-compressed", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", +]); + +// Pagination +export const PAGINATION_DEFAULT_LIMIT = 20; +export const PAGINATION_MAX_LIMIT = 100; + +// Startup housekeeping +export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1; +export const UNUSED_INVITES_RETENTION_DAYS = 7; + +// Validation constraints +export const VALIDATION = { + USERNAME_MIN: 1, + USERNAME_MAX: 32, + PASSWORD_MIN: 8, + PASSWORD_MAX: 128, + DUMP_TITLE_MAX: 200, + DUMP_COMMENT_MAX: 5000, + PLAYLIST_TITLE_MAX: 100, + PLAYLIST_DESCRIPTION_MAX: 2000, + COMMENT_BODY_MAX: 5000, + USER_DESCRIPTION_MAX: 2000, +} as const; + +// SEO/OG +export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "http://localhost:3000"; diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 78fa59c..84c8ba2 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -7,14 +7,14 @@ import { isAuthPayload, isInvitePayload, } from "../model/interfaces.ts"; +import { JWT_SECRET } from "../config.ts"; -const jwtSecret = Deno.env.get("GERBEUR_JWT_SECRET"); -if (!jwtSecret) { +if (!JWT_SECRET) { throw new Error( "GERBEUR_JWT_SECRET environment variable is required. Generate one with: openssl rand -hex 32", ); } -const JWT_KEY = new TextEncoder().encode(jwtSecret); +const JWT_KEY = new TextEncoder().encode(JWT_SECRET); // ── Invite tokens ───────────────────────────────────────────────────────────── diff --git a/api/lib/pagination.ts b/api/lib/pagination.ts index b1ba0da..ad830bd 100644 --- a/api/lib/pagination.ts +++ b/api/lib/pagination.ts @@ -1,3 +1,8 @@ +import { + PAGINATION_DEFAULT_LIMIT, + PAGINATION_MAX_LIMIT, +} from "../config.ts"; + /** * Parses page/limit query parameters with sensible defaults and bounds. * page: clamped to [1, ∞) @@ -5,7 +10,7 @@ */ export function parsePagination( params: URLSearchParams, - defaultLimit = 20, + defaultLimit = PAGINATION_DEFAULT_LIMIT, ): { page: number; limit: number } { const page = Math.max( 1, @@ -16,7 +21,7 @@ export function parsePagination( 1, parseInt(params.get("limit") ?? String(defaultLimit)) || defaultLimit, ), - 100, + PAGINATION_MAX_LIMIT, ); return { page, limit }; } diff --git a/api/lib/upload.ts b/api/lib/upload.ts index 5e4ba88..af6bf11 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -1,20 +1,23 @@ import type { Context } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; +import { + ALLOWED_IMAGE_MIMES, + ATTACHMENTS_DIR, + AVATARS_DIR, + DUMPS_DIR, + MAX_IMAGE_SIZE_BYTES, + PLAYLIST_IMAGES_DIR, + UPLOADS_DIR, +} from "../config.ts"; -export const UPLOADS_DIR = "api/uploads"; -export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; -export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; -export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; -export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; - -export const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB - -export const ALLOWED_IMAGE_MIMES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]); +export { + ALLOWED_IMAGE_MIMES, + ATTACHMENTS_DIR, + AVATARS_DIR, + DUMPS_DIR, + PLAYLIST_IMAGES_DIR, + UPLOADS_DIR, +}; /** Detect image MIME type from magic bytes, ignoring the browser-declared type. */ export function detectImageMime(data: Uint8Array): string | null { @@ -39,7 +42,7 @@ export function detectImageMime(data: Uint8Array): string | null { /** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */ export function validateImageUpload(data: Uint8Array): string { - if (data.length > MAX_IMAGE_SIZE) { + if (data.length > MAX_IMAGE_SIZE_BYTES) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, diff --git a/api/main.ts b/api/main.ts index 4956569..093f3e8 100644 --- a/api/main.ts +++ b/api/main.ts @@ -13,6 +13,7 @@ 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 searchRouter from "./routes/search.ts"; import { ALLOWED_ORIGINS, BASE_URL, LISTEN_HOST, PORT } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; @@ -83,6 +84,10 @@ app.use( invitesRouter.routes(), invitesRouter.allowedMethods(), ); +app.use( + searchRouter.routes(), + searchRouter.allowedMethods(), +); app.use(routeStaticFilesFrom([ `${Deno.cwd()}/dist`, `${Deno.cwd()}/public`, diff --git a/api/middleware/og.ts b/api/middleware/og.ts index 9f97a65..ca6b23f 100644 --- a/api/middleware/og.ts +++ b/api/middleware/og.ts @@ -2,6 +2,7 @@ import { Context, Next } from "@oak/oak"; import { getDump } from "../services/dump-service.ts"; import { getUserByUsername } from "../services/user-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts"; +import { OG_SITE_NAME } from "../config.ts"; interface OGMeta { title: string; @@ -10,8 +11,6 @@ interface OGMeta { url: string; } -const SITE_NAME = "gerbeur"; - function escapeAttr(s: string): string { return s .replace(/&/g, "&") @@ -24,7 +23,7 @@ function buildTags(meta: OGMeta): string { const card = meta.imageUrl ? "summary_large_image" : "summary"; const tags = [ `${escapeAttr(meta.title)}`, - ``, + ``, ``, ``, ``, diff --git a/api/model/db.ts b/api/model/db.ts index 578c34b..a86afd8 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -9,26 +9,31 @@ import { type RichContent, type User, } from "./interfaces.ts"; +import { + ATTACHMENTS_DIR, + DB_PATH, + ORPHANED_ATTACHMENTS_RETENTION_HOURS, + UNUSED_INVITES_RETENTION_DAYS, +} from "../config.ts"; -export const db = new DatabaseSync("api/sql/gerbeur.db"); +export const db = new DatabaseSync(DB_PATH); db.exec("PRAGMA foreign_keys = ON;"); // Purge expired unused invites on startup db.prepare( - `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`, + `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, ).run(); // Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour const orphanedAttachments = db.prepare( - `SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`, + `SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, ).all() as { id: string }[]; if (orphanedAttachments.length > 0) { - const { ATTACHMENTS_DIR } = await import("../lib/upload.ts"); for (const { id } of orphanedAttachments) { await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {}); } db.prepare( - `DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-1 hour');`, + `DELETE FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, ).run(); } diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index eeb57c1..025a445 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -1,21 +1,9 @@ +import { VALIDATION } from "../config.ts"; + /** * Backend */ -// ── Validation constants (shared with frontend via src/config/api.ts) ────────── -export const VALIDATION = { - USERNAME_MIN: 1, - USERNAME_MAX: 32, - PASSWORD_MIN: 8, - PASSWORD_MAX: 128, - DUMP_TITLE_MAX: 200, - DUMP_COMMENT_MAX: 5000, - PLAYLIST_TITLE_MAX: 100, - PLAYLIST_DESCRIPTION_MAX: 2000, - COMMENT_BODY_MAX: 5000, - USER_DESCRIPTION_MAX: 2000, -} as const; - export interface RichContent { type: string; url: string; diff --git a/api/routes/search.ts b/api/routes/search.ts new file mode 100644 index 0000000..69b1978 --- /dev/null +++ b/api/routes/search.ts @@ -0,0 +1,47 @@ +import { Router } from "@oak/oak"; +import { parseOptionalAuth } from "../lib/auth.ts"; +import { parsePagination } from "../lib/pagination.ts"; +import { searchDumps } from "../services/dump-service.ts"; +import { searchUsersGlobal } from "../services/user-service.ts"; +import { searchPlaylists } from "../services/playlist-service.ts"; + +const router = new Router({ prefix: "/api/search" }); + +router.get("/", async (ctx) => { + const params = ctx.request.url.searchParams; + const q = (params.get("q") ?? "").trim(); + const { page, limit } = parsePagination(params); + const requestingUserId = (await parseOptionalAuth(ctx)) ?? undefined; + + if (!q) { + ctx.response.body = { + success: true, + data: { + dumps: { items: [], total: 0, hasMore: false }, + users: [], + playlists: [], + }, + }; + return; + } + + const { items: dumpItems, total: dumpTotal } = searchDumps( + q, + page, + limit, + requestingUserId, + ); + const users = searchUsersGlobal(q, 20); + const playlists = searchPlaylists(q, 20, requestingUserId); + + ctx.response.body = { + success: true, + data: { + dumps: { items: dumpItems, total: dumpTotal, hasMore: page * limit < dumpTotal }, + users, + playlists, + }, + }; +}); + +export default router; diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index f55448f..b725287 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -17,28 +17,17 @@ import { notifyUserFollowersNewDump, } from "./notification-service.ts"; import { makeSlug, UUID_RE } from "../lib/slugify.ts"; -import { DUMPS_DIR } from "../lib/upload.ts"; +import { + DUMP_ALLOWED_MIME_PREFIXES, + DUMP_ALLOWED_MIME_TYPES, + DUMP_MAX_FILE_SIZE_BYTES, + DUMPS_DIR, +} from "../config.ts"; import { linkAttachments } from "./attachment-service.ts"; -const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB - -const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"]; -const ALLOWED_MIME_TYPES = new Set([ - "application/pdf", - "application/json", - "application/zip", - "application/x-zip-compressed", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "application/msword", - "application/vnd.ms-excel", - "application/vnd.ms-powerpoint", -]); - function isAllowedMime(mime: string): boolean { - return ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || - ALLOWED_MIME_TYPES.has(mime); + return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || + DUMP_ALLOWED_MIME_TYPES.has(mime); } function titleFromUrl(url: string): string { @@ -128,7 +117,7 @@ export async function createFileDump( `File type '${file.type}' is not allowed`, ); } - if (file.size > MAX_FILE_SIZE) { + if (file.size > DUMP_MAX_FILE_SIZE_BYTES) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, @@ -216,6 +205,39 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump { return dump; } +export function searchDumps( + query: string, + page: number, + limit: number, + requestingUserId?: string, +): { items: Dump[]; total: number } { + if (!query.trim()) return { items: [], total: 0 }; + const pattern = `%${query}%`; + const offset = (page - 1) * limit; + + const searchClause = + `(title LIKE ? OR comment LIKE ? OR json_extract(rich_content,'$.title') LIKE ? OR json_extract(rich_content,'$.description') LIKE ?)`; + const searchParams = [pattern, pattern, pattern, pattern] as const; + + if (requestingUserId) { + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, + ).all(requestingUserId, ...searchParams, limit, offset); + const totalRow = db.prepare( + `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`, + ).get(requestingUserId, ...searchParams) as { count: number } | undefined; + return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 }; + } else { + const rows = db.prepare( + `SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, + ).all(...searchParams, limit, offset); + const totalRow = db.prepare( + `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`, + ).get(...searchParams) as { count: number } | undefined; + return { items: rows.filter(isDumpRow).map(dumpRowToApi), total: totalRow?.count ?? 0 }; + } +} + export function listDumps( page: number, limit: number, @@ -367,7 +389,7 @@ export async function replaceFileDump( `File type '${file.type}' is not allowed`, ); } - if (file.size > MAX_FILE_SIZE) { + if (file.size > DUMP_MAX_FILE_SIZE_BYTES) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, diff --git a/api/services/email-service.ts b/api/services/email-service.ts new file mode 100644 index 0000000..c63f0d2 --- /dev/null +++ b/api/services/email-service.ts @@ -0,0 +1,93 @@ +import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer"; + +import { SMTPS_URL } from "../config.ts"; + +export type EmailRecipient = string | string[]; + +export interface EmailMessage { + from: string; + to: EmailRecipient; + subject: string; + text?: string; + html?: string; + cc?: EmailRecipient; + bcc?: EmailRecipient; + replyTo?: string; +} + +export interface EmailSendResult { + messageId: string; + accepted: string[]; + rejected: string[]; +} + +let transporter: Transporter | null = null; + +export function isEmailEnabled(): boolean { + return SMTPS_URL.length > 0; +} + +function normalizeRecipients( + recipient: string | readonly (string | { address?: string })[] | undefined, +): string[] { + if (!recipient) return []; + if (typeof recipient === "string") { + return recipient.split(",").map((s) => s.trim()).filter(Boolean); + } + return recipient.flatMap((entry) => + typeof entry === "string" ? entry.trim() : (entry.address?.trim() ?? "") + ).filter(Boolean); +} + +function getTransporter(): Transporter { + if (!isEmailEnabled()) { + throw new Error( + "GERBEUR_SMTPS_URL environment variable is required to send emails.", + ); + } + + if (!transporter) { + transporter = nodemailer.createTransport(SMTPS_URL); + } + + return transporter; +} + +export async function verifyEmailTransport(): Promise { + if (!isEmailEnabled()) return false; + await getTransporter().verify(); + return true; +} + +export async function sendEmail(message: EmailMessage): Promise { + if (normalizeRecipients(message.to).length === 0) { + throw new Error("Email recipient is required."); + } + + if (!message.subject.trim()) { + throw new Error("Email subject is required."); + } + + if (!message.text?.trim() && !message.html?.trim()) { + throw new Error("Email body is required (text and/or html)."); + } + + const options: SendMailOptions = { + from: message.from, + to: message.to, + cc: message.cc, + bcc: message.bcc, + subject: message.subject, + text: message.text, + html: message.html, + replyTo: message.replyTo, + }; + + const info = await getTransporter().sendMail(options); + + return { + messageId: info.messageId, + accepted: normalizeRecipients(info.accepted), + rejected: normalizeRecipients(info.rejected), + }; +} diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 576db7f..fc344f3 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -112,6 +112,26 @@ export function getPlaylist( return { ...playlist, dumps: visibleDumps }; } +export function searchPlaylists( + query: string, + limit: number, + requestingUserId?: string, +): Playlist[] { + if (!query.trim()) return []; + const pattern = `%${query}%`; + const searchClause = `(p.title LIKE ? OR p.description LIKE ?)`; + + const rows = requestingUserId + ? db.prepare( + `SELECT ${PLAYLIST_SELECT} WHERE (p.is_public = 1 OR p.user_id = ?) AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, + ).all(requestingUserId, pattern, pattern, limit) + : db.prepare( + `SELECT ${PLAYLIST_SELECT} WHERE p.is_public = 1 AND ${searchClause} ORDER BY p.created_at DESC LIMIT ?;`, + ).all(pattern, pattern, limit); + + return rows.filter(isPlaylistRow).map(playlistRowToApi); +} + export function listPlaylistsByUser( userId: string, requestingUserId: string | null, diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts index 3cc6170..1709aad 100644 --- a/api/services/providers/youtube.ts +++ b/api/services/providers/youtube.ts @@ -1,6 +1,6 @@ import type { RichContent } from "../../model/interfaces.ts"; import type { RichContentProvider } from "../rich-content-service.ts"; -import { fetchWithTimeout } from "../rich-content-service.ts"; +import { extractOgTag, fetchWithTimeout } from "../rich-content-service.ts"; function extractVideoId(url: string): string | null { try { @@ -24,38 +24,123 @@ function extractVideoId(url: string): string | null { return null; } +function extractPlaylistId(url: string): string | null { + try { + return new URL(url).searchParams.get("list"); + } catch { + return null; + } +} + +/** Matches /channel/UC…, /@handle, /c/name, /user/name */ +function extractChannelPath(url: string): string | null { + try { + const u = new URL(url); + if (u.hostname === "youtube.com" || u.hostname === "www.youtube.com") { + if ( + u.pathname.startsWith("/channel/") || + u.pathname.startsWith("/@") || + u.pathname.startsWith("/c/") || + u.pathname.startsWith("/user/") + ) { + return u.pathname; + } + } + } catch { + // invalid URL + } + return null; +} + +async function fetchOEmbed( + url: string, +): Promise<{ title?: string; thumbnailUrl?: string }> { + try { + const oembedUrl = + `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; + const res = await fetchWithTimeout(oembedUrl); + if (res.ok) { + const data = await res.json() as { + title?: string; + thumbnail_url?: string; + }; + return { title: data.title, thumbnailUrl: data.thumbnail_url }; + } + } catch { + // oembed failed — carry on + } + return {}; +} + export const youtubeProvider: RichContentProvider = { name: "youtube", matches(url: string): boolean { - return extractVideoId(url) !== null; + return ( + extractVideoId(url) !== null || + extractPlaylistId(url) !== null || + extractChannelPath(url) !== null + ); }, async fetch(url: string): Promise { - const videoId = extractVideoId(url)!; - const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; - let title: string | undefined; + const videoId = extractVideoId(url); + const listId = extractPlaylistId(url); + const channelPath = extractChannelPath(url); - try { - const oembedUrl = - `https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`; - const res = await fetchWithTimeout(oembedUrl); - if (res.ok) { - const data = await res.json() as { title?: string }; - title = data.title; + // ── Channel ─────────────────────────────────────────────────────────────── + if (channelPath && !videoId) { + // oEmbed doesn't support channel URLs — scrape og:image from the page instead + let title: string | undefined; + let thumbnailUrl: string | undefined; + try { + const res = await fetchWithTimeout(url); + if (res.ok) { + const html = await res.text(); + title = extractOgTag(html, "title"); + thumbnailUrl = extractOgTag(html, "image"); + } + } catch { + // scrape failed — carry on with undefined values } - } catch { - // oembed failed — thumbnail still works + return { + type: "youtube", + siteName: "YouTube", + url, + title, + thumbnailUrl, + // channels are not embeddable as a player + }; } + // ── Playlist (no specific video) ────────────────────────────────────────── + if (listId && !videoId) { + const { title, thumbnailUrl } = await fetchOEmbed(url); + return { + type: "youtube", + siteName: "YouTube", + url, + title, + thumbnailUrl, + embedUrl: `https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`, + }; + } + + // ── Video (with optional playlist context) ──────────────────────────────── + const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; + const { title } = await fetchOEmbed(url); + + const embedParams = new URLSearchParams({ rel: "0" }); + if (listId) embedParams.set("list", listId); + return { type: "youtube", siteName: "YouTube", url, - videoId, + videoId: videoId!, title, thumbnailUrl, - embedUrl: `https://www.youtube.com/embed/${videoId}?rel=0`, + embedUrl: `https://www.youtube.com/embed/${videoId}?${embedParams}`, }; }, }; diff --git a/api/services/user-service.ts b/api/services/user-service.ts index f149fc2..2eb27ef 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -102,6 +102,15 @@ export function searchUsers( })); } +export function searchUsersGlobal(query: string, limit: number): User[] { + if (!query.trim()) return []; + const pattern = `%${query}%`; + const rows = db.prepare( + `${USER_SELECT} WHERE (u.username LIKE ? OR u.description LIKE ?) ORDER BY u.username LIMIT ?;`, + ).all(pattern, pattern, limit); + return rows.filter(isUserRow).map(userRowToApi); +} + export function listUsers(): User[] { const userRows = db.prepare( `${USER_SELECT}`, diff --git a/api/sql/init.ts b/api/sql/init.ts index 10a94b9..3d5caad 100644 --- a/api/sql/init.ts +++ b/api/sql/init.ts @@ -1,14 +1,13 @@ import { DatabaseSync } from "node:sqlite"; - -const DB_FILE = "api/sql/gerbeur.db"; +import { DB_PATH } from "../config.ts"; try { - await Deno.stat(DB_FILE); + await Deno.stat(DB_PATH); console.log("Database already exists, skipping initialization."); } catch { console.log("Initializing database from schema..."); const schema = Deno.readTextFileSync("api/sql/schema.sql"); - const db = new DatabaseSync(DB_FILE); + const db = new DatabaseSync(DB_PATH); db.exec(schema); db.close(); console.log("Database initialized."); diff --git a/deno.json b/deno.json index b8ac649..136ab3c 100644 --- a/deno.json +++ b/deno.json @@ -24,6 +24,7 @@ "@db/sqlite": "jsr:@db/sqlite@^0.13.0", "@oak/oak": "jsr:@oak/oak@^17.2.0", "@panva/jose": "jsr:@panva/jose@^6.2.1", - "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1" + "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1", + "nodemailer": "npm:nodemailer@^8.0.4" } } diff --git a/deno.lock b/deno.lock index 8842ce6..fd1a028 100644 --- a/deno.lock +++ b/deno.lock @@ -31,6 +31,8 @@ "npm:eslint@^9.39.4": "9.39.4", "npm:frimousse@0.3": "0.3.0_react@19.2.4_typescript@5.9.3", "npm:globals@^17.4.0": "17.4.0", + "npm:nodemailer@*": "8.0.4", + "npm:nodemailer@^8.0.4": "8.0.4", "npm:path-to-regexp@^6.3.0": "6.3.0", "npm:react-dom@^19.2.4": "19.2.4_react@19.2.4", "npm:react-markdown@^10.1.0": "10.1.0_@types+react@19.2.14_react@19.2.4", @@ -1640,6 +1642,9 @@ "node-releases@2.0.36": { "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==" }, + "nodemailer@8.0.4": { + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==" + }, "optionator@0.9.4": { "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dependencies": [ @@ -2048,7 +2053,8 @@ "jsr:@db/sqlite@0.13", "jsr:@oak/oak@^17.2.0", "jsr:@panva/jose@^6.2.1", - "jsr:@tajpouria/cors@^1.2.1" + "jsr:@tajpouria/cors@^1.2.1", + "npm:nodemailer@^8.0.4" ], "packageJson": { "dependencies": [ diff --git a/src/App.css b/src/App.css index da13840..e637165 100644 --- a/src/App.css +++ b/src/App.css @@ -1573,10 +1573,10 @@ body.has-player .fab-new { transform: translateY(-1px); } -@media (min-width: 740px) { +@media (min-width: 860px) { .app-header--has-center { display: grid; - grid-template-columns: 1fr auto 1fr; + grid-template-columns: auto 1fr auto; } } @@ -1593,14 +1593,38 @@ body.has-player .fab-new { display: none; align-items: center; justify-content: center; + min-width: 0; + overflow: hidden; + container-type: inline-size; } -@media (min-width: 740px) { +@media (min-width: 860px) { .app-header-center { display: flex; } } +/* When the search bar is expanded, immediately clear the rest of the center — + the expanded input needs the full column width */ +.app-header-center:has(.search-bar--expanded) .index-presence, +.app-header-center:has(.search-bar--expanded) .feed-sort { + display: none; +} + +/* As the center column shrinks (viewport narrow, search collapsed), + shed content in order: presence first, then tabs (still in .index-below-header) */ +@container (max-width: 460px) { + .index-presence { + display: none; + } +} + +@container (max-width: 280px) { + .feed-sort { + display: none; + } +} + .header-center-slot { display: flex; align-items: center; @@ -1617,7 +1641,33 @@ body.has-player .fab-new { justify-content: flex-end; gap: 0.6rem; margin-left: auto; - flex-wrap: wrap; + flex-shrink: 0; + flex-wrap: nowrap; +} + +/* Text links — visible only at wide viewports */ +.nav-links { + display: none; + align-items: center; + gap: 0.6rem; +} + +@media (min-width: 1150px) { + .nav-links { + display: flex; + } +} + +/* Avatar menu — visible only below the text-links breakpoint */ +.nav-compact { + display: flex; + align-items: center; +} + +@media (min-width: 1150px) { + .nav-compact { + display: none; + } } .app-header-user { @@ -1637,6 +1687,57 @@ body.has-player .fab-new { background: var(--color-header-user-bg-hover); } +/* ── User menu (compact nav) ── */ +.user-menu { + position: relative; +} + +.user-menu-trigger { + display: flex; + align-items: center; + background: transparent; + border: none; + border-radius: 8px; + padding: 0.25rem; + cursor: pointer; + transition: background 0.15s; +} + +.user-menu-trigger:hover { + background: var(--color-header-user-bg-hover); +} + +.user-menu-dropdown { + position: absolute; + top: calc(100% + 0.4rem); + right: 0; + min-width: 150px; + background: var(--color-surface); + border: 2px solid var(--color-border-subtle); + border-radius: 10px; + padding: 0.35rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + z-index: 100; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.user-menu-item { + display: block; + padding: 0.45rem 0.75rem; + border-radius: 7px; + text-decoration: none; + color: var(--color-text); + font-size: 0.9rem; + font-weight: 600; + transition: background 0.12s; +} + +.user-menu-item:hover { + background: var(--color-header-user-bg-hover); +} + /* ── Auth card ── */ .auth-card { width: 100%; @@ -1780,6 +1881,7 @@ body.has-player .fab-new { display: inline-flex; align-items: center; justify-content: center; + white-space: nowrap; cursor: pointer; border-radius: 8px; font-family: inherit; @@ -1866,7 +1968,7 @@ body.has-player .fab-new { gap: 0; } -@media (min-width: 740px) { +@media (min-width: 860px) { .index-page .dump-feed { margin-top: 1rem; } @@ -1913,7 +2015,7 @@ body.has-player .fab-new { padding: 0.6rem 1.25rem 0; } -@media (min-width: 740px) { +@media (min-width: 860px) { .index-below-header { display: none; } @@ -3597,3 +3699,144 @@ body.has-player .fab-new { z-index: 9999; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); } + +/* ── Search bar (in header center slot) ── */ +.search-bar { + display: flex; + align-items: center; + gap: 0.4rem; + width: 100%; + max-width: 380px; +} + +.search-bar--collapsible { + width: auto; + max-width: none; +} + +.search-bar--collapsible.search-bar--expanded { + width: 100%; + max-width: 320px; +} + +.search-bar-input { + flex: 1; + padding: 0.3rem 0.7rem; + border-radius: 8px; + border: 2px solid var(--color-border-subtle); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.875rem; + font-family: inherit; + outline: none; + transition: max-width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, + border-width 0.25s ease, border-color 0.15s; + min-width: 0; +} + +/* Collapsed state: hide the input while keeping it in the DOM */ +.search-bar--collapsible .search-bar-input { + max-width: 0; + opacity: 0; + padding-left: 0; + padding-right: 0; + border-width: 0; + pointer-events: none; +} + +/* Expanded state: animate it open */ +.search-bar--collapsible.search-bar--expanded .search-bar-input { + max-width: 280px; + opacity: 1; + padding-left: 0.7rem; + padding-right: 0.7rem; + border-width: 2px; + pointer-events: auto; +} + +.search-bar-input:focus { + border-color: var(--color-accent); +} + +.search-bar-btn { + padding: 0.3rem 0.55rem; + background: transparent; + border: 2px solid var(--color-border-subtle); + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + flex-shrink: 0; + transition: border-color 0.15s; +} + +.search-bar-btn:hover { + border-color: var(--color-accent); +} + +/* ── Search page ── */ +.search-page { + display: flex; + flex-direction: column; + align-items: center; + padding-bottom: 3rem; +} + +.search-tabs { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 1rem 1.25rem 0; + width: 100%; + max-width: 860px; + box-sizing: border-box; +} + +.search-page-empty { + padding: 3rem 1.25rem; + color: var(--color-text-secondary); + text-align: center; + font-size: 0.95rem; +} + +/* ── User results ── */ +.user-results { + list-style: none; + margin: 0; + padding: 0.75rem 1.25rem 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 860px; + width: 100%; + box-sizing: border-box; +} + +.user-result-item { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.75rem 1rem; + background: var(--color-surface); + border-radius: 10px; + border: 2px solid var(--color-border-subtle); + text-decoration: none; + color: var(--color-text); + transition: border-color 0.15s; +} + +.user-result-item:hover { + border-color: var(--color-accent); +} + +.user-result-name { + font-weight: 600; + font-size: 0.95rem; +} + +.user-result-description { + font-size: 0.85rem; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/App.tsx b/src/App.tsx index c6131a3..d4eb484 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { UserUpvoted } from "./pages/UserUpvoted.tsx"; import { UserPlaylists } from "./pages/UserPlaylists.tsx"; import { PlaylistDetail } from "./pages/PlaylistDetail.tsx"; import { Notifications } from "./pages/Notifications.tsx"; +import { Search } from "./pages/Search.tsx"; import { AuthProvider } from "./contexts/AuthProvider.tsx"; import { PlayerProvider } from "./contexts/PlayerProvider.tsx"; @@ -64,6 +65,7 @@ function AppRoutes() { element={} /> } /> + } /> - - {user.username} - - - Playlists - + {/* Full text links — hidden below the compact breakpoint */} +
+ + {user.username} + + + Playlists + +
+ + {/* Compact avatar menu — hidden above the compact breakpoint */} +
+ +
+ + + + {user && ( + + )} + + ); +} diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index 487853b..a775fb6 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -1,15 +1,17 @@ import { type ReactNode } from "react"; import { AppHeader } from "./AppHeader.tsx"; +import { SearchBar } from "./SearchBar.tsx"; interface PageShellProps { children: ReactNode; centered?: boolean; + centerSlot?: ReactNode; } -export function PageShell({ children, centered = false }: PageShellProps) { +export function PageShell({ children, centered = false, centerSlot }: PageShellProps) { return (
- + } />
diff --git a/src/components/PresenceRow.tsx b/src/components/PresenceRow.tsx new file mode 100644 index 0000000..34b85e3 --- /dev/null +++ b/src/components/PresenceRow.tsx @@ -0,0 +1,30 @@ +import { Link } from "react-router"; +import { Avatar } from "./Avatar.tsx"; +import { useWS } from "../hooks/useWS.ts"; + +export function PresenceRow() { + const { onlineUsers } = useWS(); + + if (onlineUsers.length === 0) return null; + + return ( +
+ {onlineUsers.map((u) => ( + + + + ))} +
+ ); +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..923e48e --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,75 @@ +import { type FormEvent, useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router"; + +interface SearchBarProps { + collapsible?: boolean; +} + +export function SearchBar({ collapsible = false }: SearchBarProps) { + const [value, setValue] = useState( + () => new URLSearchParams(location.search).get("q") ?? "", + ); + const [expanded, setExpanded] = useState(!collapsible); + const inputRef = useRef(null); + const navigate = useNavigate(); + + useEffect(() => { + if (expanded) inputRef.current?.focus(); + }, [expanded]); + + function handleIconClick() { + if (!collapsible) return; + if (expanded) { + setExpanded(false); + setValue(""); + } else { + setExpanded(true); + } + } + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const q = value.trim(); + if (!q) return; + navigate(`/search?q=${encodeURIComponent(q)}&tab=dumps`); + if (collapsible) { + setExpanded(false); + setValue(""); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape" && collapsible) { + setExpanded(false); + setValue(""); + } + } + + return ( +
+ setValue(e.target.value)} + onKeyDown={handleKeyDown} + aria-label="Search" + tabIndex={expanded ? 0 : -1} + /> + +
+ ); +} diff --git a/src/components/UserMenu.tsx b/src/components/UserMenu.tsx new file mode 100644 index 0000000..d852a8b --- /dev/null +++ b/src/components/UserMenu.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router"; +import { Avatar } from "./Avatar.tsx"; +import type { User } from "../model.ts"; + +export function UserMenu({ user }: { user: User }) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + function onMouseDown(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onMouseDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + return ( +
+ + {open && ( +
+ setOpen(false)} + > + @{user.username} + + setOpen(false)} + > + Playlists + +
+ )} +
+ ); +} diff --git a/src/config/api.ts b/src/config/api.ts index df41295..5e5422a 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -22,10 +22,11 @@ export const WS_URL: string = API_URL ? API_URL.replace(/^http/, "ws") : `${location.protocol.replace("http", "ws")}//${location.host}`; +export const SEARCH_URL = `${API_URL}/api/search`; export const DEFAULT_PAGE_SIZE = 20; export const NOTIFICATIONS_PAGE_SIZE = 30; -// Validation constants (mirrors api/model/interfaces.ts VALIDATION) +// Validation constants (mirrors api/config.ts VALIDATION) export const VALIDATION = { USERNAME_MIN: 1, USERNAME_MAX: 32, diff --git a/src/index.css b/src/index.css index beeba16..bb80946 100644 --- a/src/index.css +++ b/src/index.css @@ -82,6 +82,7 @@ body { margin: 0; min-height: 100vh; + overflow-x: clip; background-color: var(--color-bg); color: var(--color-text); } diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index ef1f074..bace8ba 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -6,10 +6,12 @@ import { useRef, useState, } from "react"; -import { Link, useLocation, useNavigate } from "react-router"; +import { useLocation } from "react-router"; -import { Avatar } from "../components/Avatar.tsx"; import { AppHeader } from "../components/AppHeader.tsx"; +import { SearchBar } from "../components/SearchBar.tsx"; +import { PresenceRow } from "../components/PresenceRow.tsx"; +import { FeedTabBar, type FeedTab, VALID_TABS } from "../components/FeedTabBar.tsx"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; @@ -46,19 +48,13 @@ type DumpsState = loadingMore: boolean; }; -type FeedTab = "hot" | "new" | "journal" | "followed"; - -const VALID_TABS = new Set(["hot", "new", "journal", "followed"]); - export function Index() { const location = useLocation(); - const navigate = useNavigate(); const justDeletedId = (location.state as { deletedDumpId?: string } | null) ?.deletedDumpId; const { user, token } = useAuth(); const { - onlineUsers, voteCounts, myVotes, recentDumps, @@ -89,10 +85,6 @@ export function Index() { const rawTab = new URLSearchParams(location.search).get("tab") ?? "hot"; const tab: FeedTab = VALID_TABS.has(rawTab) ? rawTab as FeedTab : "hot"; - function setTab(t: FeedTab) { - navigate(`/?tab=${t}`, { replace: true }); - } - // ── Main feed fetch ── useEffect(() => { @@ -237,77 +229,22 @@ export function Index() { // ── Render ── - const presenceRow = ( -
- {onlineUsers.map((u) => ( - - - - ))} -
- ); - - const tabBar = ( -
- - - - {user && ( - - )} -
- ); - return (
- {presenceRow} - {tabBar} + + +
} disableNew={dumpsState.status === "error"} />
- {tabBar} - {presenceRow} + +
{tab === "hot" && } diff --git a/src/pages/Search.tsx b/src/pages/Search.tsx new file mode 100644 index 0000000..9008d6f --- /dev/null +++ b/src/pages/Search.tsx @@ -0,0 +1,256 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link, useSearchParams } from "react-router"; +import { AppHeader } from "../components/AppHeader.tsx"; +import { SearchBar } from "../components/SearchBar.tsx"; +import { DumpCard } from "../components/DumpCard.tsx"; +import { PlaylistCard } from "../components/PlaylistCard.tsx"; +import { ErrorCard } from "../components/ErrorCard.tsx"; +import { useAuth } from "../hooks/useAuth.ts"; +import { useWS } from "../hooks/useWS.ts"; +import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; +import { + deserializeDump, + deserializePlaylist, + deserializePublicUser, + type Dump, + type Playlist, + type PublicUser, + type RawDump, + type RawPlaylist, + type RawPublicUser, +} from "../model.ts"; +import { DEFAULT_PAGE_SIZE, SEARCH_URL } from "../config/api.ts"; + +type Tab = "dumps" | "users" | "playlists"; + +type SearchState = + | { status: "idle" } + | { status: "loading" } + | { status: "error"; error: string } + | { + status: "loaded"; + q: string; + dumps: { + items: Dump[]; + total: number; + hasMore: boolean; + page: number; + loadingMore: boolean; + }; + users: PublicUser[]; + playlists: Playlist[]; + }; + +export function Search() { + const [searchParams, setSearchParams] = useSearchParams(); + const q = searchParams.get("q") ?? ""; + const tab = (searchParams.get("tab") ?? "dumps") as Tab; + + const { token, user } = useAuth(); + const { voteCounts, myVotes, castVote, removeVote } = useWS(); + + const [state, setState] = useState({ status: "idle" }); + const abortRef = useRef(null); + + const fetchSearch = useCallback(async (query: string, page: number) => { + if (!query.trim()) { + setState({ status: "idle" }); + return; + } + + if (page === 1) { + setState({ status: "loading" }); + } + + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + try { + const url = new URL(SEARCH_URL); + url.searchParams.set("q", query); + url.searchParams.set("page", String(page)); + url.searchParams.set("limit", String(DEFAULT_PAGE_SIZE)); + + const res = await fetch(url.toString(), { + signal: controller.signal, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + + if (!res.ok) throw new Error(`Search failed (${res.status})`); + + const body = await res.json() as { + success: true; + data: { + dumps: { items: RawDump[]; total: number; hasMore: boolean }; + users: RawPublicUser[]; + playlists: RawPlaylist[]; + }; + }; + + const { data } = body; + + if (page === 1) { + setState({ + status: "loaded", + q: query, + dumps: { + items: data.dumps.items.map(deserializeDump), + total: data.dumps.total, + hasMore: data.dumps.hasMore, + page: 1, + loadingMore: false, + }, + users: data.users.map(deserializePublicUser), + playlists: data.playlists.map(deserializePlaylist), + }); + } else { + setState((prev) => { + if (prev.status !== "loaded") return prev; + return { + ...prev, + dumps: { + ...prev.dumps, + items: [...prev.dumps.items, ...data.dumps.items.map(deserializeDump)], + hasMore: data.dumps.hasMore, + page, + loadingMore: false, + }, + }; + }); + } + } catch (err) { + if ((err as Error).name === "AbortError") return; + setState({ status: "error", error: (err as Error).message }); + } + }, [token]); + + useEffect(() => { + fetchSearch(q, 1); + return () => abortRef.current?.abort(); + }, [q, fetchSearch]); + + const loadMore = useCallback(() => { + if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore) return; + setState((prev) => { + if (prev.status !== "loaded") return prev; + return { ...prev, dumps: { ...prev.dumps, loadingMore: true } }; + }); + fetchSearch(q, state.dumps.page + 1); + }, [state, q, fetchSearch]); + + const sentinelRef = useInfiniteScroll( + loadMore, + state.status === "loaded" && tab === "dumps" && state.dumps.hasMore && !state.dumps.loadingMore, + ); + + function setTab(t: Tab) { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set("tab", t); + return next; + }, { replace: true }); + } + + const dumpCount = state.status === "loaded" ? state.dumps.total : null; + const userCount = state.status === "loaded" ? state.users.length : null; + const playlistCount = state.status === "loaded" ? state.playlists.length : null; + + function tabLabel(t: Tab, count: number | null) { + const label = t === "dumps" ? "Dumps" : t === "users" ? "Users" : "Playlists"; + return count !== null ? `${label} (${count})` : label; + } + + return ( +
+ } /> +
+ {q && ( +
+ {(["dumps", "users", "playlists"] as Tab[]).map((t) => ( + + ))} +
+ )} + + {state.status === "idle" && ( +

Enter a query to search.

+ )} + + {state.status === "loading" && ( +

Searching…

+ )} + + {state.status === "error" && ( + + )} + + {state.status === "loaded" && tab === "dumps" && ( + <> + {state.dumps.items.length === 0 + ?

No dumps match "{q}".

+ : ( +
    + {state.dumps.items.map((dump) => ( + + ))} +
+ )} +
+ {state.dumps.loadingMore &&

Loading more…

} + {!state.dumps.hasMore && state.dumps.items.length > 0 && ( +

You've reached the end.

+ )} + + )} + + {state.status === "loaded" && tab === "users" && ( + state.users.length === 0 + ?

No users match "{q}".

+ : ( +
    + {state.users.map((u) => ( +
  • + + @{u.username} + {u.description && ( + {u.description} + )} + +
  • + ))} +
+ ) + )} + + {state.status === "loaded" && tab === "playlists" && ( + state.playlists.length === 0 + ?

No playlists match "{q}".

+ : ( +
    + {state.playlists.map((p) => ( + + ))} +
+ ) + )} +
+
+ ); +}