src: first layout, many dependencies

- setup query templates and potential auth flow/state
- basic server & channel lists
- guessed types for db and apis
- user panel / settings templates
- many steps left
This commit is contained in:
2025-09-27 03:38:42 -04:00
parent f069340bab
commit 1592703149
45 changed files with 3583 additions and 198 deletions

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,35 +1,111 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/electron-vite.animate.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://electron-vite.github.io" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App
import React, { useEffect } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "@/components/ui/sonner";
import AppLayout from "@/components/layout/AppLayout";
import LoginPage from "@/pages/LoginPage";
import ChatPage from "@/pages/ChatPage";
import SettingsPage from "@/pages/SettingsPage";
import NotFoundPage from "@/pages/NotFoundPage";
import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore";
import ErrorBoundary from "@/components/common/ErrorBoundary";
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
refetchOnWindowFocus: false,
retry: (failureCount, error: any) => {
if (error?.status === 401) return false;
return failureCount < 3;
},
},
},
});
// Protected Route wrapper
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
// const { isAuthenticated } = useAuthStore();
// if (!isAuthenticated) {
// return <Navigate to="/login" replace />;
// }
return <>{children}</>;
};
function App() {
const { theme } = useUiStore();
// Apply theme to document
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<Router>
<div
className={`h-screen w-screen overflow-hidden ${theme === "dark" ? "bg-gray-900 text-white" : "bg-white text-gray-900"}`}
>
<Routes>
{/* Auth routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes with layout */}
<Route
path="/"
element={
// <ProtectedRoute>
<AppLayout />
// </ProtectedRoute>
}
>
{/* Default redirect to channels */}
<Route
index
element={<Navigate to="/channels/@me" replace />}
/>
{/* Chat routes */}
<Route
path="channels"
element={<Navigate to="/channels/@me" replace />}
/>
<Route path="channels/@me" element={<ChatPage />} />
<Route path="channels/:instanceId" element={<ChatPage />} />
<Route
path="channels/:instanceId/:channelId"
element={<ChatPage />}
/>
{/* Settings */}
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/:section" element={<SettingsPage />} />
</Route>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</Router>
{/* Dev tools - only in development */}
{/*process.env.NODE_ENV === "development" && <ReactQueryDevtools />*/}
{/* Toast notifications */}
<Toaster />
</QueryClientProvider>
</ErrorBoundary>
);
}
export default App;

View File

@@ -0,0 +1,13 @@
# TODO
- Messages
- Channels should include messages
- sample data
- message components
- User
- Set up fake user with auth to:
- Confirm userpanel is ok
- test login flow
- Add server ui
- Add channel ui
- Role based for above ^

View File

@@ -0,0 +1,183 @@
import React, { useState } from "react";
import { useNavigate, useParams } from "react-router";
import {
Hash,
Volume2,
ChevronDown,
ChevronRight,
Plus,
Edit,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { CategoryWithChannels } from "@/types/api";
import { Channel } from "@/types/database";
interface ChannelItemProps {
channel: Channel;
isActive: boolean;
onClick: () => void;
}
const ChannelItem: React.FC<ChannelItemProps> = ({
channel,
isActive,
onClick,
}) => {
const Icon = channel.type === "voice" ? Volume2 : Hash;
return (
<Button
variant="ghost"
className={`w-full justify-start px-2 py-1 h-8 text-left font-medium ${
isActive
? "bg-gray-600 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-gray-100"
}`}
onClick={onClick}
>
<Icon size={16} className="mr-2 flex-shrink-0" />
<span className="truncate">{channel.name}</span>
</Button>
);
};
interface CategoryHeaderProps {
category: CategoryWithChannels;
isExpanded: boolean;
onToggle: () => void;
}
const CategoryHeader: React.FC<CategoryHeaderProps> = ({
category,
isExpanded,
onToggle,
}) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between px-1 py-1 h-6 text-xs font-semibold text-gray-400 uppercase tracking-wide hover:text-gray-300 group"
onClick={(e) => {
// Only toggle if not right-clicking (which opens dropdown)
if (e.button === 0) {
onToggle();
}
}}
>
<div className="flex items-center">
{isExpanded ? (
<ChevronDown size={12} className="mr-1" />
) : (
<ChevronRight size={12} className="mr-1" />
)}
<span className="truncate">{category.name}</span>
</div>
<Plus
size={12}
className="opacity-0 group-hover:opacity-100 transition-opacity"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>
<Plus size={14} className="mr-2" />
Create Channel
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Edit size={14} className="mr-2" />
Edit Category
</DropdownMenuItem>
<DropdownMenuItem className="text-red-400 focus:text-red-400">
Delete Category
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
interface ChannelListProps {
categories: CategoryWithChannels[];
}
const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
const navigate = useNavigate();
const { instanceId, channelId } = useParams();
// Track expanded categories
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map((cat) => cat.id)), // Start with all expanded
);
const handleChannelClick = (channel: Channel) => {
navigate(`/channels/${instanceId}/${channel.id}`);
};
const toggleCategory = (categoryId: string) => {
setExpandedCategories((prev) => {
const newSet = new Set(prev);
if (newSet.has(categoryId)) {
newSet.delete(categoryId);
} else {
newSet.add(categoryId);
}
return newSet;
});
};
if (!categories || categories.length === 0) {
return (
<div className="text-sm text-gray-400 px-2 py-4 text-center">
No channels available
</div>
);
}
return (
<div className="space-y-1">
{categories
.sort((a, b) => a.position - b.position)
.map((category) => {
const isExpanded = expandedCategories.has(category.id);
return (
<div key={category.id} className="space-y-0.5">
{/* Category Header */}
<CategoryHeader
category={category}
isExpanded={isExpanded}
onToggle={() => toggleCategory(category.id)}
/>
{/* Channels */}
{isExpanded && (
<div className="ml-2 space-y-0.5">
{category.channels
.sort((a, b) => a.position - b.position)
.map((channel) => (
<ChannelItem
key={channel.id}
channel={channel}
isActive={channelId === channel.id}
onClick={() => handleChannelClick(channel)}
/>
))}
</div>
)}
</div>
);
})}
</div>
);
};
export default ChannelList;

View File

@@ -0,0 +1,137 @@
import React from "react";
import {
Avatar as ShadcnAvatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { User } from "@/types/database";
import { cn } from "@/lib/utils";
interface AvatarProps {
user: Pick<User, "id" | "username" | "nickname" | "picture" | "status">;
size?: "xs" | "sm" | "md" | "lg" | "xl";
showStatus?: boolean;
className?: string;
onClick?: () => void;
}
const sizeClasses = {
xs: "h-6 w-6",
sm: "h-8 w-8",
md: "h-10 w-10",
lg: "h-12 w-12",
xl: "h-16 w-16",
};
const statusSizes = {
xs: "w-2 h-2",
sm: "w-3 h-3",
md: "w-3 h-3",
lg: "w-4 h-4",
xl: "w-5 h-5",
};
const statusPositions = {
xs: "-bottom-0.5 -right-0.5",
sm: "-bottom-0.5 -right-0.5",
md: "-bottom-0.5 -right-0.5",
lg: "-bottom-1 -right-1",
xl: "-bottom-1 -right-1",
};
const Avatar: React.FC<AvatarProps> = ({
user,
size = "md",
showStatus = false,
className,
onClick,
}) => {
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-green-500";
case "away":
return "bg-yellow-500";
case "busy":
return "bg-red-500";
case "offline":
default:
return "bg-gray-500";
}
};
const getUserInitials = (username: string, nickname?: string) => {
const name = nickname || username;
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
const getFallbackColor = (userId: string) => {
// Generate a consistent color based on user ID
const colors = [
"bg-red-500",
"bg-blue-500",
"bg-green-500",
"bg-yellow-500",
"bg-purple-500",
"bg-pink-500",
"bg-indigo-500",
"bg-teal-500",
];
const hash = userId.split("").reduce((a, b) => {
a = (a << 5) - a + b.charCodeAt(0);
return a & a;
}, 0);
return colors[Math.abs(hash) % colors.length];
};
return (
<div className="relative inline-block">
<ShadcnAvatar
className={cn(
sizeClasses[size],
onClick && "cursor-pointer hover:opacity-80 transition-opacity",
className,
)}
onClick={onClick}
>
<AvatarImage
src={user.picture || undefined}
alt={user.nickname || user.username}
/>
<AvatarFallback
className={cn(
"text-white font-medium",
getFallbackColor(user.id),
size === "xs" && "text-xs",
size === "sm" && "text-xs",
size === "md" && "text-sm",
size === "lg" && "text-base",
size === "xl" && "text-lg",
)}
>
{getUserInitials(user.username, user.nickname)}
</AvatarFallback>
</ShadcnAvatar>
{showStatus && (
<div
className={cn(
"absolute rounded-full border-2 border-gray-800",
statusSizes[size],
statusPositions[size],
getStatusColor(user.status),
)}
/>
)}
</div>
);
};
export default Avatar;

View File

@@ -0,0 +1,119 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { AlertTriangle, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
this.setState({
error,
errorInfo,
});
}
private handleReload = () => {
window.location.reload();
};
private handleReset = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined });
};
public render() {
if (this.state.hasError) {
// Custom fallback UI
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-4">
<Alert className="border-red-500 bg-red-950/50">
<AlertTriangle className="h-4 w-4 text-red-500" />
<AlertTitle className="text-red-400">
Something went wrong
</AlertTitle>
<AlertDescription className="text-gray-300">
The application encountered an unexpected error. This might be a
temporary issue.
</AlertDescription>
</Alert>
<div className="space-y-2">
<Button
onClick={this.handleReset}
className="w-full"
variant="outline"
>
<RotateCcw size={16} className="mr-2" />
Try Again
</Button>
<Button
onClick={this.handleReload}
variant="secondary"
className="w-full"
>
Reload Application
</Button>
</div>
{/* Error details in development */}
{process.env.NODE_ENV === "development" && this.state.error && (
<details className="mt-4 p-3 bg-gray-800 rounded-lg text-sm">
<summary className="cursor-pointer text-red-400 font-medium mb-2">
Error Details (Development)
</summary>
<div className="space-y-2 text-gray-300">
<div>
<strong>Error:</strong> {this.state.error.message}
</div>
<div>
<strong>Stack:</strong>
<pre className="mt-1 text-xs overflow-auto text-gray-400">
{this.state.error.stack}
</pre>
</div>
{this.state.errorInfo && (
<div>
<strong>Component Stack:</strong>
<pre className="mt-1 text-xs overflow-auto text-gray-400">
{this.state.errorInfo.componentStack}
</pre>
</div>
)}
</div>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,41 @@
import React from "react";
import { cn } from "@/lib/utils";
interface LoadingSpinnerProps {
size?: "xs" | "sm" | "md" | "lg" | "xl";
className?: string;
color?: "white" | "blue" | "gray";
}
const sizeClasses = {
xs: "h-3 w-3",
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8",
xl: "h-12 w-12",
};
const colorClasses = {
white: "border-white",
blue: "border-blue-500",
gray: "border-gray-400",
};
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
size = "md",
className,
color = "white",
}) => {
return (
<div
className={cn(
"animate-spin rounded-full border-2 border-transparent border-t-current",
sizeClasses[size],
colorClasses[color],
className,
)}
/>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,90 @@
import React from "react";
import { Outlet } from "react-router";
import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore";
import ServerSidebar from "@/components/layout/ServerSidebar";
import ChannelSidebar from "@/components/layout/ChannelSidebar";
import UserPanel from "@/components/layout/UserPanel";
import MemberList from "@/components/layout/MemberList";
import LoadingSpinner from "@/components/common/LoadingSpinner";
const AppLayout: React.FC = () => {
const { user, isLoading } = useAuthStore();
const { showMemberList, sidebarCollapsed, isMobile } = useUiStore();
if (isLoading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gray-900">
<LoadingSpinner size="lg" />
</div>
);
}
// if (!user) {
// return (
// <div className="h-screen w-screen flex items-center justify-center bg-gray-900">
// <div className="text-red-400">Authentication required</div>
// </div>
// );
// }
return (
<div className="flex h-screen overflow-hidden bg-gray-900 text-white">
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
<div
className={`${
isMobile
? "fixed left-0 top-0 z-50 h-full w-[72px] transform transition-transform duration-200 ease-in-out"
: "relative w-[72px]"
} bg-gray-900 flex-shrink-0`}
>
<ServerSidebar />
</div>
{/* Channel Sidebar - Collapsible */}
<div
// className={`${
// sidebarCollapsed
// ? isMobile
// ? "hidden"
// : "w-0 overflow-hidden"
// : isMobile
// ? "fixed left-[72px] top-0 z-40 h-full w-60"
// : "w-60"
// } bg-gray-800 flex flex-col flex-shrink-0 transition-all duration-200 ease-in-out`}
>
<div className="flex-1 overflow-hidden">
<ChannelSidebar />
<UserPanel />
</div>
</div>
{/* Main Content Area */}
<div
className={`flex-1 flex flex-col min-w-0 ${
isMobile && !sidebarCollapsed ? "ml-60" : ""
} transition-all duration-200 ease-in-out`}
>
<Outlet />
</div>
{/* Member List - Conditionally shown */}
{showMemberList && !isMobile && (
<div className="w-60 bg-gray-800 flex-shrink-0 border-l border-gray-700">
<MemberList />
</div>
)}
{/* Mobile overlay for sidebars */}
{isMobile && !sidebarCollapsed && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-30"
onClick={() => useUiStore.getState().toggleSidebar()}
/>
)}
</div>
);
};
export default AppLayout;

View File

@@ -0,0 +1,147 @@
import React from "react";
import { useParams } from "react-router";
import { ChevronDown, Plus, Users, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useInstanceDetails } from "@/hooks/useServers";
import { useChannels } from "@/hooks/useChannel";
import { useUiStore } from "@/stores/uiStore";
import { useResponsive } from "@/hooks/useResponsive";
import ChannelList from "@/components/channel/ChannelList";
const ChannelSidebar: React.FC = () => {
const { instanceId } = useParams();
const { data: instance, isLoading: instanceLoading } =
useInstanceDetails(instanceId);
const { data: categories, isLoading: channelsLoading } =
useChannels(instanceId);
const {
toggleMemberList,
showMemberList,
toggleSidebar,
openCreateChannel,
openServerSettings,
} = useUiStore();
const { isMobile, isDesktop } = useResponsive();
// Handle Direct Messages view
if (!instanceId || instanceId === "@me") {
return (
<div className="flex flex-col h-full">
{/* DM Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700">
<div className="flex items-center space-x-2">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={toggleSidebar}
>
<X size={16} />
</Button>
)}
<h2 className="font-semibold text-white">Direct Messages</h2>
</div>
<Button variant="ghost" size="icon" className="h-6 w-6">
<Plus size={16} />
</Button>
</div>
{/* DM List */}
<ScrollArea className="flex-1 px-2">
<div className="py-2 space-y-1">
<div className="text-sm text-gray-400 px-2 py-1">
No direct messages yet
</div>
</div>
</ScrollArea>
</div>
);
}
if (instanceLoading || channelsLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
);
}
if (!instance) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-400">Server not found</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Server Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 shadow-sm">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{isMobile && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={toggleSidebar}
>
<X size={16} />
</Button>
)}
<Button
variant="ghost"
className="flex items-center justify-between w-full px-2 py-1 h-auto font-semibold text-white hover:bg-gray-700"
onClick={openServerSettings}
>
<span className="truncate">{instance.name}</span>
<ChevronDown size={18} className="flex-shrink-0 ml-1" />
</Button>
</div>
</div>
{/* Channel Categories and Channels */}
<ScrollArea className="flex-1">
<div className="p-2">
{categories && categories.length > 0 ? (
<ChannelList categories={categories} />
) : (
<div className="text-sm text-gray-400 px-2 py-4 text-center">
No channels yet
</div>
)}
</div>
</ScrollArea>
{/* Bottom Actions */}
<div className="px-2 py-2 border-t border-gray-700">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="text-gray-400 hover:text-white"
onClick={openCreateChannel}
>
<Plus size={16} className="mr-1" />
Add Channel
</Button>
{isDesktop && (
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${showMemberList ? "text-white" : "text-gray-400 hover:text-white"}`}
onClick={toggleMemberList}
>
<Users size={16} />
</Button>
)}
</div>
</div>
</div>
);
};
export default ChannelSidebar;

View File

@@ -0,0 +1,176 @@
import React from "react";
import { useParams } from "react-router";
import { Crown, Shield } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useInstanceMembers } from "@/hooks/useServers";
import { UserWithRoles } from "@/types/api";
interface MemberItemProps {
member: UserWithRoles;
isOwner?: boolean;
}
const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-green-500";
case "away":
return "bg-yellow-500";
case "busy":
return "bg-red-500";
default:
return "bg-gray-500";
}
};
const getHighestRole = (roles: any[]) => {
if (!roles || roles.length === 0) return null;
// Sort by position (higher position = higher role)
return roles.sort((a, b) => b.position - a.position)[0];
};
const highestRole = getHighestRole(member.roles);
return (
<div className="flex items-center px-2 py-1 mx-2 rounded hover:bg-gray-700/50 cursor-pointer group">
<div className="relative">
<Avatar className="h-8 w-8">
<AvatarImage src={member.picture} alt={member.username} />
<AvatarFallback className="text-xs">
{member.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Status indicator */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-gray-800 ${getStatusColor(member.status)}`}
/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="flex items-center gap-1">
{isOwner && (
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
)}
{!isOwner && highestRole && (
<Shield
size={12}
className="flex-shrink-0"
style={{ color: highestRole.color || "#ffffff" }}
/>
)}
<span
className="text-sm font-medium truncate"
style={{ color: highestRole?.color || "#ffffff" }}
>
{member.nickname || member.username}
</span>
</div>
{member.bio && (
<div className="text-xs text-gray-400 truncate">{member.bio}</div>
)}
</div>
</div>
);
};
const MemberList: React.FC = () => {
const { instanceId } = useParams();
const { members, isLoading } = useInstanceMembers(instanceId);
if (!instanceId || instanceId === "@me") {
return null;
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
);
}
if (!members || members.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-400 text-sm">No members</div>
</div>
);
}
// Group members by role
const groupedMembers = members.reduce(
(acc, member) => {
const highestRole =
member.roles?.length > 0
? member.roles.sort((a, b) => b.position - a.position)[0]
: null;
const roleName = highestRole?.name || "Members";
if (!acc[roleName]) {
acc[roleName] = [];
}
acc[roleName].push(member);
return acc;
},
{} as Record<string, UserWithRoles[]>,
);
// Sort role groups by highest role position
const sortedRoleGroups = Object.entries(groupedMembers).sort(
([roleNameA, membersA], [roleNameB, membersB]) => {
const roleA = membersA[0]?.roles?.find((r) => r.name === roleNameA);
const roleB = membersB[0]?.roles?.find((r) => r.name === roleNameB);
if (!roleA && !roleB) return 0;
if (!roleA) return 1;
if (!roleB) return -1;
return roleB.position - roleA.position;
},
);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-700">
<h3 className="text-sm font-semibold text-gray-300 uppercase tracking-wide">
Members {members.length}
</h3>
</div>
{/* Member List */}
<ScrollArea className="flex-1">
<div className="py-2">
{sortedRoleGroups.map(([roleName, roleMembers]) => (
<div key={roleName} className="mb-4">
{/* Role Header */}
<div className="px-4 py-1">
<h4 className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
{roleName} {roleMembers.length}
</h4>
</div>
{/* Role Members */}
<div className="space-y-1">
{roleMembers
.sort((a, b) => a.username.localeCompare(b.username))
.map((member) => (
<MemberItem
key={member.id}
member={member}
isOwner={false}
/>
))}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
};
export default MemberList;

View File

@@ -0,0 +1,128 @@
import React from "react";
import { useNavigate, useParams } from "react-router";
import { Plus, Home, Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useServers } from "@/hooks/useServers";
import { useUiStore } from "@/stores/uiStore";
import { useResponsive } from "@/hooks/useResponsive";
import ServerIcon from "@/components/server/ServerIcon";
const ServerSidebar: React.FC = () => {
const navigate = useNavigate();
const { instanceId } = useParams();
const { data: servers, isLoading } = useServers();
const { openCreateServer, toggleSidebar, setActiveInstance } = useUiStore();
const { isMobile } = useResponsive();
const handleServerClick = (serverId: string) => {
setActiveInstance(serverId);
navigate(`/channels/${serverId}`);
};
const handleHomeClick = () => {
setActiveInstance(null);
navigate("/channels/@me");
};
const handleMenuToggle = () => {
toggleSidebar();
};
return (
<TooltipProvider>
<div className="flex flex-col items-center py-3 space-y-2 h-full bg-gray-900 border-r border-gray-800">
{/* Mobile menu toggle */}
{isMobile && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-12 h-12 rounded-2xl hover:rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition-all duration-200"
onClick={handleMenuToggle}
>
<Menu size={24} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Toggle Menu</p>
</TooltipContent>
</Tooltip>
)}
{/* Home/DM Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={
!instanceId || instanceId === "@me" ? "default" : "ghost"
}
size="icon"
className="w-12 h-12 rounded-2xl hover:rounded-xl transition-all duration-200"
onClick={handleHomeClick}
>
<Home size={24} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Direct Messages</p>
</TooltipContent>
</Tooltip>
{/* Separator */}
<div className="w-8 h-0.5 bg-gray-600 rounded-full" />
{/* Server List */}
<div className="flex-1 flex flex-col space-y-2 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600">
{isLoading ? (
<div className="flex items-center justify-center py-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
) : (
servers?.map((server) => (
<Tooltip key={server.id}>
<TooltipTrigger asChild>
<div>
<ServerIcon
server={server}
isActive={instanceId === server.id}
onClick={() => handleServerClick(server.id)}
/>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p>{server.name}</p>
</TooltipContent>
</Tooltip>
))
)}
</div>
{/* Add Server Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="w-12 h-12 rounded-2xl hover:rounded-xl bg-gray-700 hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
onClick={openCreateServer}
>
<Plus size={24} />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Add a Server</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
);
};
export default ServerSidebar;

View File

@@ -0,0 +1,198 @@
import React, { useState } from "react";
import { Settings, Mic, MicOff, Headphones } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore";
const UserPanel: React.FC = () => {
const { user, logout } = useAuthStore();
const { openUserSettings } = useUiStore();
// Voice/Audio states (for future implementation)
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
if (!user) return null;
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-green-500";
case "away":
return "bg-yellow-500";
case "busy":
return "bg-red-500";
default:
return "bg-gray-500";
}
};
const handleStatusChange = (newStatus: string) => {
// TODO: Implement status change
console.log("Status change to:", newStatus);
};
const handleMuteToggle = () => {
setIsMuted(!isMuted);
// TODO: Implement actual mute functionality
};
const handleDeafenToggle = () => {
setIsDeafened(!isDeafened);
if (!isDeafened) {
setIsMuted(true); // Deafening also mutes
}
// TODO: Implement actual deafen functionality
};
return (
<div className="flex items-center justify-between px-2 py-2 bg-gray-900 border-t border-gray-700">
{/* User Info */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex items-center space-x-2 p-1 h-auto hover:bg-gray-700"
>
<div className="relative">
<Avatar className="h-8 w-8">
<AvatarImage src={user.picture} alt={user.username} />
<AvatarFallback className="text-xs bg-blue-600">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* Status indicator */}
<div
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-gray-900 ${getStatusColor(user.status)}`}
/>
</div>
<div className="flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-white truncate">
{user.nickname || user.username}
</div>
<div className="text-xs text-gray-400 truncate capitalize">
{user.status}
</div>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
<DropdownMenuItem onClick={() => handleStatusChange("online")}>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span>Online</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleStatusChange("away")}>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span>Away</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleStatusChange("busy")}>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span>Do Not Disturb</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleStatusChange("offline")}>
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full bg-gray-500" />
<span>Invisible</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={openUserSettings}>
<Settings size={16} className="mr-2" />
User Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={logout}
className="text-red-400 focus:text-red-400"
>
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Voice Controls */}
<div className="flex items-center space-x-1">
<TooltipProvider>
{/* Mute/Unmute */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${isMuted ? "text-red-400 hover:text-red-300" : "text-gray-400 hover:text-white"}`}
onClick={handleMuteToggle}
>
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isMuted ? "Unmute" : "Mute"}</p>
</TooltipContent>
</Tooltip>
{/* Deafen/Undeafen */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${isDeafened ? "text-red-400 hover:text-red-300" : "text-gray-400 hover:text-white"}`}
onClick={handleDeafenToggle}
>
<Headphones size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isDeafened ? "Undeafen" : "Deafen"}</p>
</TooltipContent>
</Tooltip>
{/* Settings */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-400 hover:text-white"
onClick={openUserSettings}
>
<Settings size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>User Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
};
export default UserPanel;

View File

@@ -0,0 +1,37 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/theme-provider";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,60 @@
import React from "react";
import { Button } from "@/components/ui/button";
import { Instance } from "@/types/database";
interface ServerIconProps {
server: Instance;
isActive: boolean;
onClick: () => void;
}
const ServerIcon: React.FC<ServerIconProps> = ({
server,
isActive,
onClick,
}) => {
const getServerInitials = (name: string) => {
return name
.split(" ")
.map((word) => word[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return (
<div className="relative group">
{/* Active indicator */}
<div
className={`absolute left-0 top-1/2 transform -translate-y-1/2 w-1 bg-white rounded-r transition-all duration-200 ${
isActive ? "h-10" : "h-2 group-hover:h-5"
}`}
/>
<Button
variant="ghost"
size="icon"
className={`w-12 h-12 ml-3 transition-all duration-200 ${
isActive
? "rounded-xl bg-blue-600 hover:bg-blue-500 text-white"
: "rounded-2xl hover:rounded-xl bg-gray-700 hover:bg-blue-600 text-gray-300 hover:text-white"
}`}
onClick={onClick}
>
{server.icon ? (
<img
src={server.icon}
alt={server.name}
className="w-full h-full object-cover rounded-inherit"
/>
) : (
<span className="font-semibold text-sm">
{getServerInitials(server.name)}
</span>
)}
</Button>
</div>
);
};
export default ServerIcon;

View File

@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,23 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,72 @@
// src/hooks/useChannels.ts
import { useQuery } from "@tanstack/react-query";
import { CategoryWithChannels } from "@/types/api";
// Placeholder hook for channels by instance
export const useChannels = (instanceId?: string) => {
return useQuery({
queryKey: ["channels", instanceId],
queryFn: async (): Promise<CategoryWithChannels[]> => {
if (!instanceId || instanceId === "@me") return [];
// TODO: Replace with actual API call
return [
{
id: "1",
name: "Text Channels",
instanceId: instanceId,
position: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
channels: [
{
id: "1",
name: "general",
type: "text",
categoryId: "1",
instanceId: instanceId,
position: 0,
topic: "General discussion",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "2",
name: "random",
type: "text",
categoryId: "1",
instanceId: instanceId,
position: 1,
topic: "Random chat",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "2",
name: "Voice Channels",
instanceId: instanceId,
position: 1,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
channels: [
{
id: "3",
name: "General",
type: "voice",
categoryId: "2",
instanceId: instanceId,
position: 0,
topic: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
];
},
enabled: !!instanceId && instanceId !== "@me",
staleTime: 1000 * 60 * 5, // 5 minutes
});
};

View File

@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useUiStore } from "@/stores/uiStore";
export const useResponsive = () => {
const { screenWidth, isMobile, setScreenWidth, updateIsMobile } =
useUiStore();
useEffect(() => {
const handleResize = () => {
const width = window.innerWidth;
setScreenWidth(width);
updateIsMobile();
};
// Set initial values
handleResize();
// Add event listener
window.addEventListener("resize", handleResize);
// Cleanup
return () => window.removeEventListener("resize", handleResize);
}, [setScreenWidth, updateIsMobile]);
return {
screenWidth,
isMobile,
isTablet: screenWidth >= 768 && screenWidth < 1024,
isDesktop: screenWidth >= 1024,
isLargeDesktop: screenWidth >= 1280,
};
};

View File

@@ -0,0 +1,78 @@
import { useQuery } from "@tanstack/react-query";
import { Instance, InstanceWithDetails, UserWithRoles } from "@/types/api";
// Placeholder hook for servers/instances
export const useServers = () => {
return useQuery({
queryKey: ["servers"],
queryFn: async (): Promise<Instance[]> => {
// TODO: Replace with actual API call
return [
{
id: "1",
name: "My Server",
icon: null,
description: "A cool server",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
},
staleTime: 1000 * 60 * 5, // 5 minutes
});
};
// Placeholder hook for instance details
export const useInstanceDetails = (instanceId?: string) => {
return useQuery({
queryKey: ["instance", instanceId],
queryFn: async (): Promise<InstanceWithDetails | null> => {
if (!instanceId || instanceId === "@me") return null;
// TODO: Replace with actual API call
return {
id: instanceId,
name: "My Server",
icon: null,
description: "A cool server",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
categories: [],
memberCount: 1,
roles: [],
};
},
enabled: !!instanceId && instanceId !== "@me",
staleTime: 1000 * 60 * 5,
});
};
// Placeholder hook for instance members
export const useInstanceMembers = (instanceId?: string) => {
return useQuery({
queryKey: ["instance", instanceId, "members"],
queryFn: async (): Promise<UserWithRoles[]> => {
if (!instanceId || instanceId === "@me") return [];
// TODO: Replace with actual API call
return [
{
id: "1",
username: "testuser",
nickname: "Test User",
bio: "Just testing",
picture: null,
banner: null,
hashPassword: "",
algorithms: "{}",
status: "online",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
];
},
enabled: !!instanceId && instanceId !== "@me",
staleTime: 1000 * 60 * 2, // 2 minutes (members change more frequently)
});
};

View File

@@ -1,68 +1,120 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,192 @@
import React from "react";
import { useParams } from "react-router";
import { Hash, Volume2, Users, HelpCircle, Inbox, Pin } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useInstanceDetails } from "@/hooks/useServers";
import { useChannels } from "@/hooks/useChannel";
import { useUiStore } from "@/stores/uiStore";
const ChatPage: React.FC = () => {
const { instanceId, channelId } = useParams();
const { instance } = useInstanceDetails(instanceId);
const { categories } = useChannels(instanceId);
const { toggleMemberList, showMemberList } = useUiStore();
// Find current channel
const currentChannel = categories
?.flatMap((cat) => cat.channels)
?.find((ch) => ch.id === channelId);
// Handle Direct Messages view
// if (!instanceId || instanceId === '@me') {
// return (
// <div className="flex flex-col h-full">
// {/* DM Header */}
// <div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 bg-gray-800">
// <div className="flex items-center space-x-2">
// <Inbox size={20} className="text-gray-400" />
// <span className="font-semibold text-white">Direct Messages</span>
// </div>
// <div className="flex items-center space-x-2">
// <Button variant="ghost" size="icon" className="h-8 w-8">
// <HelpCircle size={16} />
// </Button>
// </div>
// </div>
// {/* DM Content */}
// <div className="flex-1 flex items-center justify-center">
// <div className="text-center text-gray-400 max-w-md">
// <div className="w-16 h-16 mx-auto mb-4 bg-gray-700 rounded-full flex items-center justify-center">
// <Inbox size={24} />
// </div>
// <h2 className="text-xl font-semibold mb-2 text-white">No Direct Messages</h2>
// <p className="text-sm">
// When someone sends you a direct message, it will show up here.
// </p>
// </div>
// </div>
// </div>
// );
// }
if (!currentChannel && channelId) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400">
<h2 className="text-xl font-semibold mb-2">Channel not found</h2>
<p>
The channel you're looking for doesn't exist or you don't have
access to it.
</p>
</div>
</div>
);
}
// Default channel view (when just /channels/instanceId)
if (!channelId && instance) {
const firstChannel = categories?.[0]?.channels?.[0];
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400 max-w-md">
<div className="w-16 h-16 mx-auto mb-4 bg-gray-700 rounded-full flex items-center justify-center">
<Hash size={24} />
</div>
<h2 className="text-xl font-semibold mb-2 text-white">
Welcome to {instance.name}!
</h2>
<p className="text-sm mb-4">
{firstChannel
? `Select a channel from the sidebar to start chatting, or head to #${firstChannel.name} to get started.`
: "This server doesn't have any channels yet. Create one to get started!"}
</p>
</div>
</div>
);
}
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
return (
<div className="flex flex-col h-full">
{/* Channel Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-700 bg-gray-800">
<div className="flex items-center space-x-2">
<ChannelIcon size={20} className="text-gray-400" />
<span className="font-semibold text-white">
{currentChannel?.name}
</span>
{currentChannel?.topic && (
<>
<div className="w-px h-4 bg-gray-600" />
<span className="text-sm text-gray-400 truncate max-w-xs">
{currentChannel.topic}
</span>
</>
)}
</div>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pin size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${showMemberList ? "text-white bg-gray-700" : "text-gray-400"}`}
onClick={toggleMemberList}
>
<Users size={16} />
</Button>
<div className="w-40">
<Input
placeholder="Search"
className="h-8 bg-gray-700 border-none text-sm"
/>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Inbox size={16} />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8">
<HelpCircle size={16} />
</Button>
</div>
</div>
{/* Chat Content */}
<div className="flex-1 flex flex-col">
{/* Messages Area */}
<div className="flex-1 overflow-y-auto">
<div className="p-4">
{/* Welcome Message */}
<div className="flex flex-col space-y-2 mb-4">
<div className="flex items-center space-x-2">
<div className="w-16 h-16 bg-blue-600 rounded-full flex items-center justify-center">
<ChannelIcon size={24} className="text-white" />
</div>
<div>
<h3 className="text-2xl font-bold text-white">
Welcome to #{currentChannel?.name}!
</h3>
<p className="text-gray-400">
This is the start of the #{currentChannel?.name} channel.
</p>
</div>
</div>
{currentChannel?.topic && (
<div className="text-gray-400 bg-gray-800 p-3 rounded border-l-4 border-gray-600">
<strong>Topic:</strong> {currentChannel.topic}
</div>
)}
</div>
{/* Placeholder messages */}
<div className="space-y-4 text-gray-400 text-center">
<p>No messages yet. Start the conversation!</p>
</div>
</div>
</div>
{/* Message Input */}
<div className="p-4 bg-gray-800">
<div className="relative">
<Input
placeholder={`Message #${currentChannel?.name || "channel"}`}
className="w-full bg-gray-700 border-none text-white placeholder-gray-400 pr-12"
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<div className="flex items-center space-x-1">
{/* Emoji picker, file upload, etc. would go here */}
<div className="text-gray-400 text-sm">Press Enter to send</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default ChatPage;

View File

@@ -0,0 +1,109 @@
import React, { useState } from "react";
import { Navigate } from "react-router";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAuthStore } from "@/stores/authStore";
const LoginPage: React.FC = () => {
const { isAuthenticated, setAuth } = useAuthStore();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/channels/@me" replace />;
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
// TODO: Replace with actual login API call
setTimeout(() => {
setAuth(
{
id: "1",
username,
nickname: username,
bio: "Test user",
picture: null,
banner: null,
hashPassword: "",
algorithms: "{}",
status: "online",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
"fake-token",
"fake-refresh-token",
);
setIsLoading(false);
}, 1000);
} catch (error) {
console.error("Login failed:", error);
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-gray-800 border-gray-700">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold text-white">
Welcome back!
</CardTitle>
<CardDescription className="text-gray-400">
We're so excited to see you again!
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username" className="text-gray-300">
Username
</Label>
<Input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-gray-700 border-gray-600 text-white"
placeholder="Enter your username"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">
Password
</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-gray-700 border-gray-600 text-white"
placeholder="Enter your password"
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Log In"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,12 @@
const NotFoundPage: React.FC = () => {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-center text-gray-400">
<h1 className="text-4xl font-bold mb-4">404</h1>
<p className="text-xl">Page not found</p>
</div>
</div>
);
};
export default NotFoundPage;

View File

@@ -0,0 +1,12 @@
const SettingsPage: React.FC = () => {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400">
<h2 className="text-xl font-semibold mb-2">Settings</h2>
<p>User settings will go here</p>
</div>
</div>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,69 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { User } from "@/types/database";
interface AuthState {
user: User | null;
token: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
// Actions
setAuth: (user: User, token: string, refreshToken: string) => void;
updateUser: (user: Partial<User>) => void;
logout: () => void;
setLoading: (loading: boolean) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
setAuth: (user, token, refreshToken) =>
set({
user,
token,
refreshToken,
isAuthenticated: true,
isLoading: false,
}),
updateUser: (userData) =>
set((state) => ({
user: state.user ? { ...state.user, ...userData } : null,
})),
logout: () =>
set({
user: null,
token: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
}),
setLoading: (isLoading) => set({ isLoading }),
}),
{
name: "concord-auth-store",
// Persist auth data
partialize: (state) => ({
user: state.user,
token: state.token,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
},
),
);
export const useCurrentUser = () => useAuthStore((state) => state.user);
export const useIsAuthenticated = () =>
useAuthStore((state) => state.isAuthenticated);
export const useAuthToken = () => useAuthStore((state) => state.token);

View File

@@ -0,0 +1,138 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UiState {
// Sidebar states
showMemberList: boolean;
sidebarCollapsed: boolean;
// Responsive
isMobile: boolean;
screenWidth: number;
// Theme
theme: "dark" | "light";
// Modal states
showUserSettings: boolean;
showServerSettings: boolean;
showCreateChannel: boolean;
showCreateServer: boolean;
showInviteModal: boolean;
// Chat states
// isTyping: boolean;
// typingUsers: string[];
// Navigation
activeChannelId: string | null;
activeInstanceId: string | null;
// Actions
toggleMemberList: () => void;
toggleSidebar: () => void;
setTheme: (theme: "dark" | "light") => void;
setScreenWidth: (width: number) => void;
updateIsMobile: () => void;
// Modal actions
openUserSettings: () => void;
closeUserSettings: () => void;
openServerSettings: () => void;
closeServerSettings: () => void;
openCreateChannel: () => void;
closeCreateChannel: () => void;
openCreateServer: () => void;
closeCreateServer: () => void;
openInviteModal: () => void;
closeInviteModal: () => void;
// Chat actions
// setTyping: (isTyping: boolean) => void;
// addTypingUser: (userId: string) => void;
// removeTypingUser: (userId: string) => void;
// clearTypingUsers: () => void;
// Navigation actions
setActiveChannel: (channelId: string | null) => void;
setActiveInstance: (instanceId: string | null) => void;
}
export const useUiStore = create<UiState>()(
persist(
(set, get) => ({
// Initial state
showMemberList: true,
sidebarCollapsed: false,
isMobile: typeof window !== "undefined" ? window.innerWidth < 768 : false,
screenWidth: typeof window !== "undefined" ? window.innerWidth : 1024,
theme: "dark",
showUserSettings: false,
showServerSettings: false,
showCreateChannel: false,
showCreateServer: false,
showInviteModal: false,
isTyping: false,
typingUsers: [],
activeChannelId: null,
activeInstanceId: null,
// Sidebar actions
toggleMemberList: () =>
set((state) => ({ showMemberList: !state.showMemberList })),
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setTheme: (theme) => set({ theme }),
setScreenWidth: (screenWidth) => set({ screenWidth }),
updateIsMobile: () =>
set((state) => ({
isMobile: state.screenWidth < 768,
// Auto-collapse sidebar on mobile
sidebarCollapsed:
state.screenWidth < 768 ? true : state.sidebarCollapsed,
// Hide member list on small screens
showMemberList:
state.screenWidth < 1024 ? false : state.showMemberList,
})),
// Modal actions
openUserSettings: () => set({ showUserSettings: true }),
closeUserSettings: () => set({ showUserSettings: false }),
openServerSettings: () => set({ showServerSettings: true }),
closeServerSettings: () => set({ showServerSettings: false }),
openCreateChannel: () => set({ showCreateChannel: true }),
closeCreateChannel: () => set({ showCreateChannel: false }),
openCreateServer: () => set({ showCreateServer: true }),
closeCreateServer: () => set({ showCreateServer: false }),
openInviteModal: () => set({ showInviteModal: true }),
closeInviteModal: () => set({ showInviteModal: false }),
// Chat actions
// setTyping: (isTyping) => set({ isTyping }),
// addTypingUser: (userId) =>
// set((state) => ({
// typingUsers: state.typingUsers.includes(userId)
// ? state.typingUsers
// : [...state.typingUsers, userId],
// })),
// removeTypingUser: (userId) =>
// set((state) => ({
// typingUsers: state.typingUsers.filter((id) => id !== userId),
// })),
// clearTypingUsers: () => set({ typingUsers: [] }),
// Navigation actions
setActiveChannel: (channelId) => set({ activeChannelId: channelId }),
setActiveInstance: (instanceId) => set({ activeInstanceId: instanceId }),
}),
{
name: "concord-ui-store",
// Only persist UI preferences, not temporary states
partialize: (state) => ({
showMemberList: state.showMemberList,
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
}),
},
),
);

View File

@@ -0,0 +1,91 @@
import { Instance, Category, Channel, User, Role, Message } from "./database";
// API Response wrappers
export interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
export interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}
// Extended types with relations for frontend use
export interface ChannelWithCategory extends Channel {
category: Category;
}
export interface CategoryWithChannels extends Category {
channels: Channel[];
}
export interface InstanceWithDetails extends Instance {
categories: CategoryWithChannels[];
memberCount: number;
roles: Role[];
}
export interface MessageWithUser extends Message {
user: User;
}
export interface UserWithRoles extends User {
roles: Role[];
}
// Request types
export interface CreateInstanceRequest {
name: string;
description?: string;
icon?: string;
}
export interface CreateCategoryRequest {
name: string;
instanceId: string;
position?: number;
}
export interface CreateChannelRequest {
name: string;
type: "text" | "voice";
categoryId: string;
topic?: string;
position?: number;
}
export interface SendMessageRequest {
content: string;
channelId: string;
user: User;
}
export interface UpdateMessageRequest {
content: string;
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
email?: string;
}
export interface AuthResponse {
user: User;
token: string;
refreshToken: string;
}

View File

@@ -0,0 +1,93 @@
export interface Instance {
id: string;
name: string;
icon?: string;
description?: string;
createdAt: string;
updatedAt: string;
}
export interface Category {
id: string;
name: string;
instanceId: string;
position: number;
createdAt: string;
updatedAt: string;
}
export interface Channel {
id: string;
name: string;
type: "text" | "voice";
categoryId: string;
instanceId: string;
position: number;
topic?: string;
createdAt: string;
updatedAt: string;
}
export interface User {
id: string;
username: string;
nickname?: string;
bio?: string;
picture?: string;
banner?: string;
hashPassword: string; // Won't be sent to client
admin: boolean;
status: "online" | "away" | "busy" | "offline";
createdAt: string;
updatedAt: string;
}
export interface Role {
id: string;
name: string;
color?: string;
permissions: string; // JSON string of permissions
instanceId: string;
position: number;
createdAt: string;
updatedAt: string;
}
export interface Message {
id: string;
content: string;
channelId: string;
userId: string;
edited: boolean;
createdAt: string;
updatedAt: string;
// Relations
user?: User;
channel?: Channel;
}
// Direct messages
// export interface DirectMessage {
// id: string;
// content: string;
// senderId: string;
// receiverId: string;
// edited: boolean;
// createdAt: string;
// updatedAt: string;
// // Relations
// sender?: User;
// receiver?: User;
// }
export interface UserRole {
userId: string;
roleId: string;
instanceId: string;
}
export interface UserInstance {
userId: string;
instanceId: string;
joinedAt: string;
}