This commit is contained in:
2025-09-28 07:46:11 -04:00
parent d55049dfc4
commit a5e7fa4bc6
17 changed files with 687 additions and 1096 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
@@ -16,21 +16,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Hash, Volume2 } from "lucide-react";
import { Hash, Volume2, Loader2 } from "lucide-react";
import { useCreateChannel } from "@/hooks/useServers";
import { useCategoriesByInstance } from "@/hooks/useCategories"; // New hook
import { CategoryWithChannels } from "@/types/api";
interface CreateChannelModalProps {
isOpen: boolean;
onClose: () => void;
categories: CategoryWithChannels[];
instanceId: string; // Changed to use instanceId instead of categories prop
defaultCategoryId?: string;
}
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
isOpen,
onClose,
categories,
instanceId,
defaultCategoryId,
}) => {
const [name, setName] = useState("");
@@ -38,8 +39,35 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
const [type, setType] = useState<"text" | "voice">("text");
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
// Fetch categories using the new API
const {
data: categories,
isLoading: categoriesLoading,
error: categoriesError,
} = useCategoriesByInstance(instanceId);
const createChannelMutation = useCreateChannel();
// Update categoryId when defaultCategoryId changes or categories load
useEffect(() => {
if (defaultCategoryId) {
setCategoryId(defaultCategoryId);
} else if (categories && categories.length > 0 && !categoryId) {
// Auto-select first category if none selected
setCategoryId(categories[0].id);
}
}, [defaultCategoryId, categories, categoryId]);
// Reset form when modal opens/closes
useEffect(() => {
if (!isOpen) {
setName("");
setDescription("");
setType("text");
setCategoryId(defaultCategoryId || "");
}
}, [isOpen, defaultCategoryId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !categoryId) return;
@@ -69,6 +97,13 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
<DialogHeader>
<DialogTitle>Create Channel</DialogTitle>
</DialogHeader>
{categoriesError && (
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
Failed to load categories. Please try again.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Channel Type</Label>
@@ -118,16 +153,40 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
<div className="space-y-2">
<Label>Category</Label>
<Select value={categoryId} onValueChange={setCategoryId} required>
<Select
value={categoryId}
onValueChange={setCategoryId}
required
disabled={categoriesLoading}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
<SelectValue
placeholder={
categoriesLoading
? "Loading categories..."
: "Select a category"
}
/>
</SelectTrigger>
<SelectContent>
{categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
{categoriesLoading ? (
<SelectItem value="loading" disabled>
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
</SelectItem>
))}
) : categories && categories.length > 0 ? (
categories.map((category) => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))
) : (
<SelectItem value="no-categories" disabled>
No categories available
</SelectItem>
)}
</SelectContent>
</Select>
</div>
@@ -139,12 +198,22 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
<Button
type="submit"
disabled={
!name.trim() || !categoryId || createChannelMutation.isPending
!name.trim() ||
!categoryId ||
createChannelMutation.isPending ||
categoriesLoading ||
categoryId === "loading" ||
categoryId === "no-categories"
}
>
{createChannelMutation.isPending
? "Creating..."
: "Create Channel"}
{createChannelMutation.isPending ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Creating...
</div>
) : (
"Create Channel"
)}
</Button>
</div>
</form>

View File

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

@@ -6,7 +6,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Copy, Edit, Trash2, Pin, Reply } from "lucide-react";
import { Copy, Reply } from "lucide-react";
import { Message } from "@/lib/api-client";
interface MessageActionsModalProps {
@@ -14,23 +14,14 @@ interface MessageActionsModalProps {
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();
@@ -63,37 +54,6 @@ export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
<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

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

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

@@ -1,83 +0,0 @@
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>
);
};