halfway commit to allow collaboration
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
@@ -11,33 +11,22 @@ import ChatPage from "@/pages/ChatPage";
|
||||
import SettingsPage from "@/pages/SettingsPage";
|
||||
import NotFoundPage from "@/pages/NotFoundPage";
|
||||
|
||||
// import { useAuthStore } from "@/stores/authStore";
|
||||
// import { useUiStore } from "@/stores/uiStore";
|
||||
import { queryClient } from "@/lib/api-client";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import ErrorBoundary from "@/components/common/ErrorBoundary";
|
||||
import { Home } from "lucide-react";
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.status === 401) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Protected Route wrapper
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
// const { isAuthenticated } = useAuthStore();
|
||||
// if (!isAuthenticated) {
|
||||
// return <Navigate to="/login" replace />;
|
||||
// }
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
|
||||
// Enable this when you want to enforce authentication
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
|
||||
276
concord-client/src/components/direct-dropdown.tsx
Normal file
276
concord-client/src/components/direct-dropdown.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
// Basic dropdown styles
|
||||
const dropdownStyles = {
|
||||
content: `
|
||||
min-w-[220px]
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-md
|
||||
p-1
|
||||
shadow-lg
|
||||
z-50
|
||||
animate-in
|
||||
fade-in-80
|
||||
data-[state=open]:animate-in
|
||||
data-[state=closed]:animate-out
|
||||
data-[state=closed]:fade-out-0
|
||||
data-[state=open]:fade-in-0
|
||||
data-[state=closed]:zoom-out-95
|
||||
data-[state=open]:zoom-in-95
|
||||
data-[side=bottom]:slide-in-from-top-2
|
||||
data-[side=left]:slide-in-from-right-2
|
||||
data-[side=right]:slide-in-from-left-2
|
||||
data-[side=top]:slide-in-from-bottom-2
|
||||
`,
|
||||
item: `
|
||||
relative
|
||||
flex
|
||||
cursor-pointer
|
||||
select-none
|
||||
items-center
|
||||
rounded-sm
|
||||
px-2
|
||||
py-1.5
|
||||
text-sm
|
||||
outline-none
|
||||
transition-colors
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
focus:bg-gray-100 dark:focus:bg-gray-700
|
||||
data-[disabled]:pointer-events-none
|
||||
data-[disabled]:opacity-50
|
||||
`,
|
||||
separator: `
|
||||
-mx-1
|
||||
my-1
|
||||
h-px
|
||||
bg-gray-200 dark:bg-gray-700
|
||||
`,
|
||||
label: `
|
||||
px-2
|
||||
py-1.5
|
||||
text-sm
|
||||
font-semibold
|
||||
text-gray-500 dark:text-gray-400
|
||||
`,
|
||||
destructive: `
|
||||
text-red-600 dark:text-red-400
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
focus:bg-red-50 dark:focus:bg-red-900/20
|
||||
`,
|
||||
};
|
||||
|
||||
// Utility to combine class names
|
||||
const cn = (...classes: (string | undefined | false)[]) => {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
// Root dropdown menu
|
||||
export const DirectDropdownMenu = DropdownMenu.Root;
|
||||
export const DirectDropdownMenuTrigger = DropdownMenu.Trigger;
|
||||
|
||||
// Content component with styling
|
||||
interface DirectDropdownMenuContentProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Content> {
|
||||
className?: string;
|
||||
sideOffset?: number;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Content>,
|
||||
DirectDropdownMenuContentProps
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
dropdownStyles.content.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenu.Portal>
|
||||
));
|
||||
DirectDropdownMenuContent.displayName = "DirectDropdownMenuContent";
|
||||
|
||||
// Menu item component
|
||||
interface DirectDropdownMenuItemProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Item> {
|
||||
className?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Item>,
|
||||
DirectDropdownMenuItemProps
|
||||
>(({ className, destructive, ...props }, ref) => (
|
||||
<DropdownMenu.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.item.replace(/\s+/g, " ").trim(),
|
||||
destructive && dropdownStyles.destructive.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuItem.displayName = "DirectDropdownMenuItem";
|
||||
|
||||
// Separator component
|
||||
interface DirectDropdownMenuSeparatorProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Separator> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Separator>,
|
||||
DirectDropdownMenuSeparatorProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenu.Separator
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.separator.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuSeparator.displayName = "DirectDropdownMenuSeparator";
|
||||
|
||||
// Label component
|
||||
interface DirectDropdownMenuLabelProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.Label> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.Label>,
|
||||
DirectDropdownMenuLabelProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenu.Label
|
||||
ref={ref}
|
||||
className={cn(dropdownStyles.label.replace(/\s+/g, " ").trim(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuLabel.displayName = "DirectDropdownMenuLabel";
|
||||
|
||||
// Checkbox item component
|
||||
interface DirectDropdownMenuCheckboxItemProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.CheckboxItem> {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.CheckboxItem>,
|
||||
DirectDropdownMenuCheckboxItemProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenu.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.item.replace(/\s+/g, " ").trim(),
|
||||
"pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenu.ItemIndicator>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenu.CheckboxItem>
|
||||
));
|
||||
DirectDropdownMenuCheckboxItem.displayName = "DirectDropdownMenuCheckboxItem";
|
||||
|
||||
// Sub menu components
|
||||
export const DirectDropdownMenuSub = DropdownMenu.Sub;
|
||||
|
||||
interface DirectDropdownMenuSubTriggerProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.SubTrigger> {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.SubTrigger>,
|
||||
DirectDropdownMenuSubTriggerProps
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenu.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(dropdownStyles.item.replace(/\s+/g, " ").trim(), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</DropdownMenu.SubTrigger>
|
||||
));
|
||||
DirectDropdownMenuSubTrigger.displayName = "DirectDropdownMenuSubTrigger";
|
||||
|
||||
interface DirectDropdownMenuSubContentProps
|
||||
extends React.ComponentProps<typeof DropdownMenu.SubContent> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const DirectDropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenu.SubContent>,
|
||||
DirectDropdownMenuSubContentProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenu.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownStyles.content.replace(/\s+/g, " ").trim(),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DirectDropdownMenuSubContent.displayName = "DirectDropdownMenuSubContent";
|
||||
|
||||
// Example usage component to test the dropdown
|
||||
export const DirectDropdownExample: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<DirectDropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DirectDropdownMenuTrigger asChild>
|
||||
<button className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
Open Menu
|
||||
</button>
|
||||
</DirectDropdownMenuTrigger>
|
||||
|
||||
<DirectDropdownMenuContent align="end" className="w-56">
|
||||
<DirectDropdownMenuLabel>My Account</DirectDropdownMenuLabel>
|
||||
<DirectDropdownMenuSeparator />
|
||||
|
||||
<DirectDropdownMenuItem
|
||||
onClick={() => console.log("Profile clicked")}
|
||||
>
|
||||
<span>Profile</span>
|
||||
</DirectDropdownMenuItem>
|
||||
|
||||
<DirectDropdownMenuItem
|
||||
onClick={() => console.log("Settings clicked")}
|
||||
>
|
||||
<span>Settings</span>
|
||||
</DirectDropdownMenuItem>
|
||||
|
||||
<DirectDropdownMenuSeparator />
|
||||
|
||||
<DirectDropdownMenuItem
|
||||
destructive
|
||||
onClick={() => console.log("Logout clicked")}
|
||||
>
|
||||
<span>Log out</span>
|
||||
</DirectDropdownMenuItem>
|
||||
</DirectDropdownMenuContent>
|
||||
</DirectDropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,15 @@
|
||||
import React from "react";
|
||||
// src/components/layout/MemberList.tsx - Enhanced with role management
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { Crown, Shield, UserIcon } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Role } from "@/types/database";
|
||||
import { useInstanceMembers } from "@/hooks/useServers";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { User } from "@/types/database";
|
||||
import { UserRoleModal } from "@/components/modals/UserRoleModal";
|
||||
|
||||
// Status color utility
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -23,6 +27,9 @@ const getStatusColor = (status: string) => {
|
||||
|
||||
interface MemberItemProps {
|
||||
member: User;
|
||||
instanceId: string;
|
||||
currentUserRole: string;
|
||||
canManageRoles: boolean;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
@@ -43,61 +50,117 @@ const getRoleInfo = (role: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const MemberItem: React.FC<MemberItemProps> = ({ member, isOwner = false }) => {
|
||||
const { instanceId } = useParams();
|
||||
const MemberItem: React.FC<MemberItemProps> = ({
|
||||
member,
|
||||
instanceId,
|
||||
currentUserRole,
|
||||
canManageRoles,
|
||||
isOwner = false,
|
||||
}) => {
|
||||
const [showUserModal, setShowUserModal] = useState(false);
|
||||
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
|
||||
return (
|
||||
<div className="panel-button">
|
||||
<div className="relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage
|
||||
src={member.picture || undefined}
|
||||
alt={member.username}
|
||||
/>
|
||||
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
|
||||
{member.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-sidebar ${getStatusColor(member.status)}`}
|
||||
/>
|
||||
</div>
|
||||
const handleMemberClick = () => {
|
||||
if (canManageRoles && !member.admin) {
|
||||
setShowUserModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwner && (
|
||||
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
{!isOwner && userRole !== "member" && (
|
||||
<Shield
|
||||
size={12}
|
||||
className="flex-shrink-0"
|
||||
style={{ color: roleInfo.color || "var(--background)" }}
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
||||
onClick={handleMemberClick}
|
||||
disabled={!canManageRoles || member.admin}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="relative">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage
|
||||
src={member.picture || undefined}
|
||||
alt={member.username}
|
||||
/>
|
||||
<AvatarFallback className="text-xs bg-primary text-primary-foreground">
|
||||
{member.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-sidebar ${getStatusColor(member.status)}`}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: roleInfo.color || "var(--color-text-primary)" }}
|
||||
>
|
||||
{member.nickname || member.username}
|
||||
</span>
|
||||
</div>
|
||||
{member.bio && (
|
||||
<div className="text-xs text-concord-secondary truncate">
|
||||
{member.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 text-left">
|
||||
<div className="flex items-center gap-1">
|
||||
{isOwner && (
|
||||
<Crown size={12} className="text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
{!isOwner && userRole !== "member" && (
|
||||
<Shield
|
||||
size={12}
|
||||
className="flex-shrink-0"
|
||||
style={{ color: roleInfo.color || "var(--background)" }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
style={{ color: roleInfo.color || "var(--color-text-primary)" }}
|
||||
>
|
||||
{member.nickname || member.username}
|
||||
</span>
|
||||
</div>
|
||||
{member.bio && (
|
||||
<div className="text-xs text-concord-secondary truncate">
|
||||
{member.bio}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* User Role Modal */}
|
||||
<UserRoleModal
|
||||
isOpen={showUserModal}
|
||||
onClose={() => setShowUserModal(false)}
|
||||
user={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
canManageRoles={canManageRoles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberList: React.FC = () => {
|
||||
const { instanceId } = useParams();
|
||||
const { data: members, isLoading } = useInstanceMembers(instanceId);
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
// Check if current user can manage roles
|
||||
const canManageRoles = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return false;
|
||||
|
||||
// Global admins can manage roles
|
||||
if (currentUser.admin) return true;
|
||||
|
||||
// Check if user is admin or mod in this instance
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole && (userRole.role === "admin" || userRole.role === "mod");
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
const currentUserRole = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return "member";
|
||||
if (currentUser.admin) return "admin";
|
||||
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole?.role || "member";
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
if (!instanceId) {
|
||||
return null;
|
||||
@@ -177,6 +240,9 @@ const MemberList: React.FC = () => {
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
canManageRoles={canManageRoles}
|
||||
isOwner={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -10,34 +10,45 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useServers } from "@/hooks/useServers";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import ServerIcon from "@/components/server/ServerIcon";
|
||||
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
|
||||
|
||||
const ServerSidebar: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { instanceId } = useParams();
|
||||
const { data: servers, isLoading } = useServers();
|
||||
const { data: allServers = [], isLoading } = useServers();
|
||||
const { openCreateServer, setActiveInstance, getSelectedChannelForInstance } =
|
||||
useUiStore();
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
// Filter servers based on user permissions
|
||||
const accessibleServers = getAccessibleInstances(currentUser, allServers);
|
||||
const canCreateServer = isGlobalAdmin(currentUser);
|
||||
|
||||
const handleServerClick = (serverId: string) => {
|
||||
setActiveInstance(serverId);
|
||||
const lastChannelId = getSelectedChannelForInstance(serverId);
|
||||
|
||||
console.log(servers);
|
||||
|
||||
if (lastChannelId) {
|
||||
navigate(`/channels/${serverId}/${lastChannelId}`);
|
||||
} else {
|
||||
// Fallback: navigate to the server, let the page component handle finding a channel
|
||||
// A better UX would be to find and navigate to the first channel here.
|
||||
navigate(`/channels/${serverId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHomeClick = () => {
|
||||
setActiveInstance(null);
|
||||
navigate("/channels/@me");
|
||||
};
|
||||
|
||||
const handleCreateServer = () => {
|
||||
if (canCreateServer) {
|
||||
openCreateServer();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="sidebar-primary flex flex-col items-center h-full py-2 space-y-2">
|
||||
@@ -47,8 +58,10 @@ const ServerSidebar: React.FC = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={` w-12 h-12 ml-0 ${
|
||||
!instanceId || instanceId === "@me" ? "active" : ""
|
||||
className={`w-12 h-12 ml-0 rounded-2xl hover:rounded-xl transition-all duration-200 ${
|
||||
!instanceId || instanceId === "@me"
|
||||
? "bg-primary text-primary-foreground rounded-xl"
|
||||
: "hover:bg-primary/10"
|
||||
}`}
|
||||
onClick={handleHomeClick}
|
||||
>
|
||||
@@ -56,7 +69,7 @@ const ServerSidebar: React.FC = () => {
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Direct Messages</p>
|
||||
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -69,8 +82,8 @@ const ServerSidebar: React.FC = () => {
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
servers?.map((server) => (
|
||||
) : accessibleServers.length > 0 ? (
|
||||
accessibleServers.map((server) => (
|
||||
<Tooltip key={server.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
@@ -86,25 +99,43 @@ const ServerSidebar: React.FC = () => {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))
|
||||
)}
|
||||
) : currentUser ? (
|
||||
<div className="text-center py-4 px-2">
|
||||
<div className="text-xs text-concord-secondary mb-2">
|
||||
No servers available
|
||||
</div>
|
||||
{canCreateServer && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={handleCreateServer}
|
||||
>
|
||||
Create One
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Add Server Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 ml-3 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
|
||||
onClick={openCreateServer}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Add Server Button - Only show if user can create servers */}
|
||||
{canCreateServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 ml-3 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
|
||||
onClick={handleCreateServer}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { SAMPLE_USERS } from "@/hooks/useServers";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
|
||||
// Status color utility
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -46,6 +46,8 @@ const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
|
||||
onStatusChange,
|
||||
children,
|
||||
}) => {
|
||||
const { mutate: logout } = useLogout();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "online", label: "Online", color: "bg-status-online" },
|
||||
{ value: "away", label: "Away", color: "bg-status-away" },
|
||||
@@ -79,7 +81,7 @@ const UserStatusDropdown: React.FC<UserStatusDropdownProps> = ({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => useAuthStore.getState().logout()}
|
||||
onClick={() => logout()}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
Log Out
|
||||
@@ -212,12 +214,19 @@ const UserPanel: React.FC = () => {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
|
||||
const displayUser = user || SAMPLE_USERS.find((u) => u.id === "current");
|
||||
|
||||
if (!displayUser) {
|
||||
// If no authenticated user, show login prompt
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="flex-shrink-0 p-2 bg-concord-tertiary">
|
||||
<div className="text-concord-secondary text-sm">No user data</div>
|
||||
<div className="flex-shrink-0 p-2 bg-concord-tertiary border-t border-sidebar">
|
||||
<div className="text-center text-concord-secondary text-sm">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => (window.location.href = "/login")}
|
||||
>
|
||||
Login Required
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +234,7 @@ const UserPanel: React.FC = () => {
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
console.log("Status change to:", newStatus);
|
||||
// TODO: Implement API call to update user status
|
||||
// You can add a useUpdateUserStatus hook here
|
||||
};
|
||||
|
||||
const handleMuteToggle = () => setIsMuted(!isMuted);
|
||||
@@ -240,20 +250,20 @@ const UserPanel: React.FC = () => {
|
||||
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
||||
{/* User Info with Dropdown */}
|
||||
<UserStatusDropdown
|
||||
currentStatus={displayUser.status}
|
||||
currentStatus={user.status}
|
||||
onStatusChange={handleStatusChange}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex-1 flex items-center h-auto p-1 rounded-md hover:bg-concord-secondary"
|
||||
>
|
||||
<UserAvatar user={displayUser} size="md" />
|
||||
<UserAvatar user={user} size="md" />
|
||||
<div className="ml-2 flex-1 min-w-0 text-left">
|
||||
<div className="text-sm font-medium text-concord-primary truncate">
|
||||
{displayUser.nickname || displayUser.username}
|
||||
{user.nickname || user.username}
|
||||
</div>
|
||||
<div className="text-xs text-concord-secondary truncate capitalize">
|
||||
{displayUser.status}
|
||||
{user.status}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
154
concord-client/src/components/modals/CreateChannelModal.tsx
Normal file
154
concord-client/src/components/modals/CreateChannelModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Hash, Volume2 } from "lucide-react";
|
||||
import { useCreateChannel } from "@/hooks/useServers";
|
||||
import { CategoryWithChannels } from "@/types/api";
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: CategoryWithChannels[];
|
||||
defaultCategoryId?: string;
|
||||
}
|
||||
|
||||
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
defaultCategoryId,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
||||
|
||||
const createChannelMutation = useCreateChannel();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !categoryId) return;
|
||||
|
||||
try {
|
||||
await createChannelMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create channel:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Channel</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Channel Type</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "text" ? "default" : "outline"}
|
||||
onClick={() => setType("text")}
|
||||
className="flex-1"
|
||||
>
|
||||
<Hash className="h-4 w-4 mr-2" />
|
||||
Text
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "voice" ? "default" : "outline"}
|
||||
onClick={() => setType("voice")}
|
||||
className="flex-1"
|
||||
>
|
||||
<Volume2 className="h-4 w-4 mr-2" />
|
||||
Voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="awesome-channel"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description</Label>
|
||||
<Textarea
|
||||
id="channel-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this channel about?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!name.trim() || !categoryId || createChannelMutation.isPending
|
||||
}
|
||||
>
|
||||
{createChannelMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Channel"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
92
concord-client/src/components/modals/CreateServerModal.tsx
Normal file
92
concord-client/src/components/modals/CreateServerModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useCreateInstance } from "@/hooks/useServers";
|
||||
|
||||
interface CreateServerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const CreateServerModal: React.FC<CreateServerModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
|
||||
const createInstanceMutation = useCreateInstance();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
|
||||
try {
|
||||
await createInstanceMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setName("");
|
||||
setIcon("");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create server:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Server</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My Awesome Server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-icon">Server Icon URL (optional)</Label>
|
||||
<Input
|
||||
id="server-icon"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
type="url"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!name.trim() || createInstanceMutation.isPending}
|
||||
>
|
||||
{createInstanceMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Server"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
121
concord-client/src/components/modals/EditMessageModal.tsx
Normal file
121
concord-client/src/components/modals/EditMessageModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Message } from "@/lib/api-client";
|
||||
import { useEditMessage } from "@/hooks/useMessages";
|
||||
|
||||
interface EditMessageModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export const EditMessageModal: React.FC<EditMessageModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
channelId,
|
||||
}) => {
|
||||
const [content, setContent] = useState(message.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editMessageMutation = useEditMessage();
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isOpen) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [content, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && content.trim() !== message.text) {
|
||||
try {
|
||||
await editMessageMutation.mutateAsync({
|
||||
messageId: message.id,
|
||||
content: content.trim(),
|
||||
channelId,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(message.text);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Message</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message-content">Message</Label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="message-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
style={{
|
||||
minHeight: "100px",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
disabled={editMessageMutation.isPending}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press Enter to save • Shift+Enter for new line • Escape to cancel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={editMessageMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!content.trim() ||
|
||||
content.trim() === message.text ||
|
||||
editMessageMutation.isPending
|
||||
}
|
||||
>
|
||||
{editMessageMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
101
concord-client/src/components/modals/MessageActionsModal.tsx
Normal file
101
concord-client/src/components/modals/MessageActionsModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Edit, Trash2, Pin, Reply } from "lucide-react";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
interface MessageActionsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
isOwnMessage: boolean;
|
||||
canDelete: boolean; // For mods/admins
|
||||
onEdit?: (messageId: string) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (messageId: string) => void;
|
||||
onPin?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
isOwnMessage,
|
||||
canDelete,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onPin,
|
||||
}) => {
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[300px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Message Actions</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onReply?.(message.id))}
|
||||
>
|
||||
<Reply className="h-4 w-4 mr-2" />
|
||||
Reply
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() =>
|
||||
handleAction(() => navigator.clipboard.writeText(message.text))
|
||||
}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Text
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onPin?.(message.id))}
|
||||
>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
Pin Message
|
||||
</Button>
|
||||
|
||||
{isOwnMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onEdit?.(message.id))}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Message
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isOwnMessage || canDelete) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => handleAction(() => onDelete?.(message.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Message
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
155
concord-client/src/components/modals/PinnedMessagesModal.tsx
Normal file
155
concord-client/src/components/modals/PinnedMessagesModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Pin, X, ExternalLink } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Message } from "@/types/database";
|
||||
import { usePinnedMessages, usePinMessage } from "@/hooks/useMessages";
|
||||
|
||||
interface PinnedMessagesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
canManagePins: boolean;
|
||||
}
|
||||
|
||||
export const PinnedMessagesModal: React.FC<PinnedMessagesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
channelId,
|
||||
channelName,
|
||||
canManagePins,
|
||||
}) => {
|
||||
const { data: pinnedMessages, isLoading } = usePinnedMessages(channelId);
|
||||
const pinMessageMutation = usePinMessage();
|
||||
|
||||
const handleUnpin = async (messageId: string) => {
|
||||
try {
|
||||
await pinMessageMutation.mutateAsync({
|
||||
messageId,
|
||||
channelId,
|
||||
pinned: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToMessage = (messageId: string) => {
|
||||
// TODO: Implement jumping to message in chat
|
||||
console.log("Jumping to message:", messageId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5" />
|
||||
Pinned Messages in #{channelName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5" />
|
||||
Pinned Messages in #{channelName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!pinnedMessages || pinnedMessages.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Pin className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No pinned messages yet</p>
|
||||
<p className="text-sm">
|
||||
Pin important messages to keep them accessible
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[50vh]">
|
||||
<div className="space-y-4">
|
||||
{pinnedMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="border border-border rounded-lg p-4 bg-concord-secondary/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={message.user?.picture || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{message.user?.userName?.slice(0, 2).toUpperCase() ||
|
||||
"??"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{message.user?.nickName ||
|
||||
message.user?.userName ||
|
||||
"Unknown User"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(message.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-concord-primary leading-relaxed break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleJumpToMessage(message.id)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{canManagePins && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleUnpin(message.id)}
|
||||
disabled={pinMessageMutation.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
262
concord-client/src/components/modals/UserRoleModal.tsx
Normal file
262
concord-client/src/components/modals/UserRoleModal.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Crown, Shield, User, AlertTriangle } from "lucide-react";
|
||||
import { User as UserType } from "@/types/database";
|
||||
|
||||
interface UserRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: UserType;
|
||||
instanceId: string;
|
||||
currentUserRole: string;
|
||||
canManageRoles: boolean;
|
||||
}
|
||||
|
||||
export const UserRoleModal: React.FC<UserRoleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
instanceId,
|
||||
currentUserRole,
|
||||
canManageRoles,
|
||||
}) => {
|
||||
const userInstanceRole =
|
||||
user.roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
||||
const [selectedRole, setSelectedRole] = useState(userInstanceRole);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: "member",
|
||||
label: "Member",
|
||||
icon: User,
|
||||
description: "Standard server access",
|
||||
},
|
||||
{
|
||||
value: "mod",
|
||||
label: "Moderator",
|
||||
icon: Shield,
|
||||
description: "Can moderate channels and manage messages",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin",
|
||||
icon: Crown,
|
||||
description: "Full server management access",
|
||||
},
|
||||
];
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "destructive";
|
||||
case "mod":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async () => {
|
||||
if (selectedRole === userInstanceRole || !canManageRoles) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
// TODO: Implement actual role update API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
console.log(
|
||||
`Updating ${user.username}'s role to ${selectedRole} in instance ${instanceId}`,
|
||||
);
|
||||
// await userClient.updateRole({
|
||||
// userId: user.id,
|
||||
// instanceId,
|
||||
// role: selectedRole,
|
||||
// requestingUserId: currentUser.id,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update role:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKickUser = async () => {
|
||||
if (!canManageRoles) return;
|
||||
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to kick ${user.nickname || user.username} from this server?`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement kick user API call
|
||||
console.log(`Kicking ${user.username} from instance ${instanceId}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to kick user:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canModifyUser =
|
||||
canManageRoles && !user.admin && userInstanceRole !== "admin";
|
||||
const isOwnProfile = false; // TODO: Check if this is the current user's profile
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={user.picture || undefined} />
|
||||
<AvatarFallback>
|
||||
{user.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{user.nickname || user.username}
|
||||
</span>
|
||||
{user.admin && <Crown className="h-4 w-4 text-yellow-500" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getRoleColor(userInstanceRole)}>
|
||||
{userInstanceRole}
|
||||
</Badge>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
user.status === "online"
|
||||
? "bg-green-500"
|
||||
: user.status === "away"
|
||||
? "bg-yellow-500"
|
||||
: user.status === "busy"
|
||||
? "bg-red-500"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{user.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.bio && (
|
||||
<div className="text-sm text-muted-foreground">{user.bio}</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Role Management */}
|
||||
{canModifyUser && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Server Role</Label>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((role) => {
|
||||
const Icon = role.icon;
|
||||
return (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-medium">{role.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleUpdateRole}
|
||||
disabled={selectedRole === userInstanceRole || isUpdating}
|
||||
className="flex-1"
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update Role"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<Label className="text-destructive">Danger Zone</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleKickUser}
|
||||
className="w-full"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Kick from Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Only Mode */}
|
||||
{!canModifyUser && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user.admin
|
||||
? "System administrators cannot be modified"
|
||||
: "You don't have permission to modify this user"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={onClose} className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
83
concord-client/src/components/modals/UserStatusModal.tsx
Normal file
83
concord-client/src/components/modals/UserStatusModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, LogOut } from "lucide-react";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
|
||||
interface UserStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentStatus: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
}
|
||||
|
||||
export const UserStatusModal: React.FC<UserStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStatus,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const { mutate: logout } = useLogout();
|
||||
const { openUserSettings } = useUiStore();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "online", label: "Online", color: "bg-status-online" },
|
||||
{ value: "away", label: "Away", color: "bg-status-away" },
|
||||
{ value: "busy", label: "Do Not Disturb", color: "bg-status-busy" },
|
||||
{ value: "offline", label: "Invisible", color: "bg-status-offline" },
|
||||
];
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[250px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Status & Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{statusOptions.map((status) => (
|
||||
<Button
|
||||
key={status.value}
|
||||
variant={currentStatus === status.value ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onStatusChange(status.value))}
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${status.color} mr-2`} />
|
||||
{status.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => openUserSettings())}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
User Settings
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => handleAction(() => logout())}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,30 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Instance } from "@/types/database";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "../ui/dialog";
|
||||
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Category } from "@/types";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import {
|
||||
useCreateCategory,
|
||||
useCreateChannel,
|
||||
useCreateInstance,
|
||||
} from "@/hooks/useServers";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
interface ServerIconProps {
|
||||
server: Instance;
|
||||
@@ -44,7 +68,7 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
<img
|
||||
src={server.icon}
|
||||
alt={server.name}
|
||||
className="w-full h-full object-cover rounded-inherit"
|
||||
className={`w-full h-full object-cover ${isActive ? "rounded-xl" : "rounded-2xl"}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-semibold text-sm">
|
||||
@@ -56,4 +80,308 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Create Server Modal
|
||||
export const CreateServerModal: React.FC = () => {
|
||||
const { showCreateServer, closeCreateServer } = useUiStore();
|
||||
const { mutate: createInstance, isPending } = useCreateInstance();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
createInstance(
|
||||
{
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create server:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateServer} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new server to chat with friends and communities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter server name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="server-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this server about?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-icon">Server Icon URL (Optional)</Label>
|
||||
<Input
|
||||
id="server-icon"
|
||||
type="url"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Server"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Channel Modal
|
||||
export const CreateChannelModal: React.FC = () => {
|
||||
const { showCreateChannel, closeCreateChannel } = useUiStore();
|
||||
const { mutate: createChannel, isPending } = useCreateChannel();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
// You'd need to get categories for the current instance
|
||||
// This is a simplified version
|
||||
const categories: Category[] = []; // Get from context or props
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && categoryId) {
|
||||
createChannel(
|
||||
{
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create channel:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateChannel} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new text or voice channel in this server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-type">Channel Type</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value: "text" | "voice") => setType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text Channel</SelectItem>
|
||||
<SelectItem value="voice">Voice Channel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-category">Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter channel name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="channel-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this channel for?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Category Modal
|
||||
export const CreateCategoryModal: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: createCategory, isPending } = useCreateCategory();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && instanceId) {
|
||||
createCategory(
|
||||
{
|
||||
name: name.trim(),
|
||||
instanceId,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create category:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new category to organize your channels.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category-name">Category Name</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Category"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminModals: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<CreateServerModal />
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ServerIcon;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -15,7 +15,7 @@ function DropdownMenuPortal({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
@@ -26,7 +26,7 @@ function DropdownMenuTrigger({
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -41,12 +41,12 @@ function DropdownMenuContent({
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
@@ -54,7 +54,7 @@ function DropdownMenuGroup({
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
@@ -63,8 +63,8 @@ function DropdownMenuItem({
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -73,11 +73,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -91,7 +91,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -103,7 +103,7 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
@@ -114,7 +114,7 @@ function DropdownMenuRadioGroup({
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -127,7 +127,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -138,7 +138,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -146,7 +146,7 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
@@ -154,11 +154,11 @@ function DropdownMenuLabel({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
@@ -171,7 +171,7 @@ function DropdownMenuSeparator({
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
@@ -183,17 +183,17 @@ function DropdownMenuShortcut({
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -202,7 +202,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -210,14 +210,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -229,11 +229,11 @@ function DropdownMenuSubContent({
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -252,4 +252,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
};
|
||||
|
||||
213
concord-client/src/hooks/useAuth.ts
Normal file
213
concord-client/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// src/hooks/useAuth.ts - Fixed with proper types
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import {
|
||||
authClient,
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
AuthResponse,
|
||||
} from "@/lib/auth-client";
|
||||
import { BackendUser } from "@/lib/api-client";
|
||||
import { Role, UserStatus } from "@/types/database";
|
||||
|
||||
// Frontend User type
|
||||
interface FrontendUser {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname?: string | null;
|
||||
bio?: string | null;
|
||||
picture?: string | null;
|
||||
banner?: string | null;
|
||||
hashPassword: string;
|
||||
admin: boolean;
|
||||
status: UserStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
// Transform backend user to frontend user format
|
||||
function transformBackendUser(backendUser: BackendUser): FrontendUser {
|
||||
return {
|
||||
id: backendUser.id,
|
||||
username: backendUser.userName,
|
||||
nickname: backendUser.nickName,
|
||||
bio: backendUser.bio,
|
||||
picture: backendUser.picture,
|
||||
banner: backendUser.banner,
|
||||
hashPassword: "", // Don't store password
|
||||
admin: backendUser.admin,
|
||||
status: transformStatusToFrontend(backendUser.status),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
roles: backendUser.role.map((r) => ({
|
||||
instanceId: r.instanceId || "",
|
||||
role: r.role || "member",
|
||||
})) as Role[],
|
||||
};
|
||||
}
|
||||
|
||||
// Transform status from backend to frontend format
|
||||
function transformStatusToFrontend(
|
||||
backendStatus: "online" | "offline" | "dnd" | "idle" | "invis",
|
||||
): UserStatus {
|
||||
switch (backendStatus) {
|
||||
case "dnd":
|
||||
return "busy";
|
||||
case "idle":
|
||||
return "away";
|
||||
case "invis":
|
||||
return "offline";
|
||||
default:
|
||||
return "online";
|
||||
}
|
||||
}
|
||||
|
||||
// Transform status from frontend to backend format
|
||||
export function transformStatusToBackend(
|
||||
frontendStatus: UserStatus,
|
||||
): "online" | "offline" | "dnd" | "idle" | "invis" {
|
||||
switch (frontendStatus) {
|
||||
case "busy":
|
||||
return "dnd";
|
||||
case "away":
|
||||
return "idle";
|
||||
case "offline":
|
||||
return "invis";
|
||||
default:
|
||||
return "online";
|
||||
}
|
||||
}
|
||||
|
||||
// Hook for login
|
||||
export const useLogin = () => {
|
||||
const { setAuth, setLoading } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
credentials: LoginCredentials,
|
||||
): Promise<AuthResponse> => {
|
||||
setLoading(true);
|
||||
return authClient.login(credentials);
|
||||
},
|
||||
onSuccess: (data: AuthResponse) => {
|
||||
const frontendUser = transformBackendUser(data.user);
|
||||
setAuth(frontendUser, data.token, data.token); // Use token as refresh token for now
|
||||
queryClient.clear();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Login failed:", error);
|
||||
setLoading(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for registration (requires admin)
|
||||
export const useRegister = () => {
|
||||
const { setAuth, setLoading, user, token } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
|
||||
setLoading(true);
|
||||
|
||||
if (!user || !token || !user.admin) {
|
||||
throw new Error("Admin privileges required for user creation");
|
||||
}
|
||||
|
||||
return authClient.register(userData, { id: user.id, token });
|
||||
},
|
||||
onSuccess: (data: AuthResponse) => {
|
||||
const frontendUser = transformBackendUser(data.user);
|
||||
setAuth(frontendUser, data.token, data.token);
|
||||
queryClient.clear();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Registration failed:", error);
|
||||
setLoading(false);
|
||||
},
|
||||
onSettled: () => {
|
||||
setLoading(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for logout
|
||||
export const useLogout = () => {
|
||||
const { logout, user } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<void> => {
|
||||
if (user) {
|
||||
try {
|
||||
await authClient.logout(user.id);
|
||||
} catch (error) {
|
||||
console.warn("Logout endpoint failed:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
logout();
|
||||
queryClient.clear();
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Logout failed:", error);
|
||||
// Still logout locally even if server request fails
|
||||
logout();
|
||||
queryClient.clear();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for token validation
|
||||
export const useValidateToken = () => {
|
||||
const { token, user, setAuth, logout } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<{ valid: boolean; user?: BackendUser }> => {
|
||||
if (!token || !user) {
|
||||
throw new Error("No token to validate");
|
||||
}
|
||||
return authClient.validateToken(token, user.id);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!data.valid) {
|
||||
logout();
|
||||
} else if (data.user) {
|
||||
const frontendUser = transformBackendUser(data.user);
|
||||
setAuth(frontendUser, token!, useAuthStore.getState().refreshToken!);
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Token validation failed:", error);
|
||||
logout();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for token refresh
|
||||
export const useRefreshToken = () => {
|
||||
const { refreshToken, user, setAuth, logout } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<AuthResponse> => {
|
||||
if (!refreshToken || !user) {
|
||||
throw new Error("No refresh token available");
|
||||
}
|
||||
return authClient.refreshToken(refreshToken, user.id);
|
||||
},
|
||||
onSuccess: (data: AuthResponse) => {
|
||||
const frontendUser = transformBackendUser(data.user);
|
||||
setAuth(frontendUser, data.token, data.token);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Token refresh failed:", error);
|
||||
logout();
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Message } from "@/types/database";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
// Sample messages data
|
||||
export const SAMPLE_MESSAGES = [
|
||||
{
|
||||
id: "1",
|
||||
content: "Hey everyone! Just finished the new theme system. Check it out!",
|
||||
channelId: "1", // general channel
|
||||
userId: "1",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content:
|
||||
"Looking great! The dark mode especially feels much better on the eyes 👀",
|
||||
channelId: "1",
|
||||
userId: "2",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
content: "Can we add a **high contrast mode** for accessibility?",
|
||||
channelId: "1",
|
||||
userId: "3",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
content:
|
||||
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
|
||||
channelId: "1",
|
||||
userId: "3",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
content:
|
||||
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
|
||||
channelId: "1",
|
||||
userId: "1",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
||||
},
|
||||
// Messages for random channel
|
||||
{
|
||||
id: "6",
|
||||
content: "Anyone up for a game tonight?",
|
||||
channelId: "2", // random channel
|
||||
userId: "2",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
content: "I'm in! What are we playing?",
|
||||
channelId: "2",
|
||||
userId: "1",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
export const useChannelMessages = (channelId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["messages", channelId],
|
||||
queryFn: async (): Promise<Message[]> => {
|
||||
if (!channelId) return [];
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
|
||||
},
|
||||
enabled: !!channelId,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
});
|
||||
};
|
||||
313
concord-client/src/hooks/useMessages.ts
Normal file
313
concord-client/src/hooks/useMessages.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiClient, Message } from "@/lib/api-client";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
// Hook for getting messages in a channel with pagination
|
||||
export const useChannelMessages = (channelId?: string, limit = 50) => {
|
||||
return useQuery({
|
||||
queryKey: ["messages", channelId, limit],
|
||||
queryFn: async (): Promise<Message[]> => {
|
||||
if (!channelId) return [];
|
||||
|
||||
try {
|
||||
const date = new Date();
|
||||
const messages = await apiClient.getMessages({
|
||||
date: date.toISOString(),
|
||||
channelId: channelId,
|
||||
});
|
||||
|
||||
return messages || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch messages:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!channelId,
|
||||
staleTime: 1000 * 60 * 1,
|
||||
refetchInterval: 1000 * 30,
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for getting older messages (pagination)
|
||||
export const useChannelMessagesPaginated = (
|
||||
channelId?: string,
|
||||
beforeDate?: Date,
|
||||
limit = 50,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"messages",
|
||||
channelId,
|
||||
"paginated",
|
||||
beforeDate?.toISOString(),
|
||||
limit,
|
||||
],
|
||||
queryFn: async (): Promise<Message[]> => {
|
||||
if (!channelId || !beforeDate) return [];
|
||||
|
||||
try {
|
||||
const messages = await apiClient.getMessages({
|
||||
date: beforeDate.toISOString(),
|
||||
channelId: channelId,
|
||||
});
|
||||
|
||||
return messages || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch paginated messages:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!channelId && !!beforeDate,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for sending messages
|
||||
export const useSendMessage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
channelId: string;
|
||||
content: string;
|
||||
repliedMessageId?: string | null;
|
||||
}) => {
|
||||
if (!user || !token) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
channelId: data.channelId,
|
||||
userId: user.id,
|
||||
content: data.content,
|
||||
token: token,
|
||||
repliedMessageId: data.repliedMessageId,
|
||||
};
|
||||
|
||||
try {
|
||||
const message = await apiClient.sendMessage(requestData);
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.error("Failed to send message:", error);
|
||||
throw new Error("Failed to send message");
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["messages", variables.channelId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Send message failed:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for deleting messages
|
||||
export const useDeleteMessage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: { messageId: string; channelId: string }) => {
|
||||
if (!user || !token) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call when available
|
||||
|
||||
return { success: true, messageId: data.messageId };
|
||||
},
|
||||
onSuccess: (result, variables) => {
|
||||
// Update the cache to mark message as deleted
|
||||
queryClient.setQueryData(
|
||||
["messages", variables.channelId],
|
||||
(oldData: Message[] | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
return oldData.map((msg) =>
|
||||
msg.id === result.messageId
|
||||
? { ...msg, content: "[Message deleted]", deleted: true }
|
||||
: msg,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Delete message failed:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for editing messages
|
||||
export const useEditMessage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
messageId: string;
|
||||
content: string;
|
||||
channelId: string;
|
||||
}) => {
|
||||
if (!user || !token) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call when available
|
||||
console.log(
|
||||
"Editing message:",
|
||||
data.messageId,
|
||||
"New content:",
|
||||
data.content,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.messageId,
|
||||
content: data.content,
|
||||
edited: true,
|
||||
};
|
||||
},
|
||||
onSuccess: (result, variables) => {
|
||||
// Update the cache with edited message
|
||||
queryClient.setQueryData(
|
||||
["messages", variables.channelId],
|
||||
(oldData: Message[] | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
return oldData.map((msg) =>
|
||||
msg.id === result.messageId
|
||||
? { ...msg, content: result.content, edited: result.edited }
|
||||
: msg,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Edit message failed:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for pinning/unpinning messages
|
||||
export const usePinMessage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
pinned: boolean;
|
||||
}) => {
|
||||
if (!user || !token) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call when available
|
||||
console.log(
|
||||
`${data.pinned ? "Pinning" : "Unpinning"} message:`,
|
||||
data.messageId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: data.messageId,
|
||||
pinned: data.pinned,
|
||||
};
|
||||
},
|
||||
onSuccess: (result, variables) => {
|
||||
// Update the cache with pinned status
|
||||
queryClient.setQueryData(
|
||||
["messages", variables.channelId],
|
||||
(oldData: Message[] | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
return oldData.map((msg) =>
|
||||
msg.id === result.messageId
|
||||
? { ...msg, pinned: result.pinned }
|
||||
: msg,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Also invalidate pinned messages query if it exists
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["pinned-messages", variables.channelId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Pin message failed:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for getting pinned messages
|
||||
export const usePinnedMessages = (channelId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["pinned-messages", channelId],
|
||||
queryFn: async (): Promise<Message[]> => {
|
||||
if (!channelId) return [];
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual API call when available
|
||||
// For now, return empty array
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch pinned messages:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
enabled: !!channelId,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for loading more messages (infinite scroll)
|
||||
export const useLoadMoreMessages = (channelId?: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: { beforeDate: Date }) => {
|
||||
if (!channelId) return [];
|
||||
|
||||
try {
|
||||
const messages = await apiClient.getMessages({
|
||||
date: data.beforeDate.toISOString(),
|
||||
channelId: channelId,
|
||||
});
|
||||
|
||||
return messages || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to load more messages:", error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
onSuccess: (newMessages) => {
|
||||
if (newMessages.length > 0) {
|
||||
// Prepend new messages to existing messages
|
||||
queryClient.setQueryData(
|
||||
["messages", channelId],
|
||||
(oldData: Message[] | undefined) => {
|
||||
if (!oldData) return newMessages;
|
||||
|
||||
// Remove duplicates and sort by creation date
|
||||
const combined = [...newMessages, ...oldData];
|
||||
const unique = combined.filter(
|
||||
(msg, index, arr) =>
|
||||
arr.findIndex((m) => m.id === msg.id) === index,
|
||||
);
|
||||
|
||||
return unique.sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() -
|
||||
new Date(b.createdAt).getTime(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,334 +1,117 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Instance, User } from "@/types/database";
|
||||
import { InstanceWithDetails } from "@/types/api";
|
||||
import { CategoryWithChannels } from "@/types/api";
|
||||
// src/hooks/useServers.ts - Fixed with proper types
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
apiClient,
|
||||
Instance,
|
||||
Category,
|
||||
Channel,
|
||||
BackendUser,
|
||||
} from "@/lib/api-client";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
|
||||
// Sample users data with proper Role structure
|
||||
export const SAMPLE_USERS: User[] = [
|
||||
{
|
||||
id: "1",
|
||||
username: "alice_dev",
|
||||
nickname: "Alice",
|
||||
bio: "Frontend developer who loves React",
|
||||
picture: null,
|
||||
banner: null,
|
||||
status: "online" as const,
|
||||
// Extended types with relations for frontend use
|
||||
export interface CategoryWithChannels extends Category {
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
export interface InstanceWithDetails extends Instance {
|
||||
categories: CategoryWithChannels[];
|
||||
}
|
||||
|
||||
// Transform backend user to frontend user format for compatibility
|
||||
function transformBackendUserToFrontend(backendUser: BackendUser) {
|
||||
return {
|
||||
id: backendUser.id,
|
||||
username: backendUser.userName,
|
||||
nickname: backendUser.nickName,
|
||||
bio: backendUser.bio,
|
||||
picture: backendUser.picture,
|
||||
banner: backendUser.banner,
|
||||
hashPassword: "",
|
||||
admin: false,
|
||||
admin: backendUser.admin,
|
||||
status:
|
||||
backendUser.status === "dnd"
|
||||
? "busy"
|
||||
: backendUser.status === "idle"
|
||||
? "away"
|
||||
: backendUser.status === "invis"
|
||||
? "offline"
|
||||
: backendUser.status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
roles: [], // Will be populated per instance
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
username: "bob_designer",
|
||||
nickname: "Bob",
|
||||
bio: "UI/UX Designer & Coffee Enthusiast",
|
||||
picture:
|
||||
"https://media.istockphoto.com/id/1682296067/photo/happy-studio-portrait-or-professional-man-real-estate-agent-or-asian-businessman-smile-for.jpg?s=612x612&w=0&k=20&c=9zbG2-9fl741fbTWw5fNgcEEe4ll-JegrGlQQ6m54rg=",
|
||||
banner: null,
|
||||
status: "away" as const,
|
||||
hashPassword: "",
|
||||
admin: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
username: "charlie_backend",
|
||||
nickname: "Charlie",
|
||||
bio: "Backend wizard, scaling systems since 2018",
|
||||
picture: null,
|
||||
banner: null,
|
||||
status: "busy" as const,
|
||||
hashPassword: "",
|
||||
admin: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: "current",
|
||||
username: "you",
|
||||
nickname: "You",
|
||||
bio: "That's you!",
|
||||
picture: null,
|
||||
banner: null,
|
||||
status: "online" as const,
|
||||
hashPassword: "",
|
||||
admin: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
roles: [],
|
||||
},
|
||||
];
|
||||
roles: backendUser.role.map((r) => ({
|
||||
instanceId: r.instanceId || "",
|
||||
role: r.role || "member",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Sample servers data
|
||||
const SAMPLE_SERVERS: Instance[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Dev Team",
|
||||
icon: null,
|
||||
description: "Our development team server",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Gaming Squad",
|
||||
icon: null,
|
||||
description: "Gaming and fun times",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Book Club",
|
||||
icon: null,
|
||||
description: "Monthly book discussions",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// Sample messages data
|
||||
export const SAMPLE_MESSAGES = [
|
||||
{
|
||||
id: "1",
|
||||
content: "Hey everyone! Just finished the new theme system. Check it out!",
|
||||
channelId: "1", // general channel
|
||||
userId: "1",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
content:
|
||||
"Looking great! The dark mode especially feels much better on the eyes 👀",
|
||||
channelId: "1",
|
||||
userId: "2",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 4 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
content: "Can we add a **high contrast mode** for accessibility?",
|
||||
channelId: "1",
|
||||
userId: "3",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
content:
|
||||
"```typescript\nconst theme = {\n primary: 'oklch(0.6 0.2 240)',\n secondary: 'oklch(0.8 0.1 60)'\n};\n```\nHere's how the new color system works!",
|
||||
channelId: "1",
|
||||
userId: "3",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
content:
|
||||
"Perfect timing! I was just about to ask about the color format. _OKLCH_ is so much better than HSL for this.",
|
||||
channelId: "1",
|
||||
userId: "1",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 1 * 60 * 1000).toISOString(),
|
||||
},
|
||||
// Messages for random channel
|
||||
{
|
||||
id: "6",
|
||||
content: "Anyone up for a game tonight?",
|
||||
channelId: "2", // random channel
|
||||
userId: "2",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
content: "I'm in! What are we playing?",
|
||||
channelId: "2",
|
||||
userId: "1",
|
||||
edited: false,
|
||||
createdAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - 25 * 60 * 1000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
// Sample categories with channels
|
||||
const createSampleCategories = (instanceId: string): CategoryWithChannels[] => [
|
||||
{
|
||||
id: "1",
|
||||
name: "Text Channels",
|
||||
instanceId: instanceId,
|
||||
position: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
channels: [
|
||||
{
|
||||
id: "1",
|
||||
name: "general",
|
||||
type: "text",
|
||||
categoryId: "1",
|
||||
instanceId: instanceId,
|
||||
position: 0,
|
||||
description: "General discussion about development and projects",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "random",
|
||||
type: "text",
|
||||
categoryId: "1",
|
||||
instanceId: instanceId,
|
||||
position: 1,
|
||||
description: "Random chat and off-topic discussions",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "announcements",
|
||||
type: "text",
|
||||
categoryId: "1",
|
||||
instanceId: instanceId,
|
||||
position: 2,
|
||||
description: "Important announcements and updates",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Voice Channels",
|
||||
instanceId: instanceId,
|
||||
position: 1,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
channels: [
|
||||
{
|
||||
id: "4",
|
||||
name: "General",
|
||||
type: "voice",
|
||||
categoryId: "2",
|
||||
instanceId: instanceId,
|
||||
position: 0,
|
||||
description: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
name: "Focus Room",
|
||||
type: "voice",
|
||||
categoryId: "2",
|
||||
instanceId: instanceId,
|
||||
position: 1,
|
||||
description: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Project Channels",
|
||||
instanceId: instanceId,
|
||||
position: 2,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
channels: [
|
||||
{
|
||||
id: "6",
|
||||
name: "frontend",
|
||||
type: "text",
|
||||
categoryId: "3",
|
||||
instanceId: instanceId,
|
||||
position: 0,
|
||||
description: "Frontend development discussions",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "7",
|
||||
name: "backend",
|
||||
type: "text",
|
||||
categoryId: "3",
|
||||
instanceId: instanceId,
|
||||
position: 1,
|
||||
description: "Backend and API development",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Placeholder hook for channels by instance
|
||||
export const useChannels = (instanceId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["channels", instanceId],
|
||||
queryFn: async (): Promise<CategoryWithChannels[]> => {
|
||||
if (!instanceId) return [];
|
||||
|
||||
return createSampleCategories(instanceId);
|
||||
},
|
||||
enabled: !!instanceId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for getting messages in a channel
|
||||
export const useChannelMessages = (channelId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["messages", channelId],
|
||||
queryFn: async () => {
|
||||
if (!channelId) return [];
|
||||
|
||||
// Return messages for this channel
|
||||
return SAMPLE_MESSAGES.filter((msg) => msg.channelId === channelId);
|
||||
},
|
||||
enabled: !!channelId,
|
||||
staleTime: 1000 * 60 * 1, // 1 minute (messages are more dynamic)
|
||||
});
|
||||
};
|
||||
|
||||
// Placeholder hook for servers/instances
|
||||
// Hook for getting all servers/instances
|
||||
export const useServers = () => {
|
||||
return useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: async (): Promise<Instance[]> => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return SAMPLE_SERVERS;
|
||||
try {
|
||||
const instances = await apiClient.getInstances();
|
||||
return instances;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch servers:", error);
|
||||
throw new Error("Failed to fetch servers");
|
||||
}
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for getting detailed instance info with categories and channels
|
||||
export const useInstanceDetails = (instanceId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["instance", instanceId],
|
||||
queryFn: async (): Promise<InstanceWithDetails | null> => {
|
||||
if (!instanceId) return null;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const server = SAMPLE_SERVERS.find((s) => s.id === instanceId);
|
||||
if (!server) return null;
|
||||
try {
|
||||
// Get instance basic info
|
||||
const instances = await apiClient.getInstances();
|
||||
const instance = instances.find((s) => s.id === instanceId);
|
||||
if (!instance) return null;
|
||||
|
||||
return {
|
||||
...server,
|
||||
categories: createSampleCategories(instanceId),
|
||||
};
|
||||
// Get categories for this instance
|
||||
const categories = await apiClient.getCategoriesByInstance(instanceId);
|
||||
|
||||
// For each category, get its channels
|
||||
const categoriesWithChannels: CategoryWithChannels[] =
|
||||
await Promise.all(
|
||||
categories.map(async (category): Promise<CategoryWithChannels> => {
|
||||
try {
|
||||
const channels = await apiClient.getChannelsByCategory(
|
||||
category.id,
|
||||
);
|
||||
return {
|
||||
...category,
|
||||
channels: channels || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to fetch channels for category ${category.id}:`,
|
||||
error,
|
||||
);
|
||||
return {
|
||||
...category,
|
||||
channels: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...instance,
|
||||
categories: categoriesWithChannels,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch instance details:", error);
|
||||
throw new Error("Failed to fetch instance details");
|
||||
}
|
||||
},
|
||||
enabled: !!instanceId,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
@@ -339,21 +122,149 @@ export const useInstanceDetails = (instanceId?: string) => {
|
||||
export const useInstanceMembers = (instanceId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["instance", instanceId, "members"],
|
||||
queryFn: async (): Promise<User[]> => {
|
||||
queryFn: async () => {
|
||||
if (!instanceId) return [];
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
return SAMPLE_USERS.map((user, index) => ({
|
||||
...user,
|
||||
roles: [
|
||||
{
|
||||
instanceId: instanceId,
|
||||
role: index === 0 ? "admin" : index === 1 ? "mod" : "member",
|
||||
},
|
||||
],
|
||||
}));
|
||||
try {
|
||||
const backendUsers = await apiClient.getUsersByInstance(instanceId);
|
||||
// Transform backend users to frontend format for compatibility
|
||||
return backendUsers.map(transformBackendUserToFrontend);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch instance members:", error);
|
||||
throw new Error("Failed to fetch instance members");
|
||||
}
|
||||
},
|
||||
enabled: !!instanceId,
|
||||
staleTime: 1000 * 60 * 2,
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for creating a new server/instance
|
||||
export const useCreateInstance = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: { name: string; icon?: string }) => {
|
||||
if (!user || !token) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
...data,
|
||||
requestingUserId: user.id,
|
||||
requestingUserToken: token,
|
||||
};
|
||||
|
||||
try {
|
||||
const instance = await apiClient.createInstance(requestData);
|
||||
return instance;
|
||||
} catch (error) {
|
||||
console.error("Failed to create instance:", error);
|
||||
throw new Error("Failed to create instance");
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate servers list to refetch
|
||||
queryClient.invalidateQueries({ queryKey: ["servers"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for creating a new category
|
||||
export const useCreateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
name: string;
|
||||
instanceId?: string;
|
||||
position: number;
|
||||
}) => {
|
||||
if (!user || !token) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
...data,
|
||||
admin: user.admin,
|
||||
requestingUserId: user.id,
|
||||
requestingUserToken: token,
|
||||
};
|
||||
|
||||
try {
|
||||
const category = await apiClient.createCategory(requestData);
|
||||
return category;
|
||||
} catch (error) {
|
||||
console.error("Failed to create category:", error);
|
||||
throw new Error("Failed to create category");
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate instance details to refetch categories
|
||||
if (variables.instanceId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["instance", variables.instanceId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Hook for creating a new channel
|
||||
export const useCreateChannel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { user, token } = useAuthStore();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: {
|
||||
type: "text" | "voice";
|
||||
name: string;
|
||||
description: string;
|
||||
categoryId?: string;
|
||||
}) => {
|
||||
if (!user || !token) {
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const requestData = {
|
||||
...data,
|
||||
admin: user.admin,
|
||||
requestingUserId: user.id,
|
||||
requestingUserToken: token,
|
||||
};
|
||||
|
||||
try {
|
||||
const channel = await apiClient.createChannel(requestData);
|
||||
return channel;
|
||||
} catch (error) {
|
||||
console.error("Failed to create channel:", error);
|
||||
throw new Error("Failed to create channel");
|
||||
}
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate related queries
|
||||
if (variables.categoryId) {
|
||||
// Find the instance this category belongs to and invalidate it
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["instance"],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Placeholder hook for channels by instance (for backward compatibility)
|
||||
export const useChannels = (instanceId?: string) => {
|
||||
const { data: instance } = useInstanceDetails(instanceId);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["channels", instanceId],
|
||||
queryFn: async (): Promise<CategoryWithChannels[]> => {
|
||||
return instance?.categories || [];
|
||||
},
|
||||
enabled: !!instanceId && !!instance,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
});
|
||||
};
|
||||
|
||||
351
concord-client/src/lib/api-client.ts
Normal file
351
concord-client/src/lib/api-client.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
// Base API configuration
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:3000";
|
||||
|
||||
// Enhanced QueryClient with error handling
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
refetchOnWindowFocus: true,
|
||||
retry: (failureCount, error: any) => {
|
||||
// Don't retry on auth errors
|
||||
if (error?.status === 401 || error?.status === 403) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
},
|
||||
mutations: {
|
||||
retry: (failureCount, error: any) => {
|
||||
if (error?.status === 401 || error?.status === 403) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// API Response types based on your backend
|
||||
export interface ApiResponse<T> {
|
||||
success?: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Specific response types for your backend
|
||||
export interface Instance {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
instanceId: string;
|
||||
position: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "voice";
|
||||
categoryId: string;
|
||||
instanceId: string;
|
||||
position: number;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BackendUser {
|
||||
id: string;
|
||||
userName: string;
|
||||
nickName: string | null;
|
||||
bio: string | null;
|
||||
picture: string | null;
|
||||
banner: string | null;
|
||||
admin: boolean;
|
||||
status: "online" | "offline" | "dnd" | "idle" | "invis";
|
||||
role: Array<{
|
||||
userId: string;
|
||||
instanceId: string;
|
||||
role?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
channelId: string;
|
||||
userId: string;
|
||||
edited: boolean;
|
||||
createdAt: string;
|
||||
deleted: boolean;
|
||||
updatedAt: string;
|
||||
replyToId?: string | null;
|
||||
user?: BackendUser;
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper with auth and error handling
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string = API_BASE_URL) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
return headers;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const headers = await this.getAuthHeaders();
|
||||
|
||||
const config: RequestInit = {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Handle auth error - logout user
|
||||
const authStore = await import("@/stores/authStore");
|
||||
authStore.useAuthStore.getState().logout();
|
||||
throw new Error("Authentication required");
|
||||
}
|
||||
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Unknown error" }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
console.error(`API request failed: ${endpoint}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance/Server methods
|
||||
async getInstances(): Promise<Instance[]> {
|
||||
const response = await this.request<
|
||||
{ success: boolean; data: Instance[] } | Instance[]
|
||||
>("/api/instance");
|
||||
|
||||
// Handle both wrapped and direct responses
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if ("data" in response && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async createInstance(data: {
|
||||
name: string;
|
||||
icon?: string;
|
||||
requestingUserId: string;
|
||||
requestingUserToken: string;
|
||||
}): Promise<Instance> {
|
||||
return this.request<Instance>("/api/instance", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Categories methods
|
||||
async getCategoriesByInstance(instanceId: string): Promise<Category[]> {
|
||||
try {
|
||||
const response = await this.request<Category[] | { data: Category[] }>(
|
||||
`/api/category/instance/${instanceId}`,
|
||||
);
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if ("data" in response && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Categories endpoint not available for instance ${instanceId}`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createCategory(data: {
|
||||
name: string;
|
||||
position: number;
|
||||
instanceId?: string;
|
||||
admin: boolean;
|
||||
requestingUserId: string;
|
||||
requestingUserToken: string;
|
||||
}): Promise<Category> {
|
||||
return this.request<Category>("/api/category", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Channel methods
|
||||
async getChannelsByCategory(categoryId: string): Promise<Channel[]> {
|
||||
try {
|
||||
const response = await this.request<Channel[] | { data: Channel[] }>(
|
||||
`/api/channel/category/${categoryId}`,
|
||||
);
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if ("data" in response && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Channels endpoint not available for category ${categoryId}`,
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async createChannel(data: {
|
||||
type: "text" | "voice";
|
||||
name: string;
|
||||
description: string;
|
||||
categoryId?: string;
|
||||
admin: boolean;
|
||||
requestingUserId: string;
|
||||
requestingUserToken: string;
|
||||
}): Promise<Channel> {
|
||||
return this.request<Channel>("/api/channel", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Message methods
|
||||
async getMessages(params: {
|
||||
date: string;
|
||||
channelId: string;
|
||||
}): Promise<Message[]> {
|
||||
const query = new URLSearchParams(params);
|
||||
const response = await this.request<Message[] | { data: Message[] }>(
|
||||
`/api/message?${query}`,
|
||||
);
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if ("data" in response && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async sendMessage(data: {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
token: string;
|
||||
repliedMessageId?: string | null;
|
||||
}): Promise<Message> {
|
||||
return this.request<Message>("/api/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// User methods
|
||||
async getUsersByInstance(instanceId: string): Promise<BackendUser[]> {
|
||||
const query = new URLSearchParams({ instanceId });
|
||||
const response = await this.request<
|
||||
BackendUser[] | { data: BackendUser[] }
|
||||
>(`/api/user?${query}`);
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if ("data" in response && Array.isArray(response.data)) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async getUser(id: string): Promise<BackendUser> {
|
||||
const response = await this.request<BackendUser | { data: BackendUser }>(
|
||||
`/api/user/${id}`,
|
||||
);
|
||||
|
||||
if ("data" in response) {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return response as BackendUser;
|
||||
}
|
||||
|
||||
async createUser(data: {
|
||||
username: string;
|
||||
nickname?: string;
|
||||
bio?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
status?: "online" | "offline" | "dnd" | "idle" | "invis";
|
||||
admin?: boolean;
|
||||
requestingUserId: string;
|
||||
requestingUserToken: string;
|
||||
passwordhash: string;
|
||||
}): Promise<{ success: boolean; data?: BackendUser; error?: string }> {
|
||||
try {
|
||||
const response = await this.request<BackendUser>("/api/user", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return { success: true, data: response };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const apiClient = new ApiClient();
|
||||
175
concord-client/src/lib/auth-client.ts
Normal file
175
concord-client/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { apiClient, BackendUser } from "./api-client";
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
bio?: string;
|
||||
picture?: string;
|
||||
banner?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: BackendUser;
|
||||
token: string;
|
||||
}
|
||||
|
||||
class AuthClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(
|
||||
baseUrl: string = import.meta.env.VITE_API_URL || "http://localhost:3000",
|
||||
) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Login failed" }));
|
||||
throw new Error(errorData.error || "Login failed");
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
throw error instanceof Error ? error : new Error("Login failed");
|
||||
}
|
||||
}
|
||||
|
||||
async register(
|
||||
data: RegisterData,
|
||||
adminUser: { id: string; token: string },
|
||||
): Promise<AuthResponse> {
|
||||
try {
|
||||
const createUserData = {
|
||||
username: data.username,
|
||||
nickname: data.nickname,
|
||||
bio: data.bio,
|
||||
picture: data.picture,
|
||||
banner: data.banner,
|
||||
status: "online" as const,
|
||||
admin: false,
|
||||
requestingUserId: adminUser.id,
|
||||
requestingUserToken: adminUser.token,
|
||||
passwordhash: data.password,
|
||||
};
|
||||
|
||||
const response = await apiClient.createUser(createUserData);
|
||||
|
||||
if (!response.success || !response.data) {
|
||||
throw new Error(response.error || "Registration failed");
|
||||
}
|
||||
try {
|
||||
const loginResponse = await this.login({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
});
|
||||
return loginResponse;
|
||||
} catch (loginError) {
|
||||
throw new Error(
|
||||
"Registration successful, but auto-login failed. Please login manually.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Registration failed:", error);
|
||||
throw error instanceof Error ? error : new Error("Registration failed");
|
||||
}
|
||||
}
|
||||
|
||||
async validateToken(
|
||||
token: string,
|
||||
userId: string,
|
||||
): Promise<{ valid: boolean; user?: BackendUser }> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/validate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ token, userId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
const data: { valid: boolean; user?: BackendUser } =
|
||||
await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Token validation failed:", error);
|
||||
return { valid: false };
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(oldToken: string, userId: string): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ userId, oldToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ error: "Token refresh failed" }));
|
||||
throw new Error(errorData.error || "Token refresh failed");
|
||||
}
|
||||
|
||||
const data: AuthResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Token refresh failed:", error);
|
||||
throw error instanceof Error ? error : new Error("Token refresh failed");
|
||||
}
|
||||
}
|
||||
|
||||
async logout(userId: string): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
"Logout endpoint failed, but continuing with local logout",
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => ({ success: true }));
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.warn("Logout request failed:", error);
|
||||
return { success: true }; // Always succeed locally
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authClient = new AuthClient();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,13 +10,17 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useLogin } from "@/hooks/useAuth";
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const { isAuthenticated, setAuth } = useAuthStore();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Use the real login hook
|
||||
const { mutate: login, isPending, error } = useLogin();
|
||||
|
||||
// Redirect if already authenticated
|
||||
if (isAuthenticated) {
|
||||
@@ -25,35 +29,12 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// TODO: Replace with actual login API call
|
||||
setTimeout(() => {
|
||||
setAuth(
|
||||
{
|
||||
id: "1",
|
||||
username,
|
||||
nickname: username,
|
||||
bio: "Test user",
|
||||
picture: "",
|
||||
banner: "",
|
||||
hashPassword: "",
|
||||
admin: false,
|
||||
status: "online",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
roles: [],
|
||||
},
|
||||
"fake-token",
|
||||
"fake-refresh-token",
|
||||
);
|
||||
setIsLoading(false);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Login failed:", error);
|
||||
setIsLoading(false);
|
||||
if (!username.trim() || !password.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
login({ username: username.trim(), password });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -69,6 +50,16 @@ const LoginPage: React.FC = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Login failed. Please try again."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-concord-primary">
|
||||
Username
|
||||
@@ -80,9 +71,11 @@ const LoginPage: React.FC = () => {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="bg-concord-tertiary border-concord text-concord-primary"
|
||||
placeholder="Enter your username"
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-concord-primary">
|
||||
Password
|
||||
@@ -94,11 +87,17 @@ const LoginPage: React.FC = () => {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="bg-concord-tertiary border-concord text-concord-primary"
|
||||
placeholder="Enter your password"
|
||||
disabled={isPending}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Logging in..." : "Log In"}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isPending || !username.trim() || !password.trim()}
|
||||
>
|
||||
{isPending ? "Logging in..." : "Log In"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
Mic,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ThemeSelector } from "@/components/theme-selector";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
@@ -44,6 +45,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: "appearance",
|
||||
title: "Appearance",
|
||||
@@ -58,6 +65,389 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const SecuritySettings: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [isChangingPassword, setIsChangingPassword] = useState(false);
|
||||
const [passwordError, setPasswordError] = useState("");
|
||||
const [passwordSuccess, setPasswordSuccess] = useState("");
|
||||
const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
|
||||
|
||||
const handlePasswordChange = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPasswordError("");
|
||||
setPasswordSuccess("");
|
||||
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setPasswordError("All password fields are required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError("New password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsChangingPassword(true);
|
||||
|
||||
try {
|
||||
// TODO: Implement actual password change API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
console.log("Changing password for user:", user?.id);
|
||||
// const result = await authClient.changePassword({
|
||||
// userId: user.id,
|
||||
// currentPassword,
|
||||
// newPassword,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
setPasswordSuccess("Password changed successfully");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (error) {
|
||||
setPasswordError(
|
||||
"Failed to change password. Please check your current password.",
|
||||
);
|
||||
} finally {
|
||||
setIsChangingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Lock className="h-5 w-5" />
|
||||
Change Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password to keep your account secure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{passwordError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{passwordError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<Alert>
|
||||
<AlertDescription>{passwordSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handlePasswordChange} className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current-password">Current Password</Label>
|
||||
<Input
|
||||
id="current-password"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="max-w-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
isChangingPassword ||
|
||||
!currentPassword ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
}
|
||||
>
|
||||
{isChangingPassword
|
||||
? "Changing Password..."
|
||||
: "Change Password"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Security Options
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Additional security features for your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Two-Factor Authentication
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add an extra layer of security to your account
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={twoFactorEnabled}
|
||||
onCheckedChange={setTwoFactorEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">Active Sessions</Label>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Manage devices that are currently logged into your account
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
View Active Sessions
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base font-medium">Account Backup</Label>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Download a copy of your account data
|
||||
</p>
|
||||
<Button variant="outline" size="sm">
|
||||
Request Data Export
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountSettings: React.FC = () => {
|
||||
const { user, updateUser } = useAuthStore();
|
||||
const [username, setUsername] = useState(user?.username || "");
|
||||
const [nickname, setNickname] = useState(user?.nickname || "");
|
||||
const [bio, setBio] = useState(user?.bio || "");
|
||||
const [isChanged, setIsChanged] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [saveSuccess, setSaveSuccess] = useState("");
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaveError("");
|
||||
setSaveSuccess("");
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
// TODO: Implement actual profile update API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
console.log("Updating profile:", { username, nickname, bio });
|
||||
// const updatedUser = await userClient.updateProfile({
|
||||
// userId: user.id,
|
||||
// username: username.trim(),
|
||||
// nickname: nickname.trim() || null,
|
||||
// bio: bio.trim() || null,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
// Update local state
|
||||
updateUser({
|
||||
username: username.trim(),
|
||||
nickname: nickname.trim() || null,
|
||||
bio: bio.trim() || null,
|
||||
});
|
||||
|
||||
setSaveSuccess("Profile updated successfully");
|
||||
setIsChanged(false);
|
||||
} catch (error) {
|
||||
setSaveError("Failed to update profile. Please try again.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
setIsChanged(true);
|
||||
setSaveError("");
|
||||
setSaveSuccess("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Profile
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your profile information and display settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{saveError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{saveError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{saveSuccess && (
|
||||
<Alert>
|
||||
<AlertDescription>{saveSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
handleChange();
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="nickname">Display Name</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => {
|
||||
setNickname(e.target.value);
|
||||
handleChange();
|
||||
}}
|
||||
className="max-w-sm"
|
||||
placeholder="How others see your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Input
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => {
|
||||
setBio(e.target.value);
|
||||
handleChange();
|
||||
}}
|
||||
className="max-w-sm"
|
||||
placeholder="Tell others about yourself"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button onClick={handleSave} disabled={!isChanged || isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
{isChanged && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setUsername(user?.username || "");
|
||||
setNickname(user?.nickname || "");
|
||||
setBio(user?.bio || "");
|
||||
setIsChanged(false);
|
||||
setSaveError("");
|
||||
setSaveSuccess("");
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Privacy
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control who can contact you and see your information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Allow Direct Messages
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let other users send you direct messages
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Show Online Status
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display when you're online to other users
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Appearance Settings Component (keeping existing implementation)
|
||||
const AppearanceSettings: React.FC = () => {
|
||||
const {
|
||||
currentLightTheme,
|
||||
@@ -67,12 +457,6 @@ const AppearanceSettings: React.FC = () => {
|
||||
setMode,
|
||||
setTheme,
|
||||
} = useTheme();
|
||||
const [compactMode, setCompactMode] = useState(false);
|
||||
const [showTimestamps, setShowTimestamps] = useState(true);
|
||||
const [animationsEnabled, setAnimationsEnabled] = useState(true);
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [highContrast, setHighContrast] = useState(false);
|
||||
|
||||
const lightThemes = getThemesForMode("light");
|
||||
const darkThemes = getThemesForMode("dark");
|
||||
|
||||
@@ -245,344 +629,13 @@ const AppearanceSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Grid */}
|
||||
<div className="mt-6">
|
||||
<Label className="text-sm font-medium">Available Themes</Label>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
{/* Light Themes */}
|
||||
{lightThemes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-sm">{theme.name}</span>
|
||||
<Sun className="h-4 w-4 text-yellow-500" />
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.primary }}
|
||||
/>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.secondary }}
|
||||
/>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.accent }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Dark Themes */}
|
||||
{darkThemes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-sm">{theme.name}</span>
|
||||
<Moon className="h-4 w-4 text-blue-400" />
|
||||
</div>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.primary }}
|
||||
/>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.secondary }}
|
||||
/>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: theme.colors.accent }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Stats */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-3 bg-muted/50 rounded-lg">
|
||||
<div className="text-lg font-semibold">{lightThemes.length}</div>
|
||||
<div className="text-sm text-muted-foreground">Light Themes</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-muted/50 rounded-lg">
|
||||
<div className="text-lg font-semibold">{darkThemes.length}</div>
|
||||
<div className="text-sm text-muted-foreground">Dark Themes</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Display Settings */}
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Eye className="h-5 w-5" />
|
||||
Display Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customize how content is displayed in the app.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Compact Mode</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use less space between messages and interface elements
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={compactMode} onCheckedChange={setCompactMode} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Show Message Timestamps
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display timestamps next to messages
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={showTimestamps}
|
||||
onCheckedChange={setShowTimestamps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Enable Animations</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Play animations throughout the interface
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={animationsEnabled}
|
||||
onCheckedChange={setAnimationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Accessibility */}
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Accessibility</CardTitle>
|
||||
<CardDescription>
|
||||
Settings to improve accessibility and usability.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">Reduce Motion</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Reduce motion and animations for users with vestibular disorders
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={reduceMotion} onCheckedChange={setReduceMotion} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">High Contrast</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Increase contrast for better visibility
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={highContrast} onCheckedChange={setHighContrast} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AccountSettings: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const [username, setUsername] = useState(user?.username || "");
|
||||
const [nickname, setNickname] = useState(user?.nickname || "");
|
||||
const [bio, setBio] = useState(user?.bio || "");
|
||||
const [email, setEmail] = useState("user@example.com");
|
||||
const [isChanged, setIsChanged] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
console.log("Saving profile changes:", { username, nickname, bio, email });
|
||||
setIsChanged(false);
|
||||
};
|
||||
|
||||
const handleChange = () => {
|
||||
setIsChanged(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col justify-center self-center items-stretch w-2/3">
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Profile
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your profile information and display settings.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
handleChange();
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
handleChange();
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="nickname">Display Name</Label>
|
||||
<Input
|
||||
id="nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => {
|
||||
setNickname(e.target.value);
|
||||
handleChange();
|
||||
}}
|
||||
className="max-w-sm"
|
||||
placeholder="How others see your name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="bio">Bio</Label>
|
||||
<Input
|
||||
id="bio"
|
||||
value={bio}
|
||||
onChange={(e) => {
|
||||
setBio(e.target.value);
|
||||
handleChange();
|
||||
}}
|
||||
className="max-w-sm"
|
||||
placeholder="Tell others about yourself"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<Button onClick={handleSave} disabled={!isChanged}>
|
||||
Save Changes
|
||||
</Button>
|
||||
{isChanged && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setUsername(user?.username || "");
|
||||
setNickname(user?.nickname || "");
|
||||
setBio(user?.bio || "");
|
||||
setEmail("user@example.com");
|
||||
setIsChanged(false);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="w-full p-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Privacy
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Control who can contact you and see your information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Allow Direct Messages
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Let other users send you direct messages
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-base font-medium">
|
||||
Show Online Status
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display when you're online to other users
|
||||
</p>
|
||||
</div>
|
||||
<Switch defaultChecked />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Voice Settings Component
|
||||
const VoiceSettings: React.FC = () => {
|
||||
const [inputVolume, setInputVolume] = useState(75);
|
||||
const [outputVolume, setOutputVolume] = useState(100);
|
||||
@@ -700,18 +753,20 @@ const VoiceSettings: React.FC = () => {
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { section } = useParams();
|
||||
const currentSection = section || "appearance";
|
||||
const currentSection = section || "account";
|
||||
|
||||
const renderSettingsContent = () => {
|
||||
switch (currentSection) {
|
||||
case "account":
|
||||
return <AccountSettings />;
|
||||
case "security":
|
||||
return <SecuritySettings />;
|
||||
case "appearance":
|
||||
return <AppearanceSettings />;
|
||||
case "voice":
|
||||
return <VoiceSettings />;
|
||||
default:
|
||||
return <AppearanceSettings />;
|
||||
return <AccountSettings />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -28,14 +28,15 @@ export interface Channel {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type UserStatus = "online" | "away" | "busy" | "offline";
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
bio?: string;
|
||||
nickname?: string | null;
|
||||
bio?: string | null;
|
||||
picture?: string | null;
|
||||
banner?: string | null;
|
||||
hashPassword: string; // Won't be sent to client
|
||||
hashPassword: string;
|
||||
admin: boolean;
|
||||
status: "online" | "away" | "busy" | "offline";
|
||||
createdAt: string;
|
||||
|
||||
46
concord-client/src/types/index.ts
Normal file
46
concord-client/src/types/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BackendUser } from "@/lib/api-client";
|
||||
import { Message } from "./database";
|
||||
|
||||
// API types
|
||||
export type {
|
||||
ApiResponse,
|
||||
Instance,
|
||||
Category,
|
||||
Channel,
|
||||
BackendUser,
|
||||
Message,
|
||||
} from "@/lib/api-client";
|
||||
|
||||
// Auth types
|
||||
export type {
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
AuthResponse,
|
||||
} from "@/lib/auth-client";
|
||||
|
||||
// Hook types
|
||||
export type { CategoryWithChannels, InstanceWithDetails } from "@/types/api";
|
||||
|
||||
// Frontend User type (for compatibility with existing components)
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname?: string | null;
|
||||
bio?: string | null;
|
||||
picture?: string | null;
|
||||
banner?: string | null;
|
||||
hashPassword: string;
|
||||
admin: boolean;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
roles: Array<{
|
||||
instanceId: string;
|
||||
role: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Message with user for chat components
|
||||
export interface MessageWithUser extends Message {
|
||||
user?: User | BackendUser;
|
||||
}
|
||||
198
concord-client/src/utils/permissions.ts
Normal file
198
concord-client/src/utils/permissions.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { User, Role } from "@/types/database";
|
||||
|
||||
export type UserPermission =
|
||||
| "view_instance"
|
||||
| "create_channel"
|
||||
| "delete_channel"
|
||||
| "create_category"
|
||||
| "delete_category"
|
||||
| "manage_instance"
|
||||
| "delete_messages"
|
||||
| "pin_messages"
|
||||
| "manage_users"
|
||||
| "create_instance"
|
||||
| "manage_roles";
|
||||
|
||||
export type UserRole = "admin" | "mod" | "member";
|
||||
|
||||
// Check if user has a specific role in an instance
|
||||
export function hasInstanceRole(
|
||||
user: User | null,
|
||||
instanceId: string,
|
||||
role: UserRole,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
|
||||
// Global admins have all permissions
|
||||
if (user.admin) return true;
|
||||
|
||||
const userRole = user.roles.find((r) => r.instanceId === instanceId);
|
||||
if (!userRole) return false;
|
||||
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return userRole.role === "admin";
|
||||
case "mod":
|
||||
return userRole.role === "admin" || userRole.role === "mod";
|
||||
case "member":
|
||||
return (
|
||||
userRole.role === "admin" ||
|
||||
userRole.role === "mod" ||
|
||||
userRole.role === "member"
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has access to view an instance
|
||||
export function canViewInstance(
|
||||
user: User | null,
|
||||
instanceId: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
|
||||
// Global admins can view all instances
|
||||
if (user.admin) return true;
|
||||
|
||||
// Check if user has any role in this instance
|
||||
return user.roles.some((role) => role.instanceId === instanceId);
|
||||
}
|
||||
|
||||
// Check if user has a specific permission in an instance
|
||||
export function hasPermission(
|
||||
user: User | null,
|
||||
instanceId: string,
|
||||
permission: UserPermission,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
|
||||
// Global admins have all permissions everywhere
|
||||
if (user.admin) return true;
|
||||
|
||||
const userRole = user.roles.find((r) => r.instanceId === instanceId);
|
||||
if (!userRole) return false;
|
||||
|
||||
switch (permission) {
|
||||
case "view_instance":
|
||||
return hasInstanceRole(user, instanceId, "member");
|
||||
|
||||
case "create_channel":
|
||||
case "delete_channel":
|
||||
case "create_category":
|
||||
case "delete_category":
|
||||
case "manage_instance":
|
||||
case "manage_users":
|
||||
case "manage_roles":
|
||||
return hasInstanceRole(user, instanceId, "admin");
|
||||
|
||||
case "delete_messages":
|
||||
case "pin_messages":
|
||||
return hasInstanceRole(user, instanceId, "mod");
|
||||
|
||||
case "create_instance":
|
||||
return user.admin; // Only global admins can create instances
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's role in a specific instance
|
||||
export function getUserRole(
|
||||
user: User | null,
|
||||
instanceId: string,
|
||||
): UserRole | null {
|
||||
if (!user) return null;
|
||||
|
||||
// Global admins are always admins
|
||||
if (user.admin) return "admin";
|
||||
|
||||
const userRole = user.roles.find((r) => r.instanceId === instanceId);
|
||||
return userRole ? (userRole.role as UserRole) : null;
|
||||
}
|
||||
|
||||
// Filter instances that user can access
|
||||
export function getAccessibleInstances(
|
||||
user: User | null,
|
||||
instances: any[],
|
||||
): any[] {
|
||||
if (!user) return [];
|
||||
|
||||
// Global admins can see all instances
|
||||
if (user.admin) return instances;
|
||||
|
||||
// Filter instances where user has a role
|
||||
const userInstanceIds = new Set(user.roles.map((role) => role.instanceId));
|
||||
return instances.filter((instance) => userInstanceIds.has(instance.id));
|
||||
}
|
||||
|
||||
// Check if user can delete a specific message
|
||||
export function canDeleteMessage(
|
||||
user: User | null,
|
||||
instanceId: string,
|
||||
messageUserId: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
|
||||
// Users can always delete their own messages
|
||||
if (user.id === messageUserId) return true;
|
||||
|
||||
// Mods and admins can delete any message
|
||||
return hasPermission(user, instanceId, "delete_messages");
|
||||
}
|
||||
|
||||
// Check if user can edit a specific message
|
||||
export function canEditMessage(
|
||||
user: User | null,
|
||||
messageUserId: string,
|
||||
): boolean {
|
||||
if (!user) return false;
|
||||
|
||||
// Users can only edit their own messages
|
||||
return user.id === messageUserId;
|
||||
}
|
||||
|
||||
// Check if user can pin messages
|
||||
export function canPinMessage(user: User | null, instanceId: string): boolean {
|
||||
return hasPermission(user, instanceId, "pin_messages");
|
||||
}
|
||||
|
||||
// Check if user is global admin
|
||||
export function isGlobalAdmin(user: User | null): boolean {
|
||||
return user?.admin === true;
|
||||
}
|
||||
|
||||
// Helper to get role display info
|
||||
export function getRoleDisplayInfo(role: UserRole) {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return {
|
||||
name: "Admin",
|
||||
color: "#ff6b6b",
|
||||
priority: 3,
|
||||
description: "Full server permissions",
|
||||
};
|
||||
case "mod":
|
||||
return {
|
||||
name: "Moderator",
|
||||
color: "#4ecdc4",
|
||||
priority: 2,
|
||||
description: "Can moderate messages and users",
|
||||
};
|
||||
case "member":
|
||||
return {
|
||||
name: "Member",
|
||||
color: null,
|
||||
priority: 1,
|
||||
description: "Basic server access",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: "Unknown",
|
||||
color: null,
|
||||
priority: 0,
|
||||
description: "Unknown role",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user