diff --git a/api/model/db.ts b/api/model/db.ts index e80760c..b8ef507 100644 --- a/api/model/db.ts +++ b/api/model/db.ts @@ -49,6 +49,7 @@ export interface UserRow { created_at: string; updated_at: string | null; avatar_mime: string | null; + description: string | null; invited_by: string | null; // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by invited_by_username: string | null; @@ -79,6 +80,7 @@ export function isDumpRow(obj: Record): obj is DumpRow { "file_size" in obj && (typeof obj.file_size === "number" || obj.file_size === null) && "vote_count" in obj && typeof obj.vote_count === "number" && + "comment_count" in obj && typeof obj.comment_count === "number" && "is_private" in obj && typeof obj.is_private === "number"; } @@ -91,7 +93,9 @@ export function isUserRow(obj: Record): obj is UserRow { "is_admin" in obj && typeof obj.is_admin === "number" && "created_at" in obj && typeof obj.created_at === "string" && "avatar_mime" in obj && - (typeof obj.avatar_mime === "string" || obj.avatar_mime === null); + (typeof obj.avatar_mime === "string" || obj.avatar_mime === null) && + "description" in obj && + (typeof obj.description === "string" || obj.description === null); } /** @@ -151,6 +155,7 @@ export function userRowToApi(row: UserRow): User { createdAt: new Date(row.created_at), updatedAt: row.updated_at ? new Date(row.updated_at) : undefined, avatarMime: row.avatar_mime ?? undefined, + description: row.description ?? undefined, invitedByUsername: typeof row.invited_by_username === "string" ? row.invited_by_username : undefined, @@ -166,6 +171,7 @@ export function userApiToRow(user: User): UserRow { created_at: user.createdAt.toISOString(), updated_at: user.updatedAt?.toISOString() ?? null, avatar_mime: user.avatarMime ?? null, + description: user.description ?? null, invited_by: null, invited_by_username: null, }; diff --git a/api/model/interfaces.ts b/api/model/interfaces.ts index be23686..97b3432 100644 --- a/api/model/interfaces.ts +++ b/api/model/interfaces.ts @@ -44,6 +44,7 @@ export interface User { createdAt: Date; updatedAt?: Date; avatarMime?: string; + description?: string; invitedByUsername?: string; } @@ -62,6 +63,7 @@ export interface UpdateUserRequest { username?: string; password?: string; isAdmin?: boolean; + description?: string | null; } export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { @@ -83,7 +85,9 @@ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { return !!obj && typeof obj === "object" && (!("username" in obj) || typeof obj.username === "string") && (!("password" in obj) || typeof obj.password === "string") && - (!("isAdmin" in obj) || typeof obj.isAdmin === "boolean"); + (!("isAdmin" in obj) || typeof obj.isAdmin === "boolean") && + (!("description" in obj) || typeof obj.description === "string" || + obj.description === null); } export interface AuthResponse { diff --git a/api/routes/users.ts b/api/routes/users.ts index a46dc9a..b74b0c6 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -5,6 +5,7 @@ import { APIException, isLoginUserRequest, isRegisterUserRequest, + isUpdateUserRequest, type PaginatedData, } from "../model/interfaces.ts"; @@ -15,6 +16,7 @@ import { getUserById, getUserByUsername, searchUsers, + updateUser, } from "../services/user-service.ts"; import { redeemInvite, validateInvite } from "../services/invite-service.ts"; import { @@ -132,6 +134,21 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => { } }); +// Update current user profile (description, etc.) +router.patch("/me", authMiddleware, async (ctx: AuthContext) => { + const body = await ctx.request.body.json(); + if (!isUpdateUserRequest(body)) { + throw new APIException( + APIErrorCode.VALIDATION_ERROR, + 400, + "Invalid request", + ); + } + const updated = await updateUser(ctx.state.user.userId, body); + const { passwordHash: _, ...publicUser } = updated; + ctx.response.body = { success: true, data: publicUser }; +}); + // User search for @mention autocomplete router.get("/search", (ctx) => { const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); diff --git a/api/services/user-service.ts b/api/services/user-service.ts index c47c1db..4140a1b 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -10,7 +10,7 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts"; import { hashPassword } from "../lib/jwt.ts"; const USER_SELECT = - `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.invited_by, + `SELECT u.id, u.username, u.password_hash, u.is_admin, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, i.username as invited_by_username FROM users u LEFT JOIN users i ON i.id = u.invited_by`; @@ -118,24 +118,26 @@ export async function updateUser( ): Promise { const user = getUserById(userId); - const { password, ...requestFields } = request; + const { password, description, ...requestFields } = request; const now = new Date(); const updatedUser: User = { ...user, passwordHash: password ? await hashPassword(password) : user.passwordHash, ...requestFields, + description: description ?? user.description, updatedAt: now, }; const updatedUserRow = userApiToRow(updatedUser); const userResult = db.prepare( - `UPDATE users SET username = ?, password_hash = ?, is_admin = ?, updated_at = ? WHERE id = ?`, + `UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, updated_at = ? WHERE id = ?`, ).run( updatedUserRow.username, updatedUserRow.password_hash, updatedUserRow.is_admin, + updatedUserRow.description, now.toISOString(), updatedUserRow.id, ); diff --git a/api/sql/schema.sql b/api/sql/schema.sql index cfead5e..f498094 100644 --- a/api/sql/schema.sql +++ b/api/sql/schema.sql @@ -24,6 +24,7 @@ CREATE TABLE users ( created_at TEXT NOT NULL, updated_at TEXT, avatar_mime TEXT, + description TEXT, invited_by TEXT REFERENCES users(id) ); diff --git a/src/App.css b/src/App.css index ca7eba2..dfe11ce 100644 --- a/src/App.css +++ b/src/App.css @@ -917,6 +917,8 @@ body.has-player .fab-new { font-weight: 600; font-size: 1rem; line-height: 1.35; + overflow-wrap: break-word; + word-break: break-word; } .rich-content-description { @@ -1200,6 +1202,66 @@ body.has-player .fab-new { margin-top: 0.5rem; } +/* ── Profile description ── */ +.profile-description { + width: 100%; + margin-bottom: 1.5rem; +} + +.profile-description-view { + position: relative; + padding: 0.5rem 0.6rem; + border-radius: 6px; + font-size: 0.95rem; +} + +.profile-description-view--editable { + cursor: pointer; +} + +.profile-description-view--editable:hover { + background: var(--color-surface); +} + +.profile-description-text { + white-space: pre-wrap; + overflow-wrap: break-word; + word-break: break-word; + line-height: 1.6; +} + +.profile-description-empty { + color: var(--color-muted); + font-style: italic; +} + +.profile-description-edit-btn { + position: absolute; + top: 0.55rem; + right: 0.5rem; + font-size: 0.85rem; + color: var(--color-muted); + opacity: 0; + transition: opacity 0.1s; + pointer-events: none; +} + +.profile-description-view--editable:hover .profile-description-edit-btn { + opacity: 1; +} + +.profile-description-editor { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.profile-description-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + .profile-invited-by { font-size: 0.78rem; color: var(--color-text-muted); @@ -2356,6 +2418,8 @@ body.has-player .fab-new { margin: 0 0 0.5rem; opacity: 0.75; line-height: 1.75; + overflow-wrap: break-word; + word-break: break-word; } .playlist-detail-meta { @@ -2689,6 +2753,8 @@ body.has-player .fab-new { font-size: 0.9rem; line-height: 1.65; color: var(--color-text); + overflow-wrap: break-word; + word-break: break-word; } .comment-actions { diff --git a/src/components/CommentThread.tsx b/src/components/CommentThread.tsx index f6e4b1f..4f6c7d4 100644 --- a/src/components/CommentThread.tsx +++ b/src/components/CommentThread.tsx @@ -69,8 +69,8 @@ function CommentNode({ const children = tree.get(comment.id) ?? []; - async function handleReply(e: React.FormEvent) { - e.preventDefault(); + async function handleReply(e?: React.FormEvent) { + e?.preventDefault(); if (!replyBody.trim() || !token) return; setSubmitting(true); setReplyError(null); @@ -109,8 +109,8 @@ function CommentNode({ } } - async function handleEditSave(e: React.FormEvent) { - e.preventDefault(); + async function handleEditSave(e?: React.FormEvent) { + e?.preventDefault(); if (!editBody.trim() || !token) return; setEditSubmitting(true); setEditError(null); @@ -226,11 +226,7 @@ function CommentNode({ className="comment-reply-textarea" value={editBody} onChange={setEditBody} - onKeyDown={(e) => { - if ( - e.key === "Enter" && (e.ctrlKey || e.metaKey) - ) handleEditSave(e); - }} + onSubmit={handleEditSave} autoResize rows={1} /> @@ -314,11 +310,7 @@ function CommentNode({ className="comment-reply-textarea" value={replyBody} onChange={setReplyBody} - onKeyDown={(e) => { - if ( - e.key === "Enter" && (e.ctrlKey || e.metaKey) - ) handleReply(e); - }} + onSubmit={handleReply} placeholder="Write a reply…" autoResize rows={1} @@ -391,8 +383,8 @@ export function CommentThread({ const tree = buildTree(comments); const roots = tree.get("root") ?? []; - async function handleTopLevelSubmit(e: React.FormEvent) { - e.preventDefault(); + async function handleTopLevelSubmit(e?: React.FormEvent) { + e?.preventDefault(); if (!topLevelBody.trim() || !token) return; setSubmitting(true); setTopLevelError(null); @@ -444,11 +436,7 @@ export function CommentThread({ className="comment-reply-textarea" value={topLevelBody} onChange={setTopLevelBody} - onKeyDown={(e) => { - if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { - handleTopLevelSubmit(e); - } - }} + onSubmit={handleTopLevelSubmit} placeholder="Add a comment…" autoResize rows={1} diff --git a/src/components/TextEditor.tsx b/src/components/TextEditor.tsx index 3e7b154..153ee6e 100644 --- a/src/components/TextEditor.tsx +++ b/src/components/TextEditor.tsx @@ -17,6 +17,7 @@ interface TextEditorProps { id?: string; className?: string; autoResize?: boolean; + onSubmit?: () => void; onKeyDown?: (e: React.KeyboardEvent) => void; } @@ -31,6 +32,7 @@ export const TextEditor = forwardRef( id, className, autoResize = false, + onSubmit, onKeyDown, }, ref, @@ -84,6 +86,11 @@ export const TextEditor = forwardRef( detectEmojiTrigger(e.target.value, e.target.selectionStart ?? 0); }} onKeyDown={(e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + onSubmit?.(); + return; + } handleMentionKeyDown(e); if (!e.defaultPrevented) onKeyDown?.(e); }} diff --git a/src/model.ts b/src/model.ts index 3ca755e..9d0ef7b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -49,6 +49,7 @@ export interface User { createdAt: Date; updatedAt?: Date; avatarMime?: string; + description?: string; invitedByUsername?: string; } @@ -60,6 +61,7 @@ export interface PublicUser { createdAt: Date; updatedAt?: Date; avatarMime?: string; + description?: string; invitedByUsername?: string; } @@ -115,6 +117,7 @@ export interface UpdateUserRequest { username?: string; password?: string; isAdmin?: boolean; + description?: string | null; } export interface AuthResponse { diff --git a/src/pages/UserPublicProfile.tsx b/src/pages/UserPublicProfile.tsx index f2042d8..284d4a7 100644 --- a/src/pages/UserPublicProfile.tsx +++ b/src/pages/UserPublicProfile.tsx @@ -34,6 +34,8 @@ import { DumpCreateModal } from "../components/DumpCreateModal.tsx"; import { FollowUserButton } from "../components/FollowButton.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; +import { TextEditor } from "../components/TextEditor.tsx"; +import { Markdown } from "../components/Markdown.tsx"; const PAGE_SIZE = 20; @@ -246,6 +248,11 @@ export function UserPublicProfile() { const [uploading, setUploading] = useState(false); const [avatarError, setAvatarError] = useState(null); const fileInputRef = useRef(null); + + const [descEditing, setDescEditing] = useState(false); + const [descDraft, setDescDraft] = useState(""); + const [descSaving, setDescSaving] = useState(false); + const [descError, setDescError] = useState(null); const prevMyVotesRef = useRef | null>(null); useEffect(() => { @@ -515,6 +522,36 @@ export function UserPublicProfile() { } }; + const handleDescSave = async () => { + if (state.status !== "loaded") return; + setDescSaving(true); + setDescError(null); + try { + const res = await authFetch(`${API_URL}/api/users/me`, { + method: "PATCH", + body: JSON.stringify({ description: descDraft.trim() }), + }); + const body = await res.json(); + if (!res.ok || !body.success) { + setDescError(body.error?.message ?? "Failed to save"); + return; + } + setState((s) => + s.status === "loaded" + ? { + ...s, + user: { ...s.user, description: descDraft.trim() || undefined }, + } + : s + ); + setDescEditing(false); + } catch { + setDescError("Failed to save"); + } finally { + setDescSaving(false); + } + }; + if (state.status === "loading") { return ( @@ -613,6 +650,76 @@ export function UserPublicProfile() { + {(profileUser.description || isOwnProfile) && ( +
+ {descEditing + ? ( +
+ +
+ + + {descError && ( + + )} +
+
+ ) + : ( +
{ + setDescDraft(profileUser.description ?? ""); + setDescError(null); + setDescEditing(true); + } + : undefined} + > + {profileUser.description + ? ( + + {profileUser.description} + + ) + : ( +
+ Add a bio… +
+ )} + {isOwnProfile && ( + + ✎ + + )} +
+ )} +
+ )} +