v3: added onboarding email on account creation

This commit is contained in:
khannurien
2026-03-30 14:55:30 +00:00
parent cbb3505139
commit 378b3ffa46
27 changed files with 404 additions and 59 deletions

View File

@@ -27,6 +27,16 @@ GERBEUR_JWT_SECRET=
# Example: smtps://username:password@smtp.example.com:465 # Example: smtps://username:password@smtp.example.com:465
GERBEUR_SMTPS_URL= 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 # Site name used in OG meta tags
GERBEUR_SITE_NAME=gerbeur GERBEUR_SITE_NAME=gerbeur

View File

@@ -2,6 +2,17 @@ export const PROTOCOL = Deno.env.get("GERBEUR_PROTOCOL") || "http";
export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost"; export const HOSTNAME = Deno.env.get("GERBEUR_HOSTNAME") || "localhost";
export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000; export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || ""; 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() || ""; export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || "";
// GERBEUR_LISTEN_HOST controls the network interface Oak binds to. // 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. // 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_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([ export const DUMP_ALLOWED_MIME_TYPES = new Set([
"application/pdf", "application/pdf",
"application/json", "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") ?? const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ??
"http://localhost:3000"; "http://localhost:3000";
export const ALLOWED_ORIGINS: string[] = Array.from(new Set([ export const ALLOWED_ORIGINS: string[] = Array.from(
new Set([
BASE_URL, BASE_URL,
...( ...(
rawOrigins rawOrigins
? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean) ? rawOrigins.split(",").map((o) => o.trim()).filter(Boolean)
: [] : []
), ),
])); ]),
);

View File

@@ -1,7 +1,4 @@
import { import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts";
PAGINATION_DEFAULT_LIMIT,
PAGINATION_MAX_LIMIT,
} from "../config.ts";
/** /**
* Parses page/limit query parameters with sensible defaults and bounds. * Parses page/limit query parameters with sensible defaults and bounds.

View File

@@ -46,7 +46,7 @@ if (userCount.count === 0) {
const hash = scryptSync("admin", salt, 64).toString("hex"); const hash = scryptSync("admin", salt, 64).toString("hex");
const passwordHash = `${hash}.${salt}`; const passwordHash = `${hash}.${salt}`;
db.prepare( 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); ).run(crypto.randomUUID(), passwordHash);
console.log("Created default admin user (username: admin, password: admin)"); console.log("Created default admin user (username: admin, password: admin)");
} }
@@ -87,6 +87,7 @@ export interface UserRow {
invited_by: string | null; invited_by: string | null;
// Present only when joined: LEFT JOIN users i ON i.id = u.invited_by // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by
invited_by_username: string | null; invited_by_username: string | null;
email: string;
[key: string]: SQLOutputValue; // Index signature [key: string]: SQLOutputValue; // Index signature
} }
@@ -136,7 +137,8 @@ export function isUserRow(obj: unknown): obj is UserRow {
"description" in obj && "description" in obj &&
(typeof obj.description === "string" || obj.description === null) && (typeof obj.description === "string" || obj.description === null) &&
"invited_by" in obj && "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" invitedByUsername: typeof row.invited_by_username === "string"
? row.invited_by_username ? row.invited_by_username
: undefined, : undefined,
email: row.email,
}; };
} }
@@ -215,6 +218,7 @@ export function userApiToRow(user: User): UserRow {
description: user.description ?? null, description: user.description ?? null,
invited_by: null, invited_by: null,
invited_by_username: null, invited_by_username: null,
email: user.email,
}; };
} }

View File

@@ -48,6 +48,7 @@ export interface User {
avatarMime?: string; avatarMime?: string;
description?: string; description?: string;
invitedByUsername?: string; invitedByUsername?: string;
email: string;
} }
export interface LoginUserRequest { export interface LoginUserRequest {
@@ -59,6 +60,7 @@ export interface RegisterUserRequest {
username: string; username: string;
password: string; password: string;
inviteToken: string; inviteToken: string;
email: string;
} }
export interface UpdateUserRequest { export interface UpdateUserRequest {
@@ -66,6 +68,7 @@ export interface UpdateUserRequest {
password?: string; password?: string;
isAdmin?: boolean; isAdmin?: boolean;
description?: string | null; description?: string | null;
email?: string;
} }
export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
@@ -86,9 +89,13 @@ export function validateRegisterUserRequest(obj: unknown): string | null {
!obj || typeof obj !== "object" || !obj || typeof obj !== "object" ||
!("username" in obj) || typeof obj.username !== "string" || !("username" in obj) || typeof obj.username !== "string" ||
!("password" in obj) || typeof obj.password !== "string" || !("password" in obj) || typeof obj.password !== "string" ||
!("inviteToken" in obj) || typeof obj.inviteToken !== "string" !("inviteToken" in obj) || typeof obj.inviteToken !== "string" ||
!("email" in obj) || typeof obj.email !== "string"
) return "Invalid request"; ) 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 ( if (
!new RegExp( !new RegExp(
`^[a-zA-Z0-9_]{${VALIDATION.USERNAME_MIN},${VALIDATION.USERNAME_MAX}}$`, `^[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" && "description" in o && typeof o.description !== "string" &&
o.description !== null o.description !== null
) return false; ) 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 ( if (
typeof o.description === "string" && typeof o.description === "string" &&
(o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX (o.description as string).length > VALIDATION.USER_DESCRIPTION_MAX
@@ -517,7 +528,7 @@ export interface PlaylistDumpsUpdatedMessage {
export interface UserUpdatedMessage { export interface UserUpdatedMessage {
type: "user_updated"; type: "user_updated";
user: Omit<User, "passwordHash">; user: Omit<User, "passwordHash" | "email">;
} }
export interface CommentCreatedMessage { export interface CommentCreatedMessage {

View File

@@ -37,7 +37,11 @@ router.get("/", async (ctx) => {
ctx.response.body = { ctx.response.body = {
success: true, success: true,
data: { data: {
dumps: { items: dumpItems, total: dumpTotal, hasMore: page * limit < dumpTotal }, dumps: {
items: dumpItems,
total: dumpTotal,
hasMore: page * limit < dumpTotal,
},
users, users,
playlists, playlists,
}, },

View File

@@ -21,6 +21,9 @@ import {
updateUser, updateUser,
} from "../services/user-service.ts"; } from "../services/user-service.ts";
import { redeemInvite, validateInvite } from "../services/invite-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 { broadcastUserUpdated } from "../services/ws-service.ts";
import { import {
getDumpsByUser, getDumpsByUser,
@@ -53,6 +56,20 @@ router.post("/register", async (ctx) => {
console.error("[register] redeemInvite failed (user created):", err); 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({ const authToken = await createJWT({
userId: user.id, userId: user.id,
username: user.username, username: user.username,
@@ -153,7 +170,7 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
); );
} }
const updated = await updateUser(ctx.state.user.userId, body); const updated = await updateUser(ctx.state.user.userId, body);
const { passwordHash: _, ...publicUser } = updated; const { passwordHash: _, email: _email, ...publicUser } = updated;
broadcastUserUpdated(publicUser); broadcastUserUpdated(publicUser);
ctx.response.body = { success: true, data: 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) // Public user profile by internal ID (used when only userId is available, e.g. dump.userId)
router.get("/by-id/:userId", (ctx) => { router.get("/by-id/:userId", (ctx) => {
const user = getUserById(ctx.params.userId); const user = getUserById(ctx.params.userId);
const { passwordHash: _, ...publicUser } = user; const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser }; ctx.response.body = { success: true, data: publicUser };
}); });
@@ -211,7 +228,7 @@ router.get("/:username/playlists", async (ctx) => {
// Public user profile by username (no passwordHash) // Public user profile by username (no passwordHash)
router.get("/:username", (ctx) => { router.get("/:username", (ctx) => {
const user = getUserByUsername(ctx.params.username); const user = getUserByUsername(ctx.params.username);
const { passwordHash: _, ...publicUser } = user; const { passwordHash: _, email: _email, ...publicUser } = user;
ctx.response.body = { success: true, data: publicUser }; ctx.response.body = { success: true, data: publicUser };
}); });

View File

@@ -226,7 +226,10 @@ export function searchDumps(
const totalRow = db.prepare( const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`, `SELECT COUNT(*) as count FROM dumps WHERE (is_private = 0 OR user_id = ?) AND ${searchClause};`,
).get(requestingUserId, ...searchParams) as { count: number } | undefined; ).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 { } else {
const rows = db.prepare( const rows = db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE is_private = 0 AND ${searchClause} ORDER BY created_at DESC LIMIT ? OFFSET ?;`, `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( const totalRow = db.prepare(
`SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`, `SELECT COUNT(*) as count FROM dumps WHERE is_private = 0 AND ${searchClause};`,
).get(...searchParams) as { count: number } | undefined; ).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,
};
} }
} }

View File

@@ -59,7 +59,9 @@ export async function verifyEmailTransport(): Promise<boolean> {
return true; return true;
} }
export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> { export async function sendEmail(
message: EmailMessage,
): Promise<EmailSendResult> {
if (normalizeRecipients(message.to).length === 0) { if (normalizeRecipients(message.to).length === 0) {
throw new Error("Email recipient is required."); throw new Error("Email recipient is required.");
} }

View File

@@ -56,8 +56,9 @@ async function fetchOEmbed(
url: string, url: string,
): Promise<{ title?: string; thumbnailUrl?: string }> { ): Promise<{ title?: string; thumbnailUrl?: string }> {
try { try {
const oembedUrl = const oembedUrl = `https://www.youtube.com/oembed?url=${
`https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`; encodeURIComponent(url)
}&format=json`;
const res = await fetchWithTimeout(oembedUrl); const res = await fetchWithTimeout(oembedUrl);
if (res.ok) { if (res.ok) {
const data = await res.json() as { const data = await res.json() as {
@@ -122,7 +123,8 @@ export const youtubeProvider: RichContentProvider = {
url, url,
title, title,
thumbnailUrl, thumbnailUrl,
embedUrl: `https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`, embedUrl:
`https://www.youtube.com/embed/videoseries?list=${listId}&rel=0`,
}; };
} }

View File

@@ -12,7 +12,7 @@ import { hashPassword } from "../lib/jwt.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
const USER_SELECT = 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 i.username as invited_by_username
FROM users u FROM users u
LEFT JOIN users i ON i.id = u.invited_by`; LEFT JOIN users i ON i.id = u.invited_by`;
@@ -39,8 +39,8 @@ export async function createUser(
const passwordHash = await hashPassword(request.password); const passwordHash = await hashPassword(request.password);
db.prepare( db.prepare(
`INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by) `INSERT INTO users (id, username, password_hash, is_admin, created_at, invited_by, email)
VALUES (?, ?, ?, ?, ?, ?);`, VALUES (?, ?, ?, ?, ?, ?, ?);`,
).run( ).run(
userId, userId,
request.username, request.username,
@@ -48,6 +48,7 @@ export async function createUser(
0, 0,
createdAt.toISOString(), createdAt.toISOString(),
inviterId, inviterId,
request.email,
); );
return { return {
@@ -56,6 +57,7 @@ export async function createUser(
passwordHash, passwordHash,
isAdmin: false, isAdmin: false,
createdAt, createdAt,
email: request.email,
}; };
} }
@@ -129,7 +131,7 @@ export async function updateUser(
): Promise<User> { ): Promise<User> {
const user = getUserById(userId); const user = getUserById(userId);
const { password, description, ...requestFields } = request; const { password, description, email, ...requestFields } = request;
const now = new Date(); const now = new Date();
const updatedUser: User = { const updatedUser: User = {
@@ -137,18 +139,20 @@ export async function updateUser(
passwordHash: password ? await hashPassword(password) : user.passwordHash, passwordHash: password ? await hashPassword(password) : user.passwordHash,
...requestFields, ...requestFields,
description: description ?? user.description, description: description ?? user.description,
email: email ?? user.email,
updatedAt: now, updatedAt: now,
}; };
const updatedUserRow = userApiToRow(updatedUser); const updatedUserRow = userApiToRow(updatedUser);
const userResult = db.prepare( 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( ).run(
updatedUserRow.username, updatedUserRow.username,
updatedUserRow.password_hash, updatedUserRow.password_hash,
updatedUserRow.is_admin, updatedUserRow.is_admin,
updatedUserRow.description, updatedUserRow.description,
updatedUserRow.email,
now.toISOString(), now.toISOString(),
updatedUserRow.id, updatedUserRow.id,
); );

View File

@@ -163,7 +163,9 @@ export function broadcastPlaylistDumpsUpdated(
}); });
} }
export function broadcastUserUpdated(user: Omit<User, "passwordHash">): void { export function broadcastUserUpdated(
user: Omit<User, "passwordHash" | "email">,
): void {
for (const client of clients) { for (const client of clients) {
send(client.socket, { type: "user_updated", user }); send(client.socket, { type: "user_updated", user });
} }

View File

@@ -26,7 +26,8 @@ CREATE TABLE users (
updated_at TEXT, updated_at TEXT,
avatar_mime TEXT, avatar_mime TEXT,
description TEXT, description TEXT,
invited_by TEXT REFERENCES users(id) invited_by TEXT REFERENCES users(id),
email TEXT NOT NULL
); );
CREATE TABLE votes ( CREATE TABLE votes (

View File

@@ -25,6 +25,7 @@
"@oak/oak": "jsr:@oak/oak@^17.2.0", "@oak/oak": "jsr:@oak/oak@^17.2.0",
"@panva/jose": "jsr:@panva/jose@^6.2.1", "@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" "nodemailer": "npm:nodemailer@^8.0.4",
"marked": "npm:marked@^15.0.0"
} }
} }

6
deno.lock generated
View File

@@ -31,6 +31,7 @@
"npm:eslint@^9.39.4": "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:frimousse@0.3": "0.3.0_react@19.2.4_typescript@5.9.3",
"npm:globals@^17.4.0": "17.4.0", "npm:globals@^17.4.0": "17.4.0",
"npm:marked@15": "15.0.12",
"npm:nodemailer@*": "8.0.4", "npm:nodemailer@*": "8.0.4",
"npm:nodemailer@^8.0.4": "8.0.4", "npm:nodemailer@^8.0.4": "8.0.4",
"npm:path-to-regexp@^6.3.0": "6.3.0", "npm:path-to-regexp@^6.3.0": "6.3.0",
@@ -1211,6 +1212,10 @@
"markdown-table@3.0.4": { "markdown-table@3.0.4": {
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==" "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="
}, },
"marked@15.0.12": {
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"bin": true
},
"mdast-util-find-and-replace@3.0.2": { "mdast-util-find-and-replace@3.0.2": {
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"dependencies": [ "dependencies": [
@@ -2054,6 +2059,7 @@
"jsr:@oak/oak@^17.2.0", "jsr:@oak/oak@^17.2.0",
"jsr:@panva/jose@^6.2.1", "jsr:@panva/jose@^6.2.1",
"jsr:@tajpouria/cors@^1.2.1", "jsr:@tajpouria/cors@^1.2.1",
"npm:marked@15",
"npm:nodemailer@^8.0.4" "npm:nodemailer@^8.0.4"
], ],
"packageJson": { "packageJson": {

View File

@@ -1144,7 +1144,7 @@ body.has-player .fab-new {
/* ── Public profile page ── */ /* ── Public profile page ── */
.profile-header { .profile-header {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 1.5rem; gap: 1.5rem;
} }
@@ -1262,6 +1262,100 @@ body.has-player .fab-new {
gap: 0.5rem; 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 { .profile-description-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1394,12 +1488,16 @@ body.has-player .fab-new {
.btn-border-danger, .btn-border-danger,
.btn-border-success, .btn-border-success,
.btn-border { .btn-border {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.3rem 0.9rem; padding: 0.3rem 0.9rem;
border: 1.5px solid var(--color-border); border: 1.5px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.85rem; font-size: 0.85rem;
font-family: inherit;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s; transition: border-color 0.15s, color 0.15s, background 0.15s;
} }
@@ -1594,7 +1692,8 @@ body.has-player .fab-new {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 0; min-width: 0;
overflow: hidden; overflow-x: clip;
overflow-y: visible;
container-type: inline-size; container-type: inline-size;
} }
@@ -3729,8 +3828,12 @@ body.has-player .fab-new {
font-size: 0.875rem; font-size: 0.875rem;
font-family: inherit; font-family: inherit;
outline: none; outline: none;
transition: max-width 0.25s ease, opacity 0.2s ease, padding 0.25s ease, transition:
border-width 0.25s ease, border-color 0.15s; 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; min-width: 0;
} }

View File

@@ -2,7 +2,12 @@ import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../hooks/useAuth.ts"; import { useAuth } from "../hooks/useAuth.ts";
export type FeedTab = "hot" | "new" | "journal" | "followed"; export type FeedTab = "hot" | "new" | "journal" | "followed";
export const VALID_TABS = new Set<string>(["hot", "new", "journal", "followed"]); export const VALID_TABS = new Set<string>([
"hot",
"new",
"journal",
"followed",
]);
export function FeedTabBar() { export function FeedTabBar() {
const location = useLocation(); const location = useLocation();

View File

@@ -8,7 +8,9 @@ interface PageShellProps {
centerSlot?: ReactNode; centerSlot?: ReactNode;
} }
export function PageShell({ children, centered = false, centerSlot }: PageShellProps) { export function PageShell(
{ children, centered = false, centerSlot }: PageShellProps,
) {
return ( return (
<div className="page-shell"> <div className="page-shell">
<AppHeader centerSlot={centerSlot ?? <SearchBar />} /> <AppHeader centerSlot={centerSlot ?? <SearchBar />} />

View File

@@ -14,7 +14,7 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
if (expanded) inputRef.current?.focus(); if (collapsible && expanded) inputRef.current?.focus();
}, [expanded]); }, [expanded]);
function handleIconClick() { function handleIconClick() {
@@ -47,7 +47,9 @@ export function SearchBar({ collapsible = false }: SearchBarProps) {
return ( return (
<form <form
className={`search-bar${collapsible ? " search-bar--collapsible" : ""}${expanded ? " search-bar--expanded" : ""}`} className={`search-bar${collapsible ? " search-bar--collapsible" : ""}${
expanded ? " search-bar--expanded" : ""
}`}
onSubmit={handleSubmit} onSubmit={handleSubmit}
role="search" role="search"
> >

View File

@@ -174,7 +174,9 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
return ( return (
<div <div
className={`mention-textarea-wrap${dragOver ? " mention-textarea-wrap--dragover" : ""}`} className={`mention-textarea-wrap${
dragOver ? " mention-textarea-wrap--dragover" : ""
}`}
> >
<textarea <textarea
ref={textareaRef} ref={textareaRef}

View File

@@ -10,7 +10,9 @@ export function UserMenu({ user }: { user: User }) {
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onMouseDown(e: MouseEvent) { function onMouseDown(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
} }
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false); if (e.key === "Escape") setOpen(false);

View File

@@ -401,7 +401,9 @@ export function WSProvider(
if (socketRef.current?.readyState === WebSocket.OPEN) { if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send( socketRef.current.send(
JSON.stringify({ type: "vote_cast", dumpId } satisfies OutgoingWSMessage), JSON.stringify(
{ type: "vote_cast", dumpId } satisfies OutgoingWSMessage,
),
); );
} }
// If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT // If socket is not OPEN, the revert timer will handle cleanup after ACK_TIMEOUT

View File

@@ -92,6 +92,7 @@ export interface PublicUser {
avatarMime?: string; avatarMime?: string;
description?: string; description?: string;
invitedByUsername?: string; invitedByUsername?: string;
email?: string;
} }
// User is the same shape as PublicUser in the frontend; they differ only // User is the same shape as PublicUser in the frontend; they differ only
@@ -464,6 +465,7 @@ export interface RegisterRequest {
username: string; username: string;
password: string; password: string;
inviteToken: string; inviteToken: string;
email: string;
} }
export interface CreateUrlDumpRequest { export interface CreateUrlDumpRequest {
@@ -505,4 +507,5 @@ export interface ReorderPlaylistRequest {
export interface UpdateUserRequest { export interface UpdateUserRequest {
description?: string; description?: string;
email?: string;
} }

View File

@@ -11,7 +11,11 @@ import { useLocation } from "react-router";
import { AppHeader } from "../components/AppHeader.tsx"; import { AppHeader } from "../components/AppHeader.tsx";
import { SearchBar } from "../components/SearchBar.tsx"; import { SearchBar } from "../components/SearchBar.tsx";
import { PresenceRow } from "../components/PresenceRow.tsx"; import { PresenceRow } from "../components/PresenceRow.tsx";
import { FeedTabBar, type FeedTab, VALID_TABS } from "../components/FeedTabBar.tsx"; import {
type FeedTab,
FeedTabBar,
VALID_TABS,
} from "../components/FeedTabBar.tsx";
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts"; import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";

View File

@@ -111,7 +111,10 @@ export function Search() {
...prev, ...prev,
dumps: { dumps: {
...prev.dumps, ...prev.dumps,
items: [...prev.dumps.items, ...data.dumps.items.map(deserializeDump)], items: [
...prev.dumps.items,
...data.dumps.items.map(deserializeDump),
],
hasMore: data.dumps.hasMore, hasMore: data.dumps.hasMore,
page, page,
loadingMore: false, loadingMore: false,
@@ -131,7 +134,10 @@ export function Search() {
}, [q, fetchSearch]); }, [q, fetchSearch]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (state.status !== "loaded" || !state.dumps.hasMore || state.dumps.loadingMore) return; if (
state.status !== "loaded" || !state.dumps.hasMore ||
state.dumps.loadingMore
) return;
setState((prev) => { setState((prev) => {
if (prev.status !== "loaded") return prev; if (prev.status !== "loaded") return prev;
return { ...prev, dumps: { ...prev.dumps, loadingMore: true } }; return { ...prev, dumps: { ...prev.dumps, loadingMore: true } };
@@ -141,7 +147,8 @@ export function Search() {
const sentinelRef = useInfiniteScroll( const sentinelRef = useInfiniteScroll(
loadMore, loadMore,
state.status === "loaded" && tab === "dumps" && state.dumps.hasMore && !state.dumps.loadingMore, state.status === "loaded" && tab === "dumps" && state.dumps.hasMore &&
!state.dumps.loadingMore,
); );
function setTab(t: Tab) { function setTab(t: Tab) {
@@ -154,10 +161,16 @@ export function Search() {
const dumpCount = state.status === "loaded" ? state.dumps.total : null; const dumpCount = state.status === "loaded" ? state.dumps.total : null;
const userCount = state.status === "loaded" ? state.users.length : null; const userCount = state.status === "loaded" ? state.users.length : null;
const playlistCount = state.status === "loaded" ? state.playlists.length : null; const playlistCount = state.status === "loaded"
? state.playlists.length
: null;
function tabLabel(t: Tab, count: number | null) { function tabLabel(t: Tab, count: number | null) {
const label = t === "dumps" ? "Dumps" : t === "users" ? "Users" : "Playlists"; const label = t === "dumps"
? "Dumps"
: t === "users"
? "Users"
: "Playlists";
return count !== null ? `${label} (${count})` : label; return count !== null ? `${label} (${count})` : label;
} }
@@ -174,7 +187,14 @@ export function Search() {
className={`feed-sort-btn${tab === t ? " active" : ""}`} className={`feed-sort-btn${tab === t ? " active" : ""}`}
onClick={() => setTab(t)} onClick={() => setTab(t)}
> >
{tabLabel(t, t === "dumps" ? dumpCount : t === "users" ? userCount : playlistCount)} {tabLabel(
t,
t === "dumps"
? dumpCount
: t === "users"
? userCount
: playlistCount,
)}
</button> </button>
))} ))}
</div> </div>
@@ -213,7 +233,9 @@ export function Search() {
</ul> </ul>
)} )}
<div ref={sentinelRef} /> <div ref={sentinelRef} />
{state.dumps.loadingMore && <p className="feed-loading-more">Loading more</p>} {state.dumps.loadingMore && (
<p className="feed-loading-more">Loading more</p>
)}
{!state.dumps.hasMore && state.dumps.items.length > 0 && ( {!state.dumps.hasMore && state.dumps.items.length > 0 && (
<p className="feed-end">You've reached the end.</p> <p className="feed-end">You've reached the end.</p>
)} )}
@@ -227,10 +249,15 @@ export function Search() {
<ul className="user-results"> <ul className="user-results">
{state.users.map((u) => ( {state.users.map((u) => (
<li key={u.id}> <li key={u.id}>
<Link to={`/users/${u.username}`} className="user-result-item"> <Link
to={`/users/${u.username}`}
className="user-result-item"
>
<span className="user-result-name">@{u.username}</span> <span className="user-result-name">@{u.username}</span>
{u.description && ( {u.description && (
<span className="user-result-description">{u.description}</span> <span className="user-result-description">
{u.description}
</span>
)} )}
</Link> </Link>
</li> </li>

View File

@@ -233,6 +233,11 @@ export function UserPublicProfile() {
const [descDraft, setDescDraft] = useState(""); const [descDraft, setDescDraft] = useState("");
const [descSaving, setDescSaving] = useState(false); const [descSaving, setDescSaving] = useState(false);
const [descError, setDescError] = useState<string | null>(null); const [descError, setDescError] = useState<string | null>(null);
const [emailEditing, setEmailEditing] = useState(false);
const [emailDraft, setEmailDraft] = useState("");
const [emailSaving, setEmailSaving] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | null>(null); const prevMyVotesRef = useRef<Set<string> | null>(null);
useEffect(() => { useEffect(() => {
@@ -507,6 +512,36 @@ export function UserPublicProfile() {
} }
}; };
const handleEmailSave = async () => {
if (state.status !== "loaded") return;
setEmailSaving(true);
setEmailError(null);
try {
const res = await authFetch(`${API_URL}/api/users/me`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{ email: emailDraft.trim() } satisfies UpdateUserRequest,
),
});
const body = parseAPIResponse<RawPublicUser>(await res.json());
if (!body.success) {
setEmailError(body.error.message);
return;
}
const storedRaw = localStorage.getItem("authResponse");
if (storedRaw) {
const prev = deserializeAuthResponse(JSON.parse(storedRaw));
login({ ...prev, user: { ...prev.user, email: emailDraft.trim() } });
}
setEmailEditing(false);
} catch {
setEmailError("Failed to save");
} finally {
setEmailSaving(false);
}
};
const handleDescSave = async () => { const handleDescSave = async () => {
if (state.status !== "loaded") return; if (state.status !== "loaded") return;
setDescSaving(true); setDescSaving(true);
@@ -584,7 +619,7 @@ export function UserPublicProfile() {
userId={profileUser.id} userId={profileUser.id}
username={profileUser.username} username={profileUser.username}
hasAvatar={!!profileUser.avatarMime} hasAvatar={!!profileUser.avatarMime}
size={72} size={128}
version={profileUser.updatedAt?.getTime()} version={profileUser.updatedAt?.getTime()}
/> />
{isOwnProfile && ( {isOwnProfile && (
@@ -620,6 +655,66 @@ export function UserPublicProfile() {
O.G. O.G.
</p> </p>
)} )}
{isOwnProfile && (
emailEditing
? (
<form
className="profile-email-editor"
onSubmit={(e) => {
e.preventDefault();
handleEmailSave();
}}
>
<input
type="email"
className="profile-email-input"
value={emailDraft}
onChange={(e) => setEmailDraft(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Escape") setEmailEditing(false);
}}
disabled={emailSaving}
autoFocus
/>
<div className="profile-email-actions">
<button
type="submit"
className="profile-email-btn profile-email-btn--save"
disabled={emailSaving || !emailDraft.trim()}
>
{emailSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="profile-email-btn profile-email-btn--cancel"
onClick={() => setEmailEditing(false)}
disabled={emailSaving}
>
Cancel
</button>
</div>
{emailError && (
<ErrorCard title="Failed to save" message={emailError} />
)}
</form>
)
: (
<p
className="profile-email-display"
onClick={() => {
setEmailDraft(me?.email ?? "");
setEmailError(null);
setEmailEditing(true);
}}
title="Edit email"
>
{me?.email ?? "Add email…"}
<span className="profile-description-edit-btn" aria-hidden>
</span>
</p>
)
)}
{avatarError && ( {avatarError && (
<ErrorCard title="Failed to update avatar" message={avatarError} /> <ErrorCard title="Failed to update avatar" message={avatarError} />
)} )}

View File

@@ -54,13 +54,19 @@ export function UserRegister() {
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const username = formData.get("username") as string; const username = formData.get("username") as string;
const password = formData.get("password") as string; const password = formData.get("password") as string;
const email = formData.get("email") as string;
try { try {
const res = await fetch(`${API_URL}/api/users/register`, { const res = await fetch(`${API_URL}/api/users/register`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify( body: JSON.stringify(
{ username, password, inviteToken: token } satisfies RegisterRequest, {
username,
password,
inviteToken: token,
email,
} satisfies RegisterRequest,
), ),
}); });
@@ -118,6 +124,13 @@ export function UserRegister() {
disabled={formState.status === "submitting"} disabled={formState.status === "submitting"}
autoFocus autoFocus
/> />
<input
name="email"
type="email"
placeholder="Email address"
required
disabled={formState.status === "submitting"}
/>
<input <input
name="password" name="password"
type="password" type="password"