feat: user profile customization with banner, accent color, bio, and full profile modal
Add banner image, accent color, and bio fields to user profiles with full-stack support: schema migration, API validation (hex color format, 190-char bio limit), sanitizeUser propagation, and new GET /users/:id/mutuals endpoint. Rewrite AccountPanel with live preview card, avatar/banner upload via ImageCropModal, 16-preset accent color picker, and bio editor. Enhance UserProfilePopout with banner display, accent-colored names, bio rendering, and mutual counts. Add new UserProfileModal with About/Mutual Friends/Mutual Spaces tabs and friend action buttons.
This commit is contained in:
@@ -85,6 +85,14 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
columns: [
|
columns: [
|
||||||
{ name: 'banner', type: 'TEXT' }
|
{ name: 'banner', type: 'TEXT' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'users',
|
||||||
|
columns: [
|
||||||
|
{ name: 'banner', type: 'TEXT' },
|
||||||
|
{ name: 'accent_color', type: 'TEXT' },
|
||||||
|
{ name: 'bio', type: 'TEXT' },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export const users = sqliteTable('users', {
|
|||||||
homeInstance: text('home_instance'),
|
homeInstance: text('home_instance'),
|
||||||
homeUserId: text('home_user_id'),
|
homeUserId: text('home_user_id'),
|
||||||
replicatedInstances: text('replicated_instances').default('[]'),
|
replicatedInstances: text('replicated_instances').default('[]'),
|
||||||
|
banner: text('banner'),
|
||||||
|
accentColor: text('accent_color'),
|
||||||
|
bio: text('bio'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, inArray } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate, verifyPassword } from '../utils/auth.js';
|
import { authenticate, verifyPassword } from '../utils/auth.js';
|
||||||
import { connectionManager } from '../ws/handler.js';
|
import { connectionManager } from '../ws/handler.js';
|
||||||
@@ -38,7 +38,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
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 db = getDb();
|
||||||
|
|
||||||
const updateData: Record<string, string | null | undefined> = {};
|
const updateData: Record<string, string | null | undefined> = {};
|
||||||
@@ -59,6 +59,38 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
updateData.avatar = avatar;
|
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 !== undefined) {
|
||||||
if (customStatus !== null && typeof customStatus === 'string') {
|
if (customStatus !== null && typeof customStatus === 'string') {
|
||||||
const trimmed = customStatus.trim();
|
const trimmed = customStatus.trim();
|
||||||
@@ -149,4 +181,37 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
return reply.code(200).send(sanitizeUser(user));
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
|
|||||||
username: row.username,
|
username: row.username,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
avatar: row.avatar,
|
avatar: row.avatar,
|
||||||
|
banner: row.banner ?? null,
|
||||||
|
accentColor: row.accentColor ?? null,
|
||||||
|
bio: row.bio ?? null,
|
||||||
status: (row.status ?? 'offline') as User['status'],
|
status: (row.status ?? 'offline') as User['status'],
|
||||||
customStatus: row.customStatus,
|
customStatus: row.customStatus,
|
||||||
isAdmin: row.isAdmin === 1,
|
isAdmin: row.isAdmin === 1,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ export interface User {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
accentColor: string | null;
|
||||||
|
bio: string | null;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@@ -340,6 +343,9 @@ export interface UpdateSpaceRequest {
|
|||||||
export interface UpdateUserRequest {
|
export interface UpdateUserRequest {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
banner?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
bio?: string;
|
||||||
customStatus?: string;
|
customStatus?: string;
|
||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
replicatedInstances?: ReplicatedInstance[];
|
replicatedInstances?: ReplicatedInstance[];
|
||||||
@@ -404,6 +410,9 @@ export interface Friend {
|
|||||||
username: string;
|
username: string;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
|
banner: string | null;
|
||||||
|
accentColor: string | null;
|
||||||
|
bio: string | null;
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
customStatus: string | null;
|
customStatus: string | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export class BackspaceApiClient {
|
|||||||
update: (data: UpdateUserRequest) => Promise<User>;
|
update: (data: UpdateUserRequest) => Promise<User>;
|
||||||
get: (id: string) => Promise<User>;
|
get: (id: string) => Promise<User>;
|
||||||
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
|
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
|
||||||
|
getMutuals: (id: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly spaces: {
|
readonly spaces: {
|
||||||
@@ -210,6 +211,8 @@ export class BackspaceApiClient {
|
|||||||
get: (id: string) => request<User>('GET', `/users/${id}`),
|
get: (id: string) => request<User>('GET', `/users/${id}`),
|
||||||
verifyPassword: (password: string) =>
|
verifyPassword: (password: string) =>
|
||||||
request<VerifyPasswordResponse>('POST', '/users/@me/verify-password', { password }),
|
request<VerifyPasswordResponse>('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 = {
|
this.spaces = {
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ const makeFriend = (overrides: Partial<TaggedFriend> = {}): TaggedFriend => ({
|
|||||||
username: 'testfriend',
|
username: 'testfriend',
|
||||||
displayName: 'Test Friend',
|
displayName: 'Test Friend',
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
banner: null,
|
||||||
|
accentColor: null,
|
||||||
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
@@ -70,6 +73,9 @@ const makeRequest = (overrides: Partial<TaggedFriendRequest> = {}): TaggedFriend
|
|||||||
username: 'otheruser',
|
username: 'otheruser',
|
||||||
displayName: 'Other User',
|
displayName: 'Other User',
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
banner: null,
|
||||||
|
accentColor: null,
|
||||||
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
@@ -227,6 +233,9 @@ describe('FriendsPage', () => {
|
|||||||
username: 'recipient',
|
username: 'recipient',
|
||||||
displayName: 'Recipient',
|
displayName: 'Recipient',
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
banner: null,
|
||||||
|
accentColor: null,
|
||||||
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
@@ -275,6 +284,9 @@ describe('FriendsPage', () => {
|
|||||||
username: 'sender',
|
username: 'sender',
|
||||||
displayName: 'Sender',
|
displayName: 'Sender',
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
banner: null,
|
||||||
|
accentColor: null,
|
||||||
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
@@ -318,6 +330,9 @@ describe('FriendsPage', () => {
|
|||||||
username: 'sender2',
|
username: 'sender2',
|
||||||
displayName: 'Sender 2',
|
displayName: 'Sender 2',
|
||||||
avatar: null,
|
avatar: null,
|
||||||
|
banner: null,
|
||||||
|
accentColor: null,
|
||||||
|
bio: null,
|
||||||
status: 'online',
|
status: 'online',
|
||||||
customStatus: null,
|
customStatus: null,
|
||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export function ActivityPanel() {
|
|||||||
username: friend.username,
|
username: friend.username,
|
||||||
displayName: friend.displayName,
|
displayName: friend.displayName,
|
||||||
avatar: friend.avatar,
|
avatar: friend.avatar,
|
||||||
|
banner: friend.banner,
|
||||||
|
accentColor: friend.accentColor,
|
||||||
|
bio: friend.bio,
|
||||||
status: friend.status,
|
status: friend.status,
|
||||||
customStatus: friend.customStatus,
|
customStatus: friend.customStatus,
|
||||||
createdAt: friend.createdAt,
|
createdAt: friend.createdAt,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SpaceSettingsModal } from '../modals/SpaceSettings';
|
|||||||
import { ChannelSettingsModal } from '../modals/ChannelSettingsModal';
|
import { ChannelSettingsModal } from '../modals/ChannelSettingsModal';
|
||||||
import { NewDmModal } from '../modals/NewDmModal';
|
import { NewDmModal } from '../modals/NewDmModal';
|
||||||
import { AddDmMemberModal } from '../modals/AddDmMemberModal';
|
import { AddDmMemberModal } from '../modals/AddDmMemberModal';
|
||||||
|
import { UserProfileModal } from '../modals/UserProfileModal';
|
||||||
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
||||||
import { PictureInPicture } from '../voice/PictureInPicture';
|
import { PictureInPicture } from '../voice/PictureInPicture';
|
||||||
import { SoundController } from '../voice/SoundController';
|
import { SoundController } from '../voice/SoundController';
|
||||||
@@ -276,6 +277,7 @@ export function AppLayout() {
|
|||||||
<ChannelSettingsModal />
|
<ChannelSettingsModal />
|
||||||
<NewDmModal />
|
<NewDmModal />
|
||||||
<AddDmMemberModal />
|
<AddDmMemberModal />
|
||||||
|
<UserProfileModal />
|
||||||
<IncomingCallModal />
|
<IncomingCallModal />
|
||||||
<ImagePreview />
|
<ImagePreview />
|
||||||
<PictureInPicture />
|
<PictureInPicture />
|
||||||
|
|||||||
@@ -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<User | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('about');
|
||||||
|
const [mutualFriends, setMutualFriends] = useState<User[]>([]);
|
||||||
|
const [mutualSpaces, setMutualSpaces] = useState<MutualSpace[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center animate-fade-in">
|
||||||
|
<div className="absolute inset-0 bg-surface-overlay" onClick={closeModal} />
|
||||||
|
<div className="relative max-w-lg w-full mx-4 max-h-[calc(100vh-2rem)] flex flex-col bg-surface-elevated rounded-lg shadow-xl animate-slide-up overflow-hidden">
|
||||||
|
{/* Banner */}
|
||||||
|
<div
|
||||||
|
className="h-[100px] flex-shrink-0 relative"
|
||||||
|
style={bannerSrc
|
||||||
|
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
|
: { background: bannerFallback, opacity: 0.6 }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
onClick={closeModal}
|
||||||
|
className="absolute top-2 right-2 w-8 h-8 rounded-full bg-black/40 hover:bg-black/60 flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="white">
|
||||||
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header (avatar + name) */}
|
||||||
|
<div className="px-5 flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className="mt-[-48px] mb-2 w-fit rounded-full"
|
||||||
|
style={{ border: '4px solid var(--color-surface-elevated, #1e1e2a)' }}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={user.avatar}
|
||||||
|
name={displayName}
|
||||||
|
size={96}
|
||||||
|
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||||
|
userId={user.homeUserId ?? user.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<Username
|
||||||
|
username={displayName}
|
||||||
|
className="text-[20px] font-bold leading-tight"
|
||||||
|
style={user.accentColor ? { color: user.accentColor } : undefined}
|
||||||
|
/>
|
||||||
|
<div className="text-[14px] text-txt-tertiary mt-0.5">
|
||||||
|
{domain ? (
|
||||||
|
<Username username={user.username} className="text-[14px] text-txt-tertiary" />
|
||||||
|
) : (
|
||||||
|
<span>@{baseName}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{user.customStatus && (
|
||||||
|
<div className="text-[13px] text-txt-secondary italic mt-1">
|
||||||
|
{user.customStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="px-5 flex-shrink-0 border-b border-white/[0.06]">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-3 py-2 text-[13px] font-medium rounded-t-lg transition-colors relative ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'text-txt-primary'
|
||||||
|
: 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && !loadingMutuals && (
|
||||||
|
<span className="ml-1 text-[11px] text-txt-tertiary">({tab.count})</span>
|
||||||
|
)}
|
||||||
|
{activeTab === tab.key && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[2px] bg-accent-primary rounded-full" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin p-5 min-h-[200px]">
|
||||||
|
{activeTab === 'about' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Bio */}
|
||||||
|
{user.bio && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
|
About Me
|
||||||
|
</span>
|
||||||
|
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
|
||||||
|
<ReactMarkdown
|
||||||
|
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||||
|
unwrapDisallowed
|
||||||
|
>
|
||||||
|
{user.bio}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Member Since */}
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
|
Member Since
|
||||||
|
</span>
|
||||||
|
<div className="text-[13px] text-txt-secondary mt-1">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent color */}
|
||||||
|
{user.accentColor && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
|
Accent Color
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-full border border-white/10"
|
||||||
|
style={{ backgroundColor: user.accentColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-[12px] text-txt-tertiary font-mono">
|
||||||
|
{user.accentColor}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'friends' && (
|
||||||
|
<div>
|
||||||
|
{loadingMutuals ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<svg className="animate-spin w-5 h-5 text-txt-tertiary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : mutualFriends.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-txt-tertiary text-[13px]">
|
||||||
|
No mutual friends
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{mutualFriends.map((friend) => {
|
||||||
|
const fname = friend.displayName ?? parseFederatedUsername(friend.username).baseName;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={friend.id}
|
||||||
|
onClick={() => handleViewFriend(friend.id)}
|
||||||
|
className="flex items-center gap-2.5 p-2.5 rounded-lg bg-white/[0.03] hover:bg-white/[0.06] border border-white/[0.04] transition-colors text-left"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={friend.avatar}
|
||||||
|
name={fname}
|
||||||
|
size={40}
|
||||||
|
status={friend.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||||
|
userId={friend.homeUserId ?? friend.id}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[13px] font-medium text-txt-primary truncate">
|
||||||
|
{fname}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-txt-tertiary capitalize">
|
||||||
|
{friend.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'spaces' && (
|
||||||
|
<div>
|
||||||
|
{loadingMutuals ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<svg className="animate-spin w-5 h-5 text-txt-tertiary" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
) : mutualSpaces.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-txt-tertiary text-[13px]">
|
||||||
|
No mutual spaces
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{mutualSpaces.map((space) => (
|
||||||
|
<button
|
||||||
|
key={space.id}
|
||||||
|
onClick={() => handleGoToSpace(space.id)}
|
||||||
|
className="flex items-center gap-3 w-full p-2.5 rounded-lg hover:bg-white/[0.06] transition-colors text-left"
|
||||||
|
>
|
||||||
|
{space.icon ? (
|
||||||
|
<img
|
||||||
|
src={space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon)}
|
||||||
|
alt={space.name}
|
||||||
|
className="w-8 h-8 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-white/[0.06] flex items-center justify-center text-[13px] font-semibold text-txt-secondary">
|
||||||
|
{space.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-[13px] font-medium text-txt-primary truncate">
|
||||||
|
{space.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex-shrink-0 px-5 py-3 border-t border-white/[0.06] flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-white bg-accent-primary hover:bg-accent-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
Send Message
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleFriendAction}
|
||||||
|
disabled={friendActionLoading}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-[13px] font-medium border transition-colors disabled:opacity-50 ${
|
||||||
|
isFriend
|
||||||
|
? 'text-txt-danger border-txt-danger/30 hover:bg-txt-danger/10'
|
||||||
|
: 'text-txt-primary border-white/[0.08] bg-white/[0.06] hover:bg-white/[0.10]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{friendActionLoading ? '...' : isFriend ? 'Remove Friend' : 'Add Friend'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useAuthStore } from '../../../stores/authStore';
|
import { useAuthStore } from '../../../stores/authStore';
|
||||||
import { Avatar } from '../../ui/Avatar';
|
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';
|
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() {
|
export function AccountPanel() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||||
@@ -10,36 +20,163 @@ export function AccountPanel() {
|
|||||||
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
|
||||||
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
|
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
|
||||||
const [status, setStatus] = useState<UserStatus>(user?.status ?? 'online');
|
const [status, setStatus] = useState<UserStatus>(user?.status ?? 'online');
|
||||||
|
const [bio, setBio] = useState(user?.bio ?? '');
|
||||||
|
const [accentColor, setAccentColor] = useState<string | null>(user?.accentColor ?? null);
|
||||||
|
const [customHex, setCustomHex] = useState(user?.accentColor ?? '');
|
||||||
|
|
||||||
|
// Avatar upload state
|
||||||
|
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||||
|
const [avatarFilename, setAvatarFilename] = useState<string | null>(null);
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
|
const [avatarCropSrc, setAvatarCropSrc] = useState<string | null>(null);
|
||||||
|
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Banner upload state
|
||||||
|
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||||
|
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
||||||
|
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||||
|
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||||
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Reset form when user data changes (e.g. after external update)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setDisplayName(user.displayName ?? '');
|
setDisplayName(user.displayName ?? '');
|
||||||
setCustomStatus(user.customStatus ?? '');
|
setCustomStatus(user.customStatus ?? '');
|
||||||
setStatus(user.status ?? 'online');
|
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;
|
if (!user) return null;
|
||||||
|
|
||||||
|
const effectiveDisplayName = displayName.trim() || user.username;
|
||||||
|
const effectiveAccent = accentColor;
|
||||||
|
|
||||||
|
// Change detection
|
||||||
const hasChanges =
|
const hasChanges =
|
||||||
displayName !== (user.displayName ?? '') ||
|
displayName !== (user.displayName ?? '') ||
|
||||||
customStatus !== (user.customStatus ?? '') ||
|
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<HTMLInputElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 () => {
|
const handleSave = async () => {
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateProfile({
|
const updates: Record<string, string | undefined> = {};
|
||||||
displayName: displayName.trim(),
|
if (displayName !== (user.displayName ?? '')) updates.displayName = displayName.trim();
|
||||||
customStatus: customStatus.trim(),
|
if (customStatus !== (user.customStatus ?? '')) updates.customStatus = customStatus.trim();
|
||||||
status,
|
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<typeof updateProfile>[0]);
|
||||||
setSuccess('Profile updated!');
|
setSuccess('Profile updated!');
|
||||||
setTimeout(() => setSuccess(''), 2000);
|
setTimeout(() => setSuccess(''), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -53,44 +190,279 @@ export function AccountPanel() {
|
|||||||
setDisplayName(user.displayName ?? '');
|
setDisplayName(user.displayName ?? '');
|
||||||
setCustomStatus(user.customStatus ?? '');
|
setCustomStatus(user.customStatus ?? '');
|
||||||
setStatus(user.status ?? 'online');
|
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('');
|
setError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Profile preview */}
|
{/* ── Profile Customization ── */}
|
||||||
<div className="flex items-center gap-4 p-4 bg-surface-channel rounded-lg">
|
<div>
|
||||||
<Avatar
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
src={user.avatar}
|
Profile Customization
|
||||||
name={user.displayName ?? user.username}
|
</div>
|
||||||
size={64}
|
|
||||||
status={user.status}
|
{/* Live Preview Card */}
|
||||||
userId={user.homeUserId ?? user.id}
|
<div className="rounded-lg overflow-hidden border border-white/[0.06] mb-4">
|
||||||
/>
|
{/* Banner area */}
|
||||||
<div>
|
<div
|
||||||
<div className="font-bold text-lg">{user.displayName ?? user.username}</div>
|
className="h-[80px] relative"
|
||||||
<div className="text-txt-tertiary text-sm">@{user.username}</div>
|
style={displayBannerSrc
|
||||||
{user.customStatus && (
|
? { backgroundImage: `url(${displayBannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
<div className="text-txt-secondary text-sm mt-1">{user.customStatus}</div>
|
: { background: bannerFallback, opacity: 0.6 }
|
||||||
)}
|
}
|
||||||
|
/>
|
||||||
|
{/* Avatar + info */}
|
||||||
|
<div className="px-4 pb-3 bg-surface-channel">
|
||||||
|
<div
|
||||||
|
className="mt-[-28px] mb-2 w-fit rounded-full"
|
||||||
|
style={{ border: '4px solid var(--color-surface-channel, #1e1e2a)' }}
|
||||||
|
>
|
||||||
|
{displayAvatarSrc ? (
|
||||||
|
<img
|
||||||
|
src={displayAvatarSrc}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-[56px] h-[56px] rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
src={null}
|
||||||
|
name={effectiveDisplayName}
|
||||||
|
size={56}
|
||||||
|
userId={user.homeUserId ?? user.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="font-semibold text-[15px] leading-tight"
|
||||||
|
style={{ color: effectiveAccent ?? 'var(--color-txt-primary)' }}
|
||||||
|
>
|
||||||
|
{effectiveDisplayName}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-txt-tertiary">@{user.username}</div>
|
||||||
|
{bio.trim() && (
|
||||||
|
<div className="text-[12px] text-txt-secondary mt-1.5 whitespace-pre-wrap break-words line-clamp-3">
|
||||||
|
{bio.trim()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upload controls */}
|
||||||
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
|
||||||
|
{/* Avatar upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">Avatar</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
className="relative group"
|
||||||
|
>
|
||||||
|
<div className="w-[64px] h-[64px] rounded-full overflow-hidden">
|
||||||
|
{displayAvatarSrc ? (
|
||||||
|
<img src={displayAvatarSrc} alt="Avatar" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Avatar
|
||||||
|
src={null}
|
||||||
|
name={effectiveDisplayName}
|
||||||
|
size={64}
|
||||||
|
userId={user.homeUserId ?? user.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{uploadingAvatar && (
|
||||||
|
<div className="absolute inset-0 rounded-full bg-black/60 flex items-center justify-center">
|
||||||
|
<svg className="animate-spin w-5 h-5 text-white" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => avatarInputRef.current?.click()}
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
className="text-xs text-accent-primary hover:underline text-left"
|
||||||
|
>
|
||||||
|
Change Avatar
|
||||||
|
</button>
|
||||||
|
{(displayAvatarSrc || user.avatar) && avatarFilename !== '' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
className="text-xs text-txt-danger hover:underline text-left"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={avatarInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleAvatarSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Banner upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">Banner</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bannerInputRef.current?.click()}
|
||||||
|
disabled={uploadingBanner}
|
||||||
|
className="relative group w-full h-[72px] rounded-lg overflow-hidden border border-white/[0.06]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full h-full"
|
||||||
|
style={displayBannerSrc
|
||||||
|
? { backgroundImage: `url(${displayBannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
|
: { background: bannerFallback, opacity: 0.5 }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{uploadingBanner && (
|
||||||
|
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||||
|
<svg className="animate-spin w-5 h-5 text-white" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => bannerInputRef.current?.click()}
|
||||||
|
disabled={uploadingBanner}
|
||||||
|
className="text-xs text-accent-primary hover:underline"
|
||||||
|
>
|
||||||
|
Change Banner
|
||||||
|
</button>
|
||||||
|
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemoveBanner}
|
||||||
|
className="text-xs text-txt-danger hover:underline"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={bannerInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleBannerSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent Color */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">Accent Color</label>
|
||||||
|
<div className="grid grid-cols-8 gap-1.5 mb-2">
|
||||||
|
{ACCENT_PRESETS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAccentColor(color); setCustomHex(color); }}
|
||||||
|
className="w-7 h-7 rounded-full border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor: accentColor === color ? 'white' : 'transparent',
|
||||||
|
boxShadow: accentColor === color ? `0 0 0 2px ${color}40` : 'none',
|
||||||
|
}}
|
||||||
|
title={color}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customHex}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<div
|
||||||
|
className="w-6 h-6 rounded-full border border-white/10 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: accentColor }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{accentColor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAccentColor(null); setCustomHex(''); }}
|
||||||
|
className="text-xs text-txt-tertiary hover:text-txt-secondary transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-txt-secondary mb-1.5">About Me</label>
|
||||||
|
<div className="relative">
|
||||||
|
<textarea
|
||||||
|
value={bio}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.value.length <= 190) setBio(e.target.value);
|
||||||
|
}}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Tell the world about yourself..."
|
||||||
|
className="w-full px-3 py-2 bg-surface-input rounded text-sm text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary resize-none"
|
||||||
|
maxLength={190}
|
||||||
|
/>
|
||||||
|
<span className="absolute bottom-2 right-2 text-[10px] text-txt-tertiary">
|
||||||
|
{bio.length}/190
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{/* ── Account ── */}
|
||||||
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">{success}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profile section card */}
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Profile</div>
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Account</div>
|
||||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
<label className="block text-xs text-txt-secondary mb-1.5">Status</label>
|
||||||
Status
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
value={status}
|
value={status}
|
||||||
onChange={(e) => setStatus(e.target.value as UserStatus)}
|
onChange={(e) => setStatus(e.target.value as UserStatus)}
|
||||||
@@ -103,9 +475,7 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
<label className="block text-xs text-txt-secondary mb-1.5">Display Name</label>
|
||||||
Display Name
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
@@ -115,9 +485,7 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-txt-secondary mb-1.5">
|
<label className="block text-xs text-txt-secondary mb-1.5">Custom Status</label>
|
||||||
Custom Status
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={customStatus}
|
value={customStatus}
|
||||||
@@ -129,6 +497,13 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-2 bg-accent-rose/10 border border-accent-rose/30 rounded text-txt-danger text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="p-2 bg-status-online/10 border border-status-online/30 rounded text-status-online text-sm">{success}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<div className="sticky bottom-0 z-10 pointer-events-none">
|
<div className="sticky bottom-0 z-10 pointer-events-none">
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
<div className="flex justify-center pt-3 pb-1">
|
||||||
@@ -141,7 +516,7 @@ export function AccountPanel() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isLoading}
|
disabled={isLoading || uploadingAvatar || uploadingBanner}
|
||||||
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Saving...' : 'Save'}
|
{isLoading ? 'Saving...' : 'Save'}
|
||||||
@@ -150,6 +525,26 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Crop Modals */}
|
||||||
|
<ImageCropModal
|
||||||
|
isOpen={avatarCropSrc !== null}
|
||||||
|
onClose={() => setAvatarCropSrc(null)}
|
||||||
|
imageSrc={avatarCropSrc ?? ''}
|
||||||
|
onCropComplete={handleAvatarCropComplete}
|
||||||
|
title="Crop Avatar"
|
||||||
|
cropShape="round"
|
||||||
|
aspectRatio={1}
|
||||||
|
/>
|
||||||
|
<ImageCropModal
|
||||||
|
isOpen={bannerCropSrc !== null}
|
||||||
|
onClose={() => setBannerCropSrc(null)}
|
||||||
|
imageSrc={bannerCropSrc ?? ''}
|
||||||
|
onCropComplete={handleBannerCropComplete}
|
||||||
|
title="Crop Banner"
|
||||||
|
cropShape="rect"
|
||||||
|
aspectRatio={3}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { getAvatarGradient } from '../../utils/gradients';
|
import { getAvatarGradient, adjustColor } from '../../utils/gradients';
|
||||||
import { parseFederatedUsername } from '../../utils/identity';
|
import { parseFederatedUsername } from '../../utils/identity';
|
||||||
|
|
||||||
interface UserProfilePopoutProps {
|
interface UserProfilePopoutProps {
|
||||||
@@ -18,14 +19,23 @@ interface UserProfilePopoutProps {
|
|||||||
export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) {
|
export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
||||||
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
const { baseName, domain } = parseFederatedUsername(user.username);
|
const { baseName, domain } = parseFederatedUsername(user.username);
|
||||||
const displayName = user.displayName ?? baseName;
|
const displayName = user.displayName ?? baseName;
|
||||||
|
|
||||||
|
const [mutualCounts, setMutualCounts] = useState<{ friends: number; spaces: number } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.users.getMutuals(user.id)
|
||||||
|
.then((data) => setMutualCounts({ friends: data.mutualFriends.length, spaces: data.mutualSpaces.length }))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [user.id]);
|
||||||
|
|
||||||
const top = position
|
const top = position
|
||||||
? Math.min(Math.max(8, position.top), window.innerHeight - 360)
|
? Math.min(Math.max(8, position.top), window.innerHeight - 460)
|
||||||
: undefined;
|
: undefined;
|
||||||
const left = position
|
const left = position
|
||||||
? Math.min(Math.max(8, position.left), window.innerWidth - 316)
|
? Math.min(Math.max(8, position.left), window.innerWidth - 356)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
@@ -47,9 +57,22 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewFullProfile = () => {
|
||||||
|
onClose();
|
||||||
|
openModal('userProfile', { userId: user.id });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Banner display
|
||||||
|
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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-[200] w-[300px] rounded-[12px] overflow-hidden animate-fade-in select-none border border-white/[0.07]"
|
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none border border-white/[0.07]"
|
||||||
style={{
|
style={{
|
||||||
...(position
|
...(position
|
||||||
? { top, left }
|
? { top, left }
|
||||||
@@ -62,34 +85,35 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
>
|
>
|
||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
<div
|
<div
|
||||||
className="h-[48px] rounded-t-[12px]"
|
className="h-[80px] rounded-t-[12px]"
|
||||||
style={{
|
style={bannerSrc
|
||||||
background: getAvatarGradient(user.homeUserId ?? user.id, displayName).gradient,
|
? { backgroundImage: `url(${bannerSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||||
opacity: 0.6,
|
: { background: bannerFallback, opacity: 0.6 }
|
||||||
}}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
{/* Avatar — negative margin pulls it into the banner while staying in flow */}
|
{/* Avatar */}
|
||||||
<div
|
<div
|
||||||
className="mt-[-28px] mb-3 w-fit rounded-full"
|
className="mt-[-40px] mb-3 w-fit rounded-full"
|
||||||
style={{ border: '4px solid rgba(20,20,26,0.85)' }}
|
style={{ border: '4px solid rgba(20,20,26,0.85)' }}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={56}
|
size={80}
|
||||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||||
userId={user.homeUserId ?? user.id}
|
userId={user.homeUserId ?? user.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Name & info — flows naturally after avatar */}
|
{/* Name & info */}
|
||||||
<div>
|
<div>
|
||||||
<Username
|
<Username
|
||||||
username={user.displayName ?? baseName}
|
username={user.displayName ?? baseName}
|
||||||
className="text-[16px] font-semibold text-txt-primary leading-tight"
|
className="text-[16px] font-semibold leading-tight"
|
||||||
|
style={user.accentColor ? { color: user.accentColor } : undefined}
|
||||||
/>
|
/>
|
||||||
<div className="text-[13px] text-txt-tertiary">
|
<div className="text-[13px] text-txt-tertiary">
|
||||||
{domain ? (
|
{domain ? (
|
||||||
@@ -105,26 +129,66 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Bio */}
|
||||||
|
{user.bio && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-white/[0.06] my-3" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
|
About Me
|
||||||
|
</span>
|
||||||
|
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
|
||||||
|
<ReactMarkdown
|
||||||
|
allowedElements={['p', 'strong', 'em', 'a', 'br']}
|
||||||
|
unwrapDisallowed
|
||||||
|
>
|
||||||
|
{user.bio}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-white/[0.06] my-3" />
|
<div className="border-t border-white/[0.06] my-3" />
|
||||||
|
|
||||||
{/* Member since */}
|
{/* Member since + Mutuals */}
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
<div>
|
||||||
Member Since
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
</span>
|
Member Since
|
||||||
<span className="text-[12px] text-txt-secondary ml-2">
|
</span>
|
||||||
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
<span className="text-[12px] text-txt-secondary ml-2">
|
||||||
</span>
|
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{mutualCounts && (mutualCounts.friends > 0 || mutualCounts.spaces > 0) && (
|
||||||
|
<div className="text-[12px] text-txt-tertiary">
|
||||||
|
{mutualCounts.friends > 0 && (
|
||||||
|
<span>{mutualCounts.friends} mutual friend{mutualCounts.friends !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
{mutualCounts.friends > 0 && mutualCounts.spaces > 0 && (
|
||||||
|
<span className="mx-1">·</span>
|
||||||
|
)}
|
||||||
|
{mutualCounts.spaces > 0 && (
|
||||||
|
<span>{mutualCounts.spaces} mutual space{mutualCounts.spaces !== 1 ? 's' : ''}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Send Message button */}
|
{/* Actions */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
className="w-full mt-3 py-2 rounded-lg text-[13px] font-medium text-txt-primary bg-white/[0.06] hover:bg-white/[0.10] border border-white/[0.08] transition-colors"
|
className="w-full mt-3 py-2 rounded-lg text-[13px] font-medium text-txt-primary bg-white/[0.06] hover:bg-white/[0.10] border border-white/[0.08] transition-colors"
|
||||||
>
|
>
|
||||||
Send Message
|
Send Message
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleViewFullProfile}
|
||||||
|
className="w-full mt-1.5 py-2 rounded-lg text-[13px] font-medium text-txt-tertiary hover:text-txt-secondary bg-transparent hover:bg-white/[0.04] transition-colors"
|
||||||
|
>
|
||||||
|
View Full Profile
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface AuthState {
|
|||||||
register: (username: string, password: string, displayName?: string) => Promise<void>;
|
register: (username: string, password: string, displayName?: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
loadUser: () => Promise<void>;
|
loadUser: () => Promise<void>;
|
||||||
updateProfile: (data: { displayName?: string; avatar?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
|
updateProfile: (data: { displayName?: string; avatar?: string; banner?: string; accentColor?: string; bio?: string; customStatus?: string; status?: UserStatus }) => Promise<void>;
|
||||||
setUser: (user: User) => void;
|
setUser: (user: User) => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type ModalType =
|
|||||||
| 'imagePreview'
|
| 'imagePreview'
|
||||||
| 'newDm'
|
| 'newDm'
|
||||||
| 'addDmMember'
|
| 'addDmMember'
|
||||||
|
| 'userProfile'
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ export function resolveAssetUrl(filename: string | null | undefined, origin: str
|
|||||||
* Rewrite the avatar field on a user-like object for remote origins.
|
* Rewrite the avatar field on a user-like object for remote origins.
|
||||||
* Mutates in-place for efficiency (called on arrays of members/messages).
|
* Mutates in-place for efficiency (called on arrays of members/messages).
|
||||||
*/
|
*/
|
||||||
export function normalizeUserAssets<T extends { avatar?: string | null }>(user: T, origin: string): T {
|
export function normalizeUserAssets<T extends { avatar?: string | null; banner?: string | null }>(user: T, origin: string): T {
|
||||||
if (!origin) return user;
|
if (!origin) return user;
|
||||||
if (user.avatar) {
|
if (user.avatar) {
|
||||||
user.avatar = resolveAssetUrl(user.avatar, origin) ?? user.avatar;
|
user.avatar = resolveAssetUrl(user.avatar, origin) ?? user.avatar;
|
||||||
}
|
}
|
||||||
|
if (user.banner) {
|
||||||
|
user.banner = resolveAssetUrl(user.banner, origin) ?? user.banner;
|
||||||
|
}
|
||||||
// Qualify users local to the remote instance with their origin domain.
|
// Qualify users local to the remote instance with their origin domain.
|
||||||
// These users have no homeInstance (they're native there), so the client
|
// These users have no homeInstance (they're native there), so the client
|
||||||
// can't distinguish them from its own local users without this step.
|
// can't distinguish them from its own local users without this step.
|
||||||
|
|||||||
@@ -56,3 +56,11 @@ export function getSpaceGradient(id?: string | null, name?: string): GradientEnt
|
|||||||
const key = id || name || 'unknown';
|
const key = id || name || 'unknown';
|
||||||
return SPACE_GRADIENTS[hashString(key) % SPACE_GRADIENTS.length]!;
|
return SPACE_GRADIENTS[hashString(key) % SPACE_GRADIENTS.length]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shift each RGB component of a hex color by `amount` (positive = lighter, negative = darker). */
|
||||||
|
export function adjustColor(hex: string, amount: number): string {
|
||||||
|
const r = Math.max(0, Math.min(255, parseInt(hex.slice(1, 3), 16) + amount));
|
||||||
|
const g = Math.max(0, Math.min(255, parseInt(hex.slice(3, 5), 16) + amount));
|
||||||
|
const b = Math.max(0, Math.min(255, parseInt(hex.slice(5, 7), 16) + amount));
|
||||||
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user