diff --git a/concord-client/src/components/layout/ChannelSidebar.tsx b/concord-client/src/components/layout/ChannelSidebar.tsx index e16d933..3968960 100644 --- a/concord-client/src/components/layout/ChannelSidebar.tsx +++ b/concord-client/src/components/layout/ChannelSidebar.tsx @@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { useInstanceDetails } from "@/hooks/useServers"; import { useUiStore } from "@/stores/uiStore"; import ChannelList from "@/components/channel/ChannelList"; +import { CreateCategoryModal, CreateChannelModal } from "../server/ServerIcon"; const ChannelSidebar: React.FC = () => { const { instanceId } = useParams(); @@ -94,6 +95,8 @@ const ChannelSidebar: React.FC = () => { + + ); }; diff --git a/concord-client/src/components/layout/MemberList.tsx b/concord-client/src/components/layout/MemberList.tsx index cfd5422..efa426d 100644 --- a/concord-client/src/components/layout/MemberList.tsx +++ b/concord-client/src/components/layout/MemberList.tsx @@ -9,7 +9,6 @@ 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) => { @@ -28,8 +27,6 @@ const getStatusColor = (status: string) => { interface MemberItemProps { member: User; instanceId: string; - currentUserRole: string; - canManageRoles: boolean; isOwner?: boolean; } @@ -53,27 +50,17 @@ const getRoleInfo = (role: string) => { const MemberItem: React.FC = ({ member, instanceId, - currentUserRole, - canManageRoles, isOwner = false, }) => { - const [showUserModal, setShowUserModal] = useState(false); const userRole = getUserRoleForInstance(member.roles, instanceId || ""); const roleInfo = getRoleInfo(userRole); - const handleMemberClick = () => { - if (canManageRoles && !member.admin) { - setShowUserModal(true); - } - }; - return ( <> @@ -119,16 +106,6 @@ const MemberItem: React.FC = ({ - - {/* User Role Modal */} - setShowUserModal(false)} - user={member} - instanceId={instanceId} - currentUserRole={currentUserRole} - canManageRoles={canManageRoles} - /> > ); }; @@ -138,20 +115,6 @@ const MemberList: React.FC = () => { 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"; @@ -212,12 +175,9 @@ const MemberList: React.FC = () => { {/* Header */} - - - Members: {members.length} Online:{" "} - {members.filter((m) => m.status === "online").length} - - + + {members.length} Members + {/* Member List */} @@ -242,7 +202,6 @@ const MemberList: React.FC = () => { member={member} instanceId={instanceId} currentUserRole={currentUserRole} - canManageRoles={canManageRoles} isOwner={false} /> ))} diff --git a/concord-client/src/components/layout/ServerSidebar.tsx b/concord-client/src/components/layout/ServerSidebar.tsx index 650a3d2..35a342d 100644 --- a/concord-client/src/components/layout/ServerSidebar.tsx +++ b/concord-client/src/components/layout/ServerSidebar.tsx @@ -13,6 +13,7 @@ import { useUiStore } from "@/stores/uiStore"; import { useAuthStore } from "@/stores/authStore"; import ServerIcon from "@/components/server/ServerIcon"; import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions"; +import { CreateServerModal } from "../modals/CreateServerModal"; const ServerSidebar: React.FC = () => { const navigate = useNavigate(); @@ -40,7 +41,7 @@ const ServerSidebar: React.FC = () => { const handleHomeClick = () => { setActiveInstance(null); - navigate("/channels/@me"); + navigate("/"); }; const handleCreateServer = () => { diff --git a/concord-client/src/components/modals/CreateChannelModal.tsx b/concord-client/src/components/modals/CreateChannelModal.tsx index 177d318..dc2ab42 100644 --- a/concord-client/src/components/modals/CreateChannelModal.tsx +++ b/concord-client/src/components/modals/CreateChannelModal.tsx @@ -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 = ({ isOpen, onClose, - categories, + instanceId, defaultCategoryId, }) => { const [name, setName] = useState(""); @@ -38,8 +39,35 @@ export const CreateChannelModal: React.FC = ({ 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 = ({ Create Channel + + {categoriesError && ( + + Failed to load categories. Please try again. + + )} + Channel Type @@ -118,16 +153,40 @@ export const CreateChannelModal: React.FC = ({ Category - + - + - {categories.map((category) => ( - - {category.name} + {categoriesLoading ? ( + + + + Loading... + - ))} + ) : categories && categories.length > 0 ? ( + categories.map((category) => ( + + {category.name} + + )) + ) : ( + + No categories available + + )} @@ -139,12 +198,22 @@ export const CreateChannelModal: React.FC = ({ - {createChannelMutation.isPending - ? "Creating..." - : "Create Channel"} + {createChannelMutation.isPending ? ( + + + Creating... + + ) : ( + "Create Channel" + )} diff --git a/concord-client/src/components/modals/EditMessageModal.tsx b/concord-client/src/components/modals/EditMessageModal.tsx deleted file mode 100644 index 9045bce..0000000 --- a/concord-client/src/components/modals/EditMessageModal.tsx +++ /dev/null @@ -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 = ({ - isOpen, - onClose, - message, - channelId, -}) => { - const [content, setContent] = useState(message.text); - const textareaRef = useRef(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 ( - - - - Edit Message - - - - Message - 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 - /> - - Press Enter to save • Shift+Enter for new line • Escape to cancel - - - - - - Cancel - - - {editMessageMutation.isPending ? "Saving..." : "Save Changes"} - - - - - - ); -}; diff --git a/concord-client/src/components/modals/MessageActionsModal.tsx b/concord-client/src/components/modals/MessageActionsModal.tsx index a995e92..b9e229a 100644 --- a/concord-client/src/components/modals/MessageActionsModal.tsx +++ b/concord-client/src/components/modals/MessageActionsModal.tsx @@ -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 = ({ isOpen, onClose, message, - isOwnMessage, - canDelete, - onEdit, - onDelete, onReply, - onPin, }) => { const handleAction = (action: () => void) => { action(); @@ -63,37 +54,6 @@ export const MessageActionsModal: React.FC = ({ Copy Text - - handleAction(() => onPin?.(message.id))} - > - - Pin Message - - - {isOwnMessage && ( - handleAction(() => onEdit?.(message.id))} - > - - Edit Message - - )} - - {(isOwnMessage || canDelete) && ( - handleAction(() => onDelete?.(message.id))} - > - - Delete Message - - )} diff --git a/concord-client/src/components/modals/PinnedMessagesModal.tsx b/concord-client/src/components/modals/PinnedMessagesModal.tsx deleted file mode 100644 index a7ff027..0000000 --- a/concord-client/src/components/modals/PinnedMessagesModal.tsx +++ /dev/null @@ -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 = ({ - 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 ( - - - - - - Pinned Messages in #{channelName} - - - - - - - - ); - } - - return ( - - - - - - Pinned Messages in #{channelName} - - - - {!pinnedMessages || pinnedMessages.length === 0 ? ( - - - No pinned messages yet - - Pin important messages to keep them accessible - - - ) : ( - - - {pinnedMessages.map((message) => ( - - - - - - {message.user?.userName?.slice(0, 2).toUpperCase() || - "??"} - - - - - - - {message.user?.nickName || - message.user?.userName || - "Unknown User"} - - - {formatDistanceToNow(new Date(message.createdAt), { - addSuffix: true, - })} - - - - - {message.content} - - - - - handleJumpToMessage(message.id)} - > - - - - {canManagePins && ( - handleUnpin(message.id)} - disabled={pinMessageMutation.isPending} - > - - - )} - - - - ))} - - - )} - - - ); -}; diff --git a/concord-client/src/components/modals/UserRoleModal.tsx b/concord-client/src/components/modals/UserRoleModal.tsx deleted file mode 100644 index 6e7b9b9..0000000 --- a/concord-client/src/components/modals/UserRoleModal.tsx +++ /dev/null @@ -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 = ({ - 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 ( - - - - Manage User - - - - {/* User Info */} - - - - - {user.username.slice(0, 2).toUpperCase()} - - - - - - - {user.nickname || user.username} - - {user.admin && } - - - - {userInstanceRole} - - - - {user.status} - - - - - - {user.bio && ( - {user.bio} - )} - - - - {/* Role Management */} - {canModifyUser && ( - - - Server Role - - - - - - {roleOptions.map((role) => { - const Icon = role.icon; - return ( - - - - - {role.label} - - {role.description} - - - - - ); - })} - - - - - - - {isUpdating ? "Updating..." : "Update Role"} - - - Cancel - - - - - - {/* Danger Zone */} - - - - Danger Zone - - - Kick from Server - - - - )} - - {/* View Only Mode */} - {!canModifyUser && ( - - - {user.admin - ? "System administrators cannot be modified" - : "You don't have permission to modify this user"} - - - Close - - - )} - - - - ); -}; diff --git a/concord-client/src/components/modals/UserStatusModal.tsx b/concord-client/src/components/modals/UserStatusModal.tsx deleted file mode 100644 index 8202e4a..0000000 --- a/concord-client/src/components/modals/UserStatusModal.tsx +++ /dev/null @@ -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 = ({ - 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 ( - - - - Status & Settings - - - {statusOptions.map((status) => ( - handleAction(() => onStatusChange(status.value))} - > - - {status.label} - - ))} - - - handleAction(() => openUserSettings())} - > - - User Settings - - - handleAction(() => logout())} - > - - Log Out - - - - - - ); -}; diff --git a/concord-client/src/components/theme-selector.tsx b/concord-client/src/components/theme-selector.tsx index bf048e2..d255b63 100644 --- a/concord-client/src/components/theme-selector.tsx +++ b/concord-client/src/components/theme-selector.tsx @@ -1,15 +1,14 @@ import React, { useState } from "react"; -import { Moon, Sun, Monitor, Palette, Plus, Trash2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuLabel, - DropdownMenuGroup, -} from "@/components/ui/dropdown-menu"; + Moon, + Sun, + Monitor, + Palette, + Plus, + Trash2, + Settings, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -17,6 +16,7 @@ import { DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -29,6 +29,8 @@ import { SelectValue, } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; import { useTheme, ThemeDefinition, @@ -132,101 +134,107 @@ const CreateThemeModal: React.FC<{ - - {/* Basic Info */} - - - - Name - - setName(e.target.value)} - className="col-span-3" - placeholder="My Custom Theme" - /> + + + {/* Basic Info */} + + + + Name + + setName(e.target.value)} + className="col-span-3" + placeholder="My Custom Theme" + /> + + + + + Description + + setDescription(e.target.value)} + className="col-span-3" + placeholder="Optional description" + rows={2} + /> + + + + + Mode + + setMode(v)} + > + + + + + Light + Dark + + + - - - Description - - setDescription(e.target.value)} - className="col-span-3" - placeholder="Optional description" - rows={2} - /> - + - - - Mode - - setMode(v)} - > - - - - - Light - Dark - - + {/* Color sections */} + + Basic Colors + + updateColor("background", v)} + /> + updateColor("foreground", v)} + /> + updateColor("primary", v)} + /> + updateColor("secondary", v)} + /> + + + + + Sidebar Colors + + updateColor("sidebar", v)} + /> + updateColor("sidebarPrimary", v)} + /> + updateColor("sidebarAccent", v)} + /> + - - {/* Color sections */} - - Basic Colors - - updateColor("background", v)} - /> - updateColor("foreground", v)} - /> - updateColor("primary", v)} - /> - updateColor("secondary", v)} - /> - - - Sidebar Colors - - updateColor("sidebar", v)} - /> - updateColor("sidebarPrimary", v)} - /> - updateColor("sidebarAccent", v)} - /> - - - + @@ -241,12 +249,11 @@ const CreateThemeModal: React.FC<{ ); }; -// Main theme selector component +// Main theme selector component as modal export function ThemeSelector() { const { mode, currentTheme, - // themes, setMode, setTheme, addCustomTheme, @@ -254,6 +261,7 @@ export function ThemeSelector() { getThemesForMode, } = useTheme(); + const [isMainModalOpen, setIsMainModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const lightThemes = getThemesForMode("light"); @@ -272,142 +280,200 @@ export function ThemeSelector() { return ( <> - - + + - {getCurrentModeIcon()} - {currentTheme.name} + + Theme - - - Appearance - + + + + + {getCurrentModeIcon()} + Appearance Settings + + + Choose your preferred theme and color scheme + + - {/* Mode Selection */} - - Mode - setMode("light")}> - - Light - {mode === "light" && ( - - Active - - )} - - setMode("dark")}> - - Dark - {mode === "dark" && ( - - Active - - )} - - setMode("system")}> - - System - {mode === "system" && ( - - Active - - )} - - + + {/* Current Theme Display */} + + + {currentTheme.name} + {currentTheme.description && ( + + {currentTheme.description} + + )} + + Active + - - - {/* Light Themes */} - {lightThemes.length > 0 && ( - - - Light Themes - - {lightThemes.map((theme) => ( - setTheme(theme.id)} - className="justify-between" + {/* Mode Selection */} + + Display Mode + + setMode("light")} + className="flex flex-col gap-1 h-auto py-3" > - - - {theme.name} - - - {currentTheme.id === theme.id && ( - Active - )} - {theme.isCustom && ( - { - e.stopPropagation(); - removeCustomTheme(theme.id); - }} - > - - - )} - - - ))} - - )} - - {lightThemes.length > 0 && darkThemes.length > 0 && ( - - )} - - {/* Dark Themes */} - {darkThemes.length > 0 && ( - - - Dark Themes - - {darkThemes.map((theme) => ( - setTheme(theme.id)} - className="justify-between" + + Light + + setMode("dark")} + className="flex flex-col gap-1 h-auto py-3" > - - - {theme.name} - - - {currentTheme.id === theme.id && ( - Active - )} - {theme.isCustom && ( - { - e.stopPropagation(); - removeCustomTheme(theme.id); - }} - > - - - )} - - - ))} - - )} + + Dark + + setMode("system")} + className="flex flex-col gap-1 h-auto py-3" + > + + System + + + - + {/* Theme Selection */} + + + Themes + setIsCreateModalOpen(true)} + > + + Create + + - {/* Add Custom Theme */} - setIsCreateModalOpen(true)}> - - Create Theme - - - + + + {/* Light Themes */} + {lightThemes.length > 0 && ( + + + Light Themes + + {lightThemes.map((theme) => ( + setTheme(theme.id)} + > + + + + + {theme.name} + + {theme.description && ( + + {theme.description} + + )} + + + + {currentTheme.id === theme.id && ( + + Active + + )} + {theme.isCustom && ( + { + e.stopPropagation(); + removeCustomTheme(theme.id); + }} + > + + + )} + + + ))} + + )} + + {/* Dark Themes */} + {darkThemes.length > 0 && ( + + {lightThemes.length > 0 && } + + Dark Themes + + {darkThemes.map((theme) => ( + setTheme(theme.id)} + > + + + + + {theme.name} + + {theme.description && ( + + {theme.description} + + )} + + + + {currentTheme.id === theme.id && ( + + Active + + )} + {theme.isCustom && ( + { + e.stopPropagation(); + removeCustomTheme(theme.id); + }} + > + + + )} + + + ))} + + )} + + + + + + + setIsMainModalOpen(false)}> + Close + + + + { + return useQuery({ + queryKey: ["categories", instanceId], + queryFn: () => + instanceId + ? categoryApi.getCategoriesByInstance(instanceId) + : Promise.resolve([]), + enabled: !!instanceId, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +// Get single category by ID +export const useCategoryById = (categoryId: string | undefined) => { + return useQuery({ + queryKey: ["category", categoryId], + queryFn: () => + categoryId + ? categoryApi.getCategoryById(categoryId) + : Promise.resolve(null), + enabled: !!categoryId, + staleTime: 5 * 60 * 1000, + }); +}; + +// Create category mutation +export const useCreateCategory = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: categoryApi.createCategory, + onSuccess: (data, variables) => { + // Invalidate categories for the instance + queryClient.invalidateQueries({ + queryKey: ["categories", variables.instanceId], + }); + // Also invalidate instance details to refresh the sidebar + queryClient.invalidateQueries({ + queryKey: ["instance", variables.instanceId], + }); + }, + onError: (error) => { + console.error("Failed to create category:", error); + }, + }); +}; + +// Update category mutation +export const useUpdateCategory = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + categoryId, + ...data + }: { + categoryId: string; + id: string; + name?: string; + description?: string; + position?: number; + userId: string; + }) => categoryApi.updateCategory(categoryId, data), + onSuccess: (data, variables) => { + // Update the specific category in cache + queryClient.invalidateQueries({ + queryKey: ["category", variables.categoryId], + }); + // Invalidate categories list + queryClient.invalidateQueries({ queryKey: ["categories"] }); + // Invalidate instance details + queryClient.invalidateQueries({ queryKey: ["instance"] }); + }, + onError: (error) => { + console.error("Failed to update category:", error); + }, + }); +}; + +// Delete category mutation +export const useDeleteCategory = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + categoryId, + userId, + }: { + categoryId: string; + userId: string; + }) => categoryApi.deleteCategory(categoryId, userId), + onSuccess: (data, variables) => { + // Remove the category from cache + queryClient.removeQueries({ + queryKey: ["category", variables.categoryId], + }); + // Invalidate categories list + queryClient.invalidateQueries({ queryKey: ["categories"] }); + // Invalidate instance details + queryClient.invalidateQueries({ queryKey: ["instance"] }); + }, + onError: (error) => { + console.error("Failed to delete category:", error); + }, + }); +}; + +// Delete all categories by instance mutation +export const useDeleteAllCategoriesByInstance = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + instanceId, + userId, + }: { + instanceId: string; + userId: string; + }) => categoryApi.deleteAllCategoriesByInstance(instanceId, userId), + onSuccess: (data, variables) => { + // Remove all categories for this instance from cache + queryClient.removeQueries({ + queryKey: ["categories", variables.instanceId], + }); + queryClient.removeQueries({ queryKey: ["category"] }); + // Invalidate instance details + queryClient.invalidateQueries({ + queryKey: ["instance", variables.instanceId], + }); + }, + onError: (error) => { + console.error("Failed to delete all categories:", error); + }, + }); +}; diff --git a/concord-client/src/lib/api-client.ts b/concord-client/src/lib/api-client.ts index c0e5285..0a2ae37 100644 --- a/concord-client/src/lib/api-client.ts +++ b/concord-client/src/lib/api-client.ts @@ -88,10 +88,16 @@ export interface Message { createdAt: string; deleted: boolean; updatedAt: string; - replyToId?: string | null; + replies: MessageReply; user?: BackendUser; } +export interface MessageReply { + id: string; + repliesToId: string; + repliesToText: string; +} + // Enhanced fetch wrapper with auth and error handling export class ApiClient { private baseUrl: string; diff --git a/concord-client/src/pages/ChatPage.tsx b/concord-client/src/pages/ChatPage.tsx index 1a36d03..0d2857d 100644 --- a/concord-client/src/pages/ChatPage.tsx +++ b/concord-client/src/pages/ChatPage.tsx @@ -10,7 +10,7 @@ import { Reply, Plus, } from "lucide-react"; -import { formatDistanceToNow } from "date-fns"; +import { formatDistanceToNow, isValid, parseISO } from "date-fns"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -26,10 +26,8 @@ import { useTheme } from "@/components/theme-provider"; import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers"; import { useChannelMessages, - useSendMessage, - useDeleteMessage, - usePinMessage, useLoadMoreMessages, + useSendMessage, } from "@/hooks/useMessages"; import { useUiStore } from "@/stores/uiStore"; import { useAuthStore } from "@/stores/authStore"; @@ -37,8 +35,6 @@ import { Message } from "@/lib/api-client"; // Modal imports import { MessageActionsModal } from "@/components/modals/MessageActionsModal"; -import { EditMessageModal } from "@/components/modals/EditMessageModal"; -import { PinnedMessagesModal } from "@/components/modals/PinnedMessagesModal"; // User type for message component interface MessageUser { @@ -57,12 +53,7 @@ interface MessageProps { currentUser: any; replyTo?: Message; replyToUser?: MessageUser; - onEdit?: (messageId: string) => void; - onDelete?: (messageId: string) => void; onReply?: (messageId: string) => void; - onPin?: (messageId: string) => void; - canDelete?: boolean; - canPin?: boolean; } const MessageComponent: React.FC = ({ @@ -71,29 +62,33 @@ const MessageComponent: React.FC = ({ currentUser, replyTo, replyToUser, - onEdit, - onDelete, onReply, - onPin, - canDelete = false, - canPin = false, }) => { const [isHovered, setIsHovered] = useState(false); const [showActionsModal, setShowActionsModal] = useState(false); const formatTimestamp = (timestamp: string) => { try { - const date = new Date(timestamp); - if (isNaN(date.getTime())) { + // First try parsing as ISO string + let date = parseISO(timestamp); + + // If that fails, try regular Date constructor + if (!isValid(date)) { + date = new Date(timestamp); + } + + // Final check if date is valid + if (!isValid(date)) { + console.error("Invalid timestamp:", timestamp); return "Invalid date"; } + return formatDistanceToNow(date, { addSuffix: true }); } catch (error) { console.error("Error formatting timestamp:", timestamp, error); return "Invalid date"; } }; - const isOwnMessage = currentUser?.id === message.userId; const { mode } = useTheme(); @@ -101,7 +96,7 @@ const MessageComponent: React.FC = ({ const username = user.username || user.userName || "Unknown User"; const displayName = user.nickname || user.nickName || username; - const isDeleted = (message as any).deleted; + const isDeleted = message.deleted; if (isDeleted) { return ( @@ -111,13 +106,6 @@ const MessageComponent: React.FC = ({ This message has been deleted - {(message as any).deletedBy && ( - - Deleted by {(message as any).deletedBy} - {(message as any).deletedAt && - ` • ${formatTimestamp((message as any).deletedAt)}`} - - )} @@ -161,6 +149,22 @@ const MessageComponent: React.FC = ({ )} + {/* Reply line and reference */} + {replyTo && replyToUser && ( + + + + {replyToUser.nickname || + replyToUser.nickName || + replyToUser.username || + replyToUser.userName} + + + {replyTo.text.replace(/```[\s\S]*?```/g, "[code]")} + + + )} + {/* Header - always show */} @@ -288,11 +292,7 @@ const MessageComponent: React.FC = ({ onClose={() => setShowActionsModal(false)} message={message} isOwnMessage={isOwnMessage} - canDelete={canDelete} - onEdit={onEdit} - onDelete={onDelete} onReply={onReply} - onPin={canPin ? onPin : undefined} /> > ); @@ -433,16 +433,12 @@ const ChatPage: React.FC = () => { // Local state hooks - called unconditionally const [replyingTo, setReplyingTo] = useState(null); - const [editingMessage, setEditingMessage] = useState(null); - const [showPinnedMessages, setShowPinnedMessages] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const messagesEndRef = useRef(null); const messagesStartRef = useRef(null); // API mutation hooks - called unconditionally - const deleteMessageMutation = useDeleteMessage(); - const pinMessageMutation = usePinMessage(); const loadMoreMessagesMutation = useLoadMoreMessages(channelId); // Memoized values - called unconditionally @@ -460,23 +456,16 @@ const ChatPage: React.FC = () => { return currentUser.roles.some((role) => role.instanceId === instanceId); }, [currentUser, instanceId]); - const canDeleteMessages = React.useMemo(() => { - if (!currentUser || !instanceId) return false; - if (currentUser.admin) return true; - const userRole = currentUser.roles.find( - (role) => role.instanceId === instanceId, - ); - return userRole && (userRole.role === "admin" || userRole.role === "mod"); - }, [currentUser, instanceId]); + const sortedMessages = React.useMemo(() => { + if (!channelMessages) return []; - const canPinMessages = React.useMemo(() => { - if (!currentUser || !instanceId) return false; - if (currentUser.admin) return true; - const userRole = currentUser.roles.find( - (role) => role.instanceId === instanceId, - ); - return userRole && (userRole.role === "admin" || userRole.role === "mod"); - }, [currentUser, instanceId]); + // Sort messages by createdAt timestamp (oldest first, newest last) + return [...channelMessages].sort((a, b) => { + const dateA = new Date(a.createdAt).getTime(); + const dateB = new Date(b.createdAt).getTime(); + return dateA - dateB; // ascending order (oldest to newest) + }); + }, [channelMessages]); // Effects - called unconditionally useEffect(() => { @@ -511,52 +500,6 @@ const ChatPage: React.FC = () => { [channelMessages], ); - const handleEdit = React.useCallback( - (messageId: string) => { - const message = channelMessages?.find((m) => m.id === messageId); - if (message) { - setEditingMessage(message); - } - }, - [channelMessages], - ); - - const handleDelete = React.useCallback( - async (messageId: string) => { - if (confirm("Are you sure you want to delete this message?")) { - try { - await deleteMessageMutation.mutateAsync({ - messageId, - channelId: channelId!, - }); - } catch (error) { - console.error("Failed to delete message:", error); - } - } - }, - [deleteMessageMutation, channelId], - ); - - const handlePin = React.useCallback( - async (messageId: string) => { - try { - const message = channelMessages?.find((m) => m.id === messageId); - const isPinned = (message as any)?.pinned; - - await pinMessageMutation.mutateAsync({ - messageId, - channelId: channelId!, - pinned: !isPinned, - }); - } catch (error) { - console.error("Failed to pin/unpin message:", error); - } - }, - [pinMessageMutation, channelId, channelMessages], - ); - - // NOW WE CAN START CONDITIONAL LOGIC AND EARLY RETURNS - // Handle loading states if (instanceLoading || messagesLoading || usersLoading) { return ( @@ -578,7 +521,7 @@ const ChatPage: React.FC = () => { Access Denied You don't have permission to view this server. - navigate("/channels/@me")}>Go Home + navigate("/")}>Go Home ); @@ -638,7 +581,6 @@ const ChatPage: React.FC = () => { const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash; - console.log(channelMessages); return ( {/* Channel Header */} @@ -659,14 +601,6 @@ const ChatPage: React.FC = () => { - setShowPinnedMessages(true)} - > - - { {/* Messages */} - {channelMessages && channelMessages.length > 0 ? ( + {sortedMessages && sortedMessages.length > 0 ? ( - {channelMessages.map((message) => { + {sortedMessages.map((message) => { + console.log(message); const user = users?.find((u) => u.id === message.userId); const replyToMessage = channelMessages?.find( - (m) => m.id === message.replyToId, + (m) => m.id === (message as any).repliedMessageId, ); const replyToUser = replyToMessage ? users?.find((u) => u.id === replyToMessage.userId) @@ -751,13 +686,8 @@ const ChatPage: React.FC = () => { user={user} currentUser={currentUser} replyTo={replyToMessage} - onEdit={handleEdit} - onDelete={handleDelete} onReply={handleReply} - onPin={handlePin} replyToUser={replyToUser} - canDelete={canDeleteMessages} - canPin={canPinMessages} /> ); })} @@ -791,25 +721,6 @@ const ChatPage: React.FC = () => { )} - - {/* Edit Message Modal */} - {editingMessage && ( - setEditingMessage(null)} - message={editingMessage} - channelId={channelId!} - /> - )} - - {/* Pinned Messages Modal */} - setShowPinnedMessages(false)} - channelId={channelId!} - channelName={currentChannel?.name || "channel"} - canManagePins={canPinMessages ? canPinMessages : false} - /> ); }; diff --git a/concord-client/src/pages/LoginPage.tsx b/concord-client/src/pages/LoginPage.tsx index f9e5450..cfcd3da 100644 --- a/concord-client/src/pages/LoginPage.tsx +++ b/concord-client/src/pages/LoginPage.tsx @@ -24,7 +24,7 @@ const LoginPage: React.FC = () => { // Redirect if already authenticated if (isAuthenticated) { - return ; + return ; } const handleLogin = async (e: React.FormEvent) => { diff --git a/concord-client/src/pages/NotFoundPage.tsx b/concord-client/src/pages/NotFoundPage.tsx index 224e948..e5e1d0a 100644 --- a/concord-client/src/pages/NotFoundPage.tsx +++ b/concord-client/src/pages/NotFoundPage.tsx @@ -7,7 +7,7 @@ const NotFoundPage: React.FC = () => { 404 Page not found - Go Home + Go Home diff --git a/concord-client/src/pages/SettingsPage.tsx b/concord-client/src/pages/SettingsPage.tsx index f338f58..3c57ea9 100644 --- a/concord-client/src/pages/SettingsPage.tsx +++ b/concord-client/src/pages/SettingsPage.tsx @@ -5,6 +5,7 @@ import { User, Shield, Mic, + Eye, Settings, ChevronRight, Moon, @@ -45,12 +46,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [ icon: User, description: "Profile, privacy, and account settings", }, - { - id: "security", - title: "Security", - icon: Lock, - description: "Password and security settings", - }, + // { + // id: "security", + // title: "Security", + // icon: Lock, + // description: "Password and security settings", + // }, { id: "appearance", title: "Appearance", @@ -629,6 +630,98 @@ const AppearanceSettings: React.FC = () => { + + {/* Theme Grid */} + + Available Themes + + {/* Light Themes */} + {lightThemes.map((theme) => ( + setTheme(theme.id)} + className={`p-3 rounded-lg border-2 transition-all text-left ${ + currentLightTheme.id === theme.id + ? "border-primary bg-primary/10" + : "border-border hover:border-primary/50" + }`} + > + + {theme.name} + + + {theme.description && ( + + {theme.description} + + )} + + + + + + + ))} + + {/* Dark Themes */} + {darkThemes.map((theme) => ( + setTheme(theme.id)} + className={`p-3 rounded-lg border-2 transition-all text-left ${ + currentDarkTheme.id === theme.id + ? "border-primary bg-primary/10" + : "border-border hover:border-primary/50" + }`} + > + + {theme.name} + + + {theme.description && ( + + {theme.description} + + )} + + + + + + + ))} + + + + {/* Theme Stats */} + + + {lightThemes.length} + Light Themes + + + {darkThemes.length} + Dark Themes + + diff --git a/concord-server/src/services/messageService.ts b/concord-server/src/services/messageService.ts index 7030084..23b7590 100644 --- a/concord-server/src/services/messageService.ts +++ b/concord-server/src/services/messageService.ts @@ -127,6 +127,7 @@ export async function getMessagesBefore(date: string, channelId: string) { userId: message.userId!, text: message.text, deleted: message.deleted, + createdAt: message.createdAt, replies: originalMessage ? { messageId: message.id, @@ -159,7 +160,11 @@ export async function getMessagesBefore(date: string, channelId: string) { export async function editMessage(data: PutMessage) { try { const userCreds = await getUserCredentials(data.id); - if (!userCreds || userCreds.token == null || userCreds.token != data.token) { + if ( + !userCreds || + userCreds.token == null || + userCreds.token != data.token + ) { return null; }
+ {members.length} Members +
- Press Enter to save • Shift+Enter for new line • Escape to cancel -
No pinned messages yet
- Pin important messages to keep them accessible -
- {user.admin - ? "System administrators cannot be modified" - : "You don't have permission to modify this user"} -
{currentTheme.name}
+ {currentTheme.description} +
+ Light Themes +
+ {theme.name} +
+ {theme.description} +
+ Dark Themes +
You don't have permission to view this server.
Page not found