v3: added user profile description
This commit is contained in:
@@ -49,6 +49,7 @@ export interface UserRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
avatar_mime: string | null;
|
avatar_mime: string | null;
|
||||||
|
description: string | null;
|
||||||
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;
|
||||||
@@ -79,6 +80,7 @@ export function isDumpRow(obj: Record<string, SQLOutputValue>): obj is DumpRow {
|
|||||||
"file_size" in obj &&
|
"file_size" in obj &&
|
||||||
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
(typeof obj.file_size === "number" || obj.file_size === null) &&
|
||||||
"vote_count" in obj && typeof obj.vote_count === "number" &&
|
"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";
|
"is_private" in obj && typeof obj.is_private === "number";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +93,9 @@ export function isUserRow(obj: Record<string, SQLOutputValue>): obj is UserRow {
|
|||||||
"is_admin" in obj && typeof obj.is_admin === "number" &&
|
"is_admin" in obj && typeof obj.is_admin === "number" &&
|
||||||
"created_at" in obj && typeof obj.created_at === "string" &&
|
"created_at" in obj && typeof obj.created_at === "string" &&
|
||||||
"avatar_mime" in obj &&
|
"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),
|
createdAt: new Date(row.created_at),
|
||||||
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
updatedAt: row.updated_at ? new Date(row.updated_at) : undefined,
|
||||||
avatarMime: row.avatar_mime ?? undefined,
|
avatarMime: row.avatar_mime ?? undefined,
|
||||||
|
description: row.description ?? undefined,
|
||||||
invitedByUsername: typeof row.invited_by_username === "string"
|
invitedByUsername: typeof row.invited_by_username === "string"
|
||||||
? row.invited_by_username
|
? row.invited_by_username
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -166,6 +171,7 @@ export function userApiToRow(user: User): UserRow {
|
|||||||
created_at: user.createdAt.toISOString(),
|
created_at: user.createdAt.toISOString(),
|
||||||
updated_at: user.updatedAt?.toISOString() ?? null,
|
updated_at: user.updatedAt?.toISOString() ?? null,
|
||||||
avatar_mime: user.avatarMime ?? null,
|
avatar_mime: user.avatarMime ?? null,
|
||||||
|
description: user.description ?? null,
|
||||||
invited_by: null,
|
invited_by: null,
|
||||||
invited_by_username: null,
|
invited_by_username: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export interface User {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
description?: string;
|
||||||
invitedByUsername?: string;
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@ export interface UpdateUserRequest {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest {
|
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" &&
|
return !!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") &&
|
||||||
(!("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 {
|
export interface AuthResponse {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
APIException,
|
APIException,
|
||||||
isLoginUserRequest,
|
isLoginUserRequest,
|
||||||
isRegisterUserRequest,
|
isRegisterUserRequest,
|
||||||
|
isUpdateUserRequest,
|
||||||
type PaginatedData,
|
type PaginatedData,
|
||||||
} from "../model/interfaces.ts";
|
} from "../model/interfaces.ts";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
getUserById,
|
getUserById,
|
||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
searchUsers,
|
searchUsers,
|
||||||
|
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 {
|
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
|
// User search for @mention autocomplete
|
||||||
router.get("/search", (ctx) => {
|
router.get("/search", (ctx) => {
|
||||||
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
const q = (ctx.request.url.searchParams.get("q") ?? "").trim();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { db, isUserRow, userApiToRow, userRowToApi } from "../model/db.ts";
|
|||||||
import { hashPassword } from "../lib/jwt.ts";
|
import { hashPassword } from "../lib/jwt.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.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
|
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`;
|
||||||
@@ -118,24 +118,26 @@ export async function updateUser(
|
|||||||
): Promise<User> {
|
): Promise<User> {
|
||||||
const user = getUserById(userId);
|
const user = getUserById(userId);
|
||||||
|
|
||||||
const { password, ...requestFields } = request;
|
const { password, description, ...requestFields } = request;
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const updatedUser: User = {
|
const updatedUser: User = {
|
||||||
...user,
|
...user,
|
||||||
passwordHash: password ? await hashPassword(password) : user.passwordHash,
|
passwordHash: password ? await hashPassword(password) : user.passwordHash,
|
||||||
...requestFields,
|
...requestFields,
|
||||||
|
description: description ?? user.description,
|
||||||
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 = ?, updated_at = ? WHERE id = ?`,
|
`UPDATE users SET username = ?, password_hash = ?, is_admin = ?, description = ?, updated_at = ? WHERE id = ?`,
|
||||||
).run(
|
).run(
|
||||||
updatedUserRow.username,
|
updatedUserRow.username,
|
||||||
updatedUserRow.password_hash,
|
updatedUserRow.password_hash,
|
||||||
updatedUserRow.is_admin,
|
updatedUserRow.is_admin,
|
||||||
|
updatedUserRow.description,
|
||||||
now.toISOString(),
|
now.toISOString(),
|
||||||
updatedUserRow.id,
|
updatedUserRow.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ CREATE TABLE users (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT,
|
updated_at TEXT,
|
||||||
avatar_mime TEXT,
|
avatar_mime TEXT,
|
||||||
|
description TEXT,
|
||||||
invited_by TEXT REFERENCES users(id)
|
invited_by TEXT REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
66
src/App.css
66
src/App.css
@@ -917,6 +917,8 @@ body.has-player .fab-new {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-content-description {
|
.rich-content-description {
|
||||||
@@ -1200,6 +1202,66 @@ body.has-player .fab-new {
|
|||||||
margin-top: 0.5rem;
|
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 {
|
.profile-invited-by {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
@@ -2356,6 +2418,8 @@ body.has-player .fab-new {
|
|||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
line-height: 1.75;
|
line-height: 1.75;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-detail-meta {
|
.playlist-detail-meta {
|
||||||
@@ -2689,6 +2753,8 @@ body.has-player .fab-new {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.comment-actions {
|
.comment-actions {
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ function CommentNode({
|
|||||||
|
|
||||||
const children = tree.get(comment.id) ?? [];
|
const children = tree.get(comment.id) ?? [];
|
||||||
|
|
||||||
async function handleReply(e: React.FormEvent) {
|
async function handleReply(e?: React.FormEvent) {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
if (!replyBody.trim() || !token) return;
|
if (!replyBody.trim() || !token) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setReplyError(null);
|
setReplyError(null);
|
||||||
@@ -109,8 +109,8 @@ function CommentNode({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditSave(e: React.FormEvent) {
|
async function handleEditSave(e?: React.FormEvent) {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
if (!editBody.trim() || !token) return;
|
if (!editBody.trim() || !token) return;
|
||||||
setEditSubmitting(true);
|
setEditSubmitting(true);
|
||||||
setEditError(null);
|
setEditError(null);
|
||||||
@@ -226,11 +226,7 @@ function CommentNode({
|
|||||||
className="comment-reply-textarea"
|
className="comment-reply-textarea"
|
||||||
value={editBody}
|
value={editBody}
|
||||||
onChange={setEditBody}
|
onChange={setEditBody}
|
||||||
onKeyDown={(e) => {
|
onSubmit={handleEditSave}
|
||||||
if (
|
|
||||||
e.key === "Enter" && (e.ctrlKey || e.metaKey)
|
|
||||||
) handleEditSave(e);
|
|
||||||
}}
|
|
||||||
autoResize
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
@@ -314,11 +310,7 @@ function CommentNode({
|
|||||||
className="comment-reply-textarea"
|
className="comment-reply-textarea"
|
||||||
value={replyBody}
|
value={replyBody}
|
||||||
onChange={setReplyBody}
|
onChange={setReplyBody}
|
||||||
onKeyDown={(e) => {
|
onSubmit={handleReply}
|
||||||
if (
|
|
||||||
e.key === "Enter" && (e.ctrlKey || e.metaKey)
|
|
||||||
) handleReply(e);
|
|
||||||
}}
|
|
||||||
placeholder="Write a reply…"
|
placeholder="Write a reply…"
|
||||||
autoResize
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
@@ -391,8 +383,8 @@ export function CommentThread({
|
|||||||
const tree = buildTree(comments);
|
const tree = buildTree(comments);
|
||||||
const roots = tree.get("root") ?? [];
|
const roots = tree.get("root") ?? [];
|
||||||
|
|
||||||
async function handleTopLevelSubmit(e: React.FormEvent) {
|
async function handleTopLevelSubmit(e?: React.FormEvent) {
|
||||||
e.preventDefault();
|
e?.preventDefault();
|
||||||
if (!topLevelBody.trim() || !token) return;
|
if (!topLevelBody.trim() || !token) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setTopLevelError(null);
|
setTopLevelError(null);
|
||||||
@@ -444,11 +436,7 @@ export function CommentThread({
|
|||||||
className="comment-reply-textarea"
|
className="comment-reply-textarea"
|
||||||
value={topLevelBody}
|
value={topLevelBody}
|
||||||
onChange={setTopLevelBody}
|
onChange={setTopLevelBody}
|
||||||
onKeyDown={(e) => {
|
onSubmit={handleTopLevelSubmit}
|
||||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
||||||
handleTopLevelSubmit(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Add a comment…"
|
placeholder="Add a comment…"
|
||||||
autoResize
|
autoResize
|
||||||
rows={1}
|
rows={1}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface TextEditorProps {
|
|||||||
id?: string;
|
id?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
autoResize?: boolean;
|
autoResize?: boolean;
|
||||||
|
onSubmit?: () => void;
|
||||||
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
id,
|
id,
|
||||||
className,
|
className,
|
||||||
autoResize = false,
|
autoResize = false,
|
||||||
|
onSubmit,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -84,6 +86,11 @@ export const TextEditor = forwardRef<TextEditorHandle, TextEditorProps>(
|
|||||||
detectEmojiTrigger(e.target.value, e.target.selectionStart ?? 0);
|
detectEmojiTrigger(e.target.value, e.target.selectionStart ?? 0);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleMentionKeyDown(e);
|
handleMentionKeyDown(e);
|
||||||
if (!e.defaultPrevented) onKeyDown?.(e);
|
if (!e.defaultPrevented) onKeyDown?.(e);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface User {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
description?: string;
|
||||||
invitedByUsername?: string;
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ export interface PublicUser {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
avatarMime?: string;
|
avatarMime?: string;
|
||||||
|
description?: string;
|
||||||
invitedByUsername?: string;
|
invitedByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +117,7 @@ export interface UpdateUserRequest {
|
|||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
|
description?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthResponse {
|
export interface AuthResponse {
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { DumpCreateModal } from "../components/DumpCreateModal.tsx";
|
|||||||
import { FollowUserButton } from "../components/FollowButton.tsx";
|
import { FollowUserButton } from "../components/FollowButton.tsx";
|
||||||
import { ErrorCard } from "../components/ErrorCard.tsx";
|
import { ErrorCard } from "../components/ErrorCard.tsx";
|
||||||
import { friendlyFetchError } from "../utils/apiError.ts";
|
import { friendlyFetchError } from "../utils/apiError.ts";
|
||||||
|
import { TextEditor } from "../components/TextEditor.tsx";
|
||||||
|
import { Markdown } from "../components/Markdown.tsx";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
@@ -246,6 +248,11 @@ export function UserPublicProfile() {
|
|||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [avatarError, setAvatarError] = useState<string | null>(null);
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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);
|
const prevMyVotesRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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") {
|
if (state.status === "loading") {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -613,6 +650,76 @@ export function UserPublicProfile() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="profile-columns">
|
||||||
<DumpList
|
<DumpList
|
||||||
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
|
title={`Dumps (${dumps.items.length}${dumps.hasMore ? "+" : ""})`}
|
||||||
|
|||||||
Reference in New Issue
Block a user