From c5051e3485cb81a2402b878e2ee19a143c87d779 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 22 Mar 2026 20:24:29 +0000 Subject: [PATCH] v3: added emoji picker, various bug and layout fixes --- api/main.ts | 16 ++++++ api/model/db.ts | 31 ------------ api/model/interfaces.ts | 1 + api/routes/avatars.ts | 67 +++++-------------------- api/routes/files.ts | 3 +- api/routes/playlists.ts | 75 ++++++++-------------------- api/services/dump-service.ts | 12 ++--- api/services/playlist-service.ts | 14 +++--- api/services/user-service.ts | 2 +- api/services/ws-service.ts | 4 ++ api/utils/upload.ts | 57 +++++++++++++++++++++ deno.lock | 12 +++++ package.json | 1 + src/App.css | 72 ++++++++++++++++++++++++++- src/components/Avatar.tsx | 8 ++- src/components/CommentThread.tsx | 12 ++--- src/components/DumpCreateModal.tsx | 3 +- src/components/TextEditor.tsx | 78 ++++++++++++++++++++++++++++- src/config/upload.ts | 1 + src/hooks/useEmojiTrigger.ts | 79 ++++++++++++++++++++++++++++++ src/model.ts | 1 + src/pages/DumpCreate.tsx | 3 +- src/pages/Index.tsx | 1 + src/pages/UserPublicProfile.tsx | 8 ++- 24 files changed, 384 insertions(+), 177 deletions(-) create mode 100644 api/utils/upload.ts create mode 100644 src/config/upload.ts create mode 100644 src/hooks/useEmojiTrigger.ts diff --git a/api/main.ts b/api/main.ts index 3438cf5..18c95b0 100644 --- a/api/main.ts +++ b/api/main.ts @@ -16,6 +16,8 @@ import invitesRouter from "./routes/invites.ts"; import { BASE_URL, HOSTNAME, PORT } from "./config.ts"; import { errorMiddleware } from "./middleware/error.ts"; import routeStaticFilesFrom from "./lib/static.ts"; +import { DUMPS_DIR, UPLOADS_DIR } from "./utils/upload.ts"; +import { UUID_RE } from "./lib/slugify.ts"; const app = new Application(); @@ -80,7 +82,21 @@ app.addEventListener( (e) => console.log(`Uncaught error: ${e.message}`), ); +// Migrate dump files from uploads root to uploads/dumps subfolder +async function migrateDumpFiles() { + await Deno.mkdir(DUMPS_DIR, { recursive: true }); + for await (const entry of Deno.readDir(UPLOADS_DIR)) { + if (entry.isFile && UUID_RE.test(entry.name)) { + await Deno.rename( + `${UPLOADS_DIR}/${entry.name}`, + `${DUMPS_DIR}/${entry.name}`, + ).catch(() => {}); + } + } +} + if (import.meta.main) { + await migrateDumpFiles(); await app.listen({ hostname: HOSTNAME, port: PORT }); } diff --git a/api/model/db.ts b/api/model/db.ts index b09a1b0..e80760c 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -8,41 +8,10 @@ import { type RichContent, type User, } from "./interfaces.ts"; -import { makeSlug } from "../lib/slugify.ts"; export const db = new DatabaseSync("api/sql/gerbeur.db"); db.exec("PRAGMA foreign_keys = ON;"); -// Add columns to existing tables if missing (idempotent migrations) -for ( - const [table, col, def] of [ - ["dumps", "updated_at", "TEXT"], - ["users", "updated_at", "TEXT"], - ["playlists", "updated_at", "TEXT"], - ["comments", "updated_at", "TEXT"], - ["dumps", "slug", "TEXT"], - ["playlists", "slug", "TEXT"], - ] as [string, string, string][] -) { - const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { - name: string; - }[]; - if (!cols.some((c) => c.name === col)) { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def};`); - } -} - -// Backfill slugs for any records created before this migration -for (const table of ["dumps", "playlists"] as const) { - const rows = db.prepare( - `SELECT id, title FROM ${table} WHERE slug IS NULL;`, - ).all() as { id: string; title: string }[]; - const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?;`); - for (const row of rows) { - update.run(makeSlug(row.title, row.id), row.id); - } -} - // Purge expired unused invites on startup db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-7 days');`, diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index d91be12..be23686 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -363,6 +363,7 @@ export interface OnlineUser { userId: string; username: string; hasAvatar: boolean; + avatarVersion?: number; } export interface WelcomeMessage { diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index d14cd38..1635c87 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -3,35 +3,12 @@ import { authMiddleware } from "../middleware/auth.ts"; import { getUserById, updateUserAvatar } from "../services/user-service.ts"; import { updateClientAvatar } from "../services/ws-service.ts"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; - -const AVATARS_DIR = "api/uploads/avatars"; -const MAX_AVATAR_SIZE = 5 * 1024 * 1024; // 5 MB -const ALLOWED_AVATAR_MIMES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]); - -// Magic bytes for image validation -const MAGIC: Array<{ mime: string; bytes: number[]; offset?: number }> = [ - { mime: "image/jpeg", bytes: [0xFF, 0xD8, 0xFF] }, - { mime: "image/png", bytes: [0x89, 0x50, 0x4E, 0x47] }, - { mime: "image/gif", bytes: [0x47, 0x49, 0x46, 0x38] }, - { mime: "image/webp", bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 }, // RIFF -]; - -function checkMagicBytes(data: Uint8Array, mime: string): boolean { - if (mime === "image/webp") { - // RIFF....WEBP - return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && - data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && - data[10] === 0x42 && data[11] === 0x50; - } - const entry = MAGIC.find((m) => m.mime === mime); - if (!entry) return false; - return entry.bytes.every((b, i) => data[i] === b); -} +import { + AVATARS_DIR, + detectImageMime, + MAX_IMAGE_SIZE, + serveUploadedFile, +} from "../utils/upload.ts"; const router = new Router(); @@ -53,15 +30,7 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); } - if (!ALLOWED_AVATAR_MIMES.has(file.type)) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "Only JPEG, PNG, GIF, WebP images are allowed", - ); - } - - if (file.size > MAX_AVATAR_SIZE) { + if (file.size > MAX_IMAGE_SIZE) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, @@ -71,18 +40,19 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => { const data = new Uint8Array(await file.arrayBuffer()); - if (!checkMagicBytes(data, file.type)) { + const mime = detectImageMime(data); + if (!mime) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, - "File content does not match declared type", + "File content is not a recognised image (JPEG, PNG, GIF, WebP)", ); } await Deno.mkdir(AVATARS_DIR, { recursive: true }); await Deno.writeFile(`${AVATARS_DIR}/${authPayload.userId}`, data); - updateUserAvatar(authPayload.userId, file.type); - updateClientAvatar(authPayload.userId, file.type); + updateUserAvatar(authPayload.userId, mime); + updateClientAvatar(authPayload.userId, mime); const user = getUserById(authPayload.userId); ctx.response.status = 200; @@ -105,18 +75,7 @@ router.get("/api/avatars/:userId", async (ctx) => { return; } - let data: Uint8Array; - try { - data = await Deno.readFile(`${AVATARS_DIR}/${userId}`); - } catch { - ctx.response.status = 404; - return; - } - - ctx.response.headers.set("Content-Type", user.avatarMime); - ctx.response.headers.set("Content-Disposition", "inline"); - ctx.response.headers.set("Cache-Control", "public, max-age=3600"); - ctx.response.body = data; + await serveUploadedFile(ctx, `${AVATARS_DIR}/${userId}`, user.avatarMime); }); export default router; diff --git a/api/routes/files.ts b/api/routes/files.ts index 0b78590..1fb50c9 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -1,6 +1,7 @@ import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { getDump } from "../services/dump-service.ts"; +import { DUMPS_DIR } from "../utils/upload.ts"; const router = new Router({ prefix: "/api/files" }); @@ -22,7 +23,7 @@ router.get("/:dumpId", async (ctx) => { ); } - const path = `api/uploads/${dumpId}`; + const path = `${DUMPS_DIR}/${dumpId}`; let data: Uint8Array; try { diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index 33df4d6..caf23d3 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -13,36 +13,19 @@ import { createPlaylist, deletePlaylist, getPlaylist, - getPlaylistImageMime, + getPlaylistImageInfo, getPlaylistMembershipsForDump, removeDumpFromPlaylist, reorderPlaylist, setPlaylistImage, updatePlaylist, } from "../services/playlist-service.ts"; - -const PLAYLIST_IMAGES_DIR = "api/uploads/playlist-images"; -const MAX_IMAGE_SIZE = 5 * 1024 * 1024; -const ALLOWED_IMAGE_MIMES = new Set([ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]); - -function checkImageMagicBytes(data: Uint8Array, mime: string): boolean { - if (mime === "image/webp") { - return data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && - data[3] === 0x46 && data[8] === 0x57 && data[9] === 0x45 && - data[10] === 0x42 && data[11] === 0x50; - } - const magic: Record = { - "image/jpeg": [0xFF, 0xD8, 0xFF], - "image/png": [0x89, 0x50, 0x4E, 0x47], - "image/gif": [0x47, 0x49, 0x46, 0x38], - }; - return (magic[mime] ?? []).every((b, i) => data[i] === b); -} +import { + detectImageMime, + MAX_IMAGE_SIZE, + PLAYLIST_IMAGES_DIR, + serveUploadedFile, +} from "../utils/upload.ts"; const router = new Router({ prefix: "/api/playlists" }); @@ -143,14 +126,6 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Missing file field"); } - if (!ALLOWED_IMAGE_MIMES.has(file.type)) { - throw new APIException( - APIErrorCode.BAD_REQUEST, - 400, - "Only JPEG, PNG, GIF, WebP images are allowed", - ); - } - if (file.size > MAX_IMAGE_SIZE) { throw new APIException( APIErrorCode.BAD_REQUEST, @@ -160,46 +135,38 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => { } const data = new Uint8Array(await file.arrayBuffer()); - if (!checkImageMagicBytes(data, file.type)) { + const mime = detectImageMime(data); + if (!mime) { throw new APIException( APIErrorCode.BAD_REQUEST, 400, - "File content does not match declared type", + "File content is not a recognised image (JPEG, PNG, GIF, WebP)", ); } - await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); - await Deno.writeFile(`${PLAYLIST_IMAGES_DIR}/${ctx.params.playlistId}`, data); + // Resolve slug → UUID via service (validates ownership too), then write file const playlist = setPlaylistImage( ctx.params.playlistId, - file.type, + mime, ctx.state.user.userId, ); + await Deno.mkdir(PLAYLIST_IMAGES_DIR, { recursive: true }); + await Deno.writeFile(`${PLAYLIST_IMAGES_DIR}/${playlist.id}`, data); ctx.response.body = { success: true, data: playlist }; }); // GET /api/playlists/:playlistId/image — serve playlist image router.get("/:playlistId/image", async (ctx) => { - const imageMime = getPlaylistImageMime(ctx.params.playlistId); - if (!imageMime) { + const info = getPlaylistImageInfo(ctx.params.playlistId); + if (!info) { ctx.response.status = 404; return; } - - let data: Uint8Array; - try { - data = await Deno.readFile( - `${PLAYLIST_IMAGES_DIR}/${ctx.params.playlistId}`, - ); - } catch { - ctx.response.status = 404; - return; - } - - ctx.response.headers.set("Content-Type", imageMime); - ctx.response.headers.set("Content-Disposition", "inline"); - ctx.response.headers.set("Cache-Control", "public, max-age=3600"); - ctx.response.body = data; + await serveUploadedFile( + ctx, + `${PLAYLIST_IMAGES_DIR}/${info.id}`, + info.imageMime, + ); }); // PUT /api/playlists/:playlistId/order — reorder diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index ac369a1..6be4354 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -17,8 +17,8 @@ import { notifyUserFollowersNewDump, } from "./notification-service.ts"; import { makeSlug, UUID_RE } from "../lib/slugify.ts"; +import { DUMPS_DIR } from "../utils/upload.ts"; -const UPLOADS_DIR = "api/uploads"; const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB const ALLOWED_MIME_PREFIXES = ["text/", "image/", "video/", "audio/"]; @@ -138,11 +138,11 @@ export async function createFileDump( const createdAt = new Date(); const slug = makeSlug(file.name, dumpId); - await Deno.mkdir(UPLOADS_DIR, { recursive: true }); + await Deno.mkdir(DUMPS_DIR, { recursive: true }); const data = new Uint8Array(await file.arrayBuffer()); try { - await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data); + await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, data); db.prepare( `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, file_name, file_mime, file_size, is_private) @@ -162,7 +162,7 @@ export async function createFileDump( ); } catch (err) { // Roll back the file if DB insert fails - await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {}); + await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); throw err; } @@ -374,7 +374,7 @@ export async function replaceFileDump( } const data = new Uint8Array(await file.arrayBuffer()); - await Deno.writeFile(`${UPLOADS_DIR}/${dumpId}`, data); + await Deno.writeFile(`${DUMPS_DIR}/${dumpId}`, data); const now = new Date(); const newSlug = makeSlug(file.name, dumpId); @@ -513,7 +513,7 @@ export async function deleteDump(dumpId: string): Promise { } if (dump.kind === "file") { - await Deno.remove(`${UPLOADS_DIR}/${dumpId}`).catch(() => {}); + await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); } broadcastDumpDeleted(dumpId); diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index afec7e7..cbfebe4 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -356,13 +356,13 @@ export function getPlaylistMembershipsForDump( }); } -export function getPlaylistImageMime(playlistId: string): string | undefined { - const row = db.prepare(`SELECT image_mime FROM playlists WHERE id = ?;`).get( - playlistId, - ) as - | { image_mime: string | null } - | undefined; - return row?.image_mime ?? undefined; +export function getPlaylistImageInfo( + idOrSlug: string, +): { id: string; imageMime: string } | undefined { + const playlist = getPlaylistById(idOrSlug); + return playlist.imageMime + ? { id: playlist.id, imageMime: playlist.imageMime } + : undefined; } function getCurrentDumpIds(playlistId: string): string[] { diff --git a/api/services/user-service.ts b/api/services/user-service.ts index d7a9c54..c47c1db 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -10,7 +10,7 @@ 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, + `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_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`; diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index 320d50c..49ac095 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -10,6 +10,7 @@ export interface WsClient { userId?: string; username?: string; avatarMime?: string; + avatarVersion?: number; } const clients = new Set(); @@ -23,9 +24,11 @@ export function unregister(client: WsClient): void { } export function updateClientAvatar(userId: string, avatarMime: string): void { + const version = Date.now(); for (const client of clients) { if (client.userId === userId) { client.avatarMime = avatarMime; + client.avatarVersion = version; } } broadcastPresence(); @@ -39,6 +42,7 @@ export function getOnlineUsers(): OnlineUser[] { userId: client.userId, username: client.username!, hasAvatar: !!client.avatarMime, + avatarVersion: client.avatarVersion, }); } } diff --git a/api/utils/upload.ts b/api/utils/upload.ts new file mode 100644 index 0000000..249fe9a --- /dev/null +++ b/api/utils/upload.ts @@ -0,0 +1,57 @@ +import type { Context } from "@oak/oak"; + +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 MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5 MB + +export const ALLOWED_IMAGE_MIMES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]); + +/** Detect image MIME type from magic bytes, ignoring the browser-declared type. */ +export function detectImageMime(data: Uint8Array): string | null { + if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) { + return "image/jpeg"; + } + if ( + data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4E && data[3] === 0x47 + ) return "image/png"; + if ( + data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38 + ) return "image/gif"; + // RIFF....WEBP + if ( + data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && + data[3] === 0x46 && + data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && + data[11] === 0x50 + ) return "image/webp"; + return null; +} + +export async function serveUploadedFile( + ctx: Context, + filePath: string, + mime: string, + cacheMaxAge = 3600, +): Promise { + let data: Uint8Array; + try { + data = await Deno.readFile(filePath); + } catch { + ctx.response.status = 404; + return false; + } + ctx.response.headers.set("Content-Type", mime); + ctx.response.headers.set("Content-Disposition", "inline"); + ctx.response.headers.set("Cache-Control", `public, max-age=${cacheMaxAge}`); + ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); + ctx.response.body = data; + return true; +} diff --git a/deno.lock b/deno.lock index 89b225e..8842ce6 100644 --- a/deno.lock +++ b/deno.lock @@ -29,6 +29,7 @@ "npm:eslint-plugin-react-hooks@^7.0.1": "7.0.1_eslint@9.39.4", "npm:eslint-plugin-react-refresh@~0.5.2": "0.5.2_eslint@9.39.4", "npm:eslint@^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:path-to-regexp@^6.3.0": "6.3.0", "npm:react-dom@^19.2.4": "19.2.4_react@19.2.4", @@ -952,6 +953,16 @@ "flatted@3.4.2": { "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==" }, + "frimousse@0.3.0_react@19.2.4_typescript@5.9.3": { + "integrity": "sha512-kO6LMoKY/cLAYEhXXtqLRaLIE6L/DagpFPrUZaLv3LsUa1/8Iza3HhwZcgN8eZ+weXnhv69eoclNUPohcCa/IQ==", + "dependencies": [ + "react", + "typescript" + ], + "optionalPeers": [ + "typescript" + ] + }, "fsevents@2.3.3": { "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "os": ["darwin"], @@ -2050,6 +2061,7 @@ "npm:eslint-plugin-react-hooks@^7.0.1", "npm:eslint-plugin-react-refresh@~0.5.2", "npm:eslint@^9.39.4", + "npm:frimousse@0.3", "npm:globals@^17.4.0", "npm:react-dom@^19.2.4", "npm:react-markdown@^10.1.0", diff --git a/package.json b/package.json index 6b4b9f0..094ef8d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@deno/vite-plugin": "^1.0.6", "@types/react": "^19.2.14", "@vitejs/plugin-react": "^6.0.1", + "frimousse": "^0.3.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", diff --git a/src/App.css b/src/App.css index 11e3619..ca7eba2 100644 --- a/src/App.css +++ b/src/App.css @@ -2722,8 +2722,8 @@ body.has-player .fab-new { } .comment-replies { - padding-left: 1.25rem; - margin-left: 1.1rem; + padding-left: max(0.4rem, calc(1.25rem - var(--depth, 0) * 0.1rem)); + margin-left: max(0.25rem, calc(1.1rem - var(--depth, 0) * 0.09rem)); margin-top: 0.35rem; border-left: 2px solid color-mix(in srgb, var(--color-accent) 30%, transparent); @@ -3193,6 +3193,74 @@ body.has-player .fab-new { font-size: 0.9rem; color: var(--color-text); } + +/* ── Emoji picker (frimousse) ── */ +.emoji-picker-float { + position: absolute; + bottom: calc(100% + 4px); + left: 0; + z-index: 201; + width: 320px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); + overflow: hidden; +} + +.emoji-picker-float input { + display: block; + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border: none; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.9rem; + outline: none; +} +.emoji-picker-float input::placeholder { + color: var(--color-text-muted, #888); +} +/* frimousse uses bare attributes (no data- prefix) */ +.emoji-picker-float [frimousse-viewport] { + max-height: 220px; + /* frimousse already sets overflow-y: auto inline */ +} +.emoji-picker-float [frimousse-category-header] { + padding: 4px 8px 2px; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted, #888); + background: var(--color-surface); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.emoji-picker-float [frimousse-emoji] { + flex: 0 0 calc(100% / var(--frimousse-list-columns, 9)); + background: none; + border: none; + padding: 4px 0; + font-size: 1.35em; + line-height: 1; + cursor: pointer; + border-radius: 4px; + transition: background 0.1s; +} +.emoji-picker-float [frimousse-emoji]:hover, +.emoji-picker-float [frimousse-emoji][data-active] { + background: var(--color-bg); +} +.emoji-picker-float [frimousse-loading], +.emoji-picker-float [frimousse-empty] { + display: block; + padding: 16px; + text-align: center; + font-size: 0.85rem; + color: var(--color-text-muted, #888); +} + .notif-icon--mention { font-weight: 700; font-family: monospace; diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 587d586..386d868 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -6,18 +6,22 @@ interface AvatarProps { username: string; hasAvatar: boolean; size?: number; + version?: number; } export function Avatar( - { userId, username, hasAvatar, size = 36 }: AvatarProps, + { userId, username, hasAvatar, size = 36, version }: AvatarProps, ) { const [imgFailed, setImgFailed] = useState(false); const sizeStyle = { width: size, height: size }; if (hasAvatar && !imgFailed) { + const src = version + ? `${API_URL}/api/avatars/${userId}?v=${version}` + : `${API_URL}/api/avatars/${userId}`; return ( {username} { return map; } -const MAX_INDENT_DEPTH = 6; - interface CommentNodeProps { comment: Comment; tree: Map; @@ -161,9 +159,7 @@ function CommentNode({ {children.length > 0 && (
    = MAX_INDENT_DEPTH - ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } - : undefined} + style={{ "--depth": depth } as React.CSSProperties} > {children.map((child) => ( 0 && (
      = MAX_INDENT_DEPTH - ? { paddingLeft: 0, marginLeft: 0, borderLeft: "none" } - : undefined} + style={{ "--depth": depth } as React.CSSProperties} > {children.map((child) => ( ( ref, ) { const textareaRef = useRef(null); + const emojiViewportRef = useRef(null); + const emojiSearchRef = useRef(null); useImperativeHandle(ref, () => ({ focus: () => textareaRef.current?.focus(), @@ -48,6 +52,20 @@ export const TextEditor = forwardRef( handleMentionSelect, } = useMentionAutocomplete(value, onChange, textareaRef); + const { + emojiOpen, + emojiQuery, + detectEmojiTrigger, + handleEmojiSelect, + closeEmoji, + } = useEmojiTrigger(value, onChange, textareaRef); + + useEffect(() => { + if (emojiOpen) { + emojiViewportRef.current?.focus({ preventScroll: true }); + } + }, [emojiOpen]); + useEffect(() => { if (!autoResize) return; const el = textareaRef.current; @@ -61,7 +79,10 @@ export const TextEditor = forwardRef(