ui: theme system, routing fixes, major overhaul, still need to fix dropdowns

This commit is contained in:
2025-09-27 23:19:51 -04:00
parent 65dad4a9c2
commit d6a52460c5
34 changed files with 4342 additions and 898 deletions

View File

@@ -1,18 +1,20 @@
import React, { useEffect } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/theme-provider";
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 { useAuthStore } from "@/stores/authStore";
// import { useUiStore } from "@/stores/uiStore";
import ErrorBoundary from "@/components/common/ErrorBoundary";
import { Home } from "lucide-react";
// Create a client
const queryClient = new QueryClient({
@@ -33,76 +35,77 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
// const { isAuthenticated } = useAuthStore();
// if (!isAuthenticated) {
// return <Navigate to="/login" replace />;
// }
return <>{children}</>;
};
// Home page component - shows server selection
const HomePage: React.FC = () => {
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary max-w-md">
<div className="w-16 h-16 mx-auto mb-4 bg-concord-secondary rounded-full flex items-center justify-center">
<Home />
</div>
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
Welcome to Concord
</h2>
<p className="text-sm mb-4">
Select a server from the sidebar to start chatting, or create a new
server
</p>
</div>
</div>
);
};
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 />} />
<ThemeProvider defaultTheme="system" storageKey="discord-theme">
<Router>
<div className="h-screen w-screen overflow-hidden bg-background text-foreground">
<Routes>
{/* Auth routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes with layout */}
<Route
path="/"
element={
// <ProtectedRoute>
<AppLayout />
// </ProtectedRoute>
}
>
{/* Default redirect to channels */}
{/* Protected routes with layout */}
<Route
index
element={<Navigate to="/channels/@me" replace />}
/>
path="/"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
{/* Default redirect to home */}
<Route index element={<HomePage />} />
{/* 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 />}
/>
{/* Server and channel routes */}
<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>
{/* Settings */}
<Route path="settings" element={<SettingsPage />} />
<Route path="settings/:section" element={<SettingsPage />} />
</Route>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</Router>
{/* 404 */}
<Route path="*" element={<NotFoundPage />} />
</Routes>
</div>
</Router>
{/* Dev tools - only in development */}
{/*process.env.NODE_ENV === "development" && <ReactQueryDevtools />*/}
{/* Toast notifications */}
<Toaster />
{import.meta.env.DEV === true && <ReactQueryDevtools />}
{/* Toast notifications */}
<Toaster />
</ThemeProvider>
</QueryClientProvider>
</ErrorBoundary>
);

View File

@@ -35,10 +35,10 @@ const ChannelItem: React.FC<ChannelItemProps> = ({
return (
<Button
variant="ghost"
className={`w-full justify-start px-2 py-1 h-8 text-left font-medium ${
className={`w-full justify-start px-2 py-2 h-8 text-left font-medium p-3 rounded-lg transition-all ${
isActive
? "bg-gray-600 text-white"
: "text-gray-300 hover:bg-gray-700 hover:text-gray-100"
? "border-primary bg-primary/10 border-2 "
: "hover:border-primary/50"
}`}
onClick={onClick}
>

View File

@@ -49,14 +49,14 @@ const Avatar: React.FC<AvatarProps> = ({
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-green-500";
return "bg-status-online";
case "away":
return "bg-yellow-500";
return "bg-status-away";
case "busy":
return "bg-red-500";
return "bg-status-busy";
case "offline":
default:
return "bg-gray-500";
return "bg-status-offline";
}
};
@@ -71,7 +71,7 @@ const Avatar: React.FC<AvatarProps> = ({
};
const getFallbackColor = (userId: string) => {
// Generate a consistent color based on user ID
// Generate a consistent color based on user ID using theme colors
const colors = [
"bg-red-500",
"bg-blue-500",
@@ -123,7 +123,7 @@ const Avatar: React.FC<AvatarProps> = ({
{showStatus && (
<div
className={cn(
"absolute rounded-full border-2 border-gray-800",
"absolute rounded-full border-2 border-sidebar",
statusSizes[size],
statusPositions[size],
getStatusColor(user.status),

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Outlet } from "react-router";
import React, { useEffect } from "react";
import { Outlet, useLocation } from "react-router";
import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore";
@@ -10,79 +10,76 @@ 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();
const { isLoading } = useAuthStore();
const {
showMemberList,
sidebarCollapsed,
shouldShowChannelSidebar,
updateSidebarVisibility,
} = useUiStore();
const location = useLocation();
// Update sidebar visibility when route changes
useEffect(() => {
updateSidebarVisibility(location.pathname);
}, [location.pathname, updateSidebarVisibility]);
if (isLoading) {
return (
<div className="h-screen w-screen flex items-center justify-center bg-gray-900">
<div className="h-screen w-screen flex items-center justify-center bg-concord-primary">
<LoadingSpinner size="lg" />
</div>
);
}
// Uncomment if auth is required
// if (!user) {
// return (
// <div className="h-screen w-screen flex items-center justify-center bg-gray-900">
// <div className="h-screen w-screen flex items-center justify-center bg-concord-primary">
// <div className="text-red-400">Authentication required</div>
// </div>
// );
// }
return (
<div className="flex h-screen overflow-hidden bg-gray-900 text-white">
<div className="flex h-screen overflow-hidden bg-concord-primary text-concord-primary">
{/* 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`}
>
<div className="relative w-[72px] sidebar-primary 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 />
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
{shouldShowChannelSidebar && (
<div
className={`${
sidebarCollapsed
? "w-0" // Collapse by setting width to 0
: "w-60" // Default width
}
flex-col flex-shrink-0 sidebar-secondary transition-all duration-200 ease-in-out overflow-hidden`}
>
<div className="flex flex-col h-full">
<ChannelSidebar />
<UserPanel />
</div>
</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`}
!sidebarCollapsed ? "" : ""
} transition-all duration-200 ease-in-out bg-concord-secondary`}
>
<Outlet />
</div>
{/* Member List - Conditionally shown */}
{showMemberList && !isMobile && (
<div className="w-60 bg-gray-800 flex-shrink-0 border-l border-gray-700">
{/* Member List - Only shown when in a channel and member list is enabled */}
{showMemberList && shouldShowChannelSidebar && (
<div className="flex-0 sidebar-secondary order-l border-sidebar">
<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>
);
};

View File

@@ -1,145 +1,99 @@
import React from "react";
import { useParams } from "react-router";
import { ChevronDown, Plus, Users, X } from "lucide-react";
import { ChevronDown, Plus, Users } 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 categories = instance?.categories;
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>
);
// Only show for valid instance IDs
if (!instanceId) {
return null;
}
if (instanceLoading || channelsLoading) {
if (instanceLoading) {
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 className="sidebar-secondary flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
);
}
if (!instance) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-gray-400">Server not found</div>
<div className="sidebar-secondary flex items-center justify-center h-full">
<div className="text-concord-secondary">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 && (
<div className="sidebar-secondary flex-1">
<ScrollArea className="">
{/* Server Header */}
<div className="flex items-center justify-between border-b border-concord-primary shadow-sm px-4 py-3">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 flex-shrink-0"
onClick={toggleSidebar}
className="flex items-center justify-between w-full h-8 font-semibold text-concord-primary hover:bg-concord-tertiary"
onClick={openServerSettings}
>
<X size={16} />
<span className="truncate">{instance.name}</span>
<ChevronDown size={20} className="flex-shrink-0 ml-1" />
</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>
</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>
{/* Channel Categories and Channels */}
<ScrollArea className="flex-1">
<div className="p-2">
{categories && categories.length > 0 ? (
<ChannelList categories={categories} />
) : (
<div className="text-sm text-concord-secondary text-center px-2 py-4">
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>
{/* Bottom Actions */}
<div className="border-t border-sidebar px-2 py-2">
<div className="flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="interactive-hover"
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"}`}
className={`h-8 w-8 ${showMemberList ? "text-interactive-active" : "interactive-hover"}`}
onClick={toggleMemberList}
>
<Users size={16} />
</Button>
)}
</div>
</div>
</div>
</ScrollArea>
</div>
);
};

View File

@@ -1,50 +1,68 @@
import React from "react";
import { useParams } from "react-router";
import { Crown, Shield } from "lucide-react";
import { Crown, Shield, UserIcon } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Role } from "@/types/database";
import { useInstanceMembers } from "@/hooks/useServers";
import { UserWithRoles } from "@/types/api";
import { User } from "@/types/database";
// Status color utility
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-status-online";
case "away":
return "bg-status-away";
case "busy":
return "bg-status-busy";
default:
return "bg-status-offline";
}
};
interface MemberItemProps {
member: UserWithRoles;
member: User;
isOwner?: boolean;
}
// Get the user's role for this specific instance
const getUserRoleForInstance = (roles: Role[], instanceId: string) => {
return roles.find((r) => r.instanceId === instanceId)?.role || "member";
};
// Define role colors and priorities
const getRoleInfo = (role: string) => {
switch (role) {
case "admin":
return { color: "#ff6b6b", priority: 3, name: "Admin" };
case "mod":
return { color: "#4ecdc4", priority: 2, name: "Moderator" };
default:
return { color: null, priority: 1, name: "Member" };
}
};
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);
const { instanceId } = useParams();
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
const roleInfo = getRoleInfo(userRole);
return (
<div className="flex items-center px-2 py-1 mx-2 rounded hover:bg-gray-700/50 cursor-pointer group">
<div className="panel-button">
<div className="relative">
<Avatar className="h-8 w-8">
<AvatarImage src={member.picture} alt={member.username} />
<AvatarFallback className="text-xs">
<AvatarImage
src={member.picture || undefined}
alt={member.username}
/>
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
{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)}`}
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-sidebar ${getStatusColor(member.status)}`}
/>
</div>
@@ -53,22 +71,24 @@ const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
{isOwner && (
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
)}
{!isOwner && highestRole && (
{!isOwner && userRole !== "member" && (
<Shield
size={12}
className="flex-shrink-0"
style={{ color: highestRole.color || "#ffffff" }}
style={{ color: roleInfo.color || "var(--background)" }}
/>
)}
<span
className="text-sm font-medium truncate"
style={{ color: highestRole?.color || "#ffffff" }}
style={{ color: roleInfo.color || "var(--color-text-primary)" }}
>
{member.nickname || member.username}
</span>
</div>
{member.bio && (
<div className="text-xs text-gray-400 truncate">{member.bio}</div>
<div className="text-xs text-concord-secondary truncate">
{member.bio}
</div>
)}
</div>
</div>
@@ -77,16 +97,16 @@ const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
const MemberList: React.FC = () => {
const { instanceId } = useParams();
const { members, isLoading } = useInstanceMembers(instanceId);
const { data: members, isLoading } = useInstanceMembers(instanceId);
if (!instanceId || instanceId === "@me") {
if (!instanceId) {
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 className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
);
}
@@ -94,7 +114,7 @@ const MemberList: React.FC = () => {
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 className="text-concord-secondary text-sm">No members</div>
</div>
);
}
@@ -102,43 +122,39 @@ const MemberList: React.FC = () => {
// 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 userRole =
member.roles.find((r) => r.instanceId === instanceId)?.role || "member";
const roleInfo = getRoleInfo(userRole);
const roleName = highestRole?.name || "Members";
if (!acc[roleName]) {
acc[roleName] = [];
if (!acc[roleInfo.name]) {
acc[roleInfo.name] = [];
}
acc[roleName].push(member);
acc[roleInfo.name].push(member);
return acc;
},
{} as Record<string, UserWithRoles[]>,
{} as Record<string, User[]>,
);
// Sort role groups by highest role position
// Sort role groups by priority (admin > mod > member)
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;
([roleNameA], [roleNameB]) => {
const priorityA = getRoleInfo(roleNameA.toLowerCase())?.priority || 1;
const priorityB = getRoleInfo(roleNameB.toLowerCase())?.priority || 1;
return priorityB - priorityA;
},
);
return (
<div className="flex flex-col h-full">
<div className="flex flex-col flex-1 border-l border-concord-primary h-full bg-concord-secondary">
{/* 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 className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
<UserIcon size={20} className="text-concord-primary h-8" />
<div className="h-8 flex flex-col justify-center">
<h3 className="text-sm font-semibold text-concord-secondary tracking-wide">
Members: {members.length} Online:{" "}
{members.filter((m) => m.status === "online").length}
</h3>
</div>
</div>
{/* Member List */}
@@ -148,7 +164,7 @@ const MemberList: React.FC = () => {
<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">
<h4 className="text-xs font-semibold text-concord-secondary uppercase tracking-wide">
{roleName} {roleMembers.length}
</h4>
</div>

View File

@@ -1,6 +1,6 @@
import React from "react";
import { useNavigate, useParams } from "react-router";
import { Plus, Home, Menu } from "lucide-react";
import { Plus, Home } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -10,61 +10,46 @@ import {
} 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 { openCreateServer, setActiveInstance, getSelectedChannelForInstance } =
useUiStore();
const handleServerClick = (serverId: string) => {
setActiveInstance(serverId);
navigate(`/channels/${serverId}`);
};
const lastChannelId = getSelectedChannelForInstance(serverId);
console.log(servers);
if (lastChannelId) {
navigate(`/channels/${serverId}/${lastChannelId}`);
} else {
// Fallback: navigate to the server, let the page component handle finding a channel
// A better UX would be to find and navigate to the first channel here.
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>
)}
<div className="sidebar-primary flex flex-col items-center h-full py-2 space-y-2">
{/* Home/DM Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={
!instanceId || instanceId === "@me" ? "default" : "ghost"
}
variant="ghost"
size="icon"
className="w-12 h-12 rounded-2xl hover:rounded-xl transition-all duration-200"
className={` w-12 h-12 ml-0 ${
!instanceId || instanceId === "@me" ? "active" : ""
}`}
onClick={handleHomeClick}
>
<Home size={24} />
@@ -76,13 +61,13 @@ const ServerSidebar: React.FC = () => {
</Tooltip>
{/* Separator */}
<div className="w-8 h-0.5 bg-gray-600 rounded-full" />
<div className="w-8 h-0.5 bg-border rounded-full" />
{/* Server List */}
<div className="flex-1 flex flex-col space-y-2 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-600">
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2">
{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 className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
) : (
servers?.map((server) => (
@@ -110,7 +95,7 @@ const ServerSidebar: React.FC = () => {
<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"
className="w-12 h-12 ml-3 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
onClick={openCreateServer}
>
<Plus size={24} />

View File

@@ -18,179 +18,255 @@ import {
import { useAuthStore } from "@/stores/authStore";
import { useUiStore } from "@/stores/uiStore";
import { SAMPLE_USERS } from "@/hooks/useServers";
const UserPanel: React.FC = () => {
const { user, logout } = useAuthStore();
const { openUserSettings } = useUiStore();
// Status color utility
const getStatusColor = (status: string) => {
switch (status) {
case "online":
return "bg-status-online";
case "away":
return "bg-status-away";
case "busy":
return "bg-status-busy";
default:
return "bg-status-offline";
}
};
// Voice/Audio states (for future implementation)
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
// User Status Dropdown Component
interface UserStatusDropdownProps {
currentStatus: string;
onStatusChange: (status: string) => void;
children: React.ReactNode;
}
if (!user) return null;
const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
// currentStatus,
onStatusChange,
children,
}) => {
const statusOptions = [
{ value: "online", label: "Online", color: "bg-status-online" },
{ value: "away", label: "Away", color: "bg-status-away" },
{ value: "busy", label: "Do Not Disturb", color: "bg-status-busy" },
{ value: "offline", label: "Invisible", color: "bg-status-offline" },
];
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";
}
return (
<DropdownMenu>
<DropdownMenuTrigger className="checkchekchek" asChild>
{children}
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-48">
{statusOptions.map((status) => (
<DropdownMenuItem
key={status.value}
onClick={() => onStatusChange(status.value)}
>
<div className="flex items-center space-x-2">
<div className={`w-3 h-3 rounded-full ${status.color}`} />
<span>{status.label}</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => useUiStore.getState().openUserSettings()}
>
<Settings size={16} className="mr-2" />
User Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => useAuthStore.getState().logout()}
className="text-destructive focus:text-destructive"
>
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
// Voice Controls Component
interface VoiceControlsProps {
isMuted: boolean;
isDeafened: boolean;
onMuteToggle: () => void;
onDeafenToggle: () => void;
onSettingsClick: () => void;
}
const VoiceControls: React.FC<VoiceControlsProps> = ({
isMuted,
isDeafened,
onMuteToggle,
onDeafenToggle,
onSettingsClick,
}) => {
return (
<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-destructive hover:text-destructive/80" : "interactive-hover"}`}
onClick={onMuteToggle}
>
{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-destructive hover:text-destructive/80" : "interactive-hover"}`}
onClick={onDeafenToggle}
>
<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 interactive-hover"
onClick={onSettingsClick}
>
<Settings size={18} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>User Settings</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
};
// User Avatar Component
interface UserAvatarProps {
user: any;
size?: "sm" | "md" | "lg";
showStatus?: boolean;
}
const UserAvatar: React.FC<UserAvatarProps> = ({
user,
size = "md",
showStatus = true,
}) => {
const sizeClasses = {
sm: "h-6 w-6",
md: "h-8 w-8",
lg: "h-10 w-10",
};
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
const statusSizeClasses = {
sm: "w-2 h-2",
md: "w-3 h-3",
lg: "w-4 h-4",
};
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>
<div className="relative">
<Avatar className={sizeClasses[size]}>
<AvatarImage src={user.picture || undefined} alt={user.username} />
<AvatarFallback className="text-xs text-primary-foreground bg-primary">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{showStatus && (
<div
className={`absolute -bottom-0.5 -right-0.5 ${statusSizeClasses[size]} rounded-full border-2 border-sidebar ${getStatusColor(user.status)}`}
/>
)}
</div>
);
};
<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>
// Main UserPanel Component
const UserPanel: React.FC = () => {
const { user } = useAuthStore();
const { openUserSettings } = useUiStore();
<DropdownMenuSeparator />
const [isMuted, setIsMuted] = useState(false);
const [isDeafened, setIsDeafened] = useState(false);
<DropdownMenuItem onClick={openUserSettings}>
<Settings size={16} className="mr-2" />
User Settings
</DropdownMenuItem>
const displayUser = user || SAMPLE_USERS.find((u) => u.id === "current");
<DropdownMenuSeparator />
if (!displayUser) {
return (
<div className="flex-shrink-0 p-2 bg-concord-tertiary">
<div className="text-concord-secondary text-sm">No user data</div>
</div>
);
}
<DropdownMenuItem
onClick={logout}
className="text-red-400 focus:text-red-400"
>
Log Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
const handleStatusChange = (newStatus: string) => {
console.log("Status change to:", newStatus);
// TODO: Implement API call to update user status
};
const handleMuteToggle = () => setIsMuted(!isMuted);
const handleDeafenToggle = () => {
const newDeafenState = !isDeafened;
setIsDeafened(newDeafenState);
if (newDeafenState) {
setIsMuted(true); // Deafening also mutes
}
};
return (
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
{/* User Info with Dropdown */}
<UserStatusDropdown
currentStatus={displayUser.status}
onStatusChange={handleStatusChange}
>
<Button
variant="ghost"
className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary"
>
<UserAvatar user={displayUser} size="md" />
<div className="ml-2 flex-1 min-w-0 text-left">
<div className="text-sm font-medium text-concord-primary truncate">
{displayUser.nickname || displayUser.username}
</div>
<div className="text-xs text-concord-secondary truncate capitalize">
{displayUser.status}
</div>
</div>
</Button>
</UserStatusDropdown>
{/* 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>
<VoiceControls
isMuted={isMuted}
isDeafened={isDeafened}
onMuteToggle={handleMuteToggle}
onDeafenToggle={handleDeafenToggle}
onSettingsClick={openUserSettings}
/>
</div>
);
};

View File

@@ -0,0 +1,254 @@
import React, { useState } from "react";
import { formatDistanceToNow } from "date-fns";
import Avatar from "@/components/common/Avatar";
import { Button } from "@/components/ui/button";
import { Copy, Edit, Trash2, Reply, MoreHorizontal } from "lucide-react";
import { Message, User } from "@/types/database";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface MessageProps {
message: Message;
user: any;
currentUser?: any;
isGrouped?: boolean | null;
onEdit?: (messageId: string) => void;
onDelete?: (messageId: string) => void;
onReply?: (messageId: string) => void;
replyTo?: Message | null;
replyToUser?: User | null;
}
const MessageComponent: React.FC<MessageProps> = ({
message,
user,
currentUser,
isGrouped = false,
onEdit,
onDelete,
onReply,
}) => {
const [isHovered, setIsHovered] = useState(false);
const formatTimestamp = (timestamp: string) => {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
};
const renderContent = (content: string) => {
// Simple code block detection
const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
const parts = [];
let lastIndex = 0;
let match;
while ((match = codeBlockRegex.exec(content)) !== null) {
// Add text before code block
if (match.index > lastIndex) {
parts.push(
<span key={lastIndex}>{content.slice(lastIndex, match.index)}</span>,
);
}
// Add code block
const language = match[1] || "text";
const code = match[2];
parts.push(
<div key={match.index} className="my-2">
<div className="bg-concord-tertiary rounded-md p-3 border border-border">
<div className="text-xs text-concord-secondary mb-2 font-mono">
{language}
</div>
<pre className="text-sm font-mono text-concord-primary overflow-x-auto">
<code>{code}</code>
</pre>
</div>
</div>,
);
lastIndex = codeBlockRegex.lastIndex;
}
// Add remaining text
if (lastIndex < content.length) {
parts.push(<span key={lastIndex}>{content.slice(lastIndex)}</span>);
}
return parts.length > 0 ? parts : content;
};
const isOwnMessage = currentUser?.id === message.userId;
return (
<div
className={`group relative px-4 py-2 hover:bg-concord-secondary/50 transition-colors ${
isGrouped ? "mt-0.5" : "mt-4"
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex gap-3">
{/* Avatar - only show if not grouped */}
<div className="w-10 flex-shrink-0">
{!isGrouped && <Avatar user={user} size="md" showStatus={true} />}
</div>
{/* Message content */}
<div className="flex-1 min-w-0">
{/* Header - only show if not grouped */}
{!isGrouped && (
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-concord-primary">
{user.nickname || user.username}
</span>
<span className="text-xs text-concord-secondary">
{formatTimestamp(message.createdAt)}
</span>
</div>
)}
{/* Message content */}
<div className="text-concord-primary leading-relaxed">
{renderContent(message.content)}
</div>
{/* Reactions */}
</div>
{/* Message actions */}
{isHovered && (
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 interactive-hover"
onClick={() => onReply?.(message.id)}
>
<Reply className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 interactive-hover"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(message.content)}
>
<Copy className="h-4 w-4 mr-2" />
Copy Text
</DropdownMenuItem>
{isOwnMessage && (
<>
<DropdownMenuItem onClick={() => onEdit?.(message.id)}>
<Edit className="h-4 w-4 mr-2" />
Edit Message
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete?.(message.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Message
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</div>
);
};
// Message Input Component
interface MessageInputProps {
channelName?: string;
onSendMessage: (content: string) => void;
replyingTo?: Message;
onCancelReply?: () => void;
}
const MessageInput: React.FC<MessageInputProps> = ({
channelName,
onSendMessage,
replyingTo,
onCancelReply,
}) => {
const [content, setContent] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim()) {
onSendMessage(content.trim());
setContent("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div className="px-4 pb-4">
{replyingTo && (
<div className="mb-2 p-2 bg-concord-tertiary rounded-t-lg border border-b-0 border-border">
<div className="flex items-center justify-between">
<span className="text-xs text-concord-secondary">
Replying to {replyingTo.userId}
</span>
<Button
variant="ghost"
size="sm"
className="h-auto p-1"
onClick={onCancelReply}
>
×
</Button>
</div>
<div className="text-sm text-concord-primary truncate">
{replyingTo.content}
</div>
</div>
)}
<form onSubmit={handleSubmit}>
<div className="relative">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${channelName || "channel"}`}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
rows={1}
style={{
minHeight: "44px",
maxHeight: "200px",
resize: "none",
}}
/>
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
Press Enter to send
</div>
</div>
</form>
</div>
);
};
export { type MessageProps, MessageComponent, MessageInput };

View File

@@ -1,37 +0,0 @@
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

@@ -26,18 +26,17 @@ const ServerIcon: React.FC<ServerIconProps> = ({
<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"
className={`absolute left-0 top-1/2 transform -translate-y-1/2 w-1 bg-accent-foreground rounded transition-all duration-200 ${
isActive ? "h-10 rounded-xl" : "rounded-r 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"
? "rounded-xl border-primary bg-primary/10 border-2"
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
}`}
onClick={onClick}
>

View File

@@ -1,21 +1,279 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light" | "system";
export interface ThemeColors {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
border: string;
input: string;
ring: string;
// Chart colors
chart1: string;
chart2: string;
chart3: string;
chart4: string;
chart5: string;
// Sidebar colors
sidebar: string;
sidebarForeground: string;
sidebarPrimary: string;
sidebarPrimaryForeground: string;
sidebarAccent: string;
sidebarAccentForeground: string;
sidebarBorder: string;
sidebarRing: string;
}
export interface ThemeDefinition {
id: string;
name: string;
description?: string;
mode: "light" | "dark";
colors: ThemeColors;
isCustom?: boolean;
}
// Fixed themes using proper OKLCH format
const DEFAULT_THEMES: ThemeDefinition[] = [
{
id: "default-light",
name: "Default Light",
mode: "light",
colors: {
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
card: "oklch(1 0 0)",
cardForeground: "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
popoverForeground: "oklch(0.145 0 0)",
primary: "oklch(0.205 0 0)",
primaryForeground: "oklch(0.985 0 0)",
secondary: "oklch(0.97 0 0)",
secondaryForeground: "oklch(0.205 0 0)",
muted: "oklch(0.97 0 0)",
mutedForeground: "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
accentForeground: "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)",
chart1: "oklch(0.646 0.222 41.116)",
chart2: "oklch(0.6 0.118 184.704)",
chart3: "oklch(0.398 0.07 227.392)",
chart4: "oklch(0.828 0.189 84.429)",
chart5: "oklch(0.769 0.188 70.08)",
sidebar: "oklch(0.985 0 0)",
sidebarForeground: "oklch(0.145 0 0)",
sidebarPrimary: "oklch(0.205 0 0)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.97 0 0)",
sidebarAccentForeground: "oklch(0.205 0 0)",
sidebarBorder: "oklch(0.922 0 0)",
sidebarRing: "oklch(0.708 0 0)",
},
},
{
id: "default-dark",
name: "Default Dark",
mode: "dark",
colors: {
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
cardForeground: "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
popoverForeground: "oklch(0.985 0 0)",
primary: "oklch(0.922 0 0)",
primaryForeground: "oklch(0.205 0 0)",
secondary: "oklch(0.269 0 0)",
secondaryForeground: "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
mutedForeground: "oklch(0.708 0 0)",
accent: "oklch(0.269 0 0)",
accentForeground: "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)",
chart1: "oklch(0.488 0.243 264.376)",
chart2: "oklch(0.696 0.17 162.48)",
chart3: "oklch(0.769 0.188 70.08)",
chart4: "oklch(0.627 0.265 303.9)",
chart5: "oklch(0.645 0.246 16.439)",
sidebar: "oklch(0.205 0 0)",
sidebarForeground: "oklch(0.985 0 0)",
sidebarPrimary: "oklch(0.488 0.243 264.376)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.269 0 0)",
sidebarAccentForeground: "oklch(0.985 0 0)",
sidebarBorder: "oklch(1 0 0 / 10%)",
sidebarRing: "oklch(0.556 0 0)",
},
},
{
id: "paper-light",
name: "Paper",
description: "Clean paper-like theme",
mode: "light",
colors: {
background: "oklch(0.99 0.01 85)",
foreground: "oklch(0.15 0.02 65)",
card: "oklch(0.99 0.01 85)",
cardForeground: "oklch(0.15 0.02 65)",
popover: "oklch(1 0 0)",
popoverForeground: "oklch(0.15 0.02 65)",
primary: "oklch(0.25 0.03 45)",
primaryForeground: "oklch(0.98 0.01 85)",
secondary: "oklch(0.96 0.01 75)",
secondaryForeground: "oklch(0.25 0.03 45)",
muted: "oklch(0.96 0.01 75)",
mutedForeground: "oklch(0.45 0.02 55)",
accent: "oklch(0.96 0.01 75)",
accentForeground: "oklch(0.25 0.03 45)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.90 0.01 65)",
input: "oklch(0.90 0.01 65)",
ring: "oklch(0.25 0.03 45)",
chart1: "oklch(0.646 0.222 41.116)",
chart2: "oklch(0.6 0.118 184.704)",
chart3: "oklch(0.398 0.07 227.392)",
chart4: "oklch(0.828 0.189 84.429)",
chart5: "oklch(0.769 0.188 70.08)",
sidebar: "oklch(0.97 0.01 80)",
sidebarForeground: "oklch(0.15 0.02 65)",
sidebarPrimary: "oklch(0.25 0.03 45)",
sidebarPrimaryForeground: "oklch(0.98 0.01 85)",
sidebarAccent: "oklch(0.94 0.01 75)",
sidebarAccentForeground: "oklch(0.25 0.03 45)",
sidebarBorder: "oklch(0.88 0.01 65)",
sidebarRing: "oklch(0.25 0.03 45)",
},
},
{
id: "comfy-brown-dark",
name: "Comfy Brown",
description: "Warm brown theme for dark mode",
mode: "dark",
colors: {
background: "oklch(0.15 0.03 65)",
foreground: "oklch(0.95 0.01 85)",
card: "oklch(0.20 0.03 55)",
cardForeground: "oklch(0.95 0.01 85)",
popover: "oklch(0.20 0.03 55)",
popoverForeground: "oklch(0.95 0.01 85)",
primary: "oklch(0.65 0.15 45)",
primaryForeground: "oklch(0.95 0.01 85)",
secondary: "oklch(0.25 0.04 50)",
secondaryForeground: "oklch(0.95 0.01 85)",
muted: "oklch(0.25 0.04 50)",
mutedForeground: "oklch(0.70 0.02 65)",
accent: "oklch(0.25 0.04 50)",
accentForeground: "oklch(0.95 0.01 85)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(0.30 0.04 55)",
input: "oklch(0.30 0.04 55)",
ring: "oklch(0.65 0.15 45)",
chart1: "oklch(0.65 0.15 45)",
chart2: "oklch(0.55 0.12 85)",
chart3: "oklch(0.75 0.18 25)",
chart4: "oklch(0.60 0.14 105)",
chart5: "oklch(0.70 0.16 65)",
sidebar: "oklch(0.18 0.03 60)",
sidebarForeground: "oklch(0.95 0.01 85)",
sidebarPrimary: "oklch(0.65 0.15 45)",
sidebarPrimaryForeground: "oklch(0.95 0.01 85)",
sidebarAccent: "oklch(0.22 0.04 50)",
sidebarAccentForeground: "oklch(0.95 0.01 85)",
sidebarBorder: "oklch(0.28 0.04 55)",
sidebarRing: "oklch(0.65 0.15 45)",
},
},
{
id: "midnight-dark",
name: "Midnight",
description: "Deep blue midnight theme",
mode: "dark",
colors: {
background: "oklch(0.12 0.08 250)",
foreground: "oklch(0.95 0.01 230)",
card: "oklch(0.18 0.06 240)",
cardForeground: "oklch(0.95 0.01 230)",
popover: "oklch(0.18 0.06 240)",
popoverForeground: "oklch(0.95 0.01 230)",
primary: "oklch(0.60 0.20 240)",
primaryForeground: "oklch(0.95 0.01 230)",
secondary: "oklch(0.22 0.05 235)",
secondaryForeground: "oklch(0.95 0.01 230)",
muted: "oklch(0.22 0.05 235)",
mutedForeground: "oklch(0.70 0.02 230)",
accent: "oklch(0.22 0.05 235)",
accentForeground: "oklch(0.95 0.01 230)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(0.25 0.05 235)",
input: "oklch(0.25 0.05 235)",
ring: "oklch(0.60 0.20 240)",
chart1: "oklch(0.60 0.20 240)",
chart2: "oklch(0.50 0.15 200)",
chart3: "oklch(0.65 0.18 280)",
chart4: "oklch(0.55 0.16 160)",
chart5: "oklch(0.70 0.22 300)",
sidebar: "oklch(0.15 0.07 245)",
sidebarForeground: "oklch(0.95 0.01 230)",
sidebarPrimary: "oklch(0.60 0.20 240)",
sidebarPrimaryForeground: "oklch(0.95 0.01 230)",
sidebarAccent: "oklch(0.20 0.05 235)",
sidebarAccentForeground: "oklch(0.95 0.01 230)",
sidebarBorder: "oklch(0.22 0.05 235)",
sidebarRing: "oklch(0.60 0.20 240)",
},
},
];
type ThemeMode = "light" | "dark" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
defaultTheme?: ThemeMode;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
mode: ThemeMode;
currentTheme: ThemeDefinition;
currentLightTheme: ThemeDefinition;
currentDarkTheme: ThemeDefinition;
themes: ThemeDefinition[];
setMode: (mode: ThemeMode) => void;
setTheme: (themeId: string) => void;
addCustomTheme: (theme: Omit<ThemeDefinition, "id" | "isCustom">) => void;
removeCustomTheme: (themeId: string) => void;
getThemesForMode: (mode: "light" | "dark") => ThemeDefinition[];
};
const initialState: ThemeProviderState = {
theme: "system",
mode: "system",
currentTheme: DEFAULT_THEMES[1], // Default to dark theme
currentLightTheme: DEFAULT_THEMES[0], // Default light
currentDarkTheme: DEFAULT_THEMES[1], // Default dark
themes: DEFAULT_THEMES,
setMode: () => null,
setTheme: () => null,
addCustomTheme: () => null,
removeCustomTheme: () => null,
getThemesForMode: () => [],
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
@@ -23,37 +281,206 @@ const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "vite-ui-theme",
storageKey = "concord-theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
const [mode, setMode] = useState<ThemeMode>(
() =>
(localStorage.getItem(storageKey + "-mode") as ThemeMode) || defaultTheme,
);
useEffect(() => {
const [themes, setThemes] = useState<ThemeDefinition[]>(() => {
const saved = localStorage.getItem(storageKey + "-themes");
const customThemes = saved ? JSON.parse(saved) : [];
return [...DEFAULT_THEMES, ...customThemes];
});
const [currentLightThemeId, setCurrentLightThemeId] = useState<string>(() => {
const saved = localStorage.getItem(storageKey + "-light");
return saved || "default-light";
});
const [currentDarkThemeId, setCurrentDarkThemeId] = useState<string>(() => {
const saved = localStorage.getItem(storageKey + "-dark");
return saved || "default-dark";
});
const currentLightTheme =
themes.find((t) => t.id === currentLightThemeId) || DEFAULT_THEMES[0];
const currentDarkTheme =
themes.find((t) => t.id === currentDarkThemeId) || DEFAULT_THEMES[2];
// Determine the current theme based on mode and system preference
const getCurrentTheme = (): ThemeDefinition => {
switch (mode) {
case "light":
return currentLightTheme;
case "dark":
return currentDarkTheme;
case "system":
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
return systemPrefersDark ? currentDarkTheme : currentLightTheme;
default:
return currentDarkTheme;
}
};
const currentTheme = getCurrentTheme();
const applyTheme = (theme: ThemeDefinition) => {
const root = window.document.documentElement;
// Remove existing theme classes
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
// Apply mode class
root.classList.add(theme.mode);
root.classList.add(systemTheme);
return;
// Apply CSS custom properties with proper mapping
Object.entries(theme.colors).forEach(([key, value]) => {
// Convert camelCase to kebab-case and map to CSS variables
const cssVarMap: Record<string, string> = {
background: "--background",
foreground: "--foreground",
card: "--card",
cardForeground: "--card-foreground",
popover: "--popover",
popoverForeground: "--popover-foreground",
primary: "--primary",
primaryForeground: "--primary-foreground",
secondary: "--secondary",
secondaryForeground: "--secondary-foreground",
muted: "--muted",
mutedForeground: "--muted-foreground",
accent: "--accent",
accentForeground: "--accent-foreground",
destructive: "--destructive",
border: "--border",
input: "--input",
ring: "--ring",
chart1: "--chart-1",
chart2: "--chart-2",
chart3: "--chart-3",
chart4: "--chart-4",
chart5: "--chart-5",
sidebar: "--sidebar",
sidebarForeground: "--sidebar-foreground",
sidebarPrimary: "--sidebar-primary",
sidebarPrimaryForeground: "--sidebar-primary-foreground",
sidebarAccent: "--sidebar-accent",
sidebarAccentForeground: "--sidebar-accent-foreground",
sidebarBorder: "--sidebar-border",
sidebarRing: "--sidebar-ring",
};
const cssVar = cssVarMap[key];
if (cssVar) {
root.style.setProperty(cssVar, value);
}
});
};
useEffect(() => {
applyTheme(currentTheme);
}, [currentTheme]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (mode === "system") {
// Theme will be recalculated due to getCurrentTheme dependency
const newTheme = getCurrentTheme();
applyTheme(newTheme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [mode, currentLightTheme, currentDarkTheme]);
const setTheme = (themeId: string) => {
const theme = themes.find((t) => t.id === themeId);
if (!theme) return;
// Update the appropriate theme based on the theme's mode
if (theme.mode === "light") {
setCurrentLightThemeId(themeId);
localStorage.setItem(storageKey + "-light", themeId);
} else {
setCurrentDarkThemeId(themeId);
localStorage.setItem(storageKey + "-dark", themeId);
}
};
const handleSetMode = (newMode: ThemeMode) => {
setMode(newMode);
localStorage.setItem(storageKey + "-mode", newMode);
};
const addCustomTheme = (
themeData: Omit<ThemeDefinition, "id" | "isCustom">,
) => {
const newTheme: ThemeDefinition = {
...themeData,
id: `custom-${Date.now()}`,
isCustom: true,
};
const updatedThemes = [...themes, newTheme];
setThemes(updatedThemes);
// Save only custom themes to localStorage
const customThemes = updatedThemes.filter((t) => t.isCustom);
localStorage.setItem(storageKey + "-themes", JSON.stringify(customThemes));
};
const removeCustomTheme = (themeId: string) => {
const updatedThemes = themes.filter((t) => t.id !== themeId);
setThemes(updatedThemes);
// If removing current theme, switch to default
if (currentLightThemeId === themeId) {
const defaultLight = updatedThemes.find(
(t) => t.mode === "light" && !t.isCustom,
);
if (defaultLight) {
setCurrentLightThemeId(defaultLight.id);
localStorage.setItem(storageKey + "-light", defaultLight.id);
}
}
root.classList.add(theme);
}, [theme]);
if (currentDarkThemeId === themeId) {
const defaultDark = updatedThemes.find(
(t) => t.mode === "dark" && !t.isCustom,
);
if (defaultDark) {
setCurrentDarkThemeId(defaultDark.id);
localStorage.setItem(storageKey + "-dark", defaultDark.id);
}
}
// Save only custom themes to localStorage
const customThemes = updatedThemes.filter((t) => t.isCustom);
localStorage.setItem(storageKey + "-themes", JSON.stringify(customThemes));
};
const getThemesForMode = (targetMode: "light" | "dark") => {
return themes.filter((t) => t.mode === targetMode);
};
const value = {
theme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);
},
mode,
currentTheme,
currentLightTheme,
currentDarkTheme,
themes,
setMode: handleSetMode,
setTheme,
addCustomTheme,
removeCustomTheme,
getThemesForMode,
};
return (

View File

@@ -0,0 +1,419 @@
import React, { useState } from "react";
import { Moon, Sun, Monitor, Palette, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuLabel,
DropdownMenuGroup,
} from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
useTheme,
ThemeDefinition,
ThemeColors,
} from "@/components/theme-provider";
// Theme color input component for OKLCH values
const ColorInput: React.FC<{
label: string;
value: string;
onChange: (value: string) => void;
}> = ({ label, value, onChange }) => {
return (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor={label} className="text-right text-sm">
{label}
</Label>
<Input
id={label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="col-span-3 font-mono text-sm"
placeholder="oklch(0.5 0.1 180)"
/>
</div>
);
};
// Custom theme creation modal
const CreateThemeModal: React.FC<{
isOpen: boolean;
onClose: () => void;
onSave: (theme: Omit<ThemeDefinition, "id" | "isCustom">) => void;
}> = ({ isOpen, onClose, onSave }) => {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [mode, setMode] = useState<"light" | "dark">("dark");
const [colors, setColors] = useState<ThemeColors>({
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
cardForeground: "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
popoverForeground: "oklch(0.985 0 0)",
primary: "oklch(0.922 0 0)",
primaryForeground: "oklch(0.205 0 0)",
secondary: "oklch(0.269 0 0)",
secondaryForeground: "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
mutedForeground: "oklch(0.708 0 0)",
accent: "oklch(0.269 0 0)",
accentForeground: "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)",
chart1: "oklch(0.488 0.243 264.376)",
chart2: "oklch(0.696 0.17 162.48)",
chart3: "oklch(0.769 0.188 70.08)",
chart4: "oklch(0.627 0.265 303.9)",
chart5: "oklch(0.645 0.246 16.439)",
sidebar: "oklch(0.205 0 0)",
sidebarForeground: "oklch(0.985 0 0)",
sidebarPrimary: "oklch(0.488 0.243 264.376)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.269 0 0)",
sidebarAccentForeground: "oklch(0.985 0 0)",
sidebarBorder: "oklch(1 0 0 / 10%)",
sidebarRing: "oklch(0.556 0 0)",
});
const handleSave = () => {
if (!name.trim()) return;
onSave({
name: name.trim(),
description: description.trim() || undefined,
mode,
colors,
});
// Reset form
setName("");
setDescription("");
setMode("dark");
onClose();
};
const updateColor = (key: keyof ThemeColors, value: string) => {
setColors((prev) => ({ ...prev, [key]: value }));
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Custom Theme</DialogTitle>
<DialogDescription>
Create a custom theme by defining colors in OKLCH format (e.g.,
"oklch(0.5 0.1 180)")
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Basic Info */}
<div className="space-y-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="theme-name" className="text-right">
Name
</Label>
<Input
id="theme-name"
value={name}
onChange={(e) => setName(e.target.value)}
className="col-span-3"
placeholder="My Custom Theme"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="theme-description" className="text-right">
Description
</Label>
<Textarea
id="theme-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
placeholder="Optional description"
rows={2}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="theme-mode" className="text-right">
Mode
</Label>
<Select
value={mode}
onValueChange={(v: "light" | "dark") => setMode(v)}
>
<SelectTrigger className="col-span-3">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Color sections */}
<div className="space-y-4">
<h4 className="font-medium">Basic Colors</h4>
<div className="space-y-3">
<ColorInput
label="Background"
value={colors.background}
onChange={(v) => updateColor("background", v)}
/>
<ColorInput
label="Foreground"
value={colors.foreground}
onChange={(v) => updateColor("foreground", v)}
/>
<ColorInput
label="Primary"
value={colors.primary}
onChange={(v) => updateColor("primary", v)}
/>
<ColorInput
label="Secondary"
value={colors.secondary}
onChange={(v) => updateColor("secondary", v)}
/>
</div>
<h4 className="font-medium">Sidebar Colors</h4>
<div className="space-y-3">
<ColorInput
label="Sidebar"
value={colors.sidebar}
onChange={(v) => updateColor("sidebar", v)}
/>
<ColorInput
label="Sidebar Primary"
value={colors.sidebarPrimary}
onChange={(v) => updateColor("sidebarPrimary", v)}
/>
<ColorInput
label="Sidebar Accent"
value={colors.sidebarAccent}
onChange={(v) => updateColor("sidebarAccent", v)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!name.trim()}>
Create Theme
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
// Main theme selector component
export function ThemeSelector() {
const {
mode,
currentTheme,
// themes,
setMode,
setTheme,
addCustomTheme,
removeCustomTheme,
getThemesForMode,
} = useTheme();
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const lightThemes = getThemesForMode("light");
const darkThemes = getThemesForMode("dark");
const getCurrentModeIcon = () => {
switch (mode) {
case "light":
return <Sun className="h-4 w-4" />;
case "dark":
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
{getCurrentModeIcon()}
<span className="ml-2">{currentTheme.name}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
<DropdownMenuSeparator />
{/* Mode Selection */}
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">Mode</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setMode("light")}>
<Sun className="mr-2 h-4 w-4" />
<span>Light</span>
{mode === "light" && (
<Badge variant="secondary" className="ml-auto">
Active
</Badge>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span>Dark</span>
{mode === "dark" && (
<Badge variant="secondary" className="ml-auto">
Active
</Badge>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setMode("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span>System</span>
{mode === "system" && (
<Badge variant="secondary" className="ml-auto">
Active
</Badge>
)}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{/* Light Themes */}
{lightThemes.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">
Light Themes
</DropdownMenuLabel>
{lightThemes.map((theme) => (
<DropdownMenuItem
key={theme.id}
onClick={() => setTheme(theme.id)}
className="justify-between"
>
<div className="flex items-center">
<Palette className="mr-2 h-4 w-4" />
<span>{theme.name}</span>
</div>
<div className="flex items-center gap-2">
{currentTheme.id === theme.id && (
<Badge variant="secondary">Active</Badge>
)}
{theme.isCustom && (
<Button
variant="ghost"
size="sm"
className="h-auto p-1 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeCustomTheme(theme.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)}
{lightThemes.length > 0 && darkThemes.length > 0 && (
<DropdownMenuSeparator />
)}
{/* Dark Themes */}
{darkThemes.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">
Dark Themes
</DropdownMenuLabel>
{darkThemes.map((theme) => (
<DropdownMenuItem
key={theme.id}
onClick={() => setTheme(theme.id)}
className="justify-between"
>
<div className="flex items-center">
<Palette className="mr-2 h-4 w-4" />
<span>{theme.name}</span>
</div>
<div className="flex items-center gap-2">
{currentTheme.id === theme.id && (
<Badge variant="secondary">Active</Badge>
)}
{theme.isCustom && (
<Button
variant="ghost"
size="sm"
className="h-auto p-1 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeCustomTheme(theme.id);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
)}
<DropdownMenuSeparator />
{/* Add Custom Theme */}
<DropdownMenuItem onClick={() => setIsCreateModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<span>Create Theme</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CreateThemeModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
onSave={addCustomTheme}
/>
</>
);
}

View File

@@ -0,0 +1,46 @@
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 badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-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 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,61 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -1,72 +1,86 @@
// src/hooks/useChannels.ts
import { Message } from "@/types/database";
import { useQuery } from "@tanstack/react-query";
import { CategoryWithChannels } from "@/types/api";
// Placeholder hook for channels by instance
export const useChannels = (instanceId?: string) => {
// Sample messages data
export const SAMPLE_MESSAGES = [
{
id: "1",
content: "Hey everyone! Just finished the new theme system. Check it out!",
channelId: "1", // general channel
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{
id: "2",
content:
"Looking great! The dark mode especially feels much better on the eyes 👀",
channelId: "1",
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
},
{
id: "3",
content: "Can we add a **high contrast mode** for accessibility?",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
},
{
id: "4",
content:
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
},
{
id: "5",
content:
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
channelId: "1",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
},
// Messages for random channel
{
id: "6",
content: "Anyone up for a game tonight?",
channelId: "2", // random channel
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
},
{
id: "7",
content: "I'm in! What are we playing?",
channelId: "2",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
},
];
export const useChannelMessages = (channelId?: 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(),
},
],
},
];
queryKey: ["messages", channelId],
queryFn: async (): Promise<Message[]> => {
if (!channelId) return [];
await new Promise((resolve) => setTimeout(resolve, 100));
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
},
enabled: !!instanceId && instanceId !== "@me",
staleTime: 1000 * 60 * 5, // 5 minutes
enabled: !!channelId,
staleTime: 1000 * 60 * 1,
});
};

View File

@@ -1,32 +0,0 @@
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

@@ -1,78 +1,359 @@
import { useQuery } from "@tanstack/react-query";
import { Instance, InstanceWithDetails, UserWithRoles } from "@/types/api";
import { Instance, User } from "@/types/database";
import { InstanceWithDetails } from "@/types/api";
import { CategoryWithChannels } from "@/types/api";
// Sample users data with proper Role structure
export const SAMPLE_USERS: User[] = [
{
id: "1",
username: "alice_dev",
nickname: "Alice",
bio: "Frontend developer who loves React",
picture: null,
banner: null,
status: "online" as const,
hashPassword: "",
admin: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [], // Will be populated per instance
},
{
id: "2",
username: "bob_designer",
nickname: "Bob",
bio: "UI/UX Designer & Coffee Enthusiast",
picture:
"https://media.istockphoto.com/id/1682296067/photo/happy-studio-portrait-or-professional-man-real-estate-agent-or-asian-businessman-smile-for.jpg?s=612x612&w=0&k=20&c=9zbG2-9fl741fbTWw5fNgcEEe4ll-JegrGlQQ6m54rg=",
banner: null,
status: "away" as const,
hashPassword: "",
admin: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
{
id: "3",
username: "charlie_backend",
nickname: "Charlie",
bio: "Backend wizard, scaling systems since 2018",
picture: null,
banner: null,
status: "busy" as const,
hashPassword: "",
admin: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
{
id: "current",
username: "you",
nickname: "You",
bio: "That's you!",
picture: null,
banner: null,
status: "online" as const,
hashPassword: "",
admin: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
];
// Sample servers data
const SAMPLE_SERVERS: Instance[] = [
{
id: "1",
name: "Dev Team",
icon: null,
description: "Our development team server",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "2",
name: "Gaming Squad",
icon: null,
description: "Gaming and fun times",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "3",
name: "Book Club",
icon: null,
description: "Monthly book discussions",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
// Sample messages data
export const SAMPLE_MESSAGES = [
{
id: "1",
content: "Hey everyone! Just finished the new theme system. Check it out!",
channelId: "1", // general channel
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
},
{
id: "2",
content:
"Looking great! The dark mode especially feels much better on the eyes 👀",
channelId: "1",
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
},
{
id: "3",
content: "Can we add a **high contrast mode** for accessibility?",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
},
{
id: "4",
content:
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
channelId: "1",
userId: "3",
edited: false,
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
},
{
id: "5",
content:
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
channelId: "1",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
},
// Messages for random channel
{
id: "6",
content: "Anyone up for a game tonight?",
channelId: "2", // random channel
userId: "2",
edited: false,
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
},
{
id: "7",
content: "I'm in! What are we playing?",
channelId: "2",
userId: "1",
edited: false,
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
},
];
// Sample categories with channels
const createSampleCategories = (instanceId: string): CategoryWithChannels[] => [
{
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,
description: "General discussion about development and projects",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "2",
name: "random",
type: "text",
categoryId: "1",
instanceId: instanceId,
position: 1,
description: "Random chat and off-topic discussions",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "3",
name: "announcements",
type: "text",
categoryId: "1",
instanceId: instanceId,
position: 2,
description: "Important announcements and updates",
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: "4",
name: "General",
type: "voice",
categoryId: "2",
instanceId: instanceId,
position: 0,
description: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "5",
name: "Focus Room",
type: "voice",
categoryId: "2",
instanceId: instanceId,
position: 1,
description: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
{
id: "3",
name: "Project Channels",
instanceId: instanceId,
position: 2,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
channels: [
{
id: "6",
name: "frontend",
type: "text",
categoryId: "3",
instanceId: instanceId,
position: 0,
description: "Frontend development discussions",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "7",
name: "backend",
type: "text",
categoryId: "3",
instanceId: instanceId,
position: 1,
description: "Backend and API development",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
},
];
// Placeholder hook for channels by instance
export const useChannels = (instanceId?: string) => {
return useQuery({
queryKey: ["channels", instanceId],
queryFn: async (): Promise<CategoryWithChannels[]> => {
if (!instanceId) return [];
return createSampleCategories(instanceId);
},
enabled: !!instanceId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
};
// Hook for getting messages in a channel
export const useChannelMessages = (channelId?: string) => {
return useQuery({
queryKey: ["messages", channelId],
queryFn: async () => {
if (!channelId) return [];
// Return messages for this channel
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
},
enabled: !!channelId,
staleTime: 1000 * 60 * 1, // 1 minute (messages are more dynamic)
});
};
// 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(),
},
];
await new Promise((resolve) => setTimeout(resolve, 100));
return SAMPLE_SERVERS;
},
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 useInstanceDetails = (instanceId?: string) => {
return useQuery({
queryKey: ["instance", instanceId],
queryFn: async (): Promise<InstanceWithDetails | null> => {
if (!instanceId) return null;
await new Promise((resolve) => setTimeout(resolve, 100));
const server = SAMPLE_SERVERS.find((s) => s.id === instanceId);
if (!server) return null;
return {
...server,
categories: createSampleCategories(instanceId),
};
},
enabled: !!instanceId,
staleTime: 1000 * 60 * 5,
});
};
// Hook for getting all users from an instance with their roles
export const useInstanceMembers = (instanceId?: string) => {
return useQuery({
queryKey: ["instance", instanceId, "members"],
queryFn: async (): Promise<UserWithRoles[]> => {
if (!instanceId || instanceId === "@me") return [];
queryFn: async (): Promise<User[]> => {
if (!instanceId) return [];
await new Promise((resolve) => setTimeout(resolve, 100));
// 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: [],
},
];
return SAMPLE_USERS.map((user, index) => ({
...user,
roles: [
{
instanceId: instanceId,
role: index === 0 ? "admin" : index === 1 ? "mod" : "member",
},
],
}));
},
enabled: !!instanceId && instanceId !== "@me",
staleTime: 1000 * 60 * 2, // 2 minutes (members change more frequently)
enabled: !!instanceId,
staleTime: 1000 * 60 * 2,
});
};

View File

@@ -39,6 +39,23 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
/* concord-specific custom properties */
--color-text-primary: var(--foreground);
--color-text-secondary: var(--muted-foreground);
--color-text-muted: var(--muted-foreground);
--color-interactive-normal: var(--muted-foreground);
--color-interactive-hover: var(--foreground);
--color-interactive-active: var(--primary);
--color-background-primary: var(--background);
--color-background-secondary: var(--card);
--color-background-tertiary: var(--muted);
/* Status colors - these remain consistent across themes */
--color-status-online: oklch(0.6 0.15 140);
--color-status-away: oklch(0.75 0.15 85);
--color-status-busy: oklch(0.65 0.2 25);
--color-status-offline: var(--muted-foreground);
}
:root {
@@ -112,9 +129,179 @@
@layer base {
* {
@apply border-border outline-ring/50;
border-color: var(--border);
outline-color: var(--ring, oklch(0.708 0 0)) / 50%;
}
body {
@apply bg-background text-foreground;
background-color: var(--background);
color: var(--foreground);
}
}
/* Background utilities */
@utility bg-concord-primary {
background-color: var(--color-background-primary);
}
@utility bg-concord-secondary {
background-color: var(--color-background-secondary);
}
@utility bg-concord-tertiary {
background-color: var(--color-background-tertiary);
}
/* Text utilities */
@utility text-concord-primary {
color: var(--color-text-primary);
}
@utility text-concord-secondary {
color: var(--color-text-secondary);
}
@utility text-concord-muted {
color: var(--color-text-muted);
}
/* Interactive utilities */
@utility text-interactive-normal {
color: var(--color-interactive-normal);
}
@utility text-interactive-hover {
color: var(--color-interactive-hover);
}
@utility text-interactive-active {
color: var(--color-interactive-active);
}
/* Status utilities */
@utility text-status-online {
color: var(--color-status-online);
}
@utility text-status-away {
color: var(--color-status-away);
}
@utility text-status-busy {
color: var(--color-status-busy);
}
@utility text-status-offline {
color: var(--color-status-offline);
}
@utility bg-status-online {
background-color: var(--color-status-online);
}
@utility bg-status-away {
background-color: var(--color-status-away);
}
@utility bg-status-busy {
background-color: var(--color-status-busy);
}
@utility bg-status-offline {
background-color: var(--color-status-offline);
}
/* Border utilities */
@utility border-concord {
border-color: var(--color-border);
}
@utility border-sidebar {
border-color: var(--color-sidebar-border);
}
@utility interactive-hover {
transition-property: color;
transition-duration: 200ms;
transition-timing-function: ease-in-out;
color: var(--color-interactive-normal);
&:hover {
color: var(--color-interactive-hover);
}
}
@utility spacing-xs {
padding: var(--tw-spacing-1, 0.25rem);
gap: var(--tw-spacing-1, 0.25rem);
}
@utility spacing-sm {
padding: var(--tw-spacing-2, 0.5rem);
gap: var(--tw-spacing-2, 0.5rem);
}
@utility spacing-md {
padding: var(--tw-spacing-4, 1rem);
gap: var(--tw-spacing-4, 1rem);
}
@utility spacing-lg {
padding: var(--tw-spacing-6, 1.5rem);
gap: var(--tw-spacing-6, 1.5rem);
}
@utility spacing-xl {
padding: var(--tw-spacing-8, 2rem);
gap: var(--tw-spacing-8, 2rem);
}
@utility spacing-x-xs {
padding-left: var(--tw-spacing-1, 0.25rem);
padding-right: var(--tw-spacing-1, 0.25rem);
}
@utility spacing-x-sm {
padding-left: var(--tw-spacing-2, 0.5rem);
padding-right: var(--tw-spacing-2, 0.5rem);
}
@utility spacing-x-md {
padding-left: var(--tw-spacing-4, 1rem);
padding-right: var(--tw-spacing-4, 1rem);
}
@utility spacing-x-lg {
padding-left: var(--tw-spacing-6, 1.5rem);
padding-right: var(--tw-spacing-6, 1.5rem);
}
@utility spacing-x-xl {
padding-left: var(--tw-spacing-8, 2rem);
padding-right: var(--tw-spacing-8, 2rem);
}
@utility spacing-y-xs {
padding-top: var(--tw-spacing-1, 0.25rem);
padding-bottom: var(--tw-spacing-1, 0.25rem);
}
@utility spacing-y-sm {
padding-top: var(--tw-spacing-2, 0.5rem);
padding-bottom: var(--tw-spacing-2, 0.5rem);
}
@utility spacing-y-md {
padding-top: var(--tw-spacing-4, 1rem);
padding-bottom: var(--tw-spacing-4, 1rem);
}
@utility spacing-y-lg {
padding-top: var(--tw-spacing-6, 1.5rem);
padding-bottom: var(--tw-spacing-6, 1.5rem);
}
@utility spacing-y-xl {
padding-top: var(--tw-spacing-8, 2rem);
padding-bottom: var(--tw-spacing-8, 2rem);
}
@layer components {
/* Sidebar styling */
.sidebar-primary {
background-color: var(--color-sidebar);
border-right-width: 1px;
border-color: var(--color-sidebar-border);
}
.sidebar-secondary {
background-color: var(--color-background-secondary);
border-right-width: 1px;
border-color: var(--color-border);
}
.panel-button {
display: flex;
width: full;
flex-grow: 1;
border-radius: 12px;
margin: 4px 8px;
padding: 8px;
&:hover {
background-color: var(--color-background-tertiary);
}
}
}

View File

@@ -1,121 +1,476 @@
import React from "react";
import { useParams } from "react-router";
import { Hash, Volume2, Users, HelpCircle, Inbox, Pin } from "lucide-react";
import React, { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router";
import ReactMarkdown from "react-markdown";
import { Hash, Volume2, Users, Pin } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useInstanceDetails } from "@/hooks/useServers";
import { useChannels } from "@/hooks/useChannel";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Copy, Edit, Trash2, Reply, MoreHorizontal } from "lucide-react";
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
import { useChannelMessages } from "@/hooks/useChannel";
import { useUiStore } from "@/stores/uiStore";
import { useAuthStore } from "@/stores/authStore";
import { Message, User } from "@/types/database";
import { MessageProps } from "@/components/message/Message";
import SyntaxHighlighter from "react-syntax-highlighter";
import {
dark,
solarizedLight,
} from "react-syntax-highlighter/dist/esm/styles/hljs";
import { useTheme } from "@/components/theme-provider";
const MessageComponent: React.FC<MessageProps> = ({
message,
user,
currentUser,
replyTo,
onEdit,
onDelete,
onReply,
isGrouped,
}) => {
const [isHovered, setIsHovered] = useState(false);
const formatTimestamp = (timestamp: string) => {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
};
const isOwnMessage = currentUser?.id === message.userId;
const { mode } = useTheme();
return (
<div
className={`group relative px-4 hover:bg-concord-secondary/50 transition-colors ${
isGrouped ? "mt-0 py-0" : "mt-4"
}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex gap-3">
{/* Avatar - only show if not grouped */}
<div className="w-10 flex-shrink-0">
{!isGrouped && (
<Avatar className="h-10 w-10">
<AvatarImage
src={user.picture || undefined}
alt={user.username}
/>
<AvatarFallback className="text-sm bg-primary text-primary-foreground">
{user.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
</div>
{/* Message content */}
<div className="flex-1 min-w-0">
{/* Reply line and reference */}
{replyTo && (
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyTo?.user?.nickname || replyTo?.user?.username}
</span>
<span className="truncate max-w-xs opacity-75">
{replyTo.content.replace(/```[\s\S]*?```/g, "[code]")}
</span>
</div>
)}
{/* Header - only show if not grouped */}
{!isGrouped && (
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-concord-primary">
{user.nickname || user.username}
</span>
<span className="text-xs text-concord-secondary">
{formatTimestamp(message.createdAt)}
</span>
</div>
)}
{/* Message content with markdown */}
<div className="text-concord-primary leading-relaxed prose prose-sm max-w-none dark:prose-invert">
<ReactMarkdown
components={{
code: ({ node, className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
return match ? (
<div className="flex flex-row flex-1 max-w-2/3 flex-wrap !bg-transparent">
<SyntaxHighlighter
PreTag="div"
children={String(children).replace(/\n$/, "")}
language={match[1]}
style={mode === "light" ? solarizedLight : dark}
className="!bg-concord-secondary p-2 border-2 concord-border rounded-xl"
/>
</div>
) : (
<code className={className}>{children}</code>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-primary pl-4 my-2 italic text-concord-secondary bg-concord-secondary/30 py-2 rounded-r">
{children}
</blockquote>
),
p: ({ children }) => (
<p className="my-1 text-concord-primary">{children}</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-concord-primary">
{children}
</strong>
),
em: ({ children }) => (
<em className="italic text-concord-primary">{children}</em>
),
ul: ({ children }) => (
<ul className="list-disc list-inside my-2 text-concord-primary">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-inside my-2 text-concord-primary">
{children}
</ol>
),
h1: ({ children }) => (
<h1 className="text-xl font-bold my-2 text-concord-primary">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-bold my-2 text-concord-primary">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-bold my-2 text-concord-primary">
{children}
</h3>
),
a: ({ children, href }) => (
<a
href={href}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{message.content}
</ReactMarkdown>
</div>
</div>
{/* Message actions */}
{isHovered && (
<div className="absolute top-0 right-4 bg-concord-secondary border border-border rounded-md shadow-md flex">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 interactive-hover"
onClick={() => onReply?.(message.id)}
>
<Reply className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 interactive-hover"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(message.content)}
>
<Copy className="h-4 w-4 mr-2" />
Copy Text
</DropdownMenuItem>
{isOwnMessage && (
<>
<DropdownMenuItem onClick={() => onEdit?.(message.id)}>
<Edit className="h-4 w-4 mr-2" />
Edit Message
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete?.(message.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Message
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</div>
);
};
// Message Input Component
interface MessageInputProps {
channelName?: string;
onSendMessage: (content: string) => void;
replyingTo?: Message | null;
onCancelReply?: () => void;
replyingToUser: User | null;
}
const MessageInput: React.FC<MessageInputProps> = ({
channelName,
onSendMessage,
replyingTo,
onCancelReply,
replyingToUser,
}) => {
const [content, setContent] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);
// Auto-resize textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "auto";
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
}
}, [content]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim()) {
onSendMessage(content.trim());
setContent("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
formRef.current?.requestSubmit(); // <-- Programmatically submit form
}
};
return (
<div className="px-4 pb-4">
{replyingTo && replyingToUser && (
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-6 h-4 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyingToUser.nickname || replyingToUser.username}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-auto p-1 text-concord-secondary hover:text-concord-primary"
onClick={onCancelReply}
>
×
</Button>
</div>
<div className="text-sm text-concord-primary truncate pl-2">
{replyingTo.content.replace(/```[\s\S]*?```/g, "[code]")}
</div>
</div>
)}
<form ref={formRef} onSubmit={handleSubmit}>
<div className="relative">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Message #${channelName || "channel"}`}
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
style={{
minHeight: "44px",
maxHeight: "200px",
}}
/>
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
Press Enter to send Shift+Enter for new line
</div>
</div>
</form>
</div>
);
};
const ChatPage: React.FC = () => {
const { instanceId, channelId } = useParams();
const { instance } = useInstanceDetails(instanceId);
const { categories } = useChannels(instanceId);
const { data: instance } = useInstanceDetails(instanceId);
const categories = instance?.categories;
const { data: channelMessages } = useChannelMessages(channelId);
const { toggleMemberList, showMemberList } = useUiStore();
const { user: currentUser } = useAuthStore();
const { data: users } = useInstanceMembers(instanceId);
// State for messages and interactions
const [messages, setMessages] = useState<Message[]>([]);
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
// Use sample current user if none exists
const displayCurrentUser =
currentUser || users?.find((u) => u.id === "current");
// 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>
// Update messages when channel messages load
useEffect(() => {
if (channelMessages) {
setMessages(channelMessages.map((msg) => ({ ...msg, replyToId: null })));
}
}, [channelMessages]);
// {/* 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>
// );
// }
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
if (!currentChannel && channelId) {
// Require both instanceId and channelId for chat
if (!instanceId) {
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}!
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
No Channel Selected
</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>
<p>Select a channel from the sidebar to start chatting.</p>
</div>
</div>
);
}
} else if (!channelId || !currentChannel) {
const existingChannelId = categories
?.flatMap((cat) => cat.channels)
?.find((channel) => channel.position === 0)?.id;
if (existingChannelId)
navigate(`/channels/${instanceId}/${existingChannelId}`);
else
return (
<div className="flex-1 flex items-center justify-center bg-concord-primary">
<div className="text-center text-concord-secondary">
<h2 className="text-xl font-semibold mb-2 text-concord-primary">
No channels exist yet!
</h2>
<p>Ask an admin to create a channel</p>
</div>
</div>
);
}
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
// Message handlers
const handleSendMessage = (content: string) => {
if (!displayCurrentUser) return;
const newMessage: Message = {
id: Date.now().toString(),
content,
channelId: channelId || "",
userId: displayCurrentUser.id,
edited: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
replyTo: replyingTo || null,
};
setMessages((prev) => [...prev, newMessage]);
setReplyingTo(null);
};
const handleReply = (messageId: string) => {
const message = messages.find((m) => m.id === messageId);
if (message) {
setReplyingTo(message);
}
};
const handleEdit = (messageId: string) => {
// TODO: Implement edit functionality
console.log("Edit message:", messageId);
};
const handleDelete = (messageId: string) => {
setMessages((prev) => prev.filter((m) => m.id !== messageId));
};
// Group messages by user and time
const groupedMessages = messages.reduce((acc, message, index) => {
const prevMessage = index > 0 ? messages[index - 1] : null;
const isGrouped =
prevMessage &&
prevMessage.userId === message.userId &&
!message.replyTo && // Don't group replies
!prevMessage.replyTo && // Don't group if previous was a reply
new Date(message.createdAt).getTime() -
new Date(prevMessage.createdAt).getTime() <
5 * 60 * 1000; // 5 minutes
acc.push({ ...message, isGrouped });
return acc;
}, [] as Message[]);
return (
<div className="flex flex-col h-full">
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
{/* 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 justify-between px-4 py-3 border-b border-concord bg-concord-secondary">
<div className="flex items-center space-x-2">
<ChannelIcon size={20} className="text-gray-400" />
<span className="font-semibold text-white">
<ChannelIcon size={20} className="text-concord-secondary" />
<span className="font-semibold text-concord-primary">
{currentChannel?.name}
</span>
{currentChannel?.topic && (
{currentChannel?.description && (
<>
<div className="w-px h-4 bg-gray-600" />
<span className="text-sm text-gray-400 truncate max-w-xs">
{currentChannel.topic}
<div className="w-px h-4 bg-border" />
<span className="text-sm text-concord-secondary truncate max-w-xs">
{currentChannel.description}
</span>
</>
)}
</div>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 interactive-hover"
>
<Pin size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className={`h-8 w-8 ${showMemberList ? "text-white bg-gray-700" : "text-gray-400"}`}
className={`h-8 w-8 ${showMemberList ? "text-interactive-active bg-concord-tertiary" : "interactive-hover"}`}
onClick={toggleMemberList}
>
<Users size={16} />
@@ -123,66 +478,88 @@ const ChatPage: React.FC = () => {
<div className="w-40">
<Input
placeholder="Search"
className="h-8 bg-gray-700 border-none text-sm"
className="h-8 bg-concord-tertiary 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">
<div className="flex-1 flex flex-col overflow-hidden">
{/* 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>
<ScrollArea className="flex-1 min-h-0">
{/* Welcome Message */}
<div className="px-4 py-6 border-b border-concord/50 flex-shrink-0">
<div className="flex items-center space-x-3 mb-3">
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center">
<ChannelIcon size={24} className="text-primary-foreground" />
</div>
<div>
<h3 className="text-2xl font-bold text-concord-primary">
Welcome to #{currentChannel?.name}!
</h3>
</div>
</div>
{currentChannel?.description && (
<div className="text-concord-secondary bg-concord-secondary/50 p-3 rounded border-l-4 border-primary">
{currentChannel.description}
</div>
)}
</div>
<div className="pb-4">
{/* Messages */}
{groupedMessages.length > 0 ? (
<div>
{groupedMessages.map((message) => {
const user = users?.find((u) => u.id === message.userId);
const replyToMessage = messages.find(
(m) => m.id === message.replyTo?.id,
);
const replyToUser = replyToMessage?.user;
if (!user) return null;
return (
<MessageComponent
key={message.id}
message={message}
user={user}
currentUser={displayCurrentUser}
replyTo={replyToMessage}
onEdit={handleEdit}
onDelete={handleDelete}
onReply={handleReply}
replyToUser={replyToUser}
isGrouped={message.isGrouped}
/>
);
})}
</div>
) : (
<div className="flex items-center justify-center h-64">
<div className="text-center text-concord-secondary">
<p>No messages yet. Start the conversation!</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 ref={messagesEndRef} />
</div>
</div>
</ScrollArea>
{/* 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 className="flex-shrink-0">
<MessageInput
channelName={currentChannel?.name}
onSendMessage={handleSendMessage}
replyingTo={replyingTo}
onCancelReply={() => setReplyingTo(null)}
replyingToUser={
replyingTo
? users?.find((u) => u.id === replyingTo.userId) || null
: null
}
/>
</div>
</div>
</div>

View File

@@ -36,13 +36,14 @@ const LoginPage: React.FC = () => {
username,
nickname: username,
bio: "Test user",
picture: null,
banner: null,
picture: "",
banner: "",
hashPassword: "",
algorithms: "{}",
admin: false,
status: "online",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
roles: [],
},
"fake-token",
"fake-refresh-token",
@@ -56,20 +57,20 @@ const LoginPage: React.FC = () => {
};
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">
<div className="min-h-screen bg-concord-primary flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-concord-secondary border-concord">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold text-white">
<CardTitle className="text-2xl font-bold text-concord-primary">
Welcome back!
</CardTitle>
<CardDescription className="text-gray-400">
<CardDescription className="text-concord-secondary">
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">
<Label htmlFor="username" className="text-concord-primary">
Username
</Label>
<Input
@@ -77,13 +78,13 @@ const LoginPage: React.FC = () => {
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-gray-700 border-gray-600 text-white"
className="bg-concord-tertiary border-concord text-concord-primary"
placeholder="Enter your username"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">
<Label htmlFor="password" className="text-concord-primary">
Password
</Label>
<Input
@@ -91,7 +92,7 @@ const LoginPage: React.FC = () => {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-gray-700 border-gray-600 text-white"
className="bg-concord-tertiary border-concord text-concord-primary"
placeholder="Enter your password"
required
/>

View File

@@ -1,9 +1,14 @@
import { Button } from "@/components/ui/button";
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 className="min-h-screen bg-concord-primary flex items-center justify-center">
<div className="text-center text-concord-secondary">
<h1 className="text-4xl font-bold mb-4 text-concord-primary">404</h1>
<p className="text-xl mb-6">Page not found</p>
<Button asChild>
<a href="/channels/@me">Go Home</a>
</Button>
</div>
</div>
);

View File

@@ -1,9 +1,785 @@
const SettingsPage: React.FC = () => {
import React, { useState } from "react";
import { useParams } from "react-router";
import {
Palette,
User,
Shield,
Mic,
Settings,
ChevronRight,
Eye,
Moon,
Sun,
Monitor,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { ThemeSelector } from "@/components/theme-selector";
import { useTheme } from "@/components/theme-provider";
import { useAuthStore } from "@/stores/authStore";
import { Slider } from "@/components/ui/slider";
type SettingsSection = {
id: string;
title: string;
icon: React.ComponentType<{ className?: string }>;
description?: string;
};
const SETTINGS_SECTIONS: SettingsSection[] = [
{
id: "account",
title: "My Account",
icon: User,
description: "Profile, privacy, and account settings",
},
{
id: "appearance",
title: "Appearance",
icon: Palette,
description: "Themes, display, and accessibility",
},
{
id: "voice",
title: "Voice & Video",
icon: Mic,
description: "Audio and video settings",
},
];
const AppearanceSettings: React.FC = () => {
const {
currentLightTheme,
currentDarkTheme,
getThemesForMode,
mode,
setMode,
setTheme,
} = useTheme();
const [compactMode, setCompactMode] = useState(false);
const [showTimestamps, setShowTimestamps] = useState(true);
const [animationsEnabled, setAnimationsEnabled] = useState(true);
const [reduceMotion, setReduceMotion] = useState(false);
const [highContrast, setHighContrast] = useState(false);
const lightThemes = getThemesForMode("light");
const darkThemes = getThemesForMode("dark");
const getModeIcon = (themeMode: "light" | "dark" | "system") => {
switch (themeMode) {
case "light":
return <Sun className="h-4 w-4" />;
case "dark":
return <Moon className="h-4 w-4" />;
default:
return <Monitor className="h-4 w-4" />;
}
};
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 className="space-y-6 flex flex-col justify-center self-center items-center w-full">
{/* Theme Mode Selection */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getModeIcon(mode)}
Theme Mode
</CardTitle>
<CardDescription>
Choose between light, dark, or system preference.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<button
onClick={() => setMode("light")}
className={`p-3 rounded-lg border-2 transition-all text-left ${
mode === "light"
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-center mb-2">
<Sun className="h-6 w-6 text-yellow-500" />
</div>
<div className="text-center">
<div className="font-medium text-sm">Light</div>
<div className="text-xs text-muted-foreground">
Always use light theme
</div>
</div>
</button>
<button
onClick={() => setMode("dark")}
className={`p-3 rounded-lg border-2 transition-all text-left ${
mode === "dark"
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-center mb-2">
<Moon className="h-6 w-6 text-blue-400" />
</div>
<div className="text-center">
<div className="font-medium text-sm">Dark</div>
<div className="text-xs text-muted-foreground">
Always use dark theme
</div>
</div>
</button>
<button
onClick={() => setMode("system")}
className={`p-3 rounded-lg border-2 transition-all text-left ${
mode === "system"
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-center mb-2">
<Monitor className="h-6 w-6 text-gray-500" />
</div>
<div className="text-center">
<div className="font-medium text-sm">System</div>
<div className="text-xs text-muted-foreground">
Match system preference
</div>
</div>
</button>
</div>
</CardContent>
</Card>
{/* Theme Selection */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Theme Selection
</CardTitle>
<CardDescription>
Choose themes for light and dark mode. You can also create custom
themes.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Quick Theme Selector
</Label>
<p className="text-sm text-muted-foreground">
Access the theme selector with custom theme creation
</p>
</div>
<ThemeSelector />
</div>
{/* Current Theme Display */}
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 mb-2">
<Sun className="h-4 w-4 text-yellow-500" />
<Label className="text-sm font-medium">Light Theme</Label>
</div>
<div className="font-medium">{currentLightTheme.name}</div>
{currentLightTheme.description && (
<p className="text-xs text-muted-foreground mt-1">
{currentLightTheme.description}
</p>
)}
<div className="flex gap-1 mt-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentLightTheme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{
backgroundColor: currentLightTheme.colors.secondary,
}}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentLightTheme.colors.accent }}
/>
</div>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-center gap-2 mb-2">
<Moon className="h-4 w-4 text-blue-400" />
<Label className="text-sm font-medium">Dark Theme</Label>
</div>
<div className="font-medium">{currentDarkTheme.name}</div>
{currentDarkTheme.description && (
<p className="text-xs text-muted-foreground mt-1">
{currentDarkTheme.description}
</p>
)}
<div className="flex gap-1 mt-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentDarkTheme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentDarkTheme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: currentDarkTheme.colors.accent }}
/>
</div>
</div>
</div>
{/* Theme Grid */}
<div className="mt-6">
<Label className="text-sm font-medium">Available Themes</Label>
<div className="mt-3 grid grid-cols-2 gap-3">
{/* Light Themes */}
{lightThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentLightTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Sun className="h-4 w-4 text-yellow-500" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
{/* Dark Themes */}
{darkThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentDarkTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Moon className="h-4 w-4 text-blue-400" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
</div>
</div>
{/* Theme Stats */}
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{lightThemes.length}</div>
<div className="text-sm text-muted-foreground">Light Themes</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{darkThemes.length}</div>
<div className="text-sm text-muted-foreground">Dark Themes</div>
</div>
</div>
</CardContent>
</Card>
{/* Display Settings */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Display Settings
</CardTitle>
<CardDescription>
Customize how content is displayed in the app.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Compact Mode</Label>
<p className="text-sm text-muted-foreground">
Use less space between messages and interface elements
</p>
</div>
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Show Message Timestamps
</Label>
<p className="text-sm text-muted-foreground">
Display timestamps next to messages
</p>
</div>
<Switch
checked={showTimestamps}
onCheckedChange={setShowTimestamps}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Enable Animations</Label>
<p className="text-sm text-muted-foreground">
Play animations throughout the interface
</p>
</div>
<Switch
checked={animationsEnabled}
onCheckedChange={setAnimationsEnabled}
/>
</div>
</CardContent>
</Card>
{/* Accessibility */}
<Card className="w-full p-6">
<CardHeader>
<CardTitle>Accessibility</CardTitle>
<CardDescription>
Settings to improve accessibility and usability.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Reduce Motion</Label>
<p className="text-sm text-muted-foreground">
Reduce motion and animations for users with vestibular disorders
</p>
</div>
<Switch checked={reduceMotion} onCheckedChange={setReduceMotion} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">High Contrast</Label>
<p className="text-sm text-muted-foreground">
Increase contrast for better visibility
</p>
</div>
<Switch checked={highContrast} onCheckedChange={setHighContrast} />
</div>
</CardContent>
</Card>
</div>
);
};
const AccountSettings: React.FC = () => {
const { user } = useAuthStore();
const [username, setUsername] = useState(user?.username || "");
const [nickname, setNickname] = useState(user?.nickname || "");
const [bio, setBio] = useState(user?.bio || "");
const [email, setEmail] = useState("user@example.com");
const [isChanged, setIsChanged] = useState(false);
const handleSave = () => {
console.log("Saving profile changes:", { username, nickname, bio, email });
setIsChanged(false);
};
const handleChange = () => {
setIsChanged(true);
};
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Profile
</CardTitle>
<CardDescription>
Update your profile information and display settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
handleChange();
}}
className="max-w-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
value={username}
onChange={(e) => {
setUsername(e.target.value);
handleChange();
}}
className="max-w-sm"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="nickname">Display Name</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => {
setNickname(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="How others see your name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="bio">Bio</Label>
<Input
id="bio"
value={bio}
onChange={(e) => {
setBio(e.target.value);
handleChange();
}}
className="max-w-sm"
placeholder="Tell others about yourself"
/>
</div>
</div>
<div className="mt-6 flex gap-2">
<Button onClick={handleSave} disabled={!isChanged}>
Save Changes
</Button>
{isChanged && (
<Button
variant="outline"
onClick={() => {
setUsername(user?.username || "");
setNickname(user?.nickname || "");
setBio(user?.bio || "");
setEmail("user@example.com");
setIsChanged(false);
}}
>
Reset
</Button>
)}
</div>
</CardContent>
</Card>
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Privacy
</CardTitle>
<CardDescription>
Control who can contact you and see your information.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Allow Direct Messages
</Label>
<p className="text-sm text-muted-foreground">
Let other users send you direct messages
</p>
</div>
<Switch defaultChecked />
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">
Show Online Status
</Label>
<p className="text-sm text-muted-foreground">
Display when you're online to other users
</p>
</div>
<Switch defaultChecked />
</div>
</CardContent>
</Card>
</div>
);
};
const VoiceSettings: React.FC = () => {
const [inputVolume, setInputVolume] = useState(75);
const [outputVolume, setOutputVolume] = useState(100);
const [pushToTalk, setPushToTalk] = useState(false);
const [noiseSuppression, setNoiseSuppression] = useState(true);
const [echoCancellation, setEchoCancellation] = useState(true);
const [autoGainControl, setAutoGainControl] = useState(true);
return (
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-full">
<Card className="w-full p-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mic className="h-5 w-5" />
Voice Settings
</CardTitle>
<CardDescription>
Configure your microphone and audio settings.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Input Volume: {inputVolume}%</Label>
<Slider
defaultValue={[inputVolume]}
value={[inputVolume]}
max={100}
onValueChange={(v) => {
setInputVolume(v[0]);
}}
/>
</div>
<div className="space-y-2">
<Label>Output Volume: {outputVolume}%</Label>
<Slider
defaultValue={[outputVolume]}
value={[outputVolume]}
max={100}
onValueChange={(v) => {
setOutputVolume(v[0]);
}}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Push to Talk</Label>
<p className="text-sm text-muted-foreground">
Use a key to transmit voice instead of voice activity
</p>
</div>
<Switch checked={pushToTalk} onCheckedChange={setPushToTalk} />
</div>
</CardContent>
</Card>
<Card className="w-full p-6">
<CardHeader>
<CardTitle>Audio Processing</CardTitle>
<CardDescription>
Advanced audio processing features to improve call quality.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Noise Suppression</Label>
<p className="text-sm text-muted-foreground">
Reduce background noise during calls
</p>
</div>
<Switch
checked={noiseSuppression}
onCheckedChange={setNoiseSuppression}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Echo Cancellation</Label>
<p className="text-sm text-muted-foreground">
Prevent audio feedback and echo
</p>
</div>
<Switch
checked={echoCancellation}
onCheckedChange={setEchoCancellation}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label className="text-base font-medium">Auto Gain Control</Label>
<p className="text-sm text-muted-foreground">
Automatically adjust microphone sensitivity
</p>
</div>
<Switch
checked={autoGainControl}
onCheckedChange={setAutoGainControl}
/>
</div>
</CardContent>
</Card>
</div>
);
};
const SettingsPage: React.FC = () => {
const { section } = useParams();
const currentSection = section || "appearance";
const renderSettingsContent = () => {
switch (currentSection) {
case "account":
return <AccountSettings />;
case "appearance":
return <AppearanceSettings />;
case "voice":
return <VoiceSettings />;
default:
return <AppearanceSettings />;
}
};
return (
<div className="flex h-full">
{/* Sidebar */}
<div className="w-1/4 flex flex-col bg-concord-secondary border-r border-concord">
<div className="px-4 py-4 border-b border-concord">
<h1 className="text-2xl font-semibold text-concord-primary">
Settings
</h1>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
{SETTINGS_SECTIONS.map((settingsSection) => {
const Icon = settingsSection.icon;
const isActive = currentSection === settingsSection.id;
return (
<Button
key={settingsSection.id}
variant={isActive ? "secondary" : "ghost"}
className="w-full justify-start mb-1 h-auto p-2"
asChild
>
<a href={`/settings/${settingsSection.id}`}>
<Icon className="mr-2 h-4 w-4" />
<div className="flex-1 text-left">
<div className="font-medium">{settingsSection.title}</div>
{settingsSection.description && (
<div className="text-xs text-muted-foreground">
{settingsSection.description}
</div>
)}
</div>
<ChevronRight className="h-4 w-4" />
</a>
</Button>
);
})}
</div>
</ScrollArea>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="px-6 py-4 border-b border-concord">
<div className="flex items-center gap-2">
{(() => {
const section = SETTINGS_SECTIONS.find(
(s) => s.id === currentSection,
);
const Icon = section?.icon || Settings;
return <Icon className="h-5 w-5" />;
})()}
<h1 className="text-2xl font-bold text-concord-primary">
{SETTINGS_SECTIONS.find((s) => s.id === currentSection)?.title ||
"Settings"}
</h1>
</div>
</div>
{/* Content */}
<ScrollArea className="min-h-0 w-full">
<div className="p-6 flex w-full">{renderSettingsContent()}</div>
</ScrollArea>
</div>
</div>
);

View File

@@ -18,7 +18,7 @@ interface AuthState {
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
(set) => ({
user: null,
token: null,
refreshToken: null,

View File

@@ -6,13 +6,6 @@ interface UiState {
showMemberList: boolean;
sidebarCollapsed: boolean;
// Responsive
isMobile: boolean;
screenWidth: number;
// Theme
theme: "dark" | "light";
// Modal states
showUserSettings: boolean;
showServerSettings: boolean;
@@ -20,20 +13,16 @@ interface UiState {
showCreateServer: boolean;
showInviteModal: boolean;
// Chat states
// isTyping: boolean;
// typingUsers: string[];
// Navigation
activeChannelId: string | null;
activeInstanceId: string | null;
// Computed: Should show channel sidebar
shouldShowChannelSidebar: boolean;
// Actions
toggleMemberList: () => void;
toggleSidebar: () => void;
setTheme: (theme: "dark" | "light") => void;
setScreenWidth: (width: number) => void;
updateIsMobile: () => void;
// Modal actions
openUserSettings: () => void;
@@ -47,53 +36,50 @@ interface UiState {
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;
selectedChannelsByInstance: Record<string, string>;
setSelectedChannelForInstance: (
instanceId: string,
channelId: string,
) => void;
getSelectedChannelForInstance: (instanceId: string) => string | null;
updateSidebarVisibility: (pathname: string) => void;
}
// Helper function to determine if channel sidebar should be shown
const shouldShowChannelSidebar = (pathname: string): boolean => {
// Show channel sidebar for server pages (not settings, home, etc.)
const pathParts = pathname.split("/");
const isChannelsRoute = pathParts[1] === "channels";
const isSettingsRoute = pathname.includes("/settings");
return isChannelsRoute && !isSettingsRoute;
};
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,
shouldShowChannelSidebar: false,
selectedChannelsByInstance: {},
// 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 }),
@@ -107,23 +93,39 @@ export const useUiStore = create<UiState>()(
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 }),
setSelectedChannelForInstance: (instanceId, channelId) =>
set((state) => ({
selectedChannelsByInstance: {
...state.selectedChannelsByInstance,
[instanceId]: channelId,
},
})),
getSelectedChannelForInstance: (instanceId) => {
const state = get();
return state.selectedChannelsByInstance[instanceId] || null;
},
updateSidebarVisibility: (pathname) => {
const showChannelSidebar = shouldShowChannelSidebar(pathname);
const pathParts = pathname.split("/");
const instanceId = pathParts[2] || null;
const channelId = pathParts[3] || null;
set({
shouldShowChannelSidebar: showChannelSidebar,
activeInstanceId: instanceId,
activeChannelId: channelId,
});
// Store the selected channel for this instance if we have both
if (instanceId && channelId) {
get().setSelectedChannelForInstance(instanceId, channelId);
}
},
}),
{
name: "concord-ui-store",
@@ -131,7 +133,8 @@ export const useUiStore = create<UiState>()(
partialize: (state) => ({
showMemberList: state.showMemberList,
sidebarCollapsed: state.sidebarCollapsed,
theme: state.theme,
selectedChannelsByInstance: state.selectedChannelsByInstance,
}),
},
),

View File

@@ -1,4 +1,4 @@
import { Instance, Category, Channel, User, Role, Message } from "./database";
import { Instance, Category, Channel, User, Message } from "./database";
// API Response wrappers
export interface ApiResponse<T> {
@@ -30,18 +30,12 @@ export interface CategoryWithChannels extends Category {
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;

View File

@@ -1,7 +1,7 @@
export interface Instance {
id: string;
name: string;
icon?: string;
icon?: string | null;
description?: string;
createdAt: string;
updatedAt: string;
@@ -23,7 +23,7 @@ export interface Channel {
categoryId: string;
instanceId: string;
position: number;
topic?: string;
description?: string;
createdAt: string;
updatedAt: string;
}
@@ -33,24 +33,19 @@ export interface User {
username: string;
nickname?: string;
bio?: string;
picture?: string;
banner?: string;
picture?: string | null;
banner?: string | null;
hashPassword: string; // Won't be sent to client
admin: boolean;
status: "online" | "away" | "busy" | "offline";
createdAt: string;
updatedAt: string;
roles: Role[];
}
export interface Role {
id: string;
name: string;
color?: string;
permissions: string; // JSON string of permissions
instanceId: string;
position: number;
createdAt: string;
updatedAt: string;
role: "admin" | "mod" | "member";
}
export interface Message {
@@ -61,9 +56,13 @@ export interface Message {
edited: boolean;
createdAt: string;
updatedAt: string;
isGrouped?: boolean | null;
replyTo?: Message | null;
// Relations
user?: User;
channel?: Channel;
replyToId?: string | null;
}
// Direct messages