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:
Jannis Braun
2026-02-18 05:34:45 +01:00
parent 4fd17084a5
commit 5ef502f2e3
82 changed files with 4906 additions and 552 deletions
@@ -1,9 +1,89 @@
import React from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Tooltip } from '../ui/Tooltip';
interface SidebarItemProps {
id: string;
name: string;
icon?: string | null;
active: boolean;
onClick: () => void;
type?: 'server' | 'dm' | 'action';
actionType?: 'add' | 'join';
}
function SidebarItem({ name, icon, active, onClick, type = 'server', actionType }: SidebarItemProps) {
const [isHovered, setIsHovered] = useState(false);
const firstLetter = name.charAt(0).toUpperCase();
const getPillHeight = () => {
if (active) return 'h-10';
if (isHovered) return 'h-5';
return 'h-2 scale-0';
};
const getButtonClasses = () => {
const base = 'w-12 h-12 flex items-center justify-center transition-all duration-200 overflow-hidden relative group';
if (type === 'dm') {
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
}
if (type === 'action') {
return `${base} bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-green hover:bg-discord-green hover:text-white`;
}
return `${base} ${active ? 'bg-discord-blurple rounded-[16px] text-white' : 'bg-discord-bg-primary rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:bg-discord-blurple hover:text-white'}`;
};
return (
<div
className="relative flex items-center mb-2 w-full justify-center"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Pill Indicator */}
{(type === 'server' || type === 'dm') && (
<div className="absolute -left-0 w-2 h-12 flex items-center">
<div
className={`bg-white rounded-r-full transition-all duration-200 origin-left ${getPillHeight()} w-1`}
/>
</div>
)}
<Tooltip content={name} position="right">
<button onClick={onClick} className={getButtonClasses()}>
{type === 'dm' ? (
<svg width="28" height="20" viewBox="0 0 28 20" fill="currentColor">
<path d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z" transform="scale(0.85) translate(0, 0)" />
</svg>
) : type === 'action' ? (
actionType === 'add' ? (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
</svg>
)
) : icon ? (
<img
src={icon.startsWith('http') ? icon : `/api/uploads/${icon}`}
alt={name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-[16px] font-medium">{firstLetter}</span>
)}
</button>
</Tooltip>
</div>
);
}
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
@@ -26,83 +106,45 @@ export function ServerSidebar() {
};
return (
<div className="w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 gap-2">
{/* DM Button */}
<Tooltip content="Direct Messages" position="right">
<button
onClick={handleDmClick}
className={`w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 flex items-center justify-center ${
showDms
? 'bg-discord-blurple rounded-[16px]'
: 'bg-discord-bg-primary hover:bg-discord-blurple'
}`}
>
<svg width="28" height="20" viewBox="0 0 28 20" fill="white">
<path d="M23.0212 1.67671C21.3107 0.879656 19.5079 0.318797 17.6584 0C17.4062 0.461742 17.1749 0.934541 16.9708 1.4184C15.003 1.12145 12.9974 1.12145 11.0292 1.4184C10.8251 0.934541 10.5938 0.461742 10.3416 0C8.49215 0.318797 6.68934 0.879656 4.97882 1.67671C0.665804 8.44726 -0.364554 15.0614 0.225316 21.5765C2.41849 23.2105 4.70543 24.3115 7.04773 25.043C7.60419 24.2941 8.09868 23.4944 8.52321 22.6521C7.71966 22.3602 6.9466 21.9905 6.21274 21.5543C6.39845 21.4212 6.58011 21.2838 6.75775 21.1429C12.7568 23.8968 19.2811 23.8968 25.2422 21.1429C25.4199 21.2838 25.6015 21.4212 25.7873 21.5543C25.0534 21.9905 24.2804 22.3602 23.4768 22.6521C23.9013 23.4944 24.3958 24.2941 24.9523 25.043C27.2946 24.3115 29.5815 23.2105 31.7747 21.5765C32.4517 14.0051 30.5663 7.45459 26.0212 1.67671H23.0212Z" transform="scale(0.85) translate(0, 0)" />
</svg>
</button>
</Tooltip>
<nav className="w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 no-scrollbar select-none">
<SidebarItem
id="@me"
name="Direct Messages"
active={showDms}
onClick={handleDmClick}
type="dm"
/>
{/* Divider */}
<div className="w-8 h-0.5 bg-discord-bg-primary rounded-full" />
<div className="w-8 h-[2px] bg-discord-modifier-accent rounded-full mb-2" />
{/* Server Icons */}
{servers.map((server) => {
const isActive = currentServerId === server.id;
const firstLetter = server.name.charAt(0).toUpperCase();
return (
<div key={server.id} className="relative">
{/* Active indicator */}
{isActive && (
<div className="absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-10 bg-white rounded-r-full" />
)}
<Tooltip content={server.name} position="right">
<button
onClick={() => handleServerClick(server.id)}
className={`w-12 h-12 transition-all duration-200 flex items-center justify-center text-lg font-semibold ${
isActive
? 'bg-discord-blurple rounded-[16px] text-white'
: 'bg-discord-bg-primary hover:bg-discord-blurple rounded-[24px] hover:rounded-[16px] text-discord-text-primary hover:text-white'
}`}
>
{server.icon ? (
<img
src={server.icon.startsWith('http') ? server.icon : `/api/uploads/${server.icon}`}
alt={server.name}
className="w-full h-full rounded-inherit object-cover"
/>
) : (
firstLetter
)}
</button>
</Tooltip>
</div>
);
})}
{servers.map((server) => (
<SidebarItem
key={server.id}
id={server.id}
name={server.name}
icon={server.icon}
active={currentServerId === server.id}
onClick={() => handleServerClick(server.id)}
/>
))}
{/* Add Server Button */}
<Tooltip content="Add a Server" position="right">
<button
onClick={() => openModal('createServer')}
className="w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
</Tooltip>
<SidebarItem
id="add-server"
name="Add a Server"
active={false}
onClick={() => openModal('createServer')}
type="action"
actionType="add"
/>
{/* Join Server Button */}
<Tooltip content="Join a Server" position="right">
<button
onClick={() => openModal('joinServer')}
className="w-12 h-12 rounded-[24px] hover:rounded-[16px] transition-all duration-200 bg-discord-bg-primary hover:bg-discord-green text-discord-green hover:text-white flex items-center justify-center"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" />
</svg>
</button>
</Tooltip>
</div>
<SidebarItem
id="join-server"
name="Join a Server"
active={false}
onClick={() => openModal('joinServer')}
type="action"
actionType="join"
/>
</nav>
);
}