From 378b3ffa46e9745cbfcef5b521d0ee959516934e Mon Sep 17 00:00:00 2001 From: khannurien Date: Mon, 30 Mar 2026 14:55:30 +0000 Subject: [PATCH] v3: added onboarding email on account creation --- .env.example | 10 +++ api/config.ts | 36 +++++++--- api/lib/pagination.ts | 5 +- api/model/db.ts | 8 ++- api/model/interfaces.ts | 17 ++++- api/routes/search.ts | 6 +- api/routes/users.ts | 23 ++++++- api/services/dump-service.ts | 10 ++- api/services/email-service.ts | 4 +- api/services/providers/youtube.ts | 8 ++- api/services/user-service.ts | 14 ++-- api/services/ws-service.ts | 4 +- api/sql/schema.sql | 3 +- deno.json | 3 +- deno.lock | 6 ++ src/App.css | 111 ++++++++++++++++++++++++++++-- src/components/FeedTabBar.tsx | 7 +- src/components/PageShell.tsx | 4 +- src/components/SearchBar.tsx | 6 +- src/components/TextEditor.tsx | 4 +- src/components/UserMenu.tsx | 4 +- src/contexts/WSProvider.tsx | 4 +- src/model.ts | 3 + src/pages/Index.tsx | 6 +- src/pages/Search.tsx | 45 +++++++++--- src/pages/UserPublicProfile.tsx | 97 +++++++++++++++++++++++++- src/pages/UserRegister.tsx | 15 +++- 27 files changed, 404 insertions(+), 59 deletions(-) diff --git a/.env.example b/.env.example index 2fe6c67..dc3f3ae 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,16 @@ GERBEUR_JWT_SECRET= # Example: smtps://username:password@smtp.example.com:465 GERBEUR_SMTPS_URL= +# Sender address used in outgoing emails (e.g. no-reply@example.com) +# Required when GERBEUR_SMTPS_URL is set. +GERBEUR_FROM_EMAIL= + +# Markdown body for the account creation welcome email. +# Supports {{username}} and {{site_name}} placeholders. +# Defaults to a built-in template when unset. +# Use \n for line breaks in single-line .env values, or use a quoted multiline block. +GERBEUR_WELCOME_EMAIL_BODY="# Welcome to {{site_name}}!\n\nHi **{{username}}**,\n\nYour account has been created successfully. Welcome aboard!" + # Site name used in OG meta tags GERBEUR_SITE_NAME=gerbeur diff --git a/api/config.ts b/api/config.ts index af7f9cc..8e82a00 100644 --- a/api/config.ts +++ b/api/config.ts @@ -2,6 +2,17 @@ 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 FROM_EMAIL = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || ""; + +const DEFAULT_WELCOME_EMAIL_BODY = `# Welcome to {{site_name}}! + +Hi **{{username}}**, + +Your account has been created successfully. Welcome aboard!`; + +export const WELCOME_EMAIL_BODY = + Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || + DEFAULT_WELCOME_EMAIL_BODY; 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. @@ -26,7 +37,12 @@ export const ALLOWED_IMAGE_MIMES = new Set([ ]); 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_PREFIXES = [ + "text/", + "image/", + "video/", + "audio/", +]; export const DUMP_ALLOWED_MIME_TYPES = new Set([ "application/pdf", "application/json", @@ -67,11 +83,13 @@ export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "http://localhost:3000"; -export const ALLOWED_ORIGINS: string[] = Array.from(new Set([ - BASE_URL, - ...( - rawOrigins - ? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean) - : [] - ), -])); +export const ALLOWED_ORIGINS: string[] = Array.from( + new Set([ + BASE_URL, + ...( + rawOrigins + ? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean) + : [] + ), + ]), +); diff --git a/api/lib/pagination.ts b/api/lib/pagination.ts index ad830bd..74bea87 100644 --- a/api/lib/pagination.ts +++ b/api/lib/pagination.ts @@ -1,7 +1,4 @@ -import { - PAGINATION_DEFAULT_LIMIT, - PAGINATION_MAX_LIMIT, -} from "../config.ts"; +import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts"; /** * Parses page/limit query parameters with sensible defaults and bounds. diff --git a/api/model/db.ts b/api/model/db.ts index a86afd8..5bff035 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -46,7 +46,7 @@ if (userCount.count === 0) { const hash = scryptSync("admin", salt, 64).toString("hex"); const passwordHash = `${hash}.${salt}`; db.prepare( - `INSERT INTO users (id, username, password_hash, is_admin, created_at) VALUES (?, 'admin', ?, 1, datetime('now'))`, + `INSERT INTO users (id, username, password_hash, is_admin, created_at, email) VALUES (?, 'admin', ?, 1, datetime('now'), 'admin@localhost')`, ).run(crypto.randomUUID(), passwordHash); console.log("Created default admin user (username: admin, password: admin)"); } @@ -87,6 +87,7 @@ export interface UserRow { invited_by: string | null; // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by invited_by_username: string | null; + email: string; [key: string]: SQLOutputValue; // Index signature } @@ -136,7 +137,8 @@ export function isUserRow(obj: unknown): obj is UserRow { "description" in obj && (typeof obj.description === "string" || obj.description === null) && "invited_by" in obj && - (typeof obj.invited_by === "string" || obj.invited_by === null); + (typeof obj.invited_by === "string" || obj.invited_by === null) && + "email" in obj && typeof obj.email === "string"; } /** @@ -200,6 +202,7 @@ export function userRowToApi(row: UserRow): User { invitedByUsername: typeof row.invited_by_username === "string" ? row.invited_by_username : undefined, + email: row.email, }; } @@ -215,6 +218,7 @@ export function userApiToRow(user: User): UserRow { description: user.description ?? null, invited_by: null, invited_by_username: null, + email: user.email, }; } diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index 025a445..818e77e 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -48,6 +48,7 @@ export interface User { avatarMime?: string; description?: string; invitedByUsername?: string; + email: string; } export interface LoginUserRequest { @@ -59,6 +60,7 @@ export interface RegisterUserRequest { username: string; password: string; inviteToken: string; + email: string; } export interface UpdateUserRequest { @@ -66,6 +68,7 @@ export interface UpdateUserRequest { password?: string; isAdmin?: boolean; description?: string | null; + email?: string; } export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { @@ -86,9 +89,13 @@ export function validateRegisterUserRequest(obj: unknown): string | null { !obj || typeof obj !== "object" || !("username" in obj) || typeof obj.username !== "string" || !("password" in obj) || typeof obj.password !== "string" || - !("inviteToken" in obj) || typeof obj.inviteToken !== "string" + !("inviteToken" in obj) || typeof obj.inviteToken !== "string" || + !("email" in obj) || typeof obj.email !== "string" ) return "Invalid request"; - const { username, password } = obj as RegisterUserRequest; + const { username, password, email } = obj as RegisterUserRequest; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return "Invalid email address"; + } if ( !new RegExp( `^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, @@ -125,6 +132,10 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { "description" in o && typeof o.description !== "string" && o.description !== null ) return false; + if ("email" in o) { + if (typeof o.email !== "string") return false; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(o.email as string)) return false; + } if ( typeof o.description === "string" && (o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX @@ -517,7 +528,7 @@ export interface PlaylistDumpsUpdatedMessage { export interface UserUpdatedMessage { type: "user_updated"; - user: Omit; + user: Omit; } export interface CommentCreatedMessage { diff --git a/api/routes/search.ts b/api/routes/search.ts index 69b1978..280be50 100644 --- a/api/routes/search.ts +++ b/api/routes/search.ts @@ -37,7 +37,11 @@ router.get("/", async (ctx) => { ctx.response.body = { success: true, data: { - dumps: { items: dumpItems, total: dumpTotal, hasMore: page * limit < dumpTotal }, + dumps: { + items: dumpItems, + total: dumpTotal, + hasMore: page * limit < dumpTotal, + }, users, playlists, }, diff --git a/api/routes/users.ts b/api/routes/users.ts index 54c275b..58a101d 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -21,6 +21,9 @@ import { updateUser, } from "../services/user-service.ts"; import { redeemInvite, validateInvite } from "../services/invite-service.ts"; +import { isEmailEnabled, sendEmail } from "../services/email-service.ts"; +import { FROM_EMAIL, OG_SITE_NAME, WELCOME_EMAIL_BODY } from "../config.ts"; +import { marked } from "marked"; import { broadcastUserUpdated } from "../services/ws-service.ts"; import { getDumpsByUser, @@ -53,6 +56,20 @@ router.post("/register", async (ctx) => { console.error("[register] redeemInvite failed (user created):", err); } + // Send welcome email (fire-and-forget) + if (isEmailEnabled()) { + const emailMarkdown = WELCOME_EMAIL_BODY + .replaceAll("{{username}}", user.username) + .replaceAll("{{site_name}}", OG_SITE_NAME); + sendEmail({ + from: FROM_EMAIL, + to: user.email, + subject: `Welcome to ${OG_SITE_NAME}`, + text: emailMarkdown, + html: await marked(emailMarkdown), + }).catch((err) => console.error("[register] welcome email failed:", err)); + } + const authToken = await createJWT({ userId: user.id, username: user.username, @@ -153,7 +170,7 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => { ); } const updated = await updateUser(ctx.state.user.userId, body); - const { passwordHash: _, ...publicUser } = updated; + const { passwordHash: _, email: _email, ...publicUser } = updated; broadcastUserUpdated(publicUser); ctx.response.body = { success: true, data: publicUser }; }); @@ -168,7 +185,7 @@ router.get("/search", (ctx) => { // Public user profile by internal ID (used when only userId is available, e.g. dump.userId) router.get("/by-id/:userId", (ctx) => { const user = getUserById(ctx.params.userId); - const { passwordHash: _, ...publicUser } = user; + const { passwordHash: _, email: _email, ...publicUser } = user; ctx.response.body = { success: true, data: publicUser }; }); @@ -211,7 +228,7 @@ router.get("/:username/playlists", async (ctx) => { // Public user profile by username (no passwordHash) router.get("/:username", (ctx) => { const user = getUserByUsername(ctx.params.username); - const { passwordHash: _, ...publicUser } = user; + const { passwordHash: _, email: _email, ...publicUser } = user; ctx.response.body = { success: true, data: publicUser }; }); diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index b725287..ae14416 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -226,7 +226,10 @@ export function searchDumps( 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 }; + 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 ?;`, @@ -234,7 +237,10 @@ export function searchDumps( 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 }; + return { + items: rows.filter(isDumpRow).map(dumpRowToApi), + total: totalRow?.count ?? 0, + }; } } diff --git a/api/services/email-service.ts b/api/services/email-service.ts index c63f0d2..090b063 100644 --- a/api/services/email-service.ts +++ b/api/services/email-service.ts @@ -59,7 +59,9 @@ export async function verifyEmailTransport(): Promise { return true; } -export async function sendEmail(message: EmailMessage): Promise { +export async function sendEmail( + message: EmailMessage, +): Promise { if (normalizeRecipients(message.to).length === 0) { throw new Error("Email recipient is required."); } diff --git a/api/services/providers/youtube.ts b/api/services/providers/youtube.ts index 1709aad..404e918 100644 --- a/api/services/providers/youtube.ts +++ b/api/services/providers/youtube.ts @@ -56,8 +56,9 @@ async function fetchOEmbed( url: string, ): Promise<{ title?: string; thumbnailUrl?: string }> { try { - const oembedUrl = - `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; + 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 { @@ -122,7 +123,8 @@ export const youtubeProvider: RichContentProvider = { url, title, thumbnailUrl, - embedUrl: `https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`, + embedUrl: + `https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`, }; } diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 2eb27ef..5f8bb63 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -12,7 +12,7 @@ import { hashPassword } from "../lib/jwt.ts"; import { linkAttachments } from "./attachment-service.ts"; const USER_SELECT = - `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, + `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u LEFT JOIN users i ON i.id = u.invited_by`; @@ -39,8 +39,8 @@ export async function createUser( const passwordHash = await hashPassword(request.password); db.prepare( - `INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by) - VALUES (?, ?, ?, ?, ?, ?);`, + `INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by, email) + VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( userId, request.username, @@ -48,6 +48,7 @@ export async function createUser( 0, createdAt.toISOString(), inviterId, + request.email, ); return { @@ -56,6 +57,7 @@ export async function createUser( passwordHash, isAdmin: false, createdAt, + email: request.email, }; } @@ -129,7 +131,7 @@ export async function updateUser( ): Promise { const user = getUserById(userId); - const { password, description, ...requestFields } = request; + const { password, description, email, ...requestFields } = request; const now = new Date(); const updatedUser: User = { @@ -137,18 +139,20 @@ export async function updateUser( passwordHash: password ? await hashPassword(password) : user.passwordHash, ...requestFields, description: description ?? user.description, + email: email ?? user.email, updatedAt: now, }; const updatedUserRow = userApiToRow(updatedUser); const userResult = db.prepare( - `UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, updated_at = ? WHERE id = ?`, + `UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, email = ?, updated_at = ? WHERE id = ?`, ).run( updatedUserRow.username, updatedUserRow.password_hash, updatedUserRow.is_admin, updatedUserRow.description, + updatedUserRow.email, now.toISOString(), updatedUserRow.id, ); diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index ebf76a7..48fda55 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -163,7 +163,9 @@ export function broadcastPlaylistDumpsUpdated( }); } -export function broadcastUserUpdated(user: Omit): void { +export function broadcastUserUpdated( + user: Omit, +): void { for (const client of clients) { send(client.socket, { type: "user_updated", user }); } diff --git a/api/sql/schema.sql b/api/sql/schema.sql index 2fb6afd..69db838 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -26,7 +26,8 @@ CREATE TABLE users ( updated_at TEXT, avatar_mime TEXT, description TEXT, - invited_by TEXT REFERENCES users(id) + invited_by TEXT REFERENCES users(id), + email TEXT NOT NULL ); CREATE TABLE votes ( diff --git a/deno.json b/deno.json index 136ab3c..e89a09c 100644 --- a/deno.json +++ b/deno.json @@ -25,6 +25,7 @@ "@oak/oak": "jsr:@oak/oak@^17.2.0", "@panva/jose": "jsr:@panva/jose@^6.2.1", "@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1", - "nodemailer": "npm:nodemailer@^8.0.4" + "nodemailer": "npm:nodemailer@^8.0.4", + "marked": "npm:marked@^15.0.0" } } diff --git a/deno.lock b/deno.lock index fd1a028..a032679 100644 --- a/deno.lock +++ b/deno.lock @@ -31,6 +31,7 @@ "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:marked@15": "15.0.12", "npm:nodemailer@*": "8.0.4", "npm:nodemailer@^8.0.4": "8.0.4", "npm:path-to-regexp@^6.3.0": "6.3.0", @@ -1211,6 +1212,10 @@ "markdown-table@3.0.4": { "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==" }, + "marked@15.0.12": { + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "bin": true + }, "mdast-util-find-and-replace@3.0.2": { "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "dependencies": [ @@ -2054,6 +2059,7 @@ "jsr:@oak/oak@^17.2.0", "jsr:@panva/jose@^6.2.1", "jsr:@tajpouria/cors@^1.2.1", + "npm:marked@15", "npm:nodemailer@^8.0.4" ], "packageJson": { diff --git a/src/App.css b/src/App.css index e637165..c09b6f8 100644 --- a/src/App.css +++ b/src/App.css @@ -1144,7 +1144,7 @@ body.has-player .fab-new { /* ── Public profile page ── */ .profile-header { display: flex; - align-items: center; + align-items: flex-start; gap: 1.5rem; } @@ -1262,6 +1262,100 @@ body.has-player .fab-new { gap: 0.5rem; } +.profile-email-display { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.78rem; + color: var(--color-text-muted); + margin: 0.1rem 0 0.4rem; + cursor: pointer; + border-radius: 4px; + padding: 0.1rem 0.2rem; + margin-left: -0.2rem; +} + +.profile-email-display:hover { + background: var(--color-surface); +} + +.profile-email-display:hover .profile-description-edit-btn { + opacity: 1; +} + +.profile-email-editor { + margin: 0.2rem 0 0.4rem; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.profile-email-input { + width: min(260px, 100%); + padding: 0.35rem 0.6rem; + border-radius: 6px; + border: 2px solid var(--color-border); + background-color: var(--color-bg); + color: var(--color-text); + font-size: 0.85rem; + font-family: inherit; + line-height: 1.5; + transition: border-color 0.2s, box-shadow 0.2s; + outline: none; +} + +.profile-email-input:focus { + border-color: var(--color-accent); +} + +.profile-email-actions { + display: flex; + gap: 0.4rem; +} + +.profile-email-btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 1.75rem; + padding: 0 0.65rem; + border-radius: 5px; + border: 1.5px solid; + font-size: 0.8rem; + font-family: inherit; + font-weight: 500; + cursor: pointer; + box-sizing: border-box; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} + +.profile-email-btn--save { + background: var(--color-accent); + color: var(--color-on-accent); + border-color: var(--color-accent); +} + +.profile-email-btn--save:hover:not(:disabled) { + background: var(--color-accent-hover); + border-color: var(--color-accent-hover); +} + +.profile-email-btn--save:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.profile-email-btn--cancel { + background: transparent; + color: var(--color-text-muted); + border-color: var(--color-border); +} + +.profile-email-btn--cancel:hover { + color: var(--color-text); + border-color: var(--color-text-muted); +} + .profile-description-actions { display: flex; align-items: center; @@ -1394,12 +1488,16 @@ body.has-player .fab-new { .btn-border-danger, .btn-border-success, .btn-border { + display: inline-flex; + align-items: center; + justify-content: center; padding: 0.3rem 0.9rem; border: 1.5px solid var(--color-border); border-radius: 6px; background: transparent; color: var(--color-text-muted); font-size: 0.85rem; + font-family: inherit; cursor: pointer; transition: border-color 0.15s, color 0.15s, background 0.15s; } @@ -1594,7 +1692,8 @@ body.has-player .fab-new { align-items: center; justify-content: center; min-width: 0; - overflow: hidden; + overflow-x: clip; + overflow-y: visible; container-type: inline-size; } @@ -3729,8 +3828,12 @@ body.has-player .fab-new { 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; + 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; } diff --git a/src/components/FeedTabBar.tsx b/src/components/FeedTabBar.tsx index 70f87ff..9aa0d4a 100644 --- a/src/components/FeedTabBar.tsx +++ b/src/components/FeedTabBar.tsx @@ -2,7 +2,12 @@ import { useLocation, useNavigate } from "react-router"; import { useAuth } from "../hooks/useAuth.ts"; export type FeedTab = "hot" | "new" | "journal" | "followed"; -export const VALID_TABS = new Set(["hot", "new", "journal", "followed"]); +export const VALID_TABS = new Set([ + "hot", + "new", + "journal", + "followed", +]); export function FeedTabBar() { const location = useLocation(); diff --git a/src/components/PageShell.tsx b/src/components/PageShell.tsx index a775fb6..9219a3b 100644 --- a/src/components/PageShell.tsx +++ b/src/components/PageShell.tsx @@ -8,7 +8,9 @@ interface PageShellProps { centerSlot?: ReactNode; } -export function PageShell({ children, centered = false, centerSlot }: PageShellProps) { +export function PageShell( + { children, centered = false, centerSlot }: PageShellProps, +) { return (
} /> diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 923e48e..2edefa8 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -14,7 +14,7 @@ export function SearchBar({ collapsible = false }: SearchBarProps) { const navigate = useNavigate(); useEffect(() => { - if (expanded) inputRef.current?.focus(); + if (collapsible && expanded) inputRef.current?.focus(); }, [expanded]); function handleIconClick() { @@ -47,7 +47,9 @@ export function SearchBar({ collapsible = false }: SearchBarProps) { return (
diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index b537241..87cdd37 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -174,7 +174,9 @@ export const TextEditor = forwardRef( return (