chore: Initial commit of Opencord base state

This commit is contained in:
Jannis Braun
2026-02-18 02:49:21 +01:00
commit 4fd17084a5
124 changed files with 17955 additions and 0 deletions
@@ -0,0 +1,32 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (!password) {
setError('Password is required');
return;
}
try {
await login(username.trim(), password);
navigate('/channels/@me');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsxs("div", { className: "text-center mb-6", children: [_jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome back!" }), _jsx("p", { className: "text-discord-text-muted mt-1", children: "We're so excited to see you again!" })] }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "current-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Logging in...' : 'Log In' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Need an account?", ' ', _jsx(Link, { to: "/register", className: "text-[#00aff4] hover:underline", children: "Register" })] })] })] }) }));
}
@@ -0,0 +1,94 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (!password) {
setError('Password is required');
return;
}
try {
await login(username.trim(), password);
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-discord-bg-tertiary">
<div className="w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-discord-text-primary">Welcome back!</h1>
<p className="text-discord-text-muted mt-1">We're so excited to see you again!</p>
</div>
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Username <span className="text-discord-red">*</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoFocus
autoComplete="username"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Password <span className="text-discord-red">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoComplete="current-password"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Logging in...' : 'Log In'}
</button>
<p className="mt-3 text-sm text-discord-text-muted">
Need an account?{' '}
<Link to="/register" className="text-[#00aff4] hover:underline">
Register
</Link>
</p>
</form>
</div>
</div>
);
}
@@ -0,0 +1,45 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function RegisterPage() {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const register = useAuthStore((s) => s.register);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (username.trim().length < 3 || username.trim().length > 32) {
setError('Username must be between 3 and 32 characters');
return;
}
if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) {
setError('Username can only contain letters, numbers, and underscores');
return;
}
if (!password) {
setError('Password is required');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(username.trim(), password, displayName.trim() || undefined);
navigate('/channels/@me');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (_jsx("div", { className: "min-h-screen flex items-center justify-center bg-discord-bg-tertiary", children: _jsxs("div", { className: "w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl", children: [_jsx("div", { className: "text-center mb-6", children: _jsx("h1", { className: "text-2xl font-bold text-discord-text-primary", children: "Create an account" }) }), _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Username ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "text", value: username, onChange: (e) => setUsername(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoFocus: true, autoComplete: "username" })] }), _jsxs("div", { className: "mb-5", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "name" })] }), _jsxs("div", { className: "mb-5", children: [_jsxs("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: ["Password ", _jsx("span", { className: "text-discord-red", children: "*" })] }), _jsx("input", { type: "password", value: password, onChange: (e) => setPassword(e.target.value), className: "w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all", autoComplete: "new-password" })] }), _jsx("button", { type: "submit", disabled: isLoading, className: "w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed", children: isLoading ? 'Creating account...' : 'Continue' }), _jsxs("p", { className: "mt-3 text-sm text-discord-text-muted", children: ["Already have an account?", ' ', _jsx(Link, { to: "/login", className: "text-[#00aff4] hover:underline", children: "Log In" })] })] })] }) }));
}
@@ -0,0 +1,119 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
export function RegisterPage() {
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const register = useAuthStore((s) => s.register);
const isLoading = useAuthStore((s) => s.isLoading);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!username.trim()) {
setError('Username is required');
return;
}
if (username.trim().length < 3 || username.trim().length > 32) {
setError('Username must be between 3 and 32 characters');
return;
}
if (!/^[a-zA-Z0-9_]+$/.test(username.trim())) {
setError('Username can only contain letters, numbers, and underscores');
return;
}
if (!password) {
setError('Password is required');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
try {
await register(username.trim(), password, displayName.trim() || undefined);
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Registration failed');
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-discord-bg-tertiary">
<div className="w-full max-w-[480px] bg-discord-bg-primary rounded-md p-8 shadow-2xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-discord-text-primary">Create an account</h1>
</div>
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-4 p-3 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Username <span className="text-discord-red">*</span>
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoFocus
autoComplete="username"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoComplete="name"
/>
</div>
<div className="mb-5">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Password <span className="text-discord-red">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-3 py-2.5 bg-discord-bg-tertiary border-none rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple transition-all"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 bg-discord-blurple hover:bg-discord-blurple-hover text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating account...' : 'Continue'}
</button>
<p className="mt-3 text-sm text-discord-text-muted">
Already have an account?{' '}
<Link to="/login" className="text-[#00aff4] hover:underline">
Log In
</Link>
</p>
</form>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useUIStore } from '../../stores/uiStore';
export function ImagePreview() {
const imageUrl = useUIStore((s) => s.imagePreviewUrl);
const closeImagePreview = useUIStore((s) => s.closeImagePreview);
const activeModal = useUIStore((s) => s.activeModal);
if (activeModal !== 'imagePreview' || !imageUrl)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer", onClick: closeImagePreview, children: [_jsx("button", { className: "absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10", onClick: closeImagePreview, children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) }), _jsx("img", { src: imageUrl, alt: "Preview", className: "max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl", onClick: (e) => e.stopPropagation() })] }));
}
@@ -0,0 +1,32 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
export function ImagePreview() {
const imageUrl = useUIStore((s) => s.imagePreviewUrl);
const closeImagePreview = useUIStore((s) => s.closeImagePreview);
const activeModal = useUIStore((s) => s.activeModal);
if (activeModal !== 'imagePreview' || !imageUrl) return null;
return (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 animate-fade-in cursor-pointer"
onClick={closeImagePreview}
>
<button
className="absolute top-4 right-4 text-white/70 hover:text-white transition-colors z-10"
onClick={closeImagePreview}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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>
<img
src={imageUrl}
alt="Preview"
className="max-w-[90vw] max-h-[90vh] object-contain rounded shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
function formatTime(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday)
return `Today at ${time}`;
if (isYesterday)
return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function formatHoverTime(timestamp) {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function Message({ message, isCompact, isFirstInGroup }) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? '');
const [isHovered, setIsHovered] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const isAuthor = currentUser?.id === message.userId;
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
const canDelete = isAuthor || isAdminUser;
const contextMenuItems = [];
if (isAuthor) {
contextMenuItems.push({
label: 'Edit Message',
onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
},
});
}
if (canDelete) {
contextMenuItems.push({
label: 'Delete Message',
onClick: () => deleteMessage(message.id),
danger: true,
});
}
const handleEditSubmit = async (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (editContent.trim()) {
await editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}
if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(message.content ?? '');
}
};
const displayName = message.user.displayName ?? message.user.username;
const roleColor = (() => {
const member = members.find(m => m.userId === message.userId);
if (member?.role === 'owner')
return 'text-discord-red';
if (member?.role === 'admin')
return 'text-discord-blurple';
return 'text-white';
})();
const content = (_jsxs("div", { className: `group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false), children: [_jsx("div", { className: "w-[72px] flex-shrink-0 flex items-start justify-center", children: isFirstInGroup ? (_jsx(Avatar, { src: message.user.avatar, name: displayName, size: 40, className: "mt-0.5 cursor-pointer" })) : (_jsx("span", { className: `text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`, children: formatHoverTime(message.createdAt) })) }), _jsxs("div", { className: "flex-1 min-w-0", children: [isFirstInGroup && (_jsxs("div", { className: "flex items-baseline gap-2", children: [_jsx("span", { className: `font-medium cursor-pointer hover:underline ${roleColor}`, children: displayName }), _jsx("span", { className: "text-xs text-discord-text-muted", children: formatTime(message.createdAt) })] })), isEditing ? (_jsxs("div", { className: "mt-1", children: [_jsx("textarea", { value: editContent, onChange: (e) => setEditContent(e.target.value), onKeyDown: handleEditSubmit, className: "w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm", rows: 2, autoFocus: true }), _jsxs("p", { className: "text-xs text-discord-text-muted mt-1", children: ["escape to ", _jsx("button", { onClick: () => setIsEditing(false), className: "text-[#00aff4] hover:underline", children: "cancel" }), ' ', "\u2022 enter to ", _jsx("button", { onClick: () => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}, className: "text-[#00aff4] hover:underline", children: "save" })] })] })) : (_jsxs(_Fragment, { children: [message.content && (_jsxs("div", { className: "text-discord-text-primary text-sm leading-[1.375rem] break-words", children: [_jsx(ReactMarkdown, { components: {
p: ({ children }) => _jsx("span", { children: children }),
a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[#00aff4] hover:underline", children: children })),
code: ({ children }) => (_jsx("code", { className: "px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono", children: children })),
pre: ({ children }) => (_jsx("pre", { className: "mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto", children: children })),
strong: ({ children }) => _jsx("strong", { className: "font-bold", children: children }),
em: ({ children }) => _jsx("em", { className: "italic", children: children }),
}, children: message.content }), message.editedAt && (_jsx("span", { className: "text-[10px] text-discord-text-muted ml-1", children: "(edited)" }))] })), message.attachments.length > 0 && (_jsx("div", { className: "mt-1 space-y-1", children: message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (_jsx("div", { className: "max-w-[400px]", children: _jsx("img", { src: `/api/uploads/${att.filename}`, alt: att.originalName, className: "max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow", onClick: () => openImagePreview(`/api/uploads/${att.filename}`), loading: "lazy" }) }, att.id));
}
return (_jsxs("a", { href: `/api/uploads/${att.filename}`, download: att.originalName, className: "flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]", children: [_jsx("svg", { className: "w-6 h-6 text-discord-text-muted flex-shrink-0", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" }) }), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-[#00aff4] text-sm truncate hover:underline", children: att.originalName }), _jsx("p", { className: "text-xs text-discord-text-muted", children: att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB` })] })] }, att.id));
}) }))] }))] }), isHovered && !isEditing && contextMenuItems.length > 0 && (_jsxs("div", { className: "absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md", children: [isAuthor && (_jsx("button", { onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
}, className: "p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Edit", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" }) }) })), canDelete && (_jsx("button", { onClick: () => deleteMessage(message.id), className: "p-1.5 text-discord-text-muted hover:text-discord-red transition-colors", title: "Delete", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" }) }) }))] }))] }));
if (contextMenuItems.length > 0) {
return _jsx(ContextMenu, { items: contextMenuItems, children: content });
}
return content;
}
@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import type { MessageWithUser } from '@opencord/shared';
import { Avatar } from '../ui/Avatar';
import { ContextMenu } from '../ui/ContextMenu';
import { useAuthStore } from '../../stores/authStore';
import { useChatStore } from '../../stores/chatStore';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
interface MessageProps {
message: MessageWithUser;
isCompact: boolean;
isFirstInGroup: boolean;
}
function formatTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday = date.toDateString() === yesterday.toDateString();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (isToday) return `Today at ${time}`;
if (isYesterday) return `Yesterday at ${time}`;
return `${date.toLocaleDateString()} ${time}`;
}
function formatHoverTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
export function Message({ message, isCompact, isFirstInGroup }: MessageProps) {
const [isEditing, setIsEditing] = useState(false);
const [editContent, setEditContent] = useState(message.content ?? '');
const [isHovered, setIsHovered] = useState(false);
const currentUser = useAuthStore((s) => s.user);
const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useServerStore((s) => s.members);
const openImagePreview = useUIStore((s) => s.openImagePreview);
const isAuthor = currentUser?.id === message.userId;
const memberRole = members.find(m => m.userId === currentUser?.id)?.role;
const isAdminUser = memberRole === 'admin' || memberRole === 'owner';
const canDelete = isAuthor || isAdminUser;
const contextMenuItems = [];
if (isAuthor) {
contextMenuItems.push({
label: 'Edit Message',
onClick: () => {
setEditContent(message.content ?? '');
setIsEditing(true);
},
});
}
if (canDelete) {
contextMenuItems.push({
label: 'Delete Message',
onClick: () => deleteMessage(message.id),
danger: true,
});
}
const handleEditSubmit = async (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (editContent.trim()) {
await editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}
if (e.key === 'Escape') {
setIsEditing(false);
setEditContent(message.content ?? '');
}
};
const displayName = message.user.displayName ?? message.user.username;
const roleColor = (() => {
const member = members.find(m => m.userId === message.userId);
if (member?.role === 'owner') return 'text-discord-red';
if (member?.role === 'admin') return 'text-discord-blurple';
return 'text-white';
})();
const content = (
<div
className={`group relative flex px-4 py-0.5 hover:bg-discord-bg-hover/30 ${isFirstInGroup ? 'mt-4' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Avatar or timestamp column */}
<div className="w-[72px] flex-shrink-0 flex items-start justify-center">
{isFirstInGroup ? (
<Avatar
src={message.user.avatar}
name={displayName}
size={40}
className="mt-0.5 cursor-pointer"
/>
) : (
<span className={`text-[11px] text-discord-text-muted opacity-0 group-hover:opacity-100 mt-1 select-none`}>
{formatHoverTime(message.createdAt)}
</span>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{isFirstInGroup && (
<div className="flex items-baseline gap-2">
<span className={`font-medium cursor-pointer hover:underline ${roleColor}`}>
{displayName}
</span>
<span className="text-xs text-discord-text-muted">
{formatTime(message.createdAt)}
</span>
</div>
)}
{isEditing ? (
<div className="mt-1">
<textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
onKeyDown={handleEditSubmit}
className="w-full p-2 bg-discord-bg-input rounded text-discord-text-primary outline-none resize-none text-sm"
rows={2}
autoFocus
/>
<p className="text-xs text-discord-text-muted mt-1">
escape to <button onClick={() => setIsEditing(false)} className="text-[#00aff4] hover:underline">cancel</button>
{' '}&bull; enter to <button onClick={() => {
if (editContent.trim()) {
editMessage(message.id, editContent.trim());
setIsEditing(false);
}
}} className="text-[#00aff4] hover:underline">save</button>
</p>
</div>
) : (
<>
{message.content && (
<div className="text-discord-text-primary text-sm leading-[1.375rem] break-words">
<ReactMarkdown
components={{
p: ({ children }) => <span>{children}</span>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-[#00aff4] hover:underline">
{children}
</a>
),
code: ({ children }) => (
<code className="px-1 py-0.5 bg-discord-bg-tertiary rounded text-sm font-mono">
{children}
</code>
),
pre: ({ children }) => (
<pre className="mt-1 p-3 bg-discord-bg-tertiary rounded text-sm font-mono overflow-x-auto">
{children}
</pre>
),
strong: ({ children }) => <strong className="font-bold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
}}
>
{message.content}
</ReactMarkdown>
{message.editedAt && (
<span className="text-[10px] text-discord-text-muted ml-1">(edited)</span>
)}
</div>
)}
{/* Attachments */}
{message.attachments.length > 0 && (
<div className="mt-1 space-y-1">
{message.attachments.map((att) => {
const isImage = att.mimetype.startsWith('image/');
if (isImage) {
return (
<div key={att.id} className="max-w-[400px]">
<img
src={`/api/uploads/${att.filename}`}
alt={att.originalName}
className="max-w-full max-h-[300px] rounded cursor-pointer hover:shadow-lg transition-shadow"
onClick={() => openImagePreview(`/api/uploads/${att.filename}`)}
loading="lazy"
/>
</div>
);
}
return (
<a
key={att.id}
href={`/api/uploads/${att.filename}`}
download={att.originalName}
className="flex items-center gap-2 p-3 bg-discord-bg-secondary rounded border border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors max-w-[400px]"
>
<svg className="w-6 h-6 text-discord-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
</svg>
<div className="min-w-0">
<p className="text-[#00aff4] text-sm truncate hover:underline">{att.originalName}</p>
<p className="text-xs text-discord-text-muted">
{att.size < 1024 ? `${att.size} B` :
att.size < 1048576 ? `${(att.size / 1024).toFixed(1)} KB` :
`${(att.size / 1048576).toFixed(1)} MB`}
</p>
</div>
</a>
);
})}
</div>
)}
</>
)}
</div>
{/* Action buttons on hover */}
{isHovered && !isEditing && contextMenuItems.length > 0 && (
<div className="absolute -top-3 right-4 flex items-center bg-discord-bg-secondary border border-discord-bg-tertiary rounded shadow-md">
{isAuthor && (
<button
onClick={() => {
setEditContent(message.content ?? '');
setIsEditing(true);
}}
className="p-1.5 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Edit"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.293 1.293a1 1 0 011.414 1.414l-9 9a1 1 0 01-.39.242l-3 1a1 1 0 01-1.266-1.265l1-3a1 1 0 01.242-.391l9-9zM12 3l1 1-8 8-1.5.5.5-1.5L12 3z" />
</svg>
</button>
)}
{canDelete && (
<button
onClick={() => deleteMessage(message.id)}
className="p-1.5 text-discord-text-muted hover:text-discord-red transition-colors"
title="Delete"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
</svg>
</button>
)}
</div>
)}
</div>
);
if (contextMenuItems.length > 0) {
return <ContextMenu items={contextMenuItems}>{content}</ContextMenu>;
}
return content;
}
@@ -0,0 +1,99 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useRef, useCallback } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
export function MessageInput({ channelId, channelName }) {
const [content, setContent] = useState('');
const [files, setFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef(null);
const textareaRef = useRef(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const typingTimeoutRef = useRef();
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current)
return;
wsSend({ type: 'typing_start', channelId });
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
}, 3000);
}, [channelId]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0)
return;
setIsUploading(true);
try {
// Upload files first
const attachmentIds = [];
for (const file of files) {
const attachment = await api.uploads.upload(file);
attachmentIds.push(attachment.id);
}
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
setContent('');
setFiles([]);
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = undefined;
}
}
catch (err) {
console.error('Failed to send message:', err);
}
finally {
setIsUploading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = (e) => {
const items = e.clipboardData.items;
const pastedFiles = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file)
pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
setFiles((prev) => [...prev, ...pastedFiles]);
}
};
const handleDrop = (e) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
};
const handleDragOver = (e) => {
e.preventDefault();
};
const removeFile = (index) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (e) => {
setContent(e.target.value);
handleTyping();
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (_jsx("div", { className: "px-4 pb-6", children: _jsxs("div", { className: "bg-discord-bg-input rounded-lg", onDrop: handleDrop, onDragOver: handleDragOver, children: [files.length > 0 && (_jsx("div", { className: "p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2", children: files.map((file, i) => (_jsxs("div", { className: "relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]", children: [file.type.startsWith('image/') ? (_jsx("img", { src: URL.createObjectURL(file), alt: file.name, className: "max-h-[100px] rounded object-cover" })) : (_jsxs("div", { className: "flex items-center gap-2 text-sm text-discord-text-secondary", children: [_jsx("svg", { className: "w-5 h-5", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", children: _jsx("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" }) }), _jsx("span", { className: "truncate", children: file.name })] })), _jsx("button", { onClick: () => removeFile(i), className: "absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity", children: "\u2715" })] }, i))) })), _jsxs("div", { className: "flex items-end", children: [_jsx("button", { onClick: () => fileInputRef.current?.click(), className: "p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "Attach file", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, className: "hidden", onChange: (e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
} }), _jsx("textarea", { ref: textareaRef, value: content, onChange: handleChange, onKeyDown: handleKeyDown, onPaste: handlePaste, placeholder: `Message #${channelName}`, className: "flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]", rows: 1, disabled: isUploading }), isUploading && (_jsx("div", { className: "p-3 text-discord-text-muted", children: _jsxs("svg", { className: "w-5 h-5 animate-spin", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }) }))] })] }) }));
}
@@ -0,0 +1,193 @@
import React, { useState, useRef, useCallback } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { wsSend } from '../../hooks/useWebSocket';
import { api } from '../../api/client';
interface MessageInputProps {
channelId: string;
channelName: string;
}
export function MessageInput({ channelId, channelName }: MessageInputProps) {
const [content, setContent] = useState('');
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const sendMessage = useChatStore((s) => s.sendMessage);
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handleTyping = useCallback(() => {
if (typingTimeoutRef.current) return;
wsSend({ type: 'typing_start', channelId });
typingTimeoutRef.current = setTimeout(() => {
typingTimeoutRef.current = undefined;
}, 3000);
}, [channelId]);
const handleSubmit = async () => {
const trimmed = content.trim();
if (!trimmed && files.length === 0) return;
setIsUploading(true);
try {
// Upload files first
const attachmentIds: string[] = [];
for (const file of files) {
const attachment = await api.uploads.upload(file);
attachmentIds.push(attachment.id);
}
await sendMessage(channelId, trimmed || '', attachmentIds.length > 0 ? attachmentIds : undefined);
setContent('');
setFiles([]);
// Clear typing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = undefined;
}
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setIsUploading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handlePaste = (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
const pastedFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) pastedFiles.push(file);
}
}
if (pastedFiles.length > 0) {
setFiles((prev) => [...prev, ...pastedFiles]);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
setFiles((prev) => [...prev, ...droppedFiles]);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index));
};
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
handleTyping();
// Auto-resize textarea
const textarea = e.target;
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px';
};
return (
<div className="px-4 pb-6">
<div
className="bg-discord-bg-input rounded-lg"
onDrop={handleDrop}
onDragOver={handleDragOver}
>
{/* File previews */}
{files.length > 0 && (
<div className="p-2 border-b border-discord-bg-tertiary flex flex-wrap gap-2">
{files.map((file, i) => (
<div key={i} className="relative group bg-discord-bg-secondary rounded p-2 max-w-[200px]">
{file.type.startsWith('image/') ? (
<img
src={URL.createObjectURL(file)}
alt={file.name}
className="max-h-[100px] rounded object-cover"
/>
) : (
<div className="flex items-center gap-2 text-sm text-discord-text-secondary">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="truncate">{file.name}</span>
</div>
)}
<button
onClick={() => removeFile(i)}
className="absolute -top-1 -right-1 w-5 h-5 bg-discord-red rounded-full flex items-center justify-center text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
</button>
</div>
))}
</div>
)}
<div className="flex items-end">
{/* File attach button */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-3 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="Attach file"
>
<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>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => {
const selected = Array.from(e.target.files ?? []);
if (selected.length > 0) {
setFiles((prev) => [...prev, ...selected]);
}
e.target.value = '';
}}
/>
{/* Text input */}
<textarea
ref={textareaRef}
value={content}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={`Message #${channelName}`}
className="flex-1 py-3 bg-transparent text-discord-text-primary placeholder-discord-text-muted outline-none resize-none text-sm leading-[1.375rem] max-h-[300px]"
rows={1}
disabled={isUploading}
/>
{/* Send indicator */}
{isUploading && (
<div className="p-3 text-discord-text-muted">
<svg className="w-5 h-5 animate-spin" 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>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
const EMPTY_MESSAGES = [];
function isSameGroup(prev, curr) {
if (prev.userId !== curr.userId)
return false;
const timeDiff = curr.createdAt - prev.createdAt;
return timeDiff < 5 * 60 * 1000; // 5 minutes
}
function formatDateDivider(timestamp) {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function shouldShowDateDivider(prev, curr) {
if (!prev)
return true;
const prevDate = new Date(prev.createdAt).toDateString();
const currDate = new Date(curr.createdAt).toDateString();
return prevDate !== currDate;
}
export function MessageList({ channelId }) {
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
const loadMessages = useChatStore((s) => s.loadMessages);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const bottomRef = useRef(null);
const containerRef = useRef(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const prevMessagesLength = useRef(0);
useEffect(() => {
loadMessages(channelId);
}, [channelId, loadMessages]);
// Auto-scroll to bottom on new messages (if near bottom)
useEffect(() => {
if (messages.length > prevMessagesLength.current && isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
prevMessagesLength.current = messages.length;
}, [messages.length, isNearBottom]);
// Scroll to bottom on initial load
useEffect(() => {
if (messages.length > 0 && prevMessagesLength.current === 0) {
bottomRef.current?.scrollIntoView();
}
}, [messages.length]);
const handleScroll = useCallback(async () => {
const container = containerRef.current;
if (!container)
return;
// Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setIsNearBottom(distanceFromBottom < 100);
// Load more when scrolled to top
if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
setIsLoadingMore(true);
const prevScrollHeight = container.scrollHeight;
const loaded = await loadMoreMessages(channelId);
if (loaded) {
// Maintain scroll position
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - prevScrollHeight;
});
}
setIsLoadingMore(false);
}
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
if (isLoading && messages.length === 0) {
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsx(LoadingSpinner, {}) }));
}
return (_jsxs("div", { ref: containerRef, className: "flex-1 overflow-y-auto overflow-x-hidden", onScroll: handleScroll, children: [isLoadingMore && (_jsx("div", { className: "py-4", children: _jsx(LoadingSpinner, { size: 24 }) })), !hasMore && (_jsxs("div", { className: "px-4 pt-6 pb-4", children: [_jsx("h3", { className: "text-2xl font-bold text-discord-text-primary", children: "Welcome to the channel!" }), _jsx("p", { className: "text-discord-text-muted text-sm mt-1", children: "This is the start of the conversation." }), _jsx("div", { className: "mt-4 border-b border-discord-bg-hover" })] })), _jsx("div", { className: "pb-6", children: messages.map((msg, i) => {
const prevMsg = messages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
return (_jsxs(React.Fragment, { children: [showDate && (_jsxs("div", { className: "flex items-center px-4 my-4", children: [_jsx("div", { className: "flex-1 border-t border-discord-bg-hover" }), _jsx("span", { className: "px-2 text-xs font-semibold text-discord-text-muted", children: formatDateDivider(msg.createdAt) }), _jsx("div", { className: "flex-1 border-t border-discord-bg-hover" })] })), _jsx(Message, { message: msg, isCompact: !isFirstInGroup, isFirstInGroup: isFirstInGroup })] }, msg.id));
}) }), _jsx("div", { ref: bottomRef })] }));
}
@@ -0,0 +1,148 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { Message } from './Message';
import { useChatStore } from '../../stores/chatStore';
import { LoadingSpinner } from '../ui/LoadingSpinner';
import type { MessageWithUser } from '@opencord/shared';
const EMPTY_MESSAGES: MessageWithUser[] = [];
interface MessageListProps {
channelId: string;
}
function isSameGroup(prev: MessageWithUser, curr: MessageWithUser): boolean {
if (prev.userId !== curr.userId) return false;
const timeDiff = curr.createdAt - prev.createdAt;
return timeDiff < 5 * 60 * 1000; // 5 minutes
}
function formatDateDivider(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
function shouldShowDateDivider(prev: MessageWithUser | undefined, curr: MessageWithUser): boolean {
if (!prev) return true;
const prevDate = new Date(prev.createdAt).toDateString();
const currDate = new Date(curr.createdAt).toDateString();
return prevDate !== currDate;
}
export function MessageList({ channelId }: MessageListProps) {
const messages = useChatStore((s) => s.messages.get(channelId)) ?? EMPTY_MESSAGES;
const loadMessages = useChatStore((s) => s.loadMessages);
const loadMoreMessages = useChatStore((s) => s.loadMoreMessages);
const isLoading = useChatStore((s) => s.isLoading);
const hasMore = useChatStore((s) => s.hasMore.get(channelId) ?? true);
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const prevMessagesLength = useRef(0);
useEffect(() => {
loadMessages(channelId);
}, [channelId, loadMessages]);
// Auto-scroll to bottom on new messages (if near bottom)
useEffect(() => {
if (messages.length > prevMessagesLength.current && isNearBottom) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
prevMessagesLength.current = messages.length;
}, [messages.length, isNearBottom]);
// Scroll to bottom on initial load
useEffect(() => {
if (messages.length > 0 && prevMessagesLength.current === 0) {
bottomRef.current?.scrollIntoView();
}
}, [messages.length]);
const handleScroll = useCallback(async () => {
const container = containerRef.current;
if (!container) return;
// Check if near bottom
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
setIsNearBottom(distanceFromBottom < 100);
// Load more when scrolled to top
if (container.scrollTop < 50 && hasMore && !isLoadingMore) {
setIsLoadingMore(true);
const prevScrollHeight = container.scrollHeight;
const loaded = await loadMoreMessages(channelId);
if (loaded) {
// Maintain scroll position
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight - prevScrollHeight;
});
}
setIsLoadingMore(false);
}
}, [channelId, hasMore, isLoadingMore, loadMoreMessages]);
if (isLoading && messages.length === 0) {
return (
<div className="flex-1 flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
return (
<div
ref={containerRef}
className="flex-1 overflow-y-auto overflow-x-hidden"
onScroll={handleScroll}
>
{isLoadingMore && (
<div className="py-4">
<LoadingSpinner size={24} />
</div>
)}
{!hasMore && (
<div className="px-4 pt-6 pb-4">
<h3 className="text-2xl font-bold text-discord-text-primary">Welcome to the channel!</h3>
<p className="text-discord-text-muted text-sm mt-1">This is the start of the conversation.</p>
<div className="mt-4 border-b border-discord-bg-hover" />
</div>
)}
<div className="pb-6">
{messages.map((msg, i) => {
const prevMsg = messages[i - 1];
const showDate = shouldShowDateDivider(prevMsg, msg);
const isFirstInGroup = !prevMsg || showDate || !isSameGroup(prevMsg, msg);
return (
<React.Fragment key={msg.id}>
{showDate && (
<div className="flex items-center px-4 my-4">
<div className="flex-1 border-t border-discord-bg-hover" />
<span className="px-2 text-xs font-semibold text-discord-text-muted">
{formatDateDivider(msg.createdAt)}
</span>
<div className="flex-1 border-t border-discord-bg-hover" />
</div>
)}
<Message
message={msg}
isCompact={!isFirstInGroup}
isFirstInGroup={isFirstInGroup}
/>
</React.Fragment>
);
})}
</div>
<div ref={bottomRef} />
</div>
);
}
@@ -0,0 +1,29 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useMemo } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
export function TypingIndicator({ channelId }) {
const typingUsersRaw = useChatStore((s) => s.typingUsers.get(channelId));
const currentUserId = useAuthStore((s) => s.user?.id);
// Filter out current user and expired entries
const others = useMemo(() => {
if (!typingUsersRaw || typingUsersRaw.length === 0)
return [];
const now = Date.now();
return typingUsersRaw
.filter(t => now - t.timestamp < 5000 && t.userId !== currentUserId);
}, [typingUsersRaw, currentUserId]);
if (others.length === 0)
return null;
let text = '';
if (others.length === 1) {
text = `${others[0].username} is typing`;
}
else if (others.length === 2) {
text = `${others[0].username} and ${others[1].username} are typing`;
}
else {
text = 'Several people are typing';
}
return (_jsx("div", { className: "h-6 px-4 flex items-center text-xs text-discord-text-muted", children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsxs("span", { className: "flex gap-0.5", children: [_jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '0ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '150ms' } }), _jsx("span", { className: "w-1 h-1 bg-discord-text-muted rounded-full animate-bounce", style: { animationDelay: '300ms' } })] }), _jsx("span", { className: "font-medium", children: text }), _jsx("span", { children: "..." })] }) }));
}
@@ -0,0 +1,45 @@
import React, { useMemo } from 'react';
import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore';
interface TypingIndicatorProps {
channelId: string;
}
export function TypingIndicator({ channelId }: TypingIndicatorProps) {
const typingUsersRaw = useChatStore((s) => s.typingUsers.get(channelId));
const currentUserId = useAuthStore((s) => s.user?.id);
// Filter out current user and expired entries
const others = useMemo(() => {
if (!typingUsersRaw || typingUsersRaw.length === 0) return [];
const now = Date.now();
return typingUsersRaw
.filter(t => now - t.timestamp < 5000 && t.userId !== currentUserId);
}, [typingUsersRaw, currentUserId]);
if (others.length === 0) return null;
let text = '';
if (others.length === 1) {
text = `${others[0]!.username} is typing`;
} else if (others.length === 2) {
text = `${others[0]!.username} and ${others[1]!.username} are typing`;
} else {
text = 'Several people are typing';
}
return (
<div className="h-6 px-4 flex items-center text-xs text-discord-text-muted">
<div className="flex items-center gap-1">
<span className="flex gap-0.5">
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 bg-discord-text-muted rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
<span className="font-medium">{text}</span>
<span>...</span>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { ServerSidebar } from './ServerSidebar';
import { ChannelSidebar } from './ChannelSidebar';
import { MainContent } from './MainContent';
import { MemberSidebar } from './MemberSidebar';
import { MobileNav } from './MobileNav';
import { ImagePreview } from '../chat/ImagePreview';
import { CreateServerModal } from '../modals/CreateServer';
import { JoinServerModal } from '../modals/JoinServer';
import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
export function AppLayout() {
const { serverId, channelId } = useParams();
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
// Initialize WebSocket
useWebSocket();
// Responsive detection
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [setIsMobile]);
// Handle route params
useEffect(() => {
if (serverId === '@me') {
setShowDms(true);
setCurrentServer(null);
}
else if (serverId) {
setShowDms(false);
setCurrentServer(serverId);
loadServerDetail(serverId);
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
useEffect(() => {
if (channelId) {
setCurrentChannel(channelId);
loadMessages(channelId);
}
else {
setCurrentChannel(null);
}
}, [channelId, setCurrentChannel, loadMessages]);
if (isLoading || !user) {
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
}
return (_jsxs("div", { className: "h-screen flex overflow-hidden", children: [_jsx(MobileNav, {}), _jsx("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : ''}`, children: _jsxs("div", { className: "flex h-full", children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }) }), _jsxs("div", { className: "flex-1 flex min-w-0", children: [_jsx(MainContent, {}), _jsx(MemberSidebar, {})] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(ImagePreview, {})] }));
}
@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { ServerSidebar } from './ServerSidebar';
import { ChannelSidebar } from './ChannelSidebar';
import { MainContent } from './MainContent';
import { MemberSidebar } from './MemberSidebar';
import { MobileNav } from './MobileNav';
import { ImagePreview } from '../chat/ImagePreview';
import { CreateServerModal } from '../modals/CreateServer';
import { JoinServerModal } from '../modals/JoinServer';
import { CreateChannelModal } from '../modals/CreateChannel';
import { InviteModal } from '../modals/InviteModal';
import { UserSettingsModal } from '../modals/UserSettings';
import { ServerSettingsModal } from '../modals/ServerSettings';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
export function AppLayout() {
const { serverId, channelId } = useParams<{ serverId?: string; channelId?: string }>();
const { user, isLoading } = useAuth();
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
const setShowDms = useUIStore((s) => s.setShowDms);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const isMobile = useUIStore((s) => s.isMobile);
// Initialize WebSocket
useWebSocket();
// Responsive detection
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, [setIsMobile]);
// Handle route params
useEffect(() => {
if (serverId === '@me') {
setShowDms(true);
setCurrentServer(null);
} else if (serverId) {
setShowDms(false);
setCurrentServer(serverId);
loadServerDetail(serverId);
}
}, [serverId, setCurrentServer, loadServerDetail, setShowDms]);
useEffect(() => {
if (channelId) {
setCurrentChannel(channelId);
loadMessages(channelId);
} else {
setCurrentChannel(null);
}
}, [channelId, setCurrentChannel, loadMessages]);
if (isLoading || !user) {
return (
<div className="h-screen flex items-center justify-center bg-discord-bg-primary">
<div className="text-center">
<svg className="animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4" 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>
<p className="text-discord-text-muted">Loading Opencord...</p>
</div>
</div>
);
}
return (
<div className="h-screen flex overflow-hidden">
<MobileNav />
{/* Server sidebar - always visible on desktop, toggled on mobile */}
<div className={`${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : ''}`}>
<div className="flex h-full">
<ServerSidebar />
<ChannelSidebar />
</div>
</div>
{/* Main content area */}
<div className="flex-1 flex min-w-0">
<MainContent />
<MemberSidebar />
</div>
{/* Modals */}
<CreateServerModal />
<JoinServerModal />
<CreateChannelModal />
<InviteModal />
<UserSettingsModal />
<ServerSettingsModal />
<ImagePreview />
</div>
);
}
@@ -0,0 +1,47 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { VoiceChannel } from '../voice/VoiceChannel';
import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { wsSend } from '../../hooks/useWebSocket';
export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const openModal = useUIStore((s) => s.openModal);
const user = useAuthStore((s) => s.user);
const members = useServerStore((s) => s.members);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel);
const navigate = useNavigate();
const server = servers.find(s => s.id === currentServerId);
const currentMember = members.find(m => m.userId === user?.id);
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner';
const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
const handleChannelClick = (channelId) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
};
const handleVoiceJoin = (channelId) => {
setCurrentVoiceChannel(channelId);
wsSend({ type: 'voice_join', channelId });
};
const handleVoiceDisconnect = () => {
setCurrentVoiceChannel(null);
wsSend({ type: 'voice_leave' });
};
if (!server) {
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary", children: _jsx("span", { className: "font-semibold text-discord-text-primary", children: "Direct Messages" }) }), _jsx("div", { className: "flex-1 p-2 text-discord-text-muted text-sm", children: _jsx("p", { className: "px-2 py-4", children: "Select or create a DM conversation" }) }), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-members flex items-center gap-2", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[10px] text-discord-text-muted truncate", children: ["@", user.username] })] }), _jsx("button", { onClick: () => openModal('userSettings'), className: "p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "User Settings", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] }))] }));
}
return (_jsxs("div", { className: "w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0", children: [_jsxs("button", { onClick: () => openModal('serverSettings'), className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors", children: [_jsx("span", { className: "font-bold text-discord-text-primary truncate", children: server.name }), _jsx("svg", { width: "18", height: "18", viewBox: "0 0 18 18", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" }) })] }), _jsxs("div", { className: "flex-1 overflow-y-auto p-2 space-y-4", children: [textChannels.length > 0 && (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1", children: [_jsx("span", { className: "text-xs font-bold text-discord-text-muted uppercase tracking-wide", children: "Text Channels" }), isAdminUser && (_jsx("button", { onClick: () => openModal('createChannel'), className: "text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), textChannels.map((channel) => (_jsxs("button", { onClick: () => handleChannelClick(channel.id), className: `w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${currentChannelId === channel.id
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "truncate", children: channel.name })] }, channel.id)))] })), voiceChannels.length > 0 && (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-1 mb-1", children: [_jsx("span", { className: "text-xs font-bold text-discord-text-muted uppercase tracking-wide", children: "Voice Channels" }), isAdminUser && (_jsx("button", { onClick: () => openModal('createChannel'), className: "text-discord-text-muted hover:text-discord-text-secondary transition-colors", title: "Create Channel", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" }) }) }))] }), voiceChannels.map((channel) => (_jsx(VoiceChannel, { channelId: channel.id, channelName: channel.name, onClick: () => handleVoiceJoin(channel.id) }, channel.id)))] })), _jsxs("button", { onClick: () => openModal('invite'), className: "w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "currentColor", children: _jsx("path", { d: "M14 2.5a.5.5 0 00-.5-.5h-6a.5.5 0 000 1h4.793L2.146 13.146a.5.5 0 00.708.708L13 3.707V8.5a.5.5 0 001 0v-6z" }) }), "Invite People"] })] }), currentVoiceChannelId && (_jsx(VoiceControls, { onDisconnect: handleVoiceDisconnect, onToggleMic: () => { }, onToggleCamera: () => { }, onToggleScreenShare: () => { } })), user && (_jsxs("div", { className: "h-[52px] px-2 bg-discord-bg-members flex items-center gap-2", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 32, status: user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm font-medium truncate", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-[10px] text-discord-text-muted truncate", children: ["@", user.username] })] }), _jsx("button", { onClick: () => openModal('userSettings'), className: "p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors", title: "User Settings", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" }) }) })] }))] }));
}
@@ -0,0 +1,202 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { VoiceChannel } from '../voice/VoiceChannel';
import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
import { Avatar } from '../ui/Avatar';
import { wsSend } from '../../hooks/useWebSocket';
export function ChannelSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const openModal = useUIStore((s) => s.openModal);
const user = useAuthStore((s) => s.user);
const members = useServerStore((s) => s.members);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const setCurrentVoiceChannel = useVoiceStore((s) => s.setCurrentVoiceChannel);
const navigate = useNavigate();
const server = servers.find(s => s.id === currentServerId);
const currentMember = members.find(m => m.userId === user?.id);
const isAdminUser = currentMember?.role === 'admin' || currentMember?.role === 'owner';
const textChannels = channels.filter(c => c.type === 'text');
const voiceChannels = channels.filter(c => c.type === 'voice' || c.type === 'video');
const handleChannelClick = (channelId: string) => {
setCurrentChannel(channelId);
navigate(`/channels/${currentServerId}/${channelId}`);
};
const handleVoiceJoin = (channelId: string) => {
setCurrentVoiceChannel(channelId);
wsSend({ type: 'voice_join', channelId });
};
const handleVoiceDisconnect = () => {
setCurrentVoiceChannel(null);
wsSend({ type: 'voice_leave' });
};
if (!server) {
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary">
<span className="font-semibold text-discord-text-primary">Direct Messages</span>
</div>
<div className="flex-1 p-2 text-discord-text-muted text-sm">
<p className="px-2 py-4">Select or create a DM conversation</p>
</div>
{/* User area at bottom */}
{user && (
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
<div className="text-[10px] text-discord-text-muted truncate">@{user.username}</div>
</div>
<button
onClick={() => openModal('userSettings')}
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="User Settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
</div>
)}
</div>
);
}
return (
<div className="w-60 bg-discord-bg-secondary flex flex-col flex-shrink-0">
{/* Server header */}
<button
onClick={() => openModal('serverSettings')}
className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary hover:bg-discord-bg-hover transition-colors"
>
<span className="font-bold text-discord-text-primary truncate">{server.name}</span>
<svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<path d="M5.293 7.293a1 1 0 011.414 0L9 9.586l2.293-2.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" />
</svg>
</button>
{/* Channels */}
<div className="flex-1 overflow-y-auto p-2 space-y-4">
{/* Text Channels */}
{textChannels.length > 0 && (
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Text Channels</span>
{isAdminUser && (
<button
onClick={() => openModal('createChannel')}
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
{textChannels.map((channel) => (
<button
key={channel.id}
onClick={() => handleChannelClick(channel.id)}
className={`w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${
currentChannelId === channel.id
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className="truncate">{channel.name}</span>
</button>
))}
</div>
)}
{/* Voice Channels */}
{voiceChannels.length > 0 && (
<div>
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-xs font-bold text-discord-text-muted uppercase tracking-wide">Voice Channels</span>
{isAdminUser && (
<button
onClick={() => openModal('createChannel')}
className="text-discord-text-muted hover:text-discord-text-secondary transition-colors"
title="Create Channel"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg>
</button>
)}
</div>
{voiceChannels.map((channel) => (
<VoiceChannel
key={channel.id}
channelId={channel.id}
channelName={channel.name}
onClick={() => handleVoiceJoin(channel.id)}
/>
))}
</div>
)}
{/* Invite button */}
<button
onClick={() => openModal('invite')}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M14 2.5a.5.5 0 00-.5-.5h-6a.5.5 0 000 1h4.793L2.146 13.146a.5.5 0 00.708.708L13 3.707V8.5a.5.5 0 001 0v-6z" />
</svg>
Invite People
</button>
</div>
{/* Voice controls */}
{currentVoiceChannelId && (
<VoiceControls
onDisconnect={handleVoiceDisconnect}
onToggleMic={() => {}}
onToggleCamera={() => {}}
onToggleScreenShare={() => {}}
/>
)}
{/* User area */}
{user && (
<div className="h-[52px] px-2 bg-discord-bg-members flex items-center gap-2">
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={32} status={user.status} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{user.displayName ?? user.username}</div>
<div className="text-[10px] text-discord-text-muted truncate">@{user.username}</div>
</div>
<button
onClick={() => openModal('userSettings')}
className="p-1 text-discord-text-muted hover:text-discord-text-primary transition-colors"
title="User Settings"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { useVoiceStore } from '../../stores/voiceStore';
export function MainContent() {
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const currentServerId = useServerStore((s) => s.currentServerId);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const showDms = useUIStore((s) => s.showDms);
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
// DM view or no server selected
if (showDms || !currentServerId) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm", children: _jsx("span", { className: "font-bold text-discord-text-primary", children: "Home" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsxs("div", { className: "text-center", children: [_jsx("h2", { className: "text-2xl font-bold text-discord-text-primary mb-2", children: "Welcome to Opencord!" }), _jsx("p", { children: "Select a server from the sidebar or start a direct message." })] }) })] }));
}
// No channel selected
if (!currentChannelId || !channel) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm", children: _jsx("span", { className: "text-discord-text-muted", children: "Select a channel" }) }), _jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "Select a text or voice channel to get started" }) })] }));
}
// Voice/Video channel view
if (isVoiceChannel) {
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary", children: [_jsx("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm", children: _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary", children: channel.name })] }) }), _jsx(VoiceGrid, { participants: [] })] }));
}
// Text channel view
return (_jsxs("div", { className: "flex-1 flex flex-col bg-discord-bg-primary min-w-0", children: [_jsxs("div", { className: "h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm flex-shrink-0", children: [_jsxs("div", { className: "flex items-center gap-2 min-w-0", children: [_jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) }), _jsx("span", { className: "font-bold text-discord-text-primary truncate", children: channel.name }), channel.topic && (_jsxs(_Fragment, { children: [_jsx("div", { className: "w-px h-6 bg-discord-bg-hover mx-1" }), _jsx("span", { className: "text-xs text-discord-text-muted truncate", children: channel.topic })] }))] }), _jsx("div", { className: "flex items-center gap-2 flex-shrink-0", children: _jsx("button", { onClick: toggleMemberList, className: `p-1 transition-colors ${memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-primary'}`, title: "Toggle Member List", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" }) }) }) })] }), _jsx(MessageList, { channelId: currentChannelId }), _jsx(TypingIndicator, { channelId: currentChannelId }), _jsx(MessageInput, { channelId: currentChannelId, channelName: channel.name })] }));
}
@@ -0,0 +1,113 @@
import React from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useChatStore } from '../../stores/chatStore';
import { useUIStore } from '../../stores/uiStore';
import { MessageList } from '../chat/MessageList';
import { MessageInput } from '../chat/MessageInput';
import { TypingIndicator } from '../chat/TypingIndicator';
import { VoiceGrid } from '../voice/VoiceGrid';
import { useVoiceStore } from '../../stores/voiceStore';
export function MainContent() {
const channels = useServerStore((s) => s.channels);
const currentChannelId = useChatStore((s) => s.currentChannelId);
const currentServerId = useServerStore((s) => s.currentServerId);
const toggleMemberList = useUIStore((s) => s.toggleMemberList);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const showDms = useUIStore((s) => s.showDms);
const channel = channels.find(c => c.id === currentChannelId);
const isVoiceChannel = channel?.type === 'voice' || channel?.type === 'video';
// DM view or no server selected
if (showDms || !currentServerId) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm">
<span className="font-bold text-discord-text-primary">Home</span>
</div>
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
<div className="text-center">
<h2 className="text-2xl font-bold text-discord-text-primary mb-2">Welcome to Opencord!</h2>
<p>Select a server from the sidebar or start a direct message.</p>
</div>
</div>
</div>
);
}
// No channel selected
if (!currentChannelId || !channel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center border-b border-discord-bg-tertiary shadow-sm">
<span className="text-discord-text-muted">Select a channel</span>
</div>
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
<p>Select a text or voice channel to get started</p>
</div>
</div>
);
}
// Voice/Video channel view
if (isVoiceChannel) {
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary">
<div className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm">
<div className="flex items-center gap-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" />
</svg>
<span className="font-bold text-discord-text-primary">{channel.name}</span>
</div>
</div>
<VoiceGrid participants={[]} />
</div>
);
}
// Text channel view
return (
<div className="flex-1 flex flex-col bg-discord-bg-primary min-w-0">
{/* Channel header */}
<div className="h-12 px-4 flex items-center justify-between border-b border-discord-bg-tertiary shadow-sm flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted flex-shrink-0">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" />
</svg>
<span className="font-bold text-discord-text-primary truncate">{channel.name}</span>
{channel.topic && (
<>
<div className="w-px h-6 bg-discord-bg-hover mx-1" />
<span className="text-xs text-discord-text-muted truncate">{channel.topic}</span>
</>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={toggleMemberList}
className={`p-1 transition-colors ${
memberListOpen ? 'text-discord-text-primary' : 'text-discord-text-muted hover:text-discord-text-primary'
}`}
title="Toggle Member List"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M14 8.00598C14 10.211 12.206 12.006 10 12.006C7.795 12.006 6 10.211 6 8.00598C6 5.80098 7.794 4.00598 10 4.00598C12.206 4.00598 14 5.80098 14 8.00598ZM2 19.006C2 15.473 5.29 13.006 10 13.006C14.711 13.006 18 15.473 18 19.006V20.006H2V19.006ZM20 20.006H22V19.006C22 16.451 20.178 14.471 17.532 13.471C19.461 14.601 20 16.561 20 19.006V20.006Z" />
</svg>
</button>
</div>
</div>
{/* Messages */}
<MessageList channelId={currentChannelId} />
{/* Typing indicator */}
<TypingIndicator channelId={currentChannelId} />
{/* Message input */}
<MessageInput channelId={currentChannelId} channelName={channel.name} />
</div>
);
}
@@ -0,0 +1,24 @@
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
if (!memberListOpen)
return null;
const onlineMembers = members.filter(m => m.user.status !== 'offline');
const offlineMembers = members.filter(m => m.user.status === 'offline');
const roleColors = {
owner: 'text-discord-red',
admin: 'text-discord-blurple',
member: 'text-discord-text-primary',
};
return (_jsx("div", { className: "w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto", children: _jsxs("div", { className: "p-3", children: [onlineMembers.length > 0 && (_jsxs("div", { className: "mb-4", children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Online \u2014 ", onlineMembers.length] }), onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: `text-sm font-medium truncate ${roleColors[member.role] ?? 'text-discord-text-primary'}`, children: displayName }), member.user.customStatus && (_jsx("div", { className: "text-xs text-discord-text-muted truncate", children: member.user.customStatus }))] })] }, member.userId));
})] })), offlineMembers.length > 0 && (_jsxs("div", { children: [_jsxs("h3", { className: "text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1", children: ["Offline \u2014 ", offlineMembers.length] }), offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group opacity-50", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: "offline" }), _jsx("div", { className: "flex-1 min-w-0", children: _jsx("div", { className: "text-sm font-medium truncate text-discord-text-muted", children: displayName }) })] }, member.userId));
})] }))] }) }));
}
@@ -0,0 +1,89 @@
import React from 'react';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Avatar } from '../ui/Avatar';
export function MemberSidebar() {
const members = useServerStore((s) => s.members);
const memberListOpen = useUIStore((s) => s.memberListOpen);
if (!memberListOpen) return null;
const onlineMembers = members.filter(m => m.user.status !== 'offline');
const offlineMembers = members.filter(m => m.user.status === 'offline');
const roleColors: Record<string, string> = {
owner: 'text-discord-red',
admin: 'text-discord-blurple',
member: 'text-discord-text-primary',
};
return (
<div className="w-60 bg-discord-bg-members flex-shrink-0 overflow-y-auto">
<div className="p-3">
{/* Online */}
{onlineMembers.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Online {onlineMembers.length}
</h3>
{onlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${roleColors[member.role] ?? 'text-discord-text-primary'}`}>
{displayName}
</div>
{member.user.customStatus && (
<div className="text-xs text-discord-text-muted truncate">{member.user.customStatus}</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* Offline */}
{offlineMembers.length > 0 && (
<div>
<h3 className="text-xs font-semibold text-discord-text-muted uppercase tracking-wide px-2 mb-1">
Offline {offlineMembers.length}
</h3>
{offlineMembers.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div
key={member.userId}
className="flex items-center gap-3 px-2 py-1.5 rounded hover:bg-discord-bg-hover cursor-pointer group opacity-50"
>
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status="offline"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate text-discord-text-muted">
{displayName}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
import { useUIStore } from '../../stores/uiStore';
export function MobileNav() {
const isMobile = useUIStore((s) => s.isMobile);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
if (!isMobile)
return null;
return (_jsxs(_Fragment, { children: [_jsx("button", { onClick: toggleSidebar, className: "fixed top-3 left-3 z-40 p-1.5 rounded bg-discord-bg-secondary text-discord-text-primary md:hidden", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: sidebarOpen ? (_jsx("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" })) : (_jsx("path", { d: "M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" })) }) }), sidebarOpen && (_jsx("div", { className: "fixed inset-0 bg-black/50 z-30 md:hidden", onClick: toggleSidebar }))] }));
}
@@ -0,0 +1,36 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
export function MobileNav() {
const isMobile = useUIStore((s) => s.isMobile);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
if (!isMobile) return null;
return (
<>
{/* Hamburger button in header */}
<button
onClick={toggleSidebar}
className="fixed top-3 left-3 z-40 p-1.5 rounded bg-discord-bg-secondary text-discord-text-primary md:hidden"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
{sidebarOpen ? (
<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" />
) : (
<path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z" />
)}
</svg>
</button>
{/* Backdrop when sidebar is open on mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-30 md:hidden"
onClick={toggleSidebar}
/>
)}
</>
);
}
@@ -0,0 +1,33 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Tooltip } from '../ui/Tooltip';
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const navigate = useNavigate();
const handleServerClick = (serverId) => {
setCurrentServer(serverId);
setShowDms(false);
navigate(`/channels/${serverId}`);
};
const handleDmClick = () => {
setShowDms(true);
setCurrentServer(null);
navigate('/channels/@me');
};
return (_jsxs("div", { className: "w-[72px] bg-discord-bg-tertiary flex flex-col items-center py-3 overflow-y-auto flex-shrink-0 gap-2", children: [_jsx(Tooltip, { content: "Direct Messages", position: "right", children: _jsx("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'}`, children: _jsx("svg", { width: "28", height: "20", viewBox: "0 0 28 20", fill: "white", children: _jsx("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)" }) }) }) }), _jsx("div", { className: "w-8 h-0.5 bg-discord-bg-primary rounded-full" }), servers.map((server) => {
const isActive = currentServerId === server.id;
const firstLetter = server.name.charAt(0).toUpperCase();
return (_jsxs("div", { className: "relative", children: [isActive && (_jsx("div", { className: "absolute -left-1 top-1/2 -translate-y-1/2 w-1 h-10 bg-white rounded-r-full" })), _jsx(Tooltip, { content: server.name, position: "right", children: _jsx("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'}`, children: server.icon ? (_jsx("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) }) })] }, server.id));
}), _jsx(Tooltip, { content: "Add a Server", position: "right", children: _jsx("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", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) }) }), _jsx(Tooltip, { content: "Join a Server", position: "right", children: _jsx("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", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2L4.5 20.29l.71.71L12 18l6.79 3 .71-.71z" }) }) }) })] }));
}
@@ -0,0 +1,108 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { Tooltip } from '../ui/Tooltip';
export function ServerSidebar() {
const servers = useServerStore((s) => s.servers);
const currentServerId = useServerStore((s) => s.currentServerId);
const setCurrentServer = useServerStore((s) => s.setCurrentServer);
const showDms = useUIStore((s) => s.showDms);
const setShowDms = useUIStore((s) => s.setShowDms);
const openModal = useUIStore((s) => s.openModal);
const navigate = useNavigate();
const handleServerClick = (serverId: string) => {
setCurrentServer(serverId);
setShowDms(false);
navigate(`/channels/${serverId}`);
};
const handleDmClick = () => {
setShowDms(true);
setCurrentServer(null);
navigate('/channels/@me');
};
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>
{/* Divider */}
<div className="w-8 h-0.5 bg-discord-bg-primary rounded-full" />
{/* 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>
);
})}
{/* 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>
{/* 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>
);
}
@@ -0,0 +1,46 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function CreateChannelModal() {
const [name, setName] = useState('');
const [type, setType] = useState('text');
const [topic, setTopic] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useServerStore((s) => s.createChannel);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'createChannel';
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Channel name is required');
return;
}
if (!currentServerId) {
setError('No server selected');
return;
}
setIsLoading(true);
try {
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
closeModal();
setName('');
setTopic('');
setType('text');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create channel');
}
finally {
setIsLoading(false);
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create Channel", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Type" }), _jsx("div", { className: "space-y-2", children: ['text', 'voice', 'video'].map((t) => (_jsxs("label", { className: `flex items-center gap-3 p-3 rounded cursor-pointer border ${type === t
? 'border-discord-blurple bg-discord-bg-hover'
: 'border-discord-bg-tertiary bg-discord-bg-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("input", { type: "radio", name: "channelType", value: t, checked: type === t, onChange: () => setType(t), className: "hidden" }), _jsxs("div", { className: "w-5 h-5 text-discord-text-muted", children: [t === 'text' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" }) })), t === 'voice' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) })), t === 'video' && (_jsx("svg", { viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }))] }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium text-discord-text-primary capitalize", children: t }), _jsxs("div", { className: "text-xs text-discord-text-muted", children: [t === 'text' && 'Send messages, images, and files', t === 'voice' && 'Hang out with voice and video', t === 'video' && 'Share your screen and camera'] })] })] }, t))) })] }), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Channel Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "new-channel", autoFocus: true })] }), type === 'text' && (_jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Topic (optional)" }), _jsx("input", { type: "text", value: topic, onChange: (e) => setTopic(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What's this channel about?" })] })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Creating...' : 'Create Channel' })] })] }) }));
}
@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function CreateChannelModal() {
const [name, setName] = useState('');
const [type, setType] = useState<'text' | 'voice' | 'video'>('text');
const [topic, setTopic] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const createChannel = useServerStore((s) => s.createChannel);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'createChannel';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Channel name is required');
return;
}
if (!currentServerId) {
setError('No server selected');
return;
}
setIsLoading(true);
try {
await createChannel(currentServerId, name.trim(), type, topic.trim() || undefined);
closeModal();
setName('');
setTopic('');
setType('text');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create channel');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create Channel">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Channel Type
</label>
<div className="space-y-2">
{(['text', 'voice', 'video'] as const).map((t) => (
<label
key={t}
className={`flex items-center gap-3 p-3 rounded cursor-pointer border ${
type === t
? 'border-discord-blurple bg-discord-bg-hover'
: 'border-discord-bg-tertiary bg-discord-bg-secondary hover:bg-discord-bg-hover'
}`}
>
<input
type="radio"
name="channelType"
value={t}
checked={type === t}
onChange={() => setType(t)}
className="hidden"
/>
<div className="w-5 h-5 text-discord-text-muted">
{t === 'text' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41001 9L8.35001 15H14.35L15.41 9H9.41001Z" /></svg>
)}
{t === 'voice' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" /></svg>
)}
{t === 'video' && (
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" /></svg>
)}
</div>
<div>
<div className="text-sm font-medium text-discord-text-primary capitalize">{t}</div>
<div className="text-xs text-discord-text-muted">
{t === 'text' && 'Send messages, images, and files'}
{t === 'voice' && 'Hang out with voice and video'}
{t === 'video' && 'Share your screen and camera'}
</div>
</div>
</label>
))}
</div>
</div>
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Channel Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="new-channel"
autoFocus
/>
</div>
{type === 'text' && (
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Topic (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="What's this channel about?"
/>
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Channel'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,38 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
export function CreateServerModal() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createServer = useServerStore((s) => s.createServer);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const navigate = useNavigate();
const isOpen = activeModal === 'createServer';
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Server name is required');
return;
}
setIsLoading(true);
try {
const server = await createServer(name.trim());
closeModal();
setName('');
navigate(`/channels/${server.id}`);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
}
finally {
setIsLoading(false);
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Create a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: name, onChange: (e) => setName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "My Awesome Server", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Creating...' : 'Create' })] })] }) }));
}
@@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { useNavigate } from 'react-router-dom';
export function CreateServerModal() {
const [name, setName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const createServer = useServerStore((s) => s.createServer);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const navigate = useNavigate();
const isOpen = activeModal === 'createServer';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!name.trim()) {
setError('Server name is required');
return;
}
setIsLoading(true);
try {
const server = await createServer(name.trim());
closeModal();
setName('');
navigate(`/channels/${server.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create server');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Create a Server">
<form onSubmit={handleSubmit}>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Server Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="My Awesome Server"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,46 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'invite';
useEffect(() => {
if (isOpen && currentServerId) {
setIsLoading(true);
generateInvite(currentServerId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}
}, [isOpen, currentServerId, generateInvite]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
catch {
// Fallback: select the text
const input = document.querySelector('.invite-code-input');
if (input) {
input.select();
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
};
return (_jsxs(Modal, { isOpen: isOpen, onClose: closeModal, title: "Invite Friends", children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Share this invite code with friends to let them join your server." }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { type: "text", value: isLoading ? 'Generating...' : inviteCode, readOnly: true, className: "invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-sm" }), _jsx("button", { onClick: handleCopy, disabled: isLoading, className: `px-4 py-2 text-sm font-medium rounded transition-colors ${copied
? 'bg-discord-green text-white'
: 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'}`, children: copied ? 'Copied!' : 'Copy' })] })] }));
}
@@ -0,0 +1,72 @@
import React, { useState, useEffect } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
export function InviteModal() {
const [inviteCode, setInviteCode] = useState('');
const [copied, setCopied] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const generateInvite = useServerStore((s) => s.generateInvite);
const currentServerId = useServerStore((s) => s.currentServerId);
const isOpen = activeModal === 'invite';
useEffect(() => {
if (isOpen && currentServerId) {
setIsLoading(true);
generateInvite(currentServerId)
.then(code => {
setInviteCode(code);
setIsLoading(false);
})
.catch(() => setIsLoading(false));
}
}, [isOpen, currentServerId, generateInvite]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(inviteCode);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select the text
const input = document.querySelector<HTMLInputElement>('.invite-code-input');
if (input) {
input.select();
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Invite Friends">
<p className="text-discord-text-secondary text-sm mb-4">
Share this invite code with friends to let them join your server.
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={isLoading ? 'Generating...' : inviteCode}
readOnly
className="invite-code-input flex-1 px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none font-mono text-sm"
/>
<button
onClick={handleCopy}
disabled={isLoading}
className={`px-4 py-2 text-sm font-medium rounded transition-colors ${
copied
? 'bg-discord-green text-white'
: 'bg-discord-blurple hover:bg-discord-blurple-hover text-white'
}`}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</Modal>
);
}
@@ -0,0 +1,52 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useNavigate } from 'react-router-dom';
export function JoinServerModal() {
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const loadServers = useServerStore((s) => s.loadServers);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
if (!inviteCode.trim()) {
setError('Invite code is required');
return;
}
setIsLoading(true);
try {
const token = localStorage.getItem('opencord_token');
const response = await fetch('/api/servers/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ inviteCode: inviteCode.trim() }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to join server');
}
const server = await response.json();
await loadServers();
closeModal();
setInviteCode('');
navigate(`/channels/${server.id}`);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join server');
}
finally {
setIsLoading(false);
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Join a Server", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx("p", { className: "text-discord-text-secondary text-sm mb-4", children: "Enter an invite code to join an existing server." }), error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), _jsxs("div", { className: "mb-4", children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Invite Code" }), _jsx("input", { type: "text", value: inviteCode, onChange: (e) => setInviteCode(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "e.g. abc123", autoFocus: true })] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx("button", { type: "button", onClick: closeModal, className: "px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors", children: "Cancel" }), _jsx("button", { type: "submit", disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Joining...' : 'Join Server' })] })] }) }));
}
@@ -0,0 +1,99 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useNavigate } from 'react-router-dom';
export function JoinServerModal() {
const [inviteCode, setInviteCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const loadServers = useServerStore((s) => s.loadServers);
const navigate = useNavigate();
const isOpen = activeModal === 'joinServer';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!inviteCode.trim()) {
setError('Invite code is required');
return;
}
setIsLoading(true);
try {
const token = localStorage.getItem('opencord_token');
const response = await fetch('/api/servers/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ inviteCode: inviteCode.trim() }),
});
if (!response.ok) {
const data = await response.json() as { error: string };
throw new Error(data.error || 'Failed to join server');
}
const server = await response.json() as { id: string };
await loadServers();
closeModal();
setInviteCode('');
navigate(`/channels/${server.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join server');
} finally {
setIsLoading(false);
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Join a Server">
<form onSubmit={handleSubmit}>
<p className="text-discord-text-secondary text-sm mb-4">
Enter an invite code to join an existing server.
</p>
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Invite Code
</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="e.g. abc123"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-discord-text-secondary hover:text-discord-text-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Joining...' : 'Join Server'}
</button>
</div>
</form>
</Modal>
);
}
@@ -0,0 +1,84 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
export function ServerSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const currentUser = useAuthStore((s) => s.user);
const navigate = useNavigate();
const [tab, setTab] = useState('overview');
const [serverName, setServerName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id;
React.useEffect(() => {
if (server) {
setServerName(server.name);
}
}, [server]);
if (!server || !currentServerId)
return null;
const handleSave = async () => {
setError('');
setIsLoading(true);
try {
await updateServer(currentServerId, { name: serverName.trim() });
setIsLoading(false);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
try {
await deleteServer(currentServerId);
closeModal();
navigate('/channels/@me');
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
}
};
const handleRoleChange = async (userId, role) => {
try {
await api.servers.updateMember(currentServerId, userId, { role });
await loadServerDetail(currentServerId);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const handleKick = async (userId) => {
try {
await api.servers.removeMember(currentServerId, userId);
await loadServerDetail(currentServerId);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
}
};
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "Server Settings", maxWidth: "max-w-xl", children: _jsxs("div", { className: "flex gap-4", children: [_jsxs("div", { className: "w-32 flex-shrink-0 space-y-1", children: [_jsx("button", { onClick: () => setTab('overview'), className: `w-full text-left px-3 py-1.5 rounded text-sm ${tab === 'overview' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Overview" }), _jsx("button", { onClick: () => setTab('members'), className: `w-full text-left px-3 py-1.5 rounded text-sm ${tab === 'members' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: "Members" })] }), _jsxs("div", { className: "flex-1 min-w-0", children: [error && (_jsx("div", { className: "mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), tab === 'overview' && (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Server Name" }), _jsx("input", { type: "text", value: serverName, onChange: (e) => setServerName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", disabled: !isOwnerUser })] }), isOwnerUser && (_jsxs(_Fragment, { children: [_jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' }), _jsxs("div", { className: "pt-4 border-t border-discord-bg-tertiary", children: [_jsx("h3", { className: "text-sm font-bold text-discord-red mb-2", children: "Danger Zone" }), _jsx("button", { onClick: handleDelete, className: "px-4 py-2 bg-discord-red hover:bg-discord-red-hover text-white text-sm font-medium rounded transition-colors", children: confirmDelete ? 'Click again to confirm deletion' : 'Delete Server' })] })] }))] })), tab === 'members' && (_jsx("div", { className: "space-y-2 max-h-[400px] overflow-y-auto", children: members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 32, status: member.user.status }), _jsxs("div", { children: [_jsx("div", { className: "text-sm font-medium", children: displayName }), _jsx("div", { className: "text-xs text-discord-text-muted capitalize", children: member.role })] })] }), isOwnerUser && member.userId !== currentUser?.id && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("select", { value: member.role, onChange: (e) => handleRoleChange(member.userId, e.target.value), className: "px-2 py-1 bg-discord-bg-tertiary rounded text-xs text-discord-text-secondary outline-none", children: [_jsx("option", { value: "member", children: "Member" }), _jsx("option", { value: "admin", children: "Admin" })] }), _jsx("button", { onClick: () => handleKick(member.userId), className: "px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Kick" })] }))] }, member.userId));
}) }))] })] }) }));
}
@@ -0,0 +1,199 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useServerStore } from '../../stores/serverStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { useNavigate } from 'react-router-dom';
import type { MemberRole } from '@opencord/shared';
export function ServerSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const currentServerId = useServerStore((s) => s.currentServerId);
const servers = useServerStore((s) => s.servers);
const members = useServerStore((s) => s.members);
const updateServer = useServerStore((s) => s.updateServer);
const deleteServer = useServerStore((s) => s.deleteServer);
const loadServerDetail = useServerStore((s) => s.loadServerDetail);
const currentUser = useAuthStore((s) => s.user);
const navigate = useNavigate();
const [tab, setTab] = useState<'overview' | 'members'>('overview');
const [serverName, setServerName] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const isOpen = activeModal === 'serverSettings';
const server = servers.find(s => s.id === currentServerId);
const isOwnerUser = server?.ownerId === currentUser?.id;
React.useEffect(() => {
if (server) {
setServerName(server.name);
}
}, [server]);
if (!server || !currentServerId) return null;
const handleSave = async () => {
setError('');
setIsLoading(true);
try {
await updateServer(currentServerId, { name: serverName.trim() });
setIsLoading(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update server');
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!confirmDelete) {
setConfirmDelete(true);
return;
}
try {
await deleteServer(currentServerId);
closeModal();
navigate('/channels/@me');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete server');
}
};
const handleRoleChange = async (userId: string, role: MemberRole) => {
try {
await api.servers.updateMember(currentServerId, userId, { role });
await loadServerDetail(currentServerId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
const handleKick = async (userId: string) => {
try {
await api.servers.removeMember(currentServerId, userId);
await loadServerDetail(currentServerId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to kick member');
}
};
return (
<Modal isOpen={isOpen} onClose={closeModal} title="Server Settings" maxWidth="max-w-xl">
<div className="flex gap-4">
{/* Tabs */}
<div className="w-32 flex-shrink-0 space-y-1">
<button
onClick={() => setTab('overview')}
className={`w-full text-left px-3 py-1.5 rounded text-sm ${
tab === 'overview' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
Overview
</button>
<button
onClick={() => setTab('members')}
className={`w-full text-left px-3 py-1.5 rounded text-sm ${
tab === 'members' ? 'bg-discord-bg-active text-white' : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
Members
</button>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{error && (
<div className="mb-3 p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">{error}</div>
)}
{tab === 'overview' && (
<div className="space-y-4">
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Server Name
</label>
<input
type="text"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
disabled={!isOwnerUser}
/>
</div>
{isOwnerUser && (
<>
<button
onClick={handleSave}
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
<div className="pt-4 border-t border-discord-bg-tertiary">
<h3 className="text-sm font-bold text-discord-red mb-2">Danger Zone</h3>
<button
onClick={handleDelete}
className="px-4 py-2 bg-discord-red hover:bg-discord-red-hover text-white text-sm font-medium rounded transition-colors"
>
{confirmDelete ? 'Click again to confirm deletion' : 'Delete Server'}
</button>
</div>
</>
)}
</div>
)}
{tab === 'members' && (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{members.map((member) => {
const displayName = member.user.displayName ?? member.user.username;
return (
<div key={member.userId} className="flex items-center justify-between p-2 rounded hover:bg-discord-bg-hover">
<div className="flex items-center gap-2">
<Avatar
src={member.user.avatar}
name={displayName}
size={32}
status={member.user.status}
/>
<div>
<div className="text-sm font-medium">{displayName}</div>
<div className="text-xs text-discord-text-muted capitalize">{member.role}</div>
</div>
</div>
{isOwnerUser && member.userId !== currentUser?.id && (
<div className="flex items-center gap-2">
<select
value={member.role}
onChange={(e) => handleRoleChange(member.userId, e.target.value as MemberRole)}
className="px-2 py-1 bg-discord-bg-tertiary rounded text-xs text-discord-text-secondary outline-none"
>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button
onClick={() => handleKick(member.userId)}
className="px-2 py-1 text-xs text-discord-red hover:bg-discord-red/10 rounded transition-colors"
>
Kick
</button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,45 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
export function UserSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
const logout = useAuthStore((s) => s.logout);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const isOpen = activeModal === 'userSettings';
const handleSave = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
await updateProfile({
displayName: displayName.trim() || undefined,
customStatus: customStatus.trim() || undefined,
});
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
}
catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
}
finally {
setIsLoading(false);
}
};
const handleLogout = () => {
logout();
closeModal();
};
if (!user)
return null;
return (_jsx(Modal, { isOpen: isOpen, onClose: closeModal, title: "User Settings", maxWidth: "max-w-lg", children: _jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg", children: [_jsx(Avatar, { src: user.avatar, name: user.displayName ?? user.username, size: 64, status: user.status }), _jsxs("div", { children: [_jsx("div", { className: "font-bold text-lg", children: user.displayName ?? user.username }), _jsxs("div", { className: "text-discord-text-muted text-sm", children: ["@", user.username] }), user.customStatus && (_jsx("div", { className: "text-discord-text-secondary text-sm mt-1", children: user.customStatus }))] })] }), error && (_jsx("div", { className: "p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm", children: error })), success && (_jsx("div", { className: "p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm", children: success })), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Display Name" }), _jsx("input", { type: "text", value: displayName, onChange: (e) => setDisplayName(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple" })] }), _jsxs("div", { children: [_jsx("label", { className: "block text-xs font-bold text-discord-text-secondary uppercase mb-2", children: "Custom Status" }), _jsx("input", { type: "text", value: customStatus, onChange: (e) => setCustomStatus(e.target.value), className: "w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple", placeholder: "What are you up to?" })] }), _jsxs("div", { className: "flex items-center justify-between pt-2", children: [_jsx("button", { onClick: handleLogout, className: "px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors", children: "Log Out" }), _jsx("button", { onClick: handleSave, disabled: isLoading, className: "px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50", children: isLoading ? 'Saving...' : 'Save Changes' })] })] }) }));
}
@@ -0,0 +1,117 @@
import React, { useState } from 'react';
import { Modal } from '../ui/Modal';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
export function UserSettingsModal() {
const activeModal = useUIStore((s) => s.activeModal);
const closeModal = useUIStore((s) => s.closeModal);
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
const logout = useAuthStore((s) => s.logout);
const [displayName, setDisplayName] = useState(user?.displayName ?? '');
const [customStatus, setCustomStatus] = useState(user?.customStatus ?? '');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const isOpen = activeModal === 'userSettings';
const handleSave = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
await updateProfile({
displayName: displayName.trim() || undefined,
customStatus: customStatus.trim() || undefined,
});
setSuccess('Profile updated!');
setTimeout(() => setSuccess(''), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update profile');
} finally {
setIsLoading(false);
}
};
const handleLogout = () => {
logout();
closeModal();
};
if (!user) return null;
return (
<Modal isOpen={isOpen} onClose={closeModal} title="User Settings" maxWidth="max-w-lg">
<div className="space-y-6">
{/* Profile preview */}
<div className="flex items-center gap-4 p-4 bg-discord-bg-secondary rounded-lg">
<Avatar
src={user.avatar}
name={user.displayName ?? user.username}
size={64}
status={user.status}
/>
<div>
<div className="font-bold text-lg">{user.displayName ?? user.username}</div>
<div className="text-discord-text-muted text-sm">@{user.username}</div>
{user.customStatus && (
<div className="text-discord-text-secondary text-sm mt-1">{user.customStatus}</div>
)}
</div>
</div>
{error && (
<div className="p-2 bg-discord-red/10 border border-discord-red/30 rounded text-discord-red text-sm">{error}</div>
)}
{success && (
<div className="p-2 bg-discord-green/10 border border-discord-green/30 rounded text-discord-green text-sm">{success}</div>
)}
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Display Name
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
/>
</div>
<div>
<label className="block text-xs font-bold text-discord-text-secondary uppercase mb-2">
Custom Status
</label>
<input
type="text"
value={customStatus}
onChange={(e) => setCustomStatus(e.target.value)}
className="w-full px-3 py-2 bg-discord-bg-tertiary rounded text-discord-text-primary outline-none focus:ring-2 focus:ring-discord-blurple"
placeholder="What are you up to?"
/>
</div>
<div className="flex items-center justify-between pt-2">
<button
onClick={handleLogout}
className="px-4 py-2 text-sm text-discord-red hover:bg-discord-red/10 rounded transition-colors"
>
Log Out
</button>
<button
onClick={handleSave}
disabled={isLoading}
className="px-4 py-2 bg-discord-blurple hover:bg-discord-blurple-hover text-white text-sm font-medium rounded transition-colors disabled:opacity-50"
>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</Modal>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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 }) {
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) => {
e.target.style.display = 'none';
const parent = e.target.parentElement;
if (parent) {
const fallback = parent.querySelector('.avatar-fallback');
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: {
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
} }))] }));
}
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
interface AvatarProps {
src?: string | null;
name: string;
size?: number;
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
className?: string;
onClick?: () => void;
}
const statusColors: Record<string, string> = {
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 }: AvatarProps) {
const initials = name.charAt(0).toUpperCase();
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
return (
<div
className={`relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`}
style={{ width: size, height: size }}
onClick={onClick}
>
{src ? (
<img
src={src.startsWith('http') ? src : `/api/uploads/${src}`}
alt={name}
className="w-full h-full rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const fallback = parent.querySelector('.avatar-fallback') as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}
}}
/>
) : null}
<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}
>
{initials}
</div>
{status && (
<div
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`}
style={{
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
}}
/>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useRef, useState } from 'react';
export function ContextMenu({ items, children }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef(null);
const handleContextMenu = (e) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setIsOpen(true);
};
useEffect(() => {
const handleClick = () => setIsOpen(false);
const handleScroll = () => setIsOpen(false);
if (isOpen) {
document.addEventListener('click', handleClick);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [isOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const newPosition = { ...position };
if (rect.right > window.innerWidth) {
newPosition.x = window.innerWidth - rect.width - 8;
}
if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8;
}
if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition);
}
}
}, [isOpen, position]);
return (_jsxs(_Fragment, { children: [_jsx("div", { onContextMenu: handleContextMenu, children: children }), isOpen && (_jsx("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", style: { left: position.x, top: position.y }, children: items.map((item, i) => (_jsxs("button", { className: `w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${item.danger
? 'text-discord-red hover:bg-discord-red hover:text-white'
: 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'}`, style: { width: 'calc(100% - 12px)' }, onClick: (e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}, children: [item.icon && _jsx("span", { className: "w-4 h-4", children: item.icon }), item.label] }, i))) }))] }));
}
@@ -0,0 +1,91 @@
import React, { useEffect, useRef, useState } from 'react';
interface ContextMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
icon?: React.ReactNode;
}
interface ContextMenuProps {
items: ContextMenuItem[];
children: React.ReactNode;
}
export function ContextMenu({ items, children }: ContextMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement>(null);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setIsOpen(true);
};
useEffect(() => {
const handleClick = () => setIsOpen(false);
const handleScroll = () => setIsOpen(false);
if (isOpen) {
document.addEventListener('click', handleClick);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [isOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const newPosition = { ...position };
if (rect.right > window.innerWidth) {
newPosition.x = window.innerWidth - rect.width - 8;
}
if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8;
}
if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition);
}
}
}, [isOpen, position]);
return (
<>
<div onContextMenu={handleContextMenu}>{children}</div>
{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"
style={{ left: position.x, top: position.y }}
>
{items.map((item, i) => (
<button
key={i}
className={`w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${
item.danger
? 'text-discord-red hover:bg-discord-red hover:text-white'
: 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'
}`}
style={{ width: 'calc(100% - 12px)' }}
onClick={(e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
{item.label}
</button>
))}
</div>
)}
</>
);
}
@@ -0,0 +1,4 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export function LoadingSpinner({ size = 40, className = '' }) {
return (_jsx("div", { className: `flex items-center justify-center ${className}`, children: _jsxs("svg", { className: "animate-spin", width: size, height: size, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }) }));
}
@@ -0,0 +1,34 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: number;
className?: string;
}
export function LoadingSpinner({ size = 40, className = '' }: LoadingSpinnerProps) {
return (
<div className={`flex items-center justify-center ${className}`}>
<svg
className="animate-spin"
width={size}
height={size}
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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useCallback } from 'react';
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }) {
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center animate-fade-in", children: [_jsx("div", { className: "absolute inset-0 bg-black/70", onClick: onClose }), _jsxs("div", { className: `relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`, children: [title && (_jsxs("div", { className: "flex items-center justify-between px-4 pt-4", children: [_jsx("h2", { className: "text-xl font-bold text-discord-text-primary", children: title }), _jsx("button", { onClick: onClose, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors p-1", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("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" }) }) })] })), _jsx("div", { className: "p-4", children: children })] })] }));
}
+53
View File
@@ -0,0 +1,53 @@
import React, { useEffect, useCallback } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
maxWidth?: string;
}
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }: ModalProps) {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div
className="absolute inset-0 bg-black/70"
onClick={onClose}
/>
<div className={`relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`}>
{title && (
<div className="flex items-center justify-between px-4 pt-4">
<h2 className="text-xl font-bold text-discord-text-primary">{title}</h2>
<button
onClick={onClose}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors p-1"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<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>
)}
<div className="p-4">
{children}
</div>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useRef, useEffect } from 'react';
export function Tooltip({ content, children, position = 'right', delay = 200 }) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef();
const show = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
};
const hide = () => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current);
};
}, []);
const positionClasses = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
};
return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("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]}`, children: content }))] }));
}
@@ -0,0 +1,48 @@
import React, { useState, useRef, useEffect } from 'react';
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: 'top' | 'right' | 'bottom' | 'left';
delay?: number;
}
export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const show = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
};
const hide = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const positionClasses: Record<string, string> = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
};
return (
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
{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]}`}
>
{content}
</div>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useVoiceStore } from '../../stores/voiceStore';
const EMPTY_VOICE_USERS = [];
import { useServerStore } from '../../stores/serverStore';
import { Avatar } from '../ui/Avatar';
export function VoiceChannel({ channelId, channelName, onClick }) {
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
return (_jsxs("div", { children: [_jsxs("button", { onClick: onClick, className: `w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${isActive
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("span", { className: "truncate", children: channelName })] }), voiceUsers.length > 0 && (_jsx("div", { className: "ml-6 mt-0.5 space-y-0.5", children: voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId);
if (!member)
return null;
const displayName = member.user.displayName ?? member.user.username;
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover", children: [_jsx(Avatar, { src: member.user.avatar, name: displayName, size: 20, status: member.user.status }), _jsx("span", { className: "text-xs text-discord-text-secondary truncate", children: displayName })] }, userId));
}) }))] }));
}
@@ -0,0 +1,59 @@
import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore';
import { Avatar } from '../ui/Avatar';
interface VoiceChannelProps {
channelId: string;
channelName: string;
onClick: () => void;
}
export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelProps) {
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
return (
<div>
<button
onClick={onClick}
className={`w-full flex items-center gap-1.5 px-2 py-1.5 rounded text-sm group ${
isActive
? 'bg-discord-bg-active text-white'
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-bg-hover'
}`}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0 opacity-60">
<path d="M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" />
</svg>
<span className="truncate">{channelName}</span>
</button>
{/* Connected users */}
{voiceUsers.length > 0 && (
<div className="ml-6 mt-0.5 space-y-0.5">
{voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId);
if (!member) return null;
const displayName = member.user.displayName ?? member.user.username;
return (
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-bg-hover">
<Avatar
src={member.user.avatar}
name={displayName}
size={20}
status={member.user.status}
/>
<span className="text-xs text-discord-text-secondary truncate">{displayName}</span>
</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onToggleScreenShare }) {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const channels = useServerStore((s) => s.channels);
if (!currentVoiceChannelId)
return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel';
const handleMic = () => {
toggleMute();
onToggleMic();
};
const handleDeafen = () => {
toggleDeafen();
};
const handleCamera = () => {
toggleCamera();
onToggleCamera();
};
const handleScreenShare = () => {
toggleScreenShare();
onToggleScreenShare();
};
return (_jsxs("div", { className: "bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2", children: [_jsx("div", { className: "flex items-center justify-between px-1 mb-2", children: _jsxs("div", { children: [_jsxs("div", { className: "text-xs font-medium text-discord-green flex items-center gap-1", children: [_jsx("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" }) }), "Voice Connected"] }), _jsx("div", { className: "text-xs text-discord-text-muted truncate", children: channelName })] }) }), _jsxs("div", { className: "flex items-center justify-center gap-2", children: [_jsx("button", { onClick: handleMic, className: `p-2 rounded-full transition-colors ${isMuted
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isMuted ? 'Unmute' : 'Mute', children: isMuted ? (_jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("path", { d: "M2.1 2.1L1.4 2.8L7.6 9L7 12C7 14.8 9.2 17 12 17C12.9 17 13.7 16.7 14.4 16.3L16.2 18.1C15 18.9 13.6 19.4 12 19.5V22H14V24H10V22H12V19.5C8.4 19.1 5.6 16.1 5 12.5H7C7.5 14.8 9.5 16.5 12 16.5C12.5 16.5 13 16.4 13.5 16.2L14.7 17.4C13.9 17.8 13 18 12 18C8.7 18 6 15.3 6 12H4C4 15.7 7 18.8 11 19.4V22H10V24H14V22H13V19.4C14 19.3 14.9 18.9 15.7 18.4L21.9 24.6L22.6 23.9L2.1 2.1Z" })] })) : (_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2ZM17 12C17 14.76 14.76 17 12 17S7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.92V22H13V18.92C16.39 18.43 19 15.53 19 12H17Z" }) })) }), _jsx("button", { onClick: handleDeafen, className: `p-2 rounded-full transition-colors ${isDeafened
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isDeafened ? 'Undeafen' : 'Deafen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 2C6.48 2 2 6.48 2 12V20C2 21.1 2.9 22 4 22H8V12H4.04C4.28 7.57 7.77 4 12 4S19.72 7.57 19.96 12H16V22H20C21.1 22 22 21.1 22 20V12C22 6.48 17.52 2 12 2Z" }) }) }), _jsx("button", { onClick: handleCamera, className: `p-2 rounded-full transition-colors ${isCameraOn
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isCameraOn ? 'Turn Off Camera' : 'Turn On Camera', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" }) }) }), _jsx("button", { onClick: handleScreenShare, className: `p-2 rounded-full transition-colors ${isScreenSharing
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'}`, title: isScreenSharing ? 'Stop Sharing' : 'Share Screen', children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" }) }) }), _jsx("button", { onClick: onDisconnect, className: "p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors", title: "Disconnect", children: _jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" }) }) })] })] }));
}
@@ -0,0 +1,145 @@
import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
interface VoiceControlsProps {
onDisconnect: () => void;
onToggleMic: () => void;
onToggleCamera: () => void;
onToggleScreenShare: () => void;
}
export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onToggleScreenShare }: VoiceControlsProps) {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const channels = useServerStore((s) => s.channels);
if (!currentVoiceChannelId) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel';
const handleMic = () => {
toggleMute();
onToggleMic();
};
const handleDeafen = () => {
toggleDeafen();
};
const handleCamera = () => {
toggleCamera();
onToggleCamera();
};
const handleScreenShare = () => {
toggleScreenShare();
onToggleScreenShare();
};
return (
<div className="bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2">
<div className="flex items-center justify-between px-1 mb-2">
<div>
<div className="text-xs font-medium text-discord-green flex items-center gap-1">
<svg width="12" height="12" 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 2z" />
</svg>
Voice Connected
</div>
<div className="text-xs text-discord-text-muted truncate">
{channelName}
</div>
</div>
</div>
<div className="flex items-center justify-center gap-2">
{/* Mic */}
<button
onClick={handleMic}
className={`p-2 rounded-full transition-colors ${
isMuted
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" />
<path d="M2.1 2.1L1.4 2.8L7.6 9L7 12C7 14.8 9.2 17 12 17C12.9 17 13.7 16.7 14.4 16.3L16.2 18.1C15 18.9 13.6 19.4 12 19.5V22H14V24H10V22H12V19.5C8.4 19.1 5.6 16.1 5 12.5H7C7.5 14.8 9.5 16.5 12 16.5C12.5 16.5 13 16.4 13.5 16.2L14.7 17.4C13.9 17.8 13 18 12 18C8.7 18 6 15.3 6 12H4C4 15.7 7 18.8 11 19.4V22H10V24H14V22H13V19.4C14 19.3 14.9 18.9 15.7 18.4L21.9 24.6L22.6 23.9L2.1 2.1Z" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2ZM17 12C17 14.76 14.76 17 12 17S7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.92V22H13V18.92C16.39 18.43 19 15.53 19 12H17Z" />
</svg>
)}
</button>
{/* Deafen */}
<button
onClick={handleDeafen}
className={`p-2 rounded-full transition-colors ${
isDeafened
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isDeafened ? 'Undeafen' : 'Deafen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12V20C2 21.1 2.9 22 4 22H8V12H4.04C4.28 7.57 7.77 4 12 4S19.72 7.57 19.96 12H16V22H20C21.1 22 22 21.1 22 20V12C22 6.48 17.52 2 12 2Z" />
</svg>
</button>
{/* Camera */}
<button
onClick={handleCamera}
className={`p-2 rounded-full transition-colors ${
isCameraOn
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M17 10.5V7C17 6.45 16.55 6 16 6H4C3.45 6 3 6.45 3 7V17C3 17.55 3.45 18 4 18H16C16.55 18 17 17.55 17 17V13.5L21 17.5V6.5L17 10.5Z" />
</svg>
</button>
{/* Screen Share */}
<button
onClick={handleScreenShare}
className={`p-2 rounded-full transition-colors ${
isScreenSharing
? 'bg-discord-green/20 text-discord-green hover:bg-discord-green/30'
: 'bg-discord-bg-tertiary text-discord-text-secondary hover:bg-discord-bg-hover hover:text-discord-text-primary'
}`}
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
</svg>
</button>
{/* Disconnect */}
<button
onClick={onDisconnect}
className="p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors"
title="Disconnect"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,17 @@
import { jsx as _jsx } from "react/jsx-runtime";
import { VoiceUser } from './VoiceUser';
export function VoiceGrid({ participants }) {
if (participants.length === 0) {
return (_jsx("div", { className: "flex-1 flex items-center justify-center text-discord-text-muted", children: _jsx("p", { children: "No one is in this voice channel" }) }));
}
const gridClass = (() => {
if (participants.length === 1)
return 'grid-cols-1 max-w-2xl mx-auto';
if (participants.length === 2)
return 'grid-cols-2 max-w-4xl mx-auto';
if (participants.length <= 4)
return 'grid-cols-2';
return 'grid-cols-3';
})();
return (_jsx("div", { className: "flex-1 p-4 overflow-auto", children: _jsx("div", { className: `grid ${gridClass} gap-2 h-full`, children: participants.map((p) => (_jsx(VoiceUser, { participant: p }, p.identity))) }) }));
}
@@ -0,0 +1,34 @@
import React from 'react';
import { VoiceUser } from './VoiceUser';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
interface VoiceGridProps {
participants: ParticipantInfo[];
}
export function VoiceGrid({ participants }: VoiceGridProps) {
if (participants.length === 0) {
return (
<div className="flex-1 flex items-center justify-center text-discord-text-muted">
<p>No one is in this voice channel</p>
</div>
);
}
const gridClass = (() => {
if (participants.length === 1) return 'grid-cols-1 max-w-2xl mx-auto';
if (participants.length === 2) return 'grid-cols-2 max-w-4xl mx-auto';
if (participants.length <= 4) return 'grid-cols-2';
return 'grid-cols-3';
})();
return (
<div className="flex-1 p-4 overflow-auto">
<div className={`grid ${gridClass} gap-2 h-full`}>
{participants.map((p) => (
<VoiceUser key={p.identity} participant={p} />
))}
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useRef, useEffect } from 'react';
import { Avatar } from '../ui/Avatar';
export function VoiceUser({ participant }) {
const videoRef = useRef(null);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl)
return;
const track = participant.videoTrack ?? participant.screenTrack;
if (track) {
const stream = new MediaStream([track]);
videoEl.srcObject = stream;
}
else {
videoEl.srcObject = null;
}
}, [participant.videoTrack, participant.screenTrack]);
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
return (_jsxs("div", { className: `relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${participant.isSpeaking ? 'ring-2 ring-discord-green' : ''}`, style: { aspectRatio: '16/9', minHeight: '200px' }, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: participant.userId === 'local', className: "w-full h-full object-cover" })) : (_jsx(Avatar, { src: null, name: participant.username, size: 80 })), _jsx("div", { className: "absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: "text-sm font-medium text-white", children: participant.username }), _jsx("div", { className: "flex items-center gap-1", children: participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })) })] }) }), participant.isSpeaking && (_jsx("div", { className: "absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" }))] }));
}
@@ -0,0 +1,73 @@
import React, { useRef, useEffect } from 'react';
import { Avatar } from '../ui/Avatar';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
interface VoiceUserProps {
participant: ParticipantInfo;
}
export function VoiceUser({ participant }: VoiceUserProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const videoEl = videoRef.current;
if (!videoEl) return;
const track = participant.videoTrack ?? participant.screenTrack;
if (track) {
const stream = new MediaStream([track]);
videoEl.srcObject = stream;
} else {
videoEl.srcObject = null;
}
}, [participant.videoTrack, participant.screenTrack]);
const hasVideo = participant.isCameraOn || participant.isScreenSharing;
return (
<div
className={`relative bg-discord-bg-secondary rounded-xl overflow-hidden flex items-center justify-center ${
participant.isSpeaking ? 'ring-2 ring-discord-green' : ''
}`}
style={{ aspectRatio: '16/9', minHeight: '200px' }}
>
{hasVideo ? (
<video
ref={videoRef}
autoPlay
playsInline
muted={participant.userId === 'local'}
className="w-full h-full object-cover"
/>
) : (
<Avatar
src={null}
name={participant.username}
size={80}
/>
)}
{/* Bottom overlay */}
<div className="absolute bottom-0 left-0 right-0 p-2 bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{participant.username}</span>
<div className="flex items-center gap-1">
{participant.isMuted && (
<div className="w-5 h-5 bg-discord-red/80 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<path d="M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" />
</svg>
</div>
)}
</div>
</div>
</div>
{/* Speaking indicator */}
{participant.isSpeaking && (
<div className="absolute inset-0 rounded-xl ring-2 ring-discord-green animate-pulse pointer-events-none" />
)}
</div>
);
}