diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 6bcbccfa..9bb22cd7 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -85,6 +85,14 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'banner', type: 'TEXT' } ] + }, + { + name: 'users', + columns: [ + { name: 'banner', type: 'TEXT' }, + { name: 'accent_color', type: 'TEXT' }, + { name: 'bio', type: 'TEXT' }, + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index f49b4dc1..f868c731 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -12,6 +12,9 @@ export const users = sqliteTable('users', { homeInstance: text('home_instance'), homeUserId: text('home_user_id'), replicatedInstances: text('replicated_instances').default('[]'), + banner: text('banner'), + accentColor: text('accent_color'), + bio: text('bio'), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index b291d54a..1c27e99a 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq } from 'drizzle-orm'; +import { eq, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate, verifyPassword } from '../utils/auth.js'; import { connectionManager } from '../ws/handler.js'; @@ -38,7 +38,7 @@ export async function userRoutes(app: FastifyInstance): Promise { }); app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { - const { displayName, avatar, customStatus, status, replicatedInstances, homeUserId } = request.body; + const { displayName, avatar, banner, accentColor, bio, customStatus, status, replicatedInstances, homeUserId } = request.body; const db = getDb(); const updateData: Record = {}; @@ -59,6 +59,38 @@ export async function userRoutes(app: FastifyInstance): Promise { updateData.avatar = avatar; } + if (banner !== undefined) { + if (banner && typeof banner === 'string' && banner.trim().length > 0) { + updateData.banner = banner.trim(); + } else { + updateData.banner = null; + } + } + + if (accentColor !== undefined) { + if (accentColor && typeof accentColor === 'string' && accentColor.trim().length > 0) { + const hex = accentColor.trim(); + if (!/^#[0-9a-fA-F]{6}$/.test(hex)) { + return reply.code(400).send({ error: 'Accent color must be a valid hex color (e.g. #ff0000)', statusCode: 400 }); + } + updateData.accentColor = hex; + } else { + updateData.accentColor = null; + } + } + + if (bio !== undefined) { + if (bio && typeof bio === 'string') { + const trimmed = bio.trim(); + if (trimmed.length > 190) { + return reply.code(400).send({ error: 'Bio must be 190 characters or less', statusCode: 400 }); + } + updateData.bio = trimmed || null; + } else { + updateData.bio = null; + } + } + if (customStatus !== undefined) { if (customStatus !== null && typeof customStatus === 'string') { const trimmed = customStatus.trim(); @@ -149,4 +181,37 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(200).send(sanitizeUser(user)); }); + + app.get<{ Params: { id: string } }>('/api/users/:id/mutuals', { preHandler: authenticate }, async (request, reply) => { + const { id: targetId } = request.params; + const myId = request.userId; + const db = getDb(); + + // Mutual friends: users who are friends with both me and the target + const myFriendRows = db.select().from(schema.friends).where(eq(schema.friends.userId, myId)).all(); + const targetFriendRows = db.select().from(schema.friends).where(eq(schema.friends.userId, targetId)).all(); + const myFriendIds = new Set(myFriendRows.map((f) => f.friendId)); + const targetFriendIds = new Set(targetFriendRows.map((f) => f.friendId)); + const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id)); + + const mutualFriends = mutualFriendIds.length > 0 + ? db.select().from(schema.users).where(inArray(schema.users.id, mutualFriendIds)).all().map(sanitizeUser) + : []; + + // Mutual spaces: spaces both me and the target are members of + const myMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, myId)).all(); + const targetMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, targetId)).all(); + const mySpaceIds = new Set(myMemberships.map((m) => m.spaceId)); + const targetSpaceIds = new Set(targetMemberships.map((m) => m.spaceId)); + const mutualSpaceIds = [...mySpaceIds].filter((id) => targetSpaceIds.has(id)); + + const mutualSpaces = mutualSpaceIds.length > 0 + ? db.select({ id: schema.spaces.id, name: schema.spaces.name, icon: schema.spaces.icon }) + .from(schema.spaces) + .where(inArray(schema.spaces.id, mutualSpaceIds)) + .all() + : []; + + return reply.code(200).send({ mutualFriends, mutualSpaces }); + }); } diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index 07544764..72a8be3b 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -16,6 +16,9 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User { username: row.username, displayName: row.displayName, avatar: row.avatar, + banner: row.banner ?? null, + accentColor: row.accentColor ?? null, + bio: row.bio ?? null, status: (row.status ?? 'offline') as User['status'], customStatus: row.customStatus, isAdmin: row.isAdmin === 1, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4ac0e0c8..e2ade358 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -5,6 +5,9 @@ export interface User { username: string; displayName: string | null; avatar: string | null; + banner: string | null; + accentColor: string | null; + bio: string | null; status: UserStatus; customStatus: string | null; isAdmin: boolean; @@ -340,6 +343,9 @@ export interface UpdateSpaceRequest { export interface UpdateUserRequest { displayName?: string; avatar?: string; + banner?: string; + accentColor?: string; + bio?: string; customStatus?: string; status?: UserStatus; replicatedInstances?: ReplicatedInstance[]; @@ -404,6 +410,9 @@ export interface Friend { username: string; displayName: string | null; avatar: string | null; + banner: string | null; + accentColor: string | null; + bio: string | null; status: UserStatus; customStatus: string | null; createdAt: number; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 2d027599..624062a1 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -45,6 +45,7 @@ export class BackspaceApiClient { update: (data: UpdateUserRequest) => Promise; get: (id: string) => Promise; verifyPassword: (password: string) => Promise; + getMutuals: (id: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>; }; readonly spaces: { @@ -210,6 +211,8 @@ export class BackspaceApiClient { get: (id: string) => request('GET', `/users/${id}`), verifyPassword: (password: string) => request('POST', '/users/@me/verify-password', { password }), + getMutuals: (id: string) => + request<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>('GET', `/users/${id}/mutuals`), }; this.spaces = { diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx index 2e769982..9dc5dd2d 100644 --- a/packages/web/src/components/chat/FriendsPage.test.tsx +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -48,6 +48,9 @@ const makeFriend = (overrides: Partial = {}): TaggedFriend => ({ username: 'testfriend', displayName: 'Test Friend', avatar: null, + banner: null, + accentColor: null, + bio: null, status: 'online', customStatus: null, createdAt: Date.now(), @@ -70,6 +73,9 @@ const makeRequest = (overrides: Partial = {}): TaggedFriend username: 'otheruser', displayName: 'Other User', avatar: null, + banner: null, + accentColor: null, + bio: null, status: 'online', customStatus: null, isAdmin: false, @@ -227,6 +233,9 @@ describe('FriendsPage', () => { username: 'recipient', displayName: 'Recipient', avatar: null, + banner: null, + accentColor: null, + bio: null, status: 'online', customStatus: null, isAdmin: false, @@ -275,6 +284,9 @@ describe('FriendsPage', () => { username: 'sender', displayName: 'Sender', avatar: null, + banner: null, + accentColor: null, + bio: null, status: 'online', customStatus: null, isAdmin: false, @@ -318,6 +330,9 @@ describe('FriendsPage', () => { username: 'sender2', displayName: 'Sender 2', avatar: null, + banner: null, + accentColor: null, + bio: null, status: 'online', customStatus: null, isAdmin: false, diff --git a/packages/web/src/components/layout/ActivityPanel.tsx b/packages/web/src/components/layout/ActivityPanel.tsx index ff191f39..ee4445da 100644 --- a/packages/web/src/components/layout/ActivityPanel.tsx +++ b/packages/web/src/components/layout/ActivityPanel.tsx @@ -33,6 +33,9 @@ export function ActivityPanel() { username: friend.username, displayName: friend.displayName, avatar: friend.avatar, + banner: friend.banner, + accentColor: friend.accentColor, + bio: friend.bio, status: friend.status, customStatus: friend.customStatus, createdAt: friend.createdAt, diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index 2588af55..852d93f9 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -15,6 +15,7 @@ import { SpaceSettingsModal } from '../modals/SpaceSettings'; import { ChannelSettingsModal } from '../modals/ChannelSettingsModal'; import { NewDmModal } from '../modals/NewDmModal'; import { AddDmMemberModal } from '../modals/AddDmMemberModal'; +import { UserProfileModal } from '../modals/UserProfileModal'; import { IncomingCallModal } from '../voice/IncomingCallModal'; import { PictureInPicture } from '../voice/PictureInPicture'; import { SoundController } from '../voice/SoundController'; @@ -276,6 +277,7 @@ export function AppLayout() { + diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx new file mode 100644 index 00000000..81cd68d9 --- /dev/null +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -0,0 +1,412 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import ReactMarkdown from 'react-markdown'; +import type { User } from '@backspace/shared'; +import { Avatar } from '../ui/Avatar'; +import { Username } from '../ui/Username'; +import { api } from '../../api/client'; +import { useUIStore } from '../../stores/uiStore'; +import { useSpaceStore } from '../../stores/spaceStore'; +import { useSocialStore } from '../../stores/socialStore'; +import { getAvatarGradient, adjustColor } from '../../utils/gradients'; +import { parseFederatedUsername } from '../../utils/identity'; + +type Tab = 'about' | 'friends' | 'spaces'; + +interface MutualSpace { + id: string; + name: string; + icon: string | null; +} + +export function UserProfileModal() { + const activeModal = useUIStore((s) => s.activeModal); + const modalData = useUIStore((s) => s.modalData); + const closeModal = useUIStore((s) => s.closeModal); + const navigate = useNavigate(); + const addDmChannel = useSpaceStore((s) => s.addDmChannel); + const friends = useSocialStore((s) => s.friends); + const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest); + const removeFriend = useSocialStore((s) => s.removeFriend); + + const [user, setUser] = useState(null); + const [activeTab, setActiveTab] = useState('about'); + const [mutualFriends, setMutualFriends] = useState([]); + const [mutualSpaces, setMutualSpaces] = useState([]); + const [loadingMutuals, setLoadingMutuals] = useState(false); + const [friendActionLoading, setFriendActionLoading] = useState(false); + + const isOpen = activeModal === 'userProfile'; + const userId = modalData?.userId as string | undefined; + + // Determine friendship status + const isFriend = user ? friends.some((f) => f.id === user.id) : false; + + const loadUser = useCallback(async (id: string) => { + try { + const u = await api.users.get(id); + setUser(u); + } catch { + // User not found + } + }, []); + + const loadMutuals = useCallback(async (id: string) => { + setLoadingMutuals(true); + try { + const data = await api.users.getMutuals(id); + setMutualFriends(data.mutualFriends); + setMutualSpaces(data.mutualSpaces); + } catch { + setMutualFriends([]); + setMutualSpaces([]); + } finally { + setLoadingMutuals(false); + } + }, []); + + useEffect(() => { + if (isOpen && userId) { + setActiveTab('about'); + loadUser(userId); + loadMutuals(userId); + } + }, [isOpen, userId, loadUser, loadMutuals]); + + // Reset on close + useEffect(() => { + if (!isOpen) { + setUser(null); + setMutualFriends([]); + setMutualSpaces([]); + } + }, [isOpen]); + + // Escape to close + useEffect(() => { + if (!isOpen) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeModal(); + }; + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, [isOpen, closeModal]); + + if (!isOpen || !user) return null; + + const { baseName, domain } = parseFederatedUsername(user.username); + const displayName = user.displayName ?? baseName; + + // Banner + const bannerSrc = user.banner + ? (user.banner.startsWith('http') ? user.banner : api.uploads.url(user.banner)) + : null; + const bannerFallback = user.accentColor + ? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})` + : getAvatarGradient(user.homeUserId ?? user.id, displayName).gradient; + + const handleSendMessage = async () => { + try { + const existing = useSpaceStore.getState().findExistingDmForUser(user); + if (existing) { + useUIStore.getState().setShowDms(true); + closeModal(); + navigate(`/channels/@me/${existing.dm.id}`); + return; + } + const channel = await api.dm.create({ userId: user.id }); + addDmChannel(channel); + useUIStore.getState().setShowDms(true); + closeModal(); + navigate(`/channels/@me/${channel.id}`); + } catch (err) { + console.error('Failed to create DM channel:', err); + } + }; + + const handleFriendAction = async () => { + setFriendActionLoading(true); + try { + if (isFriend) { + await removeFriend(user.id); + } else { + await sendFriendRequest(user.username); + } + } catch { + // Silently fail + } finally { + setFriendActionLoading(false); + } + }; + + const handleViewFriend = (friendId: string) => { + loadUser(friendId); + loadMutuals(friendId); + setActiveTab('about'); + // Update modal data so re-opening preserves context + useUIStore.getState().openModal('userProfile', { userId: friendId }); + }; + + const handleGoToSpace = (spaceId: string) => { + closeModal(); + navigate(`/channels/${spaceId}`); + }; + + const tabs: { key: Tab; label: string; count?: number }[] = [ + { key: 'about', label: 'About' }, + { key: 'friends', label: 'Mutual Friends', count: mutualFriends.length }, + { key: 'spaces', label: 'Mutual Spaces', count: mutualSpaces.length }, + ]; + + return ( +
+
+
+ {/* Banner */} +
+ {/* Close button */} + +
+ + {/* Header (avatar + name) */} +
+
+ +
+ +
+ +
+ {domain ? ( + + ) : ( + @{baseName} + )} +
+ {user.customStatus && ( +
+ {user.customStatus} +
+ )} +
+
+ + {/* Tab bar */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Tab content */} +
+ {activeTab === 'about' && ( +
+ {/* Bio */} + {user.bio && ( +
+ + About Me + +
+ + {user.bio} + +
+
+ )} + + {/* Member Since */} +
+ + Member Since + +
+ {new Date(user.createdAt).toLocaleDateString(undefined, { + month: 'long', + day: 'numeric', + year: 'numeric', + })} +
+
+ + {/* Accent color */} + {user.accentColor && ( +
+ + Accent Color + +
+
+ + {user.accentColor} + +
+
+ )} +
+ )} + + {activeTab === 'friends' && ( +
+ {loadingMutuals ? ( +
+ + + + +
+ ) : mutualFriends.length === 0 ? ( +
+ No mutual friends +
+ ) : ( +
+ {mutualFriends.map((friend) => { + const fname = friend.displayName ?? parseFederatedUsername(friend.username).baseName; + return ( + + ); + })} +
+ )} +
+ )} + + {activeTab === 'spaces' && ( +
+ {loadingMutuals ? ( +
+ + + + +
+ ) : mutualSpaces.length === 0 ? ( +
+ No mutual spaces +
+ ) : ( +
+ {mutualSpaces.map((space) => ( + + ))} +
+ )} +
+ )} +
+ + {/* Action buttons */} +
+ + +
+
+
+ ); +} diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index de9bdc02..92c1f456 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -1,8 +1,18 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useAuthStore } from '../../../stores/authStore'; import { Avatar } from '../../ui/Avatar'; +import { ImageCropModal } from '../../ui/ImageCropModal'; +import { api } from '../../../api/client'; +import { getAvatarGradient, adjustColor } from '../../../utils/gradients'; import type { UserStatus } from '@backspace/shared'; +const ACCENT_PRESETS = [ + '#86efac', '#fca5a5', '#c4b5fd', '#7dd3fc', + '#fcd34d', '#fda4af', '#fb923c', '#7c6cf6', + '#ef4444', '#f97316', '#22d3ee', '#a3e635', + '#f472b6', '#818cf8', '#2dd4bf', '#e879f9', +]; + export function AccountPanel() { const user = useAuthStore((s) => s.user); const updateProfile = useAuthStore((s) => s.updateProfile); @@ -10,36 +20,163 @@ export function AccountPanel() { const [displayName, setDisplayName] = useState(user?.displayName ?? ''); const [customStatus, setCustomStatus] = useState(user?.customStatus ?? ''); const [status, setStatus] = useState(user?.status ?? 'online'); + const [bio, setBio] = useState(user?.bio ?? ''); + const [accentColor, setAccentColor] = useState(user?.accentColor ?? null); + const [customHex, setCustomHex] = useState(user?.accentColor ?? ''); + + // Avatar upload state + const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarFilename, setAvatarFilename] = useState(null); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const [avatarCropSrc, setAvatarCropSrc] = useState(null); + const avatarInputRef = useRef(null); + + // Banner upload state + const [bannerPreview, setBannerPreview] = useState(null); + const [bannerFilename, setBannerFilename] = useState(null); + const [uploadingBanner, setUploadingBanner] = useState(false); + const [bannerCropSrc, setBannerCropSrc] = useState(null); + const bannerInputRef = useRef(null); + const [error, setError] = useState(''); const [success, setSuccess] = useState(''); const [isLoading, setIsLoading] = useState(false); - // Reset form when user data changes (e.g. after external update) useEffect(() => { if (user) { setDisplayName(user.displayName ?? ''); setCustomStatus(user.customStatus ?? ''); setStatus(user.status ?? 'online'); + setBio(user.bio ?? ''); + setAccentColor(user.accentColor ?? null); + setCustomHex(user.accentColor ?? ''); + // Reset upload state + if (avatarPreview) URL.revokeObjectURL(avatarPreview); + if (bannerPreview) URL.revokeObjectURL(bannerPreview); + setAvatarPreview(null); + setAvatarFilename(null); + setBannerPreview(null); + setBannerFilename(null); } - }, [user]); + }, [user?.displayName, user?.customStatus, user?.status, user?.bio, user?.accentColor, user?.avatar, user?.banner]); if (!user) return null; + const effectiveDisplayName = displayName.trim() || user.username; + const effectiveAccent = accentColor; + + // Change detection const hasChanges = displayName !== (user.displayName ?? '') || customStatus !== (user.customStatus ?? '') || - status !== (user.status ?? 'online'); + status !== (user.status ?? 'online') || + bio !== (user.bio ?? '') || + accentColor !== (user.accentColor ?? null) || + avatarFilename !== null || + bannerFilename !== null; + + // Compute banner display + const currentBannerUrl = user.banner + ? (user.banner.startsWith('http') ? user.banner : api.uploads.url(user.banner)) + : null; + const displayBannerSrc = bannerPreview ?? (bannerFilename === '' ? null : currentBannerUrl); + + // Compute avatar display + const currentAvatarSrc = user.avatar + ? (user.avatar.startsWith('http') ? user.avatar : api.uploads.url(user.avatar)) + : null; + const displayAvatarSrc = avatarPreview ?? (avatarFilename === '' ? null : currentAvatarSrc); + + // Banner fallback: accent gradient or avatar gradient + const bannerFallback = effectiveAccent + ? `linear-gradient(135deg, ${effectiveAccent}, ${adjustColor(effectiveAccent, -40)})` + : getAvatarGradient(user.homeUserId ?? user.id, effectiveDisplayName).gradient; + + // ── File selection handlers ── + const handleAvatarSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => setAvatarCropSrc(reader.result as string); + reader.readAsDataURL(file); + if (avatarInputRef.current) avatarInputRef.current.value = ''; + }; + + const handleBannerSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => setBannerCropSrc(reader.result as string); + reader.readAsDataURL(file); + if (bannerInputRef.current) bannerInputRef.current.value = ''; + }; + + // ── Crop complete handlers ── + const handleAvatarCropComplete = async (blob: Blob) => { + if (avatarPreview) URL.revokeObjectURL(avatarPreview); + const previewUrl = URL.createObjectURL(blob); + setAvatarPreview(previewUrl); + setAvatarCropSrc(null); + const file = new File([blob], 'avatar.png', { type: 'image/png' }); + setUploadingAvatar(true); + try { + const attachment = await api.uploads.upload(file); + setAvatarFilename(attachment.filename); + } catch { + setError('Failed to upload avatar'); + setAvatarPreview(null); + URL.revokeObjectURL(previewUrl); + } finally { + setUploadingAvatar(false); + } + }; + + const handleBannerCropComplete = async (blob: Blob) => { + if (bannerPreview) URL.revokeObjectURL(bannerPreview); + const previewUrl = URL.createObjectURL(blob); + setBannerPreview(previewUrl); + setBannerCropSrc(null); + const file = new File([blob], 'banner.png', { type: 'image/png' }); + setUploadingBanner(true); + try { + const attachment = await api.uploads.upload(file); + setBannerFilename(attachment.filename); + } catch { + setError('Failed to upload banner'); + setBannerPreview(null); + URL.revokeObjectURL(previewUrl); + } finally { + setUploadingBanner(false); + } + }; + + const handleRemoveAvatar = () => { + if (avatarPreview) URL.revokeObjectURL(avatarPreview); + setAvatarPreview(null); + setAvatarFilename(''); + }; + + const handleRemoveBanner = () => { + if (bannerPreview) URL.revokeObjectURL(bannerPreview); + setBannerPreview(null); + setBannerFilename(''); + }; const handleSave = async () => { setError(''); setSuccess(''); setIsLoading(true); try { - await updateProfile({ - displayName: displayName.trim(), - customStatus: customStatus.trim(), - status, - }); + const updates: Record = {}; + if (displayName !== (user.displayName ?? '')) updates.displayName = displayName.trim(); + if (customStatus !== (user.customStatus ?? '')) updates.customStatus = customStatus.trim(); + if (status !== (user.status ?? 'online')) updates.status = status; + if (bio !== (user.bio ?? '')) updates.bio = bio.trim(); + if (accentColor !== (user.accentColor ?? null)) updates.accentColor = accentColor ?? ''; + if (avatarFilename !== null) updates.avatar = avatarFilename; + if (bannerFilename !== null) updates.banner = bannerFilename; + + await updateProfile(updates as Parameters[0]); setSuccess('Profile updated!'); setTimeout(() => setSuccess(''), 2000); } catch (err) { @@ -53,44 +190,279 @@ export function AccountPanel() { setDisplayName(user.displayName ?? ''); setCustomStatus(user.customStatus ?? ''); setStatus(user.status ?? 'online'); + setBio(user.bio ?? ''); + setAccentColor(user.accentColor ?? null); + setCustomHex(user.accentColor ?? ''); + if (avatarPreview) URL.revokeObjectURL(avatarPreview); + if (bannerPreview) URL.revokeObjectURL(bannerPreview); + setAvatarPreview(null); + setAvatarFilename(null); + setBannerPreview(null); + setBannerFilename(null); setError(''); }; return (
- {/* Profile preview */} -
- -
-
{user.displayName ?? user.username}
-
@{user.username}
- {user.customStatus && ( -
{user.customStatus}
- )} + {/* ── Profile Customization ── */} +
+
+ Profile Customization +
+ + {/* Live Preview Card */} +
+ {/* Banner area */} +
+ {/* Avatar + info */} +
+
+ {displayAvatarSrc ? ( + Avatar + ) : ( + + )} +
+
+ {effectiveDisplayName} +
+
@{user.username}
+ {bio.trim() && ( +
+ {bio.trim()} +
+ )} +
+
+ + {/* Upload controls */} +
+ {/* Avatar upload */} +
+ +
+ +
+ + {(displayAvatarSrc || user.avatar) && avatarFilename !== '' && ( + + )} +
+
+ +
+ + {/* Banner upload */} +
+ + +
+ + {(displayBannerSrc || user.banner) && bannerFilename !== '' && ( + + )} +
+ +
+ + {/* Accent Color */} +
+ +
+ {ACCENT_PRESETS.map((color) => ( +
+
+ { + const val = e.target.value; + setCustomHex(val); + if (/^#[0-9a-fA-F]{6}$/.test(val)) { + setAccentColor(val); + } + }} + placeholder="#hex" + className="w-24 px-2 py-1.5 bg-surface-input rounded text-xs text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary font-mono" + maxLength={7} + /> + {accentColor && ( +
+ )} + {accentColor && ( + + )} +
+
+ + {/* Bio */} +
+ +
+