sad
This commit is contained in:
@@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useInstanceDetails } from "@/hooks/useServers";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import ChannelList from "@/components/channel/ChannelList";
|
||||
import { CreateCategoryModal, CreateChannelModal } from "../server/ServerIcon";
|
||||
|
||||
const ChannelSidebar: React.FC = () => {
|
||||
const { instanceId } = useParams();
|
||||
@@ -94,6 +95,8 @@ const ChannelSidebar: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Role } from "@/types/database";
|
||||
import { useInstanceMembers } from "@/hooks/useServers";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { User } from "@/types/database";
|
||||
import { UserRoleModal } from "@/components/modals/UserRoleModal";
|
||||
|
||||
// Status color utility
|
||||
const getStatusColor = (status: string) => {
|
||||
@@ -28,8 +27,6 @@ const getStatusColor = (status: string) => {
|
||||
interface MemberItemProps {
|
||||
member: User;
|
||||
instanceId: string;
|
||||
currentUserRole: string;
|
||||
canManageRoles: boolean;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
@@ -53,27 +50,17 @@ const getRoleInfo = (role: string) => {
|
||||
const MemberItem: React.FC<MemberItemProps> = ({
|
||||
member,
|
||||
instanceId,
|
||||
currentUserRole,
|
||||
canManageRoles,
|
||||
isOwner = false,
|
||||
}) => {
|
||||
const [showUserModal, setShowUserModal] = useState(false);
|
||||
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
||||
const roleInfo = getRoleInfo(userRole);
|
||||
|
||||
const handleMemberClick = () => {
|
||||
if (canManageRoles && !member.admin) {
|
||||
setShowUserModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
||||
onClick={handleMemberClick}
|
||||
disabled={!canManageRoles || member.admin}
|
||||
disabled={member.admin}
|
||||
>
|
||||
<div className="flex items-center gap-3 w-full">
|
||||
<div className="relative">
|
||||
@@ -119,16 +106,6 @@ const MemberItem: React.FC<MemberItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* User Role Modal */}
|
||||
<UserRoleModal
|
||||
isOpen={showUserModal}
|
||||
onClose={() => setShowUserModal(false)}
|
||||
user={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
canManageRoles={canManageRoles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -138,20 +115,6 @@ const MemberList: React.FC = () => {
|
||||
const { data: members, isLoading } = useInstanceMembers(instanceId);
|
||||
const { user: currentUser } = useAuthStore();
|
||||
|
||||
// Check if current user can manage roles
|
||||
const canManageRoles = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return false;
|
||||
|
||||
// Global admins can manage roles
|
||||
if (currentUser.admin) return true;
|
||||
|
||||
// Check if user is admin or mod in this instance
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole && (userRole.role === "admin" || userRole.role === "mod");
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
const currentUserRole = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return "member";
|
||||
if (currentUser.admin) return "admin";
|
||||
@@ -212,12 +175,9 @@ const MemberList: React.FC = () => {
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
|
||||
<UserIcon size={20} className="text-concord-primary h-8" />
|
||||
<div className="h-8 flex flex-col justify-center">
|
||||
<h3 className="text-sm font-semibold text-concord-secondary tracking-wide">
|
||||
Members: {members.length} Online:{" "}
|
||||
{members.filter((m) => m.status === "online").length}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-concord-secondary tracking-wide">
|
||||
{members.length} Members
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Member List */}
|
||||
@@ -242,7 +202,6 @@ const MemberList: React.FC = () => {
|
||||
member={member}
|
||||
instanceId={instanceId}
|
||||
currentUserRole={currentUserRole}
|
||||
canManageRoles={canManageRoles}
|
||||
isOwner={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import ServerIcon from "@/components/server/ServerIcon";
|
||||
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
|
||||
import { CreateServerModal } from "../modals/CreateServerModal";
|
||||
|
||||
const ServerSidebar: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -40,7 +41,7 @@ const ServerSidebar: React.FC = () => {
|
||||
|
||||
const handleHomeClick = () => {
|
||||
setActiveInstance(null);
|
||||
navigate("/channels/@me");
|
||||
navigate("/");
|
||||
};
|
||||
|
||||
const handleCreateServer = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,21 +16,22 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Hash, Volume2 } from "lucide-react";
|
||||
import { Hash, Volume2, Loader2 } from "lucide-react";
|
||||
import { useCreateChannel } from "@/hooks/useServers";
|
||||
import { useCategoriesByInstance } from "@/hooks/useCategories"; // New hook
|
||||
import { CategoryWithChannels } from "@/types/api";
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
categories: CategoryWithChannels[];
|
||||
instanceId: string; // Changed to use instanceId instead of categories prop
|
||||
defaultCategoryId?: string;
|
||||
}
|
||||
|
||||
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
categories,
|
||||
instanceId,
|
||||
defaultCategoryId,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
@@ -38,8 +39,35 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
||||
|
||||
// Fetch categories using the new API
|
||||
const {
|
||||
data: categories,
|
||||
isLoading: categoriesLoading,
|
||||
error: categoriesError,
|
||||
} = useCategoriesByInstance(instanceId);
|
||||
|
||||
const createChannelMutation = useCreateChannel();
|
||||
|
||||
// Update categoryId when defaultCategoryId changes or categories load
|
||||
useEffect(() => {
|
||||
if (defaultCategoryId) {
|
||||
setCategoryId(defaultCategoryId);
|
||||
} else if (categories && categories.length > 0 && !categoryId) {
|
||||
// Auto-select first category if none selected
|
||||
setCategoryId(categories[0].id);
|
||||
}
|
||||
}, [defaultCategoryId, categories, categoryId]);
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
}
|
||||
}, [isOpen, defaultCategoryId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !categoryId) return;
|
||||
@@ -69,6 +97,13 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Channel</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{categoriesError && (
|
||||
<div className="p-3 text-sm text-destructive bg-destructive/10 rounded-md">
|
||||
Failed to load categories. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Channel Type</Label>
|
||||
@@ -118,16 +153,40 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId} required>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
required
|
||||
disabled={categoriesLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
categoriesLoading
|
||||
? "Loading categories..."
|
||||
: "Select a category"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
{categoriesLoading ? (
|
||||
<SelectItem value="loading" disabled>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
) : categories && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-categories" disabled>
|
||||
No categories available
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -139,12 +198,22 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!name.trim() || !categoryId || createChannelMutation.isPending
|
||||
!name.trim() ||
|
||||
!categoryId ||
|
||||
createChannelMutation.isPending ||
|
||||
categoriesLoading ||
|
||||
categoryId === "loading" ||
|
||||
categoryId === "no-categories"
|
||||
}
|
||||
>
|
||||
{createChannelMutation.isPending
|
||||
? "Creating..."
|
||||
: "Create Channel"}
|
||||
{createChannelMutation.isPending ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</div>
|
||||
) : (
|
||||
"Create Channel"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Message } from "@/lib/api-client";
|
||||
import { useEditMessage } from "@/hooks/useMessages";
|
||||
|
||||
interface EditMessageModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export const EditMessageModal: React.FC<EditMessageModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
channelId,
|
||||
}) => {
|
||||
const [content, setContent] = useState(message.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editMessageMutation = useEditMessage();
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isOpen) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [content, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && content.trim() !== message.text) {
|
||||
try {
|
||||
await editMessageMutation.mutateAsync({
|
||||
messageId: message.id,
|
||||
content: content.trim(),
|
||||
channelId,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to edit message:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
} else if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setContent(message.text);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Message</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message-content">Message</Label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="message-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-concord-tertiary border border-border rounded-lg px-4 py-3 text-concord-primary placeholder-concord-muted resize-none focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
style={{
|
||||
minHeight: "100px",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
disabled={editMessageMutation.isPending}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Press Enter to save • Shift+Enter for new line • Escape to cancel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={editMessageMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!content.trim() ||
|
||||
content.trim() === message.text ||
|
||||
editMessageMutation.isPending
|
||||
}
|
||||
>
|
||||
{editMessageMutation.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Edit, Trash2, Pin, Reply } from "lucide-react";
|
||||
import { Copy, Reply } from "lucide-react";
|
||||
import { Message } from "@/lib/api-client";
|
||||
|
||||
interface MessageActionsModalProps {
|
||||
@@ -14,23 +14,14 @@ interface MessageActionsModalProps {
|
||||
onClose: () => void;
|
||||
message: Message;
|
||||
isOwnMessage: boolean;
|
||||
canDelete: boolean; // For mods/admins
|
||||
onEdit?: (messageId: string) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (messageId: string) => void;
|
||||
onPin?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
message,
|
||||
isOwnMessage,
|
||||
canDelete,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onPin,
|
||||
}) => {
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
@@ -63,37 +54,6 @@ export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy Text
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onPin?.(message.id))}
|
||||
>
|
||||
<Pin className="h-4 w-4 mr-2" />
|
||||
Pin Message
|
||||
</Button>
|
||||
|
||||
{isOwnMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onEdit?.(message.id))}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit Message
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isOwnMessage || canDelete) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => handleAction(() => onDelete?.(message.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Message
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Pin, X, ExternalLink } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Message } from "@/types/database";
|
||||
import { usePinnedMessages, usePinMessage } from "@/hooks/useMessages";
|
||||
|
||||
interface PinnedMessagesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
canManagePins: boolean;
|
||||
}
|
||||
|
||||
export const PinnedMessagesModal: React.FC<PinnedMessagesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
channelId,
|
||||
channelName,
|
||||
canManagePins,
|
||||
}) => {
|
||||
const { data: pinnedMessages, isLoading } = usePinnedMessages(channelId);
|
||||
const pinMessageMutation = usePinMessage();
|
||||
|
||||
const handleUnpin = async (messageId: string) => {
|
||||
try {
|
||||
await pinMessageMutation.mutateAsync({
|
||||
messageId,
|
||||
channelId,
|
||||
pinned: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to unpin message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJumpToMessage = (messageId: string) => {
|
||||
// TODO: Implement jumping to message in chat
|
||||
console.log("Jumping to message:", messageId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5" />
|
||||
Pinned Messages in #{channelName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[600px] sm:max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Pin className="h-5 w-5" />
|
||||
Pinned Messages in #{channelName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!pinnedMessages || pinnedMessages.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Pin className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No pinned messages yet</p>
|
||||
<p className="text-sm">
|
||||
Pin important messages to keep them accessible
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[50vh]">
|
||||
<div className="space-y-4">
|
||||
{pinnedMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="border border-border rounded-lg p-4 bg-concord-secondary/50"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={message.user?.picture || undefined} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{message.user?.userName?.slice(0, 2).toUpperCase() ||
|
||||
"??"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{message.user?.nickName ||
|
||||
message.user?.userName ||
|
||||
"Unknown User"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(message.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-concord-primary leading-relaxed break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handleJumpToMessage(message.id)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{canManagePins && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleUnpin(message.id)}
|
||||
disabled={pinMessageMutation.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,262 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Crown, Shield, User, AlertTriangle } from "lucide-react";
|
||||
import { User as UserType } from "@/types/database";
|
||||
|
||||
interface UserRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
user: UserType;
|
||||
instanceId: string;
|
||||
currentUserRole: string;
|
||||
canManageRoles: boolean;
|
||||
}
|
||||
|
||||
export const UserRoleModal: React.FC<UserRoleModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
user,
|
||||
instanceId,
|
||||
currentUserRole,
|
||||
canManageRoles,
|
||||
}) => {
|
||||
const userInstanceRole =
|
||||
user.roles.find((r) => r.instanceId === instanceId)?.role || "member";
|
||||
const [selectedRole, setSelectedRole] = useState(userInstanceRole);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const roleOptions = [
|
||||
{
|
||||
value: "member",
|
||||
label: "Member",
|
||||
icon: User,
|
||||
description: "Standard server access",
|
||||
},
|
||||
{
|
||||
value: "mod",
|
||||
label: "Moderator",
|
||||
icon: Shield,
|
||||
description: "Can moderate channels and manage messages",
|
||||
},
|
||||
{
|
||||
value: "admin",
|
||||
label: "Admin",
|
||||
icon: Crown,
|
||||
description: "Full server management access",
|
||||
},
|
||||
];
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case "admin":
|
||||
return "destructive";
|
||||
case "mod":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async () => {
|
||||
if (selectedRole === userInstanceRole || !canManageRoles) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
// TODO: Implement actual role update API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
console.log(
|
||||
`Updating ${user.username}'s role to ${selectedRole} in instance ${instanceId}`,
|
||||
);
|
||||
// await userClient.updateRole({
|
||||
// userId: user.id,
|
||||
// instanceId,
|
||||
// role: selectedRole,
|
||||
// requestingUserId: currentUser.id,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update role:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKickUser = async () => {
|
||||
if (!canManageRoles) return;
|
||||
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to kick ${user.nickname || user.username} from this server?`,
|
||||
)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement kick user API call
|
||||
console.log(`Kicking ${user.username} from instance ${instanceId}`);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to kick user:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const canModifyUser =
|
||||
canManageRoles && !user.admin && userInstanceRole !== "admin";
|
||||
const isOwnProfile = false; // TODO: Check if this is the current user's profile
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage User</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* User Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={user.picture || undefined} />
|
||||
<AvatarFallback>
|
||||
{user.username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{user.nickname || user.username}
|
||||
</span>
|
||||
{user.admin && <Crown className="h-4 w-4 text-yellow-500" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={getRoleColor(userInstanceRole)}>
|
||||
{userInstanceRole}
|
||||
</Badge>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
user.status === "online"
|
||||
? "bg-green-500"
|
||||
: user.status === "away"
|
||||
? "bg-yellow-500"
|
||||
: user.status === "busy"
|
||||
? "bg-red-500"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground capitalize">
|
||||
{user.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.bio && (
|
||||
<div className="text-sm text-muted-foreground">{user.bio}</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Role Management */}
|
||||
{canModifyUser && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Server Role</Label>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((role) => {
|
||||
const Icon = role.icon;
|
||||
return (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<div>
|
||||
<div className="font-medium">{role.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{role.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleUpdateRole}
|
||||
disabled={selectedRole === userInstanceRole || isUpdating}
|
||||
className="flex-1"
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update Role"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<Label className="text-destructive">Danger Zone</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleKickUser}
|
||||
className="w-full"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Kick from Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Only Mode */}
|
||||
{!canModifyUser && (
|
||||
<div className="text-center py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{user.admin
|
||||
? "System administrators cannot be modified"
|
||||
: "You don't have permission to modify this user"}
|
||||
</p>
|
||||
<Button variant="outline" onClick={onClose} className="mt-4">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Settings, LogOut } from "lucide-react";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
|
||||
interface UserStatusModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentStatus: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
}
|
||||
|
||||
export const UserStatusModal: React.FC<UserStatusModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentStatus,
|
||||
onStatusChange,
|
||||
}) => {
|
||||
const { mutate: logout } = useLogout();
|
||||
const { openUserSettings } = useUiStore();
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "online", label: "Online", color: "bg-status-online" },
|
||||
{ value: "away", label: "Away", color: "bg-status-away" },
|
||||
{ value: "busy", label: "Do Not Disturb", color: "bg-status-busy" },
|
||||
{ value: "offline", label: "Invisible", color: "bg-status-offline" },
|
||||
];
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[250px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Status & Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{statusOptions.map((status) => (
|
||||
<Button
|
||||
key={status.value}
|
||||
variant={currentStatus === status.value ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => onStatusChange(status.value))}
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full ${status.color} mr-2`} />
|
||||
{status.label}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start"
|
||||
onClick={() => handleAction(() => openUserSettings())}
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
User Settings
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={() => handleAction(() => logout())}
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Log Out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useState } from "react";
|
||||
import { Moon, Sun, Monitor, Palette, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
Palette,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
useTheme,
|
||||
ThemeDefinition,
|
||||
@@ -132,101 +134,107 @@ const CreateThemeModal: React.FC<{
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="theme-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="My Custom Theme"
|
||||
/>
|
||||
<ScrollArea className="max-h-96">
|
||||
<div className="space-y-6 pr-4">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="theme-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="My Custom Theme"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="theme-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-mode" className="text-right">
|
||||
Mode
|
||||
</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(v: "light" | "dark") => setMode(v)}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="theme-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="theme-mode" className="text-right">
|
||||
Mode
|
||||
</Label>
|
||||
<Select
|
||||
value={mode}
|
||||
onValueChange={(v: "light" | "dark") => setMode(v)}
|
||||
>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Color sections */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Basic Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Background"
|
||||
value={colors.background}
|
||||
onChange={(v) => updateColor("background", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Foreground"
|
||||
value={colors.foreground}
|
||||
onChange={(v) => updateColor("foreground", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Primary"
|
||||
value={colors.primary}
|
||||
onChange={(v) => updateColor("primary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Secondary"
|
||||
value={colors.secondary}
|
||||
onChange={(v) => updateColor("secondary", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<h4 className="font-medium">Sidebar Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Sidebar"
|
||||
value={colors.sidebar}
|
||||
onChange={(v) => updateColor("sidebar", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Primary"
|
||||
value={colors.sidebarPrimary}
|
||||
onChange={(v) => updateColor("sidebarPrimary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Accent"
|
||||
value={colors.sidebarAccent}
|
||||
onChange={(v) => updateColor("sidebarAccent", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color sections */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Basic Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Background"
|
||||
value={colors.background}
|
||||
onChange={(v) => updateColor("background", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Foreground"
|
||||
value={colors.foreground}
|
||||
onChange={(v) => updateColor("foreground", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Primary"
|
||||
value={colors.primary}
|
||||
onChange={(v) => updateColor("primary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Secondary"
|
||||
value={colors.secondary}
|
||||
onChange={(v) => updateColor("secondary", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4 className="font-medium">Sidebar Colors</h4>
|
||||
<div className="space-y-3">
|
||||
<ColorInput
|
||||
label="Sidebar"
|
||||
value={colors.sidebar}
|
||||
onChange={(v) => updateColor("sidebar", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Primary"
|
||||
value={colors.sidebarPrimary}
|
||||
onChange={(v) => updateColor("sidebarPrimary", v)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Sidebar Accent"
|
||||
value={colors.sidebarAccent}
|
||||
onChange={(v) => updateColor("sidebarAccent", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
@@ -241,12 +249,11 @@ const CreateThemeModal: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
// Main theme selector component
|
||||
// Main theme selector component as modal
|
||||
export function ThemeSelector() {
|
||||
const {
|
||||
mode,
|
||||
currentTheme,
|
||||
// themes,
|
||||
setMode,
|
||||
setTheme,
|
||||
addCustomTheme,
|
||||
@@ -254,6 +261,7 @@ export function ThemeSelector() {
|
||||
getThemesForMode,
|
||||
} = useTheme();
|
||||
|
||||
const [isMainModalOpen, setIsMainModalOpen] = useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
const lightThemes = getThemesForMode("light");
|
||||
@@ -272,142 +280,200 @@ export function ThemeSelector() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Dialog open={isMainModalOpen} onOpenChange={setIsMainModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
{getCurrentModeIcon()}
|
||||
<span className="ml-2">{currentTheme.name}</span>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Theme
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{getCurrentModeIcon()}
|
||||
Appearance Settings
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose your preferred theme and color scheme
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs">Mode</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => setMode("light")}>
|
||||
<Sun className="mr-2 h-4 w-4" />
|
||||
<span>Light</span>
|
||||
{mode === "light" && (
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setMode("dark")}>
|
||||
<Moon className="mr-2 h-4 w-4" />
|
||||
<span>Dark</span>
|
||||
{mode === "dark" && (
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setMode("system")}>
|
||||
<Monitor className="mr-2 h-4 w-4" />
|
||||
<span>System</span>
|
||||
{mode === "system" && (
|
||||
<Badge variant="secondary" className="ml-auto">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<div className="space-y-6">
|
||||
{/* Current Theme Display */}
|
||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium">{currentTheme.name}</p>
|
||||
{currentTheme.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{currentTheme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Light Themes */}
|
||||
{lightThemes.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs">
|
||||
Light Themes
|
||||
</DropdownMenuLabel>
|
||||
{lightThemes.map((theme) => (
|
||||
<DropdownMenuItem
|
||||
key={theme.id}
|
||||
onClick={() => setTheme(theme.id)}
|
||||
className="justify-between"
|
||||
{/* Mode Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-medium">Display Mode</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={mode === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("light")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
<span>{theme.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-1 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
|
||||
{lightThemes.length > 0 && darkThemes.length > 0 && (
|
||||
<DropdownMenuSeparator />
|
||||
)}
|
||||
|
||||
{/* Dark Themes */}
|
||||
{darkThemes.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs">
|
||||
Dark Themes
|
||||
</DropdownMenuLabel>
|
||||
{darkThemes.map((theme) => (
|
||||
<DropdownMenuItem
|
||||
key={theme.id}
|
||||
onClick={() => setTheme(theme.id)}
|
||||
className="justify-between"
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="text-xs">Light</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("dark")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
<span>{theme.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-auto p-1 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
<Moon className="h-4 w-4" />
|
||||
<span className="text-xs">Dark</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === "system" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setMode("system")}
|
||||
className="flex flex-col gap-1 h-auto py-3"
|
||||
>
|
||||
<Monitor className="h-4 w-4" />
|
||||
<span className="text-xs">System</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
{/* Theme Selection */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Themes</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Custom Theme */}
|
||||
<DropdownMenuItem onClick={() => setIsCreateModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<span>Create Theme</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="space-y-3">
|
||||
{/* Light Themes */}
|
||||
{lightThemes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Light Themes
|
||||
</p>
|
||||
{lightThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{theme.name}
|
||||
</p>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dark Themes */}
|
||||
{darkThemes.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{lightThemes.length > 0 && <Separator />}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Dark Themes
|
||||
</p>
|
||||
{darkThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => setTheme(theme.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Palette className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{theme.name}
|
||||
</p>
|
||||
{theme.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{theme.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentTheme.id === theme.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
{theme.isCustom && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeCustomTheme(theme.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsMainModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateThemeModal
|
||||
isOpen={isCreateModalOpen}
|
||||
|
||||
139
concord-client/src/hooks/useCategories.ts
Normal file
139
concord-client/src/hooks/useCategories.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { categoryApi } from "@/lib/api-client";
|
||||
|
||||
// Get categories by instance ID
|
||||
export const useCategoriesByInstance = (instanceId: string | undefined) => {
|
||||
return useQuery({
|
||||
queryKey: ["categories", instanceId],
|
||||
queryFn: () =>
|
||||
instanceId
|
||||
? categoryApi.getCategoriesByInstance(instanceId)
|
||||
: Promise.resolve([]),
|
||||
enabled: !!instanceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
// Get single category by ID
|
||||
export const useCategoryById = (categoryId: string | undefined) => {
|
||||
return useQuery({
|
||||
queryKey: ["category", categoryId],
|
||||
queryFn: () =>
|
||||
categoryId
|
||||
? categoryApi.getCategoryById(categoryId)
|
||||
: Promise.resolve(null),
|
||||
enabled: !!categoryId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
// Create category mutation
|
||||
export const useCreateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: categoryApi.createCategory,
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate categories for the instance
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["categories", variables.instanceId],
|
||||
});
|
||||
// Also invalidate instance details to refresh the sidebar
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["instance", variables.instanceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create category:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Update category mutation
|
||||
export const useUpdateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
categoryId,
|
||||
...data
|
||||
}: {
|
||||
categoryId: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
position?: number;
|
||||
userId: string;
|
||||
}) => categoryApi.updateCategory(categoryId, data),
|
||||
onSuccess: (data, variables) => {
|
||||
// Update the specific category in cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["category", variables.categoryId],
|
||||
});
|
||||
// Invalidate categories list
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
// Invalidate instance details
|
||||
queryClient.invalidateQueries({ queryKey: ["instance"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update category:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Delete category mutation
|
||||
export const useDeleteCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
categoryId,
|
||||
userId,
|
||||
}: {
|
||||
categoryId: string;
|
||||
userId: string;
|
||||
}) => categoryApi.deleteCategory(categoryId, userId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Remove the category from cache
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["category", variables.categoryId],
|
||||
});
|
||||
// Invalidate categories list
|
||||
queryClient.invalidateQueries({ queryKey: ["categories"] });
|
||||
// Invalidate instance details
|
||||
queryClient.invalidateQueries({ queryKey: ["instance"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete category:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Delete all categories by instance mutation
|
||||
export const useDeleteAllCategoriesByInstance = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
instanceId,
|
||||
userId,
|
||||
}: {
|
||||
instanceId: string;
|
||||
userId: string;
|
||||
}) => categoryApi.deleteAllCategoriesByInstance(instanceId, userId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Remove all categories for this instance from cache
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["categories", variables.instanceId],
|
||||
});
|
||||
queryClient.removeQueries({ queryKey: ["category"] });
|
||||
// Invalidate instance details
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["instance", variables.instanceId],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to delete all categories:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -88,10 +88,16 @@ export interface Message {
|
||||
createdAt: string;
|
||||
deleted: boolean;
|
||||
updatedAt: string;
|
||||
replyToId?: string | null;
|
||||
replies: MessageReply;
|
||||
user?: BackendUser;
|
||||
}
|
||||
|
||||
export interface MessageReply {
|
||||
id: string;
|
||||
repliesToId: string;
|
||||
repliesToText: string;
|
||||
}
|
||||
|
||||
// Enhanced fetch wrapper with auth and error handling
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Reply,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { formatDistanceToNow, isValid, parseISO } from "date-fns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -26,10 +26,8 @@ import { useTheme } from "@/components/theme-provider";
|
||||
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
|
||||
import {
|
||||
useChannelMessages,
|
||||
useSendMessage,
|
||||
useDeleteMessage,
|
||||
usePinMessage,
|
||||
useLoadMoreMessages,
|
||||
useSendMessage,
|
||||
} from "@/hooks/useMessages";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
@@ -37,8 +35,6 @@ import { Message } from "@/lib/api-client";
|
||||
|
||||
// Modal imports
|
||||
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
|
||||
import { EditMessageModal } from "@/components/modals/EditMessageModal";
|
||||
import { PinnedMessagesModal } from "@/components/modals/PinnedMessagesModal";
|
||||
|
||||
// User type for message component
|
||||
interface MessageUser {
|
||||
@@ -57,12 +53,7 @@ interface MessageProps {
|
||||
currentUser: any;
|
||||
replyTo?: Message;
|
||||
replyToUser?: MessageUser;
|
||||
onEdit?: (messageId: string) => void;
|
||||
onDelete?: (messageId: string) => void;
|
||||
onReply?: (messageId: string) => void;
|
||||
onPin?: (messageId: string) => void;
|
||||
canDelete?: boolean;
|
||||
canPin?: boolean;
|
||||
}
|
||||
|
||||
const MessageComponent: React.FC<MessageProps> = ({
|
||||
@@ -71,29 +62,33 @@ const MessageComponent: React.FC<MessageProps> = ({
|
||||
currentUser,
|
||||
replyTo,
|
||||
replyToUser,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReply,
|
||||
onPin,
|
||||
canDelete = false,
|
||||
canPin = false,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||
|
||||
const formatTimestamp = (timestamp: string) => {
|
||||
try {
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) {
|
||||
// First try parsing as ISO string
|
||||
let date = parseISO(timestamp);
|
||||
|
||||
// If that fails, try regular Date constructor
|
||||
if (!isValid(date)) {
|
||||
date = new Date(timestamp);
|
||||
}
|
||||
|
||||
// Final check if date is valid
|
||||
if (!isValid(date)) {
|
||||
console.error("Invalid timestamp:", timestamp);
|
||||
return "Invalid date";
|
||||
}
|
||||
|
||||
return formatDistanceToNow(date, { addSuffix: true });
|
||||
} catch (error) {
|
||||
console.error("Error formatting timestamp:", timestamp, error);
|
||||
return "Invalid date";
|
||||
}
|
||||
};
|
||||
|
||||
const isOwnMessage = currentUser?.id === message.userId;
|
||||
const { mode } = useTheme();
|
||||
|
||||
@@ -101,7 +96,7 @@ const MessageComponent: React.FC<MessageProps> = ({
|
||||
const username = user.username || user.userName || "Unknown User";
|
||||
const displayName = user.nickname || user.nickName || username;
|
||||
|
||||
const isDeleted = (message as any).deleted;
|
||||
const isDeleted = message.deleted;
|
||||
|
||||
if (isDeleted) {
|
||||
return (
|
||||
@@ -111,13 +106,6 @@ const MessageComponent: React.FC<MessageProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-concord-secondary italic border border-border rounded px-3 py-2 bg-concord-tertiary/50">
|
||||
This message has been deleted
|
||||
{(message as any).deletedBy && (
|
||||
<span className="text-xs block mt-1">
|
||||
Deleted by {(message as any).deletedBy}
|
||||
{(message as any).deletedAt &&
|
||||
` • ${formatTimestamp((message as any).deletedAt)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,6 +149,22 @@ const MessageComponent: React.FC<MessageProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply line and reference */}
|
||||
{replyTo && replyToUser && (
|
||||
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
|
||||
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
|
||||
<span className="font-medium text-concord-primary">
|
||||
{replyToUser.nickname ||
|
||||
replyToUser.nickName ||
|
||||
replyToUser.username ||
|
||||
replyToUser.userName}
|
||||
</span>
|
||||
<span className="truncate max-w-xs opacity-75">
|
||||
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header - always show */}
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-concord-primary">
|
||||
@@ -288,11 +292,7 @@ const MessageComponent: React.FC<MessageProps> = ({
|
||||
onClose={() => setShowActionsModal(false)}
|
||||
message={message}
|
||||
isOwnMessage={isOwnMessage}
|
||||
canDelete={canDelete}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onReply={onReply}
|
||||
onPin={canPin ? onPin : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -433,16 +433,12 @@ const ChatPage: React.FC = () => {
|
||||
|
||||
// Local state hooks - called unconditionally
|
||||
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
|
||||
const [editingMessage, setEditingMessage] = useState<Message | null>(null);
|
||||
const [showPinnedMessages, setShowPinnedMessages] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesStartRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// API mutation hooks - called unconditionally
|
||||
const deleteMessageMutation = useDeleteMessage();
|
||||
const pinMessageMutation = usePinMessage();
|
||||
const loadMoreMessagesMutation = useLoadMoreMessages(channelId);
|
||||
|
||||
// Memoized values - called unconditionally
|
||||
@@ -460,23 +456,16 @@ const ChatPage: React.FC = () => {
|
||||
return currentUser.roles.some((role) => role.instanceId === instanceId);
|
||||
}, [currentUser, instanceId]);
|
||||
|
||||
const canDeleteMessages = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return false;
|
||||
if (currentUser.admin) return true;
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole && (userRole.role === "admin" || userRole.role === "mod");
|
||||
}, [currentUser, instanceId]);
|
||||
const sortedMessages = React.useMemo(() => {
|
||||
if (!channelMessages) return [];
|
||||
|
||||
const canPinMessages = React.useMemo(() => {
|
||||
if (!currentUser || !instanceId) return false;
|
||||
if (currentUser.admin) return true;
|
||||
const userRole = currentUser.roles.find(
|
||||
(role) => role.instanceId === instanceId,
|
||||
);
|
||||
return userRole && (userRole.role === "admin" || userRole.role === "mod");
|
||||
}, [currentUser, instanceId]);
|
||||
// Sort messages by createdAt timestamp (oldest first, newest last)
|
||||
return [...channelMessages].sort((a, b) => {
|
||||
const dateA = new Date(a.createdAt).getTime();
|
||||
const dateB = new Date(b.createdAt).getTime();
|
||||
return dateA - dateB; // ascending order (oldest to newest)
|
||||
});
|
||||
}, [channelMessages]);
|
||||
|
||||
// Effects - called unconditionally
|
||||
useEffect(() => {
|
||||
@@ -511,52 +500,6 @@ const ChatPage: React.FC = () => {
|
||||
[channelMessages],
|
||||
);
|
||||
|
||||
const handleEdit = React.useCallback(
|
||||
(messageId: string) => {
|
||||
const message = channelMessages?.find((m) => m.id === messageId);
|
||||
if (message) {
|
||||
setEditingMessage(message);
|
||||
}
|
||||
},
|
||||
[channelMessages],
|
||||
);
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
async (messageId: string) => {
|
||||
if (confirm("Are you sure you want to delete this message?")) {
|
||||
try {
|
||||
await deleteMessageMutation.mutateAsync({
|
||||
messageId,
|
||||
channelId: channelId!,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to delete message:", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
[deleteMessageMutation, channelId],
|
||||
);
|
||||
|
||||
const handlePin = React.useCallback(
|
||||
async (messageId: string) => {
|
||||
try {
|
||||
const message = channelMessages?.find((m) => m.id === messageId);
|
||||
const isPinned = (message as any)?.pinned;
|
||||
|
||||
await pinMessageMutation.mutateAsync({
|
||||
messageId,
|
||||
channelId: channelId!,
|
||||
pinned: !isPinned,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to pin/unpin message:", error);
|
||||
}
|
||||
},
|
||||
[pinMessageMutation, channelId, channelMessages],
|
||||
);
|
||||
|
||||
// NOW WE CAN START CONDITIONAL LOGIC AND EARLY RETURNS
|
||||
|
||||
// Handle loading states
|
||||
if (instanceLoading || messagesLoading || usersLoading) {
|
||||
return (
|
||||
@@ -578,7 +521,7 @@ const ChatPage: React.FC = () => {
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="mb-4">You don't have permission to view this server.</p>
|
||||
<Button onClick={() => navigate("/channels/@me")}>Go Home</Button>
|
||||
<Button onClick={() => navigate("/")}>Go Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -638,7 +581,6 @@ const ChatPage: React.FC = () => {
|
||||
|
||||
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
|
||||
|
||||
console.log(channelMessages);
|
||||
return (
|
||||
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
|
||||
{/* Channel Header */}
|
||||
@@ -659,14 +601,6 @@ const ChatPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 interactive-hover"
|
||||
onClick={() => setShowPinnedMessages(true)}
|
||||
>
|
||||
<Pin size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -731,12 +665,13 @@ const ChatPage: React.FC = () => {
|
||||
|
||||
<div className="pb-4">
|
||||
{/* Messages */}
|
||||
{channelMessages && channelMessages.length > 0 ? (
|
||||
{sortedMessages && sortedMessages.length > 0 ? (
|
||||
<div>
|
||||
{channelMessages.map((message) => {
|
||||
{sortedMessages.map((message) => {
|
||||
console.log(message);
|
||||
const user = users?.find((u) => u.id === message.userId);
|
||||
const replyToMessage = channelMessages?.find(
|
||||
(m) => m.id === message.replyToId,
|
||||
(m) => m.id === (message as any).repliedMessageId,
|
||||
);
|
||||
const replyToUser = replyToMessage
|
||||
? users?.find((u) => u.id === replyToMessage.userId)
|
||||
@@ -751,13 +686,8 @@ const ChatPage: React.FC = () => {
|
||||
user={user}
|
||||
currentUser={currentUser}
|
||||
replyTo={replyToMessage}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onReply={handleReply}
|
||||
onPin={handlePin}
|
||||
replyToUser={replyToUser}
|
||||
canDelete={canDeleteMessages}
|
||||
canPin={canPinMessages}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -791,25 +721,6 @@ const ChatPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Message Modal */}
|
||||
{editingMessage && (
|
||||
<EditMessageModal
|
||||
isOpen={!!editingMessage}
|
||||
onClose={() => setEditingMessage(null)}
|
||||
message={editingMessage}
|
||||
channelId={channelId!}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Pinned Messages Modal */}
|
||||
<PinnedMessagesModal
|
||||
isOpen={showPinnedMessages}
|
||||
onClose={() => setShowPinnedMessages(false)}
|
||||
channelId={channelId!}
|
||||
channelName={currentChannel?.name || "channel"}
|
||||
canManagePins={canPinMessages ? canPinMessages : false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,7 +24,7 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
// Redirect if already authenticated
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/channels/@me" replace />;
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ const NotFoundPage: React.FC = () => {
|
||||
<h1 className="text-4xl font-bold mb-4 text-concord-primary">404</h1>
|
||||
<p className="text-xl mb-6">Page not found</p>
|
||||
<Button asChild>
|
||||
<a href="/channels/@me">Go Home</a>
|
||||
<a href="/">Go Home</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
User,
|
||||
Shield,
|
||||
Mic,
|
||||
Eye,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
Moon,
|
||||
@@ -45,12 +46,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
icon: User,
|
||||
description: "Profile, privacy, and account settings",
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
title: "Security",
|
||||
icon: Lock,
|
||||
description: "Password and security settings",
|
||||
},
|
||||
// {
|
||||
// id: "security",
|
||||
// title: "Security",
|
||||
// icon: Lock,
|
||||
// description: "Password and security settings",
|
||||
// },
|
||||
{
|
||||
id: "appearance",
|
||||
title: "Appearance",
|
||||
@@ -629,6 +630,98 @@ const AppearanceSettings: React.FC = () => {
|
||||
</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>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user