v3: added onboarding email on account creation
This commit is contained in:
10
.env.example
10
.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
|
||||
|
||||
|
||||
@@ -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)
|
||||
: []
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "passwordHash">;
|
||||
user: Omit<User, "passwordHash" | "email">;
|
||||
}
|
||||
|
||||
export interface CommentCreatedMessage {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ export async function verifyEmailTransport(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function sendEmail(message: EmailMessage): Promise<EmailSendResult> {
|
||||
export async function sendEmail(
|
||||
message: EmailMessage,
|
||||
): Promise<EmailSendResult> {
|
||||
if (normalizeRecipients(message.to).length === 0) {
|
||||
throw new Error("Email recipient is required.");
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
send(client.socket, { type: "user_updated", user });
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
6
deno.lock
generated
6
deno.lock
generated
@@ -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": {
|
||||
|
||||
111
src/App.css
111
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>(["hot", "new", "journal", "followed"]);
|
||||
export const VALID_TABS = new Set<string>([
|
||||
"hot",
|
||||
"new",
|
||||
"journal",
|
||||
"followed",
|
||||
]);
|
||||
|
||||
export function FeedTabBar() {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -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 (
|
||||
<div className="page-shell">
|
||||
<AppHeader centerSlot={centerSlot ?? <SearchBar />} />
|
||||
|
||||
@@ -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 (
|
||||
<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}
|
||||
role="search"
|
||||
>
|
||||
|
||||
@@ -174,7 +174,9 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mention-textarea-wrap${dragOver ? " mention-textarea-wrap--dragover" : ""}`}
|
||||
className={`mention-textarea-wrap${
|
||||
dragOver ? " mention-textarea-wrap--dragover" : ""
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -10,7 +10,9 @@ export function UserMenu({ user }: { user: User }) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
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) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
|
||||
@@ -401,7 +401,9 @@ export function WSProvider(
|
||||
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
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
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface PublicUser {
|
||||
avatarMime?: string;
|
||||
description?: string;
|
||||
invitedByUsername?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
// User is the same shape as PublicUser in the frontend; they differ only
|
||||
@@ -464,6 +465,7 @@ export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
inviteToken: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface CreateUrlDumpRequest {
|
||||
@@ -505,4 +507,5 @@ export interface ReorderPlaylistRequest {
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
description?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,11 @@ import { useLocation } from "react-router";
|
||||
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 {
|
||||
type FeedTab,
|
||||
FeedTabBar,
|
||||
VALID_TABS,
|
||||
} from "../components/FeedTabBar.tsx";
|
||||
|
||||
import { API_URL, DEFAULT_PAGE_SIZE } from "../config/api.ts";
|
||||
|
||||
|
||||
@@ -111,7 +111,10 @@ export function Search() {
|
||||
...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,
|
||||
page,
|
||||
loadingMore: false,
|
||||
@@ -131,7 +134,10 @@ export function Search() {
|
||||
}, [q, fetchSearch]);
|
||||
|
||||
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) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return { ...prev, dumps: { ...prev.dumps, loadingMore: true } };
|
||||
@@ -141,7 +147,8 @@ export function Search() {
|
||||
|
||||
const sentinelRef = useInfiniteScroll(
|
||||
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) {
|
||||
@@ -154,10 +161,16 @@ export function Search() {
|
||||
|
||||
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;
|
||||
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";
|
||||
const label = t === "dumps"
|
||||
? "Dumps"
|
||||
: t === "users"
|
||||
? "Users"
|
||||
: "Playlists";
|
||||
return count !== null ? `${label} (${count})` : label;
|
||||
}
|
||||
|
||||
@@ -174,7 +187,14 @@ export function Search() {
|
||||
className={`feed-sort-btn${tab === t ? " active" : ""}`}
|
||||
onClick={() => setTab(t)}
|
||||
>
|
||||
{tabLabel(t, t === "dumps" ? dumpCount : t === "users" ? userCount : playlistCount)}
|
||||
{tabLabel(
|
||||
t,
|
||||
t === "dumps"
|
||||
? dumpCount
|
||||
: t === "users"
|
||||
? userCount
|
||||
: playlistCount,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -213,7 +233,9 @@ export function Search() {
|
||||
</ul>
|
||||
)}
|
||||
<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 && (
|
||||
<p className="feed-end">You've reached the end.</p>
|
||||
)}
|
||||
@@ -227,10 +249,15 @@ export function Search() {
|
||||
<ul className="user-results">
|
||||
{state.users.map((u) => (
|
||||
<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>
|
||||
{u.description && (
|
||||
<span className="user-result-description">{u.description}</span>
|
||||
<span className="user-result-description">
|
||||
{u.description}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -233,6 +233,11 @@ export function UserPublicProfile() {
|
||||
const [descDraft, setDescDraft] = useState("");
|
||||
const [descSaving, setDescSaving] = useState(false);
|
||||
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);
|
||||
|
||||
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 () => {
|
||||
if (state.status !== "loaded") return;
|
||||
setDescSaving(true);
|
||||
@@ -584,7 +619,7 @@ export function UserPublicProfile() {
|
||||
userId={profileUser.id}
|
||||
username={profileUser.username}
|
||||
hasAvatar={!!profileUser.avatarMime}
|
||||
size={72}
|
||||
size={128}
|
||||
version={profileUser.updatedAt?.getTime()}
|
||||
/>
|
||||
{isOwnProfile && (
|
||||
@@ -620,6 +655,66 @@ export function UserPublicProfile() {
|
||||
O.G.
|
||||
</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 && (
|
||||
<ErrorCard title="Failed to update avatar" message={avatarError} />
|
||||
)}
|
||||
|
||||
@@ -54,13 +54,19 @@ export function UserRegister() {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("username") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const email = formData.get("email") as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/users/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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"}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
required
|
||||
disabled={formState.status === "submitting"}
|
||||
/>
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
|
||||
Reference in New Issue
Block a user