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}
|
||||
|
||||
Reference in New Issue
Block a user