ui: theme system, routing fixes, major overhaul, still need to fix dropdowns
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
254
concord-client/src/components/message/Message.tsx
Normal file
254
concord-client/src/components/message/Message.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
419
concord-client/src/components/theme-selector.tsx
Normal file
419
concord-client/src/components/theme-selector.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
46
concord-client/src/components/ui/badge.tsx
Normal file
46
concord-client/src/components/ui/badge.tsx
Normal 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 }
|
||||
141
concord-client/src/components/ui/dialog.tsx
Normal file
141
concord-client/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
183
concord-client/src/components/ui/select.tsx
Normal file
183
concord-client/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
28
concord-client/src/components/ui/separator.tsx
Normal file
28
concord-client/src/components/ui/separator.tsx
Normal 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 }
|
||||
61
concord-client/src/components/ui/slider.tsx
Normal file
61
concord-client/src/components/ui/slider.tsx
Normal 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 }
|
||||
29
concord-client/src/components/ui/switch.tsx
Normal file
29
concord-client/src/components/ui/switch.tsx
Normal 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 }
|
||||
18
concord-client/src/components/ui/textarea.tsx
Normal file
18
concord-client/src/components/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user