v3: added user profile description

This commit is contained in:
khannurien
2026-03-22 21:07:17 +00:00
parent c5051e3485
commit d94a319d96
10 changed files with 227 additions and 26 deletions

View File

@@ -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 {

View File

@@ -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}

View File

@@ -17,6 +17,7 @@ interface TextEditorProps {
id?: string;
className?: string;
autoResize?: boolean;
onSubmit?: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
}
@@ -31,6 +32,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
id,
className,
autoResize = false,
onSubmit,
onKeyDown,
},
ref,
@@ -84,6 +86,11 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
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);
}}

View File

@@ -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 {

View File

@@ -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<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [descEditing, setDescEditing] = useState(false);
const [descDraft, setDescDraft] = useState("");
const [descSaving, setDescSaving] = useState(false);
const [descError, setDescError] = useState<string | null>(null);
const prevMyVotesRef = useRef<Set<string> | 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 (
<PageShell>
@@ -613,6 +650,76 @@ export function UserPublicProfile() {
</div>
</div>
{(profileUser.description || isOwnProfile) && (
<div className="profile-description">
{descEditing
? (
<div className="profile-description-editor">
<TextEditor
className="comment-reply-textarea"
value={descDraft}
onChange={setDescDraft}
onSubmit={handleDescSave}
placeholder="Tell people about yourself…"
autoResize
/>
<div className="profile-description-actions">
<button
type="button"
className="btn-primary"
onClick={handleDescSave}
disabled={descSaving}
>
{descSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn-border"
onClick={() => setDescEditing(false)}
disabled={descSaving}
>
Cancel
</button>
{descError && (
<ErrorCard title="Failed to save" message={descError} />
)}
</div>
</div>
)
: (
<div
className={`profile-description-view${
isOwnProfile ? " profile-description-view--editable" : ""
}`}
onClick={isOwnProfile
? () => {
setDescDraft(profileUser.description ?? "");
setDescError(null);
setDescEditing(true);
}
: undefined}
>
{profileUser.description
? (
<Markdown className="profile-description-text">
{profileUser.description}
</Markdown>
)
: (
<div className="profile-description-empty">
Add a bio
</div>
)}
{isOwnProfile && (
<span className="profile-description-edit-btn" aria-hidden>
</span>
)}
</div>
)}
</div>
)}
<div className="profile-columns">
<DumpList
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}