halfway commit to allow collaboration
This commit is contained in:
276
concord-client/src/components/direct-dropdown.tsx
Normal file
276
concord-client/src/components/direct-dropdown.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
// Basic dropdown styles
|
||||
const dropdownStyles = {
|
||||
content: `
|
||||
min-w-[220px]
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-md
|
||||
p-1
|
||||
shadow-lg
|
||||
z-50
|
||||
animate-in
|
||||
fade-in-80
|
||||
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
|
||||
`,
|
||||
item: `
|
||||
relative
|
||||
flex
|
||||
cursor-pointer
|
||||
select-none
|
||||
items-center
|
||||
rounded-sm
|
||||
px-2
|
||||
py-1.5
|
||||
text-sm
|
||||
outline-none
|
||||
transition-colors
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:bg-gray-100 dark:focus:bg-gray-700
|
||||
data-[disabled]:pointer-events-none
|
||||
data-[disabled]:opacity-50
|
||||
`,
|
||||
separator: `
|
||||
-mx-1
|
||||
my-1
|
||||
h-px
|
||||
bg-gray-200 dark:bg-gray-700
|
||||
`,
|
||||
label: `
|
||||
px-2
|
||||
py-1.5
|
||||
text-sm
|
||||
font-semibold
|
||||
text-gray-500 dark:text-gray-400
|
||||
`,
|
||||
destructive: `
|
||||
text-red-600 dark:text-red-400
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
focus:bg-red-50 dark:focus:bg-red-900/20
|
||||
`,
|
||||
};
|
||||
|
||||
// Utility to combine class names
|
||||
const cn = (...classes: (string | undefined | false)[]) => {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
// Root dropdown menu
|
||||
export const DirectDropdownMenu = DropdownMenu.Root;
|
||||
export const DirectDropdownMenuTrigger = DropdownMenu.Trigger;
|
||||
|
||||
// Content component with styling
|
||||
interface DirectDropdownMenuContentProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Content> {
|
||||
className?: string;
|
||||
sideOffset?: number;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Content>,
|
||||
DirectDropdownMenuContentProps
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
dropdownStyles.content.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenu.Portal>
|
||||
));
|
||||
DirectDropdownMenuContent.displayName = "DirectDropdownMenuContent";
|
||||
|
||||
// Menu item component
|
||||
interface DirectDropdownMenuItemProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Item> {
|
||||
className?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Item>,
|
||||
DirectDropdownMenuItemProps
|
||||
>(({ className, destructive, ...props }, ref) => (
|
||||
<DropdownMenu.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.item.replace(/\s+/g, " ").trim(),
|
||||
destructive && dropdownStyles.destructive.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuItem.displayName = "DirectDropdownMenuItem";
|
||||
|
||||
// Separator component
|
||||
interface DirectDropdownMenuSeparatorProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Separator> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Separator>,
|
||||
DirectDropdownMenuSeparatorProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenu.Separator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.separator.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuSeparator.displayName = "DirectDropdownMenuSeparator";
|
||||
|
||||
// Label component
|
||||
interface DirectDropdownMenuLabelProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Label> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Label>,
|
||||
DirectDropdownMenuLabelProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenu.Label
|
||||
ref={ref}
|
||||
className={cn(dropdownStyles.label.replace(/\s+/g, " ").trim(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuLabel.displayName = "DirectDropdownMenuLabel";
|
||||
|
||||
// Checkbox item component
|
||||
interface DirectDropdownMenuCheckboxItemProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.CheckboxItem> {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.CheckboxItem>,
|
||||
DirectDropdownMenuCheckboxItemProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.item.replace(/\s+/g, " ").trim(),
|
||||
"pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
));
|
||||
DirectDropdownMenuCheckboxItem.displayName = "DirectDropdownMenuCheckboxItem";
|
||||
|
||||
// Sub menu components
|
||||
export const DirectDropdownMenuSub = DropdownMenu.Sub;
|
||||
|
||||
interface DirectDropdownMenuSubTriggerProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.SubTrigger> {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.SubTrigger>,
|
||||
DirectDropdownMenuSubTriggerProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenu.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(dropdownStyles.item.replace(/\s+/g, " ").trim(), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenu.SubTrigger>
|
||||
));
|
||||
DirectDropdownMenuSubTrigger.displayName = "DirectDropdownMenuSubTrigger";
|
||||
|
||||
interface DirectDropdownMenuSubContentProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.SubContent> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.SubContent>,
|
||||
DirectDropdownMenuSubContentProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenu.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.content.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuSubContent.displayName = "DirectDropdownMenuSubContent";
|
||||
|
||||
// Example usage component to test the dropdown
|
||||
export const DirectDropdownExample: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<DirectDropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DirectDropdownMenuTrigger asChild>
|
||||
<button className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
Open Menu
|
||||
</button>
|
||||
</DirectDropdownMenuTrigger>
|
||||
|
||||
<DirectDropdownMenuContent align="end" className="w-56">
|
||||
<DirectDropdownMenuLabel>My Account</DirectDropdownMenuLabel>
|
||||
<DirectDropdownMenuSeparator />
|
||||
|
||||
<DirectDropdownMenuItem
|
||||
onClick={() => console.log("Profile clicked")}
|
||||
>
|
||||
<span>Profile</span>
|
||||
</DirectDropdownMenuItem>
|
||||
|
||||
<DirectDropdownMenuItem
|
||||
onClick={() => console.log("Settings clicked")}
|
||||
>
|
||||
<span>Settings</span>
|
||||
</DirectDropdownMenuItem>
|
||||
|
||||
<DirectDropdownMenuSeparator />
|
||||
|
||||
<DirectDropdownMenuItem
|
||||
destructive
|
||||
onClick={() => console.log("Logout clicked")}
|
||||
>
|
||||
<span>Log out</span>
|
||||
</DirectDropdownMenuItem>
|
||||
</DirectDropdownMenuContent>
|
||||
</DirectDropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from "react";
|
||||
// src/components/layout/MemberList.tsx - Enhanced with role management
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { Crown, Shield, UserIcon } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Role } from "@/types/database";
|
||||
import { useInstanceMembers } from "@/hooks/useServers";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { User } from "@/types/database";
|
||||
import { UserRoleModal } from "@/components/modals/UserRoleModal";
|
||||
|
||||
// Status color utility
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -23,6 +27,9 @@ const getStatusColor = (status: string) => {
|
||||
|
||||
interface MemberItemProps {
|
||||
member: User;
|
||||
instanceId: string;
|
||||
currentUserRole: string;
|
||||
canManageRoles: boolean;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
@@ -43,61 +50,117 @@ const getRoleInfo = (role: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
|
||||
const { instanceId } = useParams();
|
||||
const MemberItem: React.FC<MemberItemProps> = ({
|
||||
member,
|
||||
instanceId,
|
||||
currentUserRole,
|
||||
canManageRoles,
|
||||
isOwner = false,
|
||||
}) => {
|
||||
const [showUserModal, setShowUserModal] = useState(false);
|
||||
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
|
||||
return (
|
||||
<div className="panel-button">
|
||||
<div className="relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
<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-sidebar ${getStatusColor(member.status)}`}
|
||||
/>
|
||||
</div>
|
||||
const handleMemberClick = () => {
|
||||
if (canManageRoles && !member.admin) {
|
||||
setShowUserModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwner && (
|
||||
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
{!isOwner && userRole !== "member" && (
|
||||
<Shield
|
||||
size={12}
|
||||
className="flex-shrink-0"
|
||||
style={{ color: roleInfo.color || "var(--background)" }}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
||||
onClick={handleMemberClick}
|
||||
disabled={!canManageRoles || member.admin}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
<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-sidebar ${getStatusColor(member.status)}`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: roleInfo.color || "var(--color-text-primary)" }}
|
||||
>
|
||||
{member.nickname || member.username}
|
||||
</span>
|
||||
</div>
|
||||
{member.bio && (
|
||||
<div className="text-xs text-concord-secondary truncate">
|
||||
{member.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwner && (
|
||||
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
{!isOwner && userRole !== "member" && (
|
||||
<Shield
|
||||
size={12}
|
||||
className="flex-shrink-0"
|
||||
style={{ color: roleInfo.color || "var(--background)" }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: roleInfo.color || "var(--color-text-primary)" }}
|
||||
>
|
||||
{member.nickname || member.username}
|
||||
</span>
|
||||
</div>
|
||||
{member.bio && (
|
||||
<div className="text-xs text-concord-secondary truncate">
|
||||
{member.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* User Role Modal */}
|
||||
<UserRoleModal
|
||||
isOpen={showUserModal}
|
||||
onClose={() => setShowUserModal(false)}
|
||||
user={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
canManageRoles={canManageRoles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberList: React.FC = () => {
|
||||
const { instanceId } = useParams();
|
||||
const { data: members, isLoading } = useInstanceMembers(instanceId);
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
// Check if current user can manage roles
|
||||
const canManageRoles = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return false;
|
||||
|
||||
// Global admins can manage roles
|
||||
if (currentUser.admin) return true;
|
||||
|
||||
// Check if user is admin or mod in this instance
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole && (userRole.role === "admin" || userRole.role === "mod");
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
const currentUserRole = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return "member";
|
||||
if (currentUser.admin) return "admin";
|
||||
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole?.role || "member";
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
if (!instanceId) {
|
||||
return null;
|
||||
@@ -177,6 +240,9 @@ const MemberList: React.FC = () => {
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
canManageRoles={canManageRoles}
|
||||
isOwner={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,34 +10,45 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import ServerIcon from "@/components/server/ServerIcon";
|
||||
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
|
||||
|
||||
const ServerSidebar: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { instanceId } = useParams();
|
||||
const { data: servers, isLoading } = useServers();
|
||||
const { data: allServers = [], isLoading } = useServers();
|
||||
const { openCreateServer, setActiveInstance, getSelectedChannelForInstance } =
|
||||
useUiStore();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
// Filter servers based on user permissions
|
||||
const accessibleServers = getAccessibleInstances(currentUser, allServers);
|
||||
const canCreateServer = isGlobalAdmin(currentUser);
|
||||
|
||||
const handleServerClick = (serverId: string) => {
|
||||
setActiveInstance(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 handleCreateServer = () => {
|
||||
if (canCreateServer) {
|
||||
openCreateServer();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="sidebar-primary flex flex-col items-center h-full py-2 space-y-2">
|
||||
@@ -47,8 +58,10 @@ const ServerSidebar: React.FC = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={` w-12 h-12 ml-0 ${
|
||||
!instanceId || instanceId === "@me" ? "active" : ""
|
||||
className={`w-12 h-12 ml-0 rounded-2xl hover:rounded-xl transition-all duration-200 ${
|
||||
!instanceId || instanceId === "@me"
|
||||
? "bg-primary text-primary-foreground rounded-xl"
|
||||
: "hover:bg-primary/10"
|
||||
}`}
|
||||
onClick={handleHomeClick}
|
||||
>
|
||||
@@ -56,7 +69,7 @@ const ServerSidebar: React.FC = () => {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Direct Messages</p>
|
||||
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -69,8 +82,8 @@ const ServerSidebar: React.FC = () => {
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
servers?.map((server) => (
|
||||
) : accessibleServers.length > 0 ? (
|
||||
accessibleServers.map((server) => (
|
||||
<Tooltip key={server.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
@@ -86,25 +99,43 @@ const ServerSidebar: React.FC = () => {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))
|
||||
)}
|
||||
) : currentUser ? (
|
||||
<div className="text-center py-4 px-2">
|
||||
<div className="text-xs text-concord-secondary mb-2">
|
||||
No servers available
|
||||
</div>
|
||||
{canCreateServer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={handleCreateServer}
|
||||
>
|
||||
Create One
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Add Server Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Add Server Button - Only show if user can create servers */}
|
||||
{canCreateServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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={handleCreateServer}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { SAMPLE_USERS } from "@/hooks/useServers";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
|
||||
// Status color utility
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -46,6 +46,8 @@ const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
|
||||
onStatusChange,
|
||||
children,
|
||||
}) => {
|
||||
const { mutate: logout } = useLogout();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "online", label: "Online", color: "bg-status-online" },
|
||||
{ value: "away", label: "Away", color: "bg-status-away" },
|
||||
@@ -79,7 +81,7 @@ const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => useAuthStore.getState().logout()}
|
||||
onClick={() => logout()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Log Out
|
||||
@@ -212,12 +214,19 @@ const UserPanel: React.FC = () => {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
|
||||
const displayUser = user || SAMPLE_USERS.find((u) => u.id === "current");
|
||||
|
||||
if (!displayUser) {
|
||||
// If no authenticated user, show login prompt
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex-shrink-0 p-2 bg-concord-tertiary">
|
||||
<div className="text-concord-secondary text-sm">No user data</div>
|
||||
<div className="flex-shrink-0 p-2 bg-concord-tertiary border-t border-sidebar">
|
||||
<div className="text-center text-concord-secondary text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => (window.location.href = "/login")}
|
||||
>
|
||||
Login Required
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +234,7 @@ const UserPanel: React.FC = () => {
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
console.log("Status change to:", newStatus);
|
||||
// TODO: Implement API call to update user status
|
||||
// You can add a useUpdateUserStatus hook here
|
||||
};
|
||||
|
||||
const handleMuteToggle = () => setIsMuted(!isMuted);
|
||||
@@ -240,20 +250,20 @@ const UserPanel: React.FC = () => {
|
||||
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
||||
{/* User Info with Dropdown */}
|
||||
<UserStatusDropdown
|
||||
currentStatus={displayUser.status}
|
||||
currentStatus={user.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" />
|
||||
<UserAvatar user={user} 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}
|
||||
{user.nickname || user.username}
|
||||
</div>
|
||||
<div className="text-xs text-concord-secondary truncate capitalize">
|
||||
{displayUser.status}
|
||||
{user.status}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
154
concord-client/src/components/modals/CreateChannelModal.tsx
Normal file
154
concord-client/src/components/modals/CreateChannelModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Hash, Volume2 } from "lucide-react";
|
||||
import { useCreateChannel } from "@/hooks/useServers";
|
||||
import { CategoryWithChannels } from "@/types/api";
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: CategoryWithChannels[];
|
||||
defaultCategoryId?: string;
|
||||
}
|
||||
|
||||
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
defaultCategoryId,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
||||
|
||||
const createChannelMutation = useCreateChannel();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !categoryId) return;
|
||||
|
||||
try {
|
||||
await createChannelMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create channel:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Channel</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Channel Type</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "text" ? "default" : "outline"}
|
||||
onClick={() => setType("text")}
|
||||
className="flex-1"
|
||||
>
|
||||
<Hash className="h-4 w-4 mr-2" />
|
||||
Text
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "voice" ? "default" : "outline"}
|
||||
onClick={() => setType("voice")}
|
||||
className="flex-1"
|
||||
>
|
||||
<Volume2 className="h-4 w-4 mr-2" />
|
||||
Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="awesome-channel"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description</Label>
|
||||
<Textarea
|
||||
id="channel-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this channel about?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!name.trim() || !categoryId || createChannelMutation.isPending
|
||||
}
|
||||
>
|
||||
{createChannelMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Channel"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
92
concord-client/src/components/modals/CreateServerModal.tsx
Normal file
92
concord-client/src/components/modals/CreateServerModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCreateInstance } from "@/hooks/useServers";
|
||||
|
||||
interface CreateServerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CreateServerModal: React.FC<CreateServerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
|
||||
const createInstanceMutation = useCreateInstance();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
try {
|
||||
await createInstanceMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName("");
|
||||
setIcon("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create server:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Server</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Awesome Server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-icon">Server Icon URL (optional)</Label>
|
||||
<Input
|
||||
id="server-icon"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!name.trim() || createInstanceMutation.isPending}
|
||||
>
|
||||
{createInstanceMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Server"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
121
concord-client/src/components/modals/EditMessageModal.tsx
Normal file
121
concord-client/src/components/modals/EditMessageModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Message } from "@/lib/api-client";
|
||||
import { useEditMessage } from "@/hooks/useMessages";
|
||||
|
||||
interface EditMessageModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export const EditMessageModal: React.FC<EditMessageModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
channelId,
|
||||
}) => {
|
||||
const [content, setContent] = useState(message.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editMessageMutation = useEditMessage();
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isOpen) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [content, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && content.trim() !== message.text) {
|
||||
try {
|
||||
await editMessageMutation.mutateAsync({
|
||||
messageId: message.id,
|
||||
content: content.trim(),
|
||||
channelId,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(message.text);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Message</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message-content">Message</Label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="message-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
style={{
|
||||
minHeight: "100px",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
disabled={editMessageMutation.isPending}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press Enter to save • Shift+Enter for new line • Escape to cancel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={editMessageMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!content.trim() ||
|
||||
content.trim() === message.text ||
|
||||
editMessageMutation.isPending
|
||||
}
|
||||
>
|
||||
{editMessageMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
101
concord-client/src/components/modals/MessageActionsModal.tsx
Normal file
101
concord-client/src/components/modals/MessageActionsModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Edit, Trash2, Pin, Reply } from "lucide-react";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
interface MessageActionsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
isOwnMessage: boolean;
|
||||
canDelete: boolean; // For mods/admins
|
||||
onEdit?: (messageId: string) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (messageId: string) => void;
|
||||
onPin?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
isOwnMessage,
|
||||
canDelete,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onPin,
|
||||
}) => {
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[300px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Actions</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onReply?.(message.id))}
|
||||
>
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() =>
|
||||
handleAction(() => navigator.clipboard.writeText(message.text))
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Text
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onPin?.(message.id))}
|
||||
>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
Pin Message
|
||||
</Button>
|
||||
|
||||
{isOwnMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onEdit?.(message.id))}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Message
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isOwnMessage || canDelete) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => handleAction(() => onDelete?.(message.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Message
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
155
concord-client/src/components/modals/PinnedMessagesModal.tsx
Normal file
155
concord-client/src/components/modals/PinnedMessagesModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Pin, X, ExternalLink } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Message } from "@/types/database";
|
||||
import { usePinnedMessages, usePinMessage } from "@/hooks/useMessages";
|
||||
|
||||
interface PinnedMessagesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
canManagePins: boolean;
|
||||
}
|
||||
|
||||
export const PinnedMessagesModal: React.FC<PinnedMessagesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
channelId,
|
||||
channelName,
|
||||
canManagePins,
|
||||
}) => {
|
||||
const { data: pinnedMessages, isLoading } = usePinnedMessages(channelId);
|
||||
const pinMessageMutation = usePinMessage();
|
||||
|
||||
const handleUnpin = async (messageId: string) => {
|
||||
try {
|
||||
await pinMessageMutation.mutateAsync({
|
||||
messageId,
|
||||
channelId,
|
||||
pinned: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToMessage = (messageId: string) => {
|
||||
// TODO: Implement jumping to message in chat
|
||||
console.log("Jumping to message:", messageId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5" />
|
||||
Pinned Messages in #{channelName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5" />
|
||||
Pinned Messages in #{channelName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!pinnedMessages || pinnedMessages.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Pin className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No pinned messages yet</p>
|
||||
<p className="text-sm">
|
||||
Pin important messages to keep them accessible
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[50vh]">
|
||||
<div className="space-y-4">
|
||||
{pinnedMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="border border-border rounded-lg p-4 bg-concord-secondary/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={message.user?.picture || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{message.user?.userName?.slice(0, 2).toUpperCase() ||
|
||||
"??"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{message.user?.nickName ||
|
||||
message.user?.userName ||
|
||||
"Unknown User"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(message.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-concord-primary leading-relaxed break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleJumpToMessage(message.id)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{canManagePins && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleUnpin(message.id)}
|
||||
disabled={pinMessageMutation.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
262
concord-client/src/components/modals/UserRoleModal.tsx
Normal file
262
concord-client/src/components/modals/UserRoleModal.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Crown, Shield, User, AlertTriangle } from "lucide-react";
|
||||
import { User as UserType } from "@/types/database";
|
||||
|
||||
interface UserRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: UserType;
|
||||
instanceId: string;
|
||||
currentUserRole: string;
|
||||
canManageRoles: boolean;
|
||||
}
|
||||
|
||||
export const UserRoleModal: React.FC<UserRoleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
instanceId,
|
||||
currentUserRole,
|
||||
canManageRoles,
|
||||
}) => {
|
||||
const userInstanceRole =
|
||||
user.roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
||||
const [selectedRole, setSelectedRole] = useState(userInstanceRole);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: "member",
|
||||
label: "Member",
|
||||
icon: User,
|
||||
description: "Standard server access",
|
||||
},
|
||||
{
|
||||
value: "mod",
|
||||
label: "Moderator",
|
||||
icon: Shield,
|
||||
description: "Can moderate channels and manage messages",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin",
|
||||
icon: Crown,
|
||||
description: "Full server management access",
|
||||
},
|
||||
];
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "destructive";
|
||||
case "mod":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async () => {
|
||||
if (selectedRole === userInstanceRole || !canManageRoles) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
// TODO: Implement actual role update API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
console.log(
|
||||
`Updating ${user.username}'s role to ${selectedRole} in instance ${instanceId}`,
|
||||
);
|
||||
// await userClient.updateRole({
|
||||
// userId: user.id,
|
||||
// instanceId,
|
||||
// role: selectedRole,
|
||||
// requestingUserId: currentUser.id,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update role:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKickUser = async () => {
|
||||
if (!canManageRoles) return;
|
||||
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to kick ${user.nickname || user.username} from this server?`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement kick user API call
|
||||
console.log(`Kicking ${user.username} from instance ${instanceId}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to kick user:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canModifyUser =
|
||||
canManageRoles && !user.admin && userInstanceRole !== "admin";
|
||||
const isOwnProfile = false; // TODO: Check if this is the current user's profile
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={user.picture || undefined} />
|
||||
<AvatarFallback>
|
||||
{user.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{user.nickname || user.username}
|
||||
</span>
|
||||
{user.admin && <Crown className="h-4 w-4 text-yellow-500" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getRoleColor(userInstanceRole)}>
|
||||
{userInstanceRole}
|
||||
</Badge>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
user.status === "online"
|
||||
? "bg-green-500"
|
||||
: user.status === "away"
|
||||
? "bg-yellow-500"
|
||||
: user.status === "busy"
|
||||
? "bg-red-500"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{user.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.bio && (
|
||||
<div className="text-sm text-muted-foreground">{user.bio}</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Role Management */}
|
||||
{canModifyUser && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Server Role</Label>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((role) => {
|
||||
const Icon = role.icon;
|
||||
return (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-medium">{role.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleUpdateRole}
|
||||
disabled={selectedRole === userInstanceRole || isUpdating}
|
||||
className="flex-1"
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update Role"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<Label className="text-destructive">Danger Zone</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleKickUser}
|
||||
className="w-full"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Kick from Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Only Mode */}
|
||||
{!canModifyUser && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user.admin
|
||||
? "System administrators cannot be modified"
|
||||
: "You don't have permission to modify this user"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={onClose} className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
83
concord-client/src/components/modals/UserStatusModal.tsx
Normal file
83
concord-client/src/components/modals/UserStatusModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, LogOut } from "lucide-react";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
|
||||
interface UserStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentStatus: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
}
|
||||
|
||||
export const UserStatusModal: React.FC<UserStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStatus,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const { mutate: logout } = useLogout();
|
||||
const { openUserSettings } = useUiStore();
|
||||
|
||||
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 handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[250px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Status & Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{statusOptions.map((status) => (
|
||||
<Button
|
||||
key={status.value}
|
||||
variant={currentStatus === status.value ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onStatusChange(status.value))}
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${status.color} mr-2`} />
|
||||
{status.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => openUserSettings())}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
User Settings
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => handleAction(() => logout())}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,30 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Instance } from "@/types/database";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "../ui/dialog";
|
||||
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Category } from "@/types";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import {
|
||||
useCreateCategory,
|
||||
useCreateChannel,
|
||||
useCreateInstance,
|
||||
} from "@/hooks/useServers";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
interface ServerIconProps {
|
||||
server: Instance;
|
||||
@@ -44,7 +68,7 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
<img
|
||||
src={server.icon}
|
||||
alt={server.name}
|
||||
className="w-full h-full object-cover rounded-inherit"
|
||||
className={`w-full h-full object-cover ${isActive ? "rounded-xl" : "rounded-2xl"}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-semibold text-sm">
|
||||
@@ -56,4 +80,308 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Create Server Modal
|
||||
export const CreateServerModal: React.FC = () => {
|
||||
const { showCreateServer, closeCreateServer } = useUiStore();
|
||||
const { mutate: createInstance, isPending } = useCreateInstance();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
createInstance(
|
||||
{
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create server:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateServer} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new server to chat with friends and communities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter server name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="server-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this server about?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-icon">Server Icon URL (Optional)</Label>
|
||||
<Input
|
||||
id="server-icon"
|
||||
type="url"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Server"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Channel Modal
|
||||
export const CreateChannelModal: React.FC = () => {
|
||||
const { showCreateChannel, closeCreateChannel } = useUiStore();
|
||||
const { mutate: createChannel, isPending } = useCreateChannel();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
// You'd need to get categories for the current instance
|
||||
// This is a simplified version
|
||||
const categories: Category[] = []; // Get from context or props
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && categoryId) {
|
||||
createChannel(
|
||||
{
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create channel:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateChannel} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new text or voice channel in this server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-type">Channel Type</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value: "text" | "voice") => setType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text Channel</SelectItem>
|
||||
<SelectItem value="voice">Voice Channel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-category">Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter channel name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="channel-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this channel for?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Category Modal
|
||||
export const CreateCategoryModal: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: createCategory, isPending } = useCreateCategory();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && instanceId) {
|
||||
createCategory(
|
||||
{
|
||||
name: name.trim(),
|
||||
instanceId,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create category:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new category to organize your channels.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category-name">Category Name</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Category"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminModals: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<CreateServerModal />
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ServerIcon;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -26,7 +26,7 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -41,12 +41,12 @@ function DropdownMenuContent({
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -54,7 +54,7 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@@ -63,8 +63,8 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -73,11 +73,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -146,7 +146,7 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@@ -154,11 +154,11 @@ function DropdownMenuLabel({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -171,7 +171,7 @@ function DropdownMenuSeparator({
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
@@ -183,17 +183,17 @@ function DropdownMenuShortcut({
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -229,11 +229,11 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -252,4 +252,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user