halfway commit to allow collaboration

This commit is contained in:
2025-09-28 05:58:29 -04:00
parent b79d3ac2cf
commit af8371ed84
34 changed files with 4418 additions and 1223 deletions

View 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>
);
};

View File

@@ -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}
/>
))}

View File

@@ -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>
);

View File

@@ -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>

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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;

View File

@@ -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,
}
};