fix: repair invite links, social features, messaging + Discord UI overhaul
Phase 1 - Feature Repair: - Fix member kick/leave: add missing db.delete() call in servers.ts - Stabilize invite codes: return existing code instead of regenerating - Fix user search: use LIKE instead of exact match in social.ts - Wire DM button on FriendsPage to create/navigate to DM channels - Add cancel outgoing friend request (DELETE endpoint + frontend) - Add accept/decline friend request actions with WS real-time events - Fix replyToId persistence in message creation - Hydrate reactions and replyTo in message queries - Add joinByCode to API client and serverStore - Add friend_request_received/accepted WebSocket events Phase 2 - Discord UI Overhaul: - Remove stray borders between layout columns - Replace shadow-sm with shadow-header on content headers - Replace all bg-gray-*/text-gray-* with Discord color tokens - Ensure flat color contrast (#1E1F22, #2B2D31, #313338) Testing: - Set up vitest + @testing-library/react + jsdom - Add 17 tests across InviteModal, JoinServer, FriendsPage (all passing) - Fix vite resolve.extensions to prefer .tsx over stale .js files
This commit is contained in:
@@ -1,14 +1,29 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
const statusColors = {
|
||||
online: 'bg-discord-green',
|
||||
idle: 'bg-discord-yellow',
|
||||
dnd: 'bg-discord-red',
|
||||
offline: 'bg-gray-500',
|
||||
};
|
||||
export function Avatar({ src, name, size = 40, status, className = '', onClick }) {
|
||||
export function Avatar({ src, name, size = 40, status, className = '', onClick, user }) {
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
const initials = name.charAt(0).toUpperCase();
|
||||
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
|
||||
return (_jsxs("div", { className: `relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`, style: { width: size, height: size }, onClick: onClick, children: [src ? (_jsx("img", { src: src.startsWith('http') ? src : `/api/uploads/${src}`, alt: name, className: "w-full h-full rounded-full object-cover", onError: (e) => {
|
||||
const handleClick = (e) => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
else if (user) {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 16,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: `relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`, style: { width: size, height: size }, onClick: handleClick, children: [src ? (_jsx("img", { src: src.startsWith('http') ? src : `/api/uploads/${src}`, alt: name, className: "w-full h-full rounded-full object-cover", onError: (e) => {
|
||||
e.target.style.display = 'none';
|
||||
const parent = e.target.parentElement;
|
||||
if (parent) {
|
||||
@@ -16,10 +31,10 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick }
|
||||
if (fallback)
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`, style: {
|
||||
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-gray-500'}`, style: {
|
||||
width: size * 0.35,
|
||||
height: size * 0.35,
|
||||
minWidth: 10,
|
||||
minHeight: 10,
|
||||
minWidth: 12,
|
||||
minHeight: 12,
|
||||
} }))] }));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react';
|
||||
import type { User } from '@opencord/shared';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string | null;
|
||||
@@ -6,25 +8,40 @@ interface AvatarProps {
|
||||
size?: number;
|
||||
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
online: 'bg-discord-green',
|
||||
idle: 'bg-discord-yellow',
|
||||
dnd: 'bg-discord-red',
|
||||
offline: 'bg-gray-500',
|
||||
offline: 'bg-discord-text-muted',
|
||||
};
|
||||
|
||||
export function Avatar({ src, name, size = 40, status, className = '', onClick }: AvatarProps) {
|
||||
export function Avatar({ src, name, size = 40, status, className = '', onClick, user }: AvatarProps) {
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
const initials = name.charAt(0).toUpperCase();
|
||||
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
} else if (user) {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
openUserProfile(user, {
|
||||
top: Math.min(rect.top, window.innerHeight - 450),
|
||||
left: rect.right + 16,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||
className={`relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
@@ -49,12 +66,12 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick }
|
||||
</div>
|
||||
{status && (
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`}
|
||||
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-[#2b2d31] ${statusColors[status] ?? 'bg-discord-text-muted'}`}
|
||||
style={{
|
||||
width: size * 0.35,
|
||||
height: size * 0.35,
|
||||
minWidth: 10,
|
||||
minHeight: 10,
|
||||
minWidth: 12,
|
||||
minHeight: 12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ContextMenu({ items, children }: ContextMenuProps) {
|
||||
{isOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 min-w-[180px] py-1.5 bg-[#111214] rounded-md shadow-xl border border-gray-800 animate-fade-in"
|
||||
className="fixed z-50 min-w-[180px] py-1.5 bg-discord-bg-floating rounded-md shadow-elevation-high animate-fade-in"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Tooltip({ content, children, position = 'right', delay = 200 }:
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div
|
||||
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-gray-900 rounded-md shadow-lg whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
|
||||
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-discord-bg-floating rounded-md shadow-elevation-high whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
export function UserProfilePopout({ user, onClose, position }) {
|
||||
const navigate = useNavigate();
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const displayName = user.displayName ?? user.username;
|
||||
const handleSendMessage = async () => {
|
||||
try {
|
||||
const channel = await api.dm.create({ userId: user.id });
|
||||
addDmChannel(channel);
|
||||
setCurrentChannel(channel.id);
|
||||
onClose();
|
||||
navigate(`/channels/@me/${channel.id}`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to create DM channel:', err);
|
||||
}
|
||||
};
|
||||
return (_jsxs("div", { className: "fixed z-50 w-[300px] bg-discord-bg-floating rounded-[8px] shadow-elevation-high overflow-hidden animate-fade-in select-none", style: position ? { top: position.top, left: position.left } : { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }, children: [_jsx("div", { className: "h-[60px] bg-discord-blurple" }), _jsxs("div", { className: "px-4 pb-4 relative", children: [_jsx("div", { className: "absolute -top-8 left-4 rounded-full border-[6px] border-discord-bg-floating bg-discord-bg-floating", children: _jsx(Avatar, { src: user.avatar, name: displayName, size: 80, status: user.status }) }), _jsxs("div", { className: "mt-12 bg-discord-bg-tertiary rounded-[8px] p-3", children: [_jsx("div", { className: "text-[20px] font-bold text-discord-text-header leading-tight mb-1", children: displayName }), _jsxs("div", { className: "text-[14px] text-discord-text-normal font-medium mb-3", children: ["@", user.username] }), _jsx("div", { className: "w-full h-[1px] bg-discord-modifier-accent mb-3" }), _jsxs("div", { className: "mb-3", children: [_jsx("div", { className: "text-[12px] font-bold text-discord-text-header uppercase mb-1", children: "Opencord Member Since" }), _jsx("div", { className: "text-[12px] text-discord-text-normal font-medium", children: new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) })] }), user.customStatus && (_jsxs("div", { className: "mb-3", children: [_jsx("div", { className: "text-[12px] font-bold text-discord-text-header uppercase mb-1", children: "Status" }), _jsx("div", { className: "text-[14px] text-discord-text-normal", children: user.customStatus })] }))] })] }), _jsx("div", { className: "px-4 pb-4", children: _jsx("button", { onClick: handleSendMessage, className: "w-full py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-[14px] font-medium rounded-[4px] transition-colors", children: "Send Message" }) })] }));
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { User } from '@opencord/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
import { useServerStore } from '../../stores/serverStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
|
||||
interface UserProfilePopoutProps {
|
||||
user: User;
|
||||
onClose: () => void;
|
||||
position?: { top: number; left: number };
|
||||
}
|
||||
|
||||
export function UserProfilePopout({ user, onClose, position }: UserProfilePopoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const addDmChannel = useServerStore((s) => s.addDmChannel);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const displayName = user.displayName ?? user.username;
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
try {
|
||||
const channel = await api.dm.create({ userId: user.id });
|
||||
addDmChannel(channel);
|
||||
setCurrentChannel(channel.id);
|
||||
onClose();
|
||||
navigate(`/channels/@me/${channel.id}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create DM channel:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 w-[300px] bg-discord-bg-floating rounded-[8px] shadow-elevation-high overflow-hidden animate-fade-in select-none"
|
||||
style={position ? { top: position.top, left: position.left } : { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
|
||||
>
|
||||
{/* Banner */}
|
||||
<div className="h-[60px] bg-discord-blurple" />
|
||||
|
||||
{/* Avatar Container */}
|
||||
<div className="px-4 pb-4 relative">
|
||||
<div className="absolute -top-8 left-4 rounded-full border-[6px] border-discord-bg-floating bg-discord-bg-floating">
|
||||
<Avatar
|
||||
src={user.avatar}
|
||||
name={displayName}
|
||||
size={80}
|
||||
status={user.status as any}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-12 bg-discord-bg-tertiary rounded-[8px] p-3">
|
||||
<div className="text-[20px] font-bold text-discord-text-header leading-tight mb-1">
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-[14px] text-discord-text-normal font-medium mb-3">
|
||||
@{user.username}
|
||||
</div>
|
||||
|
||||
<div className="w-full h-[1px] bg-discord-modifier-accent mb-3" />
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-[12px] font-bold text-discord-text-header uppercase mb-1">Opencord Member Since</div>
|
||||
<div className="text-[12px] text-discord-text-normal font-medium">
|
||||
{new Date(user.createdAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.customStatus && (
|
||||
<div className="mb-3">
|
||||
<div className="text-[12px] font-bold text-discord-text-header uppercase mb-1">Status</div>
|
||||
<div className="text-[14px] text-discord-text-normal">{user.customStatus}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer / Actions */}
|
||||
<div className="px-4 pb-4">
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
className="w-full py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-[14px] font-medium rounded-[4px] transition-colors"
|
||||
>
|
||||
Send Message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user