sad
This commit is contained in:
@@ -6,6 +6,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
|||||||
import { useInstanceDetails } from "@/hooks/useServers";
|
import { useInstanceDetails } from "@/hooks/useServers";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useUiStore } from "@/stores/uiStore";
|
||||||
import ChannelList from "@/components/channel/ChannelList";
|
import ChannelList from "@/components/channel/ChannelList";
|
||||||
|
import { CreateCategoryModal, CreateChannelModal } from "../server/ServerIcon";
|
||||||
|
|
||||||
const ChannelSidebar: React.FC = () => {
|
const ChannelSidebar: React.FC = () => {
|
||||||
const { instanceId } = useParams();
|
const { instanceId } = useParams();
|
||||||
@@ -94,6 +95,8 @@ const ChannelSidebar: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<CreateChannelModal />
|
||||||
|
<CreateCategoryModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { Role } from "@/types/database";
|
|||||||
import { useInstanceMembers } from "@/hooks/useServers";
|
import { useInstanceMembers } from "@/hooks/useServers";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { User } from "@/types/database";
|
import { User } from "@/types/database";
|
||||||
import { UserRoleModal } from "@/components/modals/UserRoleModal";
|
|
||||||
|
|
||||||
// Status color utility
|
// Status color utility
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@@ -28,8 +27,6 @@ const getStatusColor = (status: string) => {
|
|||||||
interface MemberItemProps {
|
interface MemberItemProps {
|
||||||
member: User;
|
member: User;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
currentUserRole: string;
|
|
||||||
canManageRoles: boolean;
|
|
||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,27 +50,17 @@ const getRoleInfo = (role: string) => {
|
|||||||
const MemberItem: React.FC<MemberItemProps> = ({
|
const MemberItem: React.FC<MemberItemProps> = ({
|
||||||
member,
|
member,
|
||||||
instanceId,
|
instanceId,
|
||||||
currentUserRole,
|
|
||||||
canManageRoles,
|
|
||||||
isOwner = false,
|
isOwner = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [showUserModal, setShowUserModal] = useState(false);
|
|
||||||
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
const userRole = getUserRoleForInstance(member.roles, instanceId || "");
|
||||||
const roleInfo = getRoleInfo(userRole);
|
const roleInfo = getRoleInfo(userRole);
|
||||||
|
|
||||||
const handleMemberClick = () => {
|
|
||||||
if (canManageRoles && !member.admin) {
|
|
||||||
setShowUserModal(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
className="w-full justify-start p-2 h-auto hover:bg-concord-tertiary/50"
|
||||||
onClick={handleMemberClick}
|
disabled={member.admin}
|
||||||
disabled={!canManageRoles || member.admin}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 w-full">
|
<div className="flex items-center gap-3 w-full">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -119,16 +106,6 @@ const MemberItem: React.FC<MemberItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</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 { data: members, isLoading } = useInstanceMembers(instanceId);
|
||||||
const { user: currentUser } = useAuthStore();
|
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(() => {
|
const currentUserRole = React.useMemo(() => {
|
||||||
if (!currentUser || !instanceId) return "member";
|
if (!currentUser || !instanceId) return "member";
|
||||||
if (currentUser.admin) return "admin";
|
if (currentUser.admin) return "admin";
|
||||||
@@ -212,12 +175,9 @@ const MemberList: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
|
<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" />
|
<UserIcon size={20} className="text-concord-primary h-8" />
|
||||||
<div className="h-8 flex flex-col justify-center">
|
<p className="text-sm font-semibold text-concord-secondary tracking-wide">
|
||||||
<h3 className="text-sm font-semibold text-concord-secondary tracking-wide">
|
{members.length} Members
|
||||||
Members: {members.length} Online:{" "}
|
</p>
|
||||||
{members.filter((m) => m.status === "online").length}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member List */}
|
{/* Member List */}
|
||||||
@@ -242,7 +202,6 @@ const MemberList: React.FC = () => {
|
|||||||
member={member}
|
member={member}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
currentUserRole={currentUserRole}
|
currentUserRole={currentUserRole}
|
||||||
canManageRoles={canManageRoles}
|
|
||||||
isOwner={false}
|
isOwner={false}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useUiStore } from "@/stores/uiStore";
|
|||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import ServerIcon from "@/components/server/ServerIcon";
|
import ServerIcon from "@/components/server/ServerIcon";
|
||||||
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
|
import { getAccessibleInstances, isGlobalAdmin } from "@/utils/permissions";
|
||||||
|
import { CreateServerModal } from "../modals/CreateServerModal";
|
||||||
|
|
||||||
const ServerSidebar: React.FC = () => {
|
const ServerSidebar: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -40,7 +41,7 @@ const ServerSidebar: React.FC = () => {
|
|||||||
|
|
||||||
const handleHomeClick = () => {
|
const handleHomeClick = () => {
|
||||||
setActiveInstance(null);
|
setActiveInstance(null);
|
||||||
navigate("/channels/@me");
|
navigate("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateServer = () => {
|
const handleCreateServer = () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -16,21 +16,22 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Hash, Volume2 } from "lucide-react";
|
import { Hash, Volume2, Loader2 } from "lucide-react";
|
||||||
import { useCreateChannel } from "@/hooks/useServers";
|
import { useCreateChannel } from "@/hooks/useServers";
|
||||||
|
import { useCategoriesByInstance } from "@/hooks/useCategories"; // New hook
|
||||||
import { CategoryWithChannels } from "@/types/api";
|
import { CategoryWithChannels } from "@/types/api";
|
||||||
|
|
||||||
interface CreateChannelModalProps {
|
interface CreateChannelModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
categories: CategoryWithChannels[];
|
instanceId: string; // Changed to use instanceId instead of categories prop
|
||||||
defaultCategoryId?: string;
|
defaultCategoryId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
categories,
|
instanceId,
|
||||||
defaultCategoryId,
|
defaultCategoryId,
|
||||||
}) => {
|
}) => {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -38,8 +39,35 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|||||||
const [type, setType] = useState<"text" | "voice">("text");
|
const [type, setType] = useState<"text" | "voice">("text");
|
||||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
||||||
|
|
||||||
|
// Fetch categories using the new API
|
||||||
|
const {
|
||||||
|
data: categories,
|
||||||
|
isLoading: categoriesLoading,
|
||||||
|
error: categoriesError,
|
||||||
|
} = useCategoriesByInstance(instanceId);
|
||||||
|
|
||||||
const createChannelMutation = useCreateChannel();
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!name.trim() || !categoryId) return;
|
if (!name.trim() || !categoryId) return;
|
||||||
@@ -69,6 +97,13 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create Channel</DialogTitle>
|
<DialogTitle>Create Channel</DialogTitle>
|
||||||
</DialogHeader>
|
</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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Channel Type</Label>
|
<Label>Channel Type</Label>
|
||||||
@@ -118,16 +153,40 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Category</Label>
|
<Label>Category</Label>
|
||||||
<Select value={categoryId} onValueChange={setCategoryId} required>
|
<Select
|
||||||
|
value={categoryId}
|
||||||
|
onValueChange={setCategoryId}
|
||||||
|
required
|
||||||
|
disabled={categoriesLoading}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a category" />
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
categoriesLoading
|
||||||
|
? "Loading categories..."
|
||||||
|
: "Select a category"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{categories.map((category) => (
|
{categoriesLoading ? (
|
||||||
<SelectItem key={category.id} value={category.id}>
|
<SelectItem value="loading" disabled>
|
||||||
{category.name}
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
</SelectItem>
|
</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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,12 +198,22 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
!name.trim() || !categoryId || createChannelMutation.isPending
|
!name.trim() ||
|
||||||
|
!categoryId ||
|
||||||
|
createChannelMutation.isPending ||
|
||||||
|
categoriesLoading ||
|
||||||
|
categoryId === "loading" ||
|
||||||
|
categoryId === "no-categories"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{createChannelMutation.isPending
|
{createChannelMutation.isPending ? (
|
||||||
? "Creating..."
|
<div className="flex items-center gap-2">
|
||||||
: "Create Channel"}
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Create Channel"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import { Message } from "@/lib/api-client";
|
||||||
|
|
||||||
interface MessageActionsModalProps {
|
interface MessageActionsModalProps {
|
||||||
@@ -14,23 +14,14 @@ interface MessageActionsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
message: Message;
|
message: Message;
|
||||||
isOwnMessage: boolean;
|
isOwnMessage: boolean;
|
||||||
canDelete: boolean; // For mods/admins
|
|
||||||
onEdit?: (messageId: string) => void;
|
|
||||||
onDelete?: (messageId: string) => void;
|
|
||||||
onReply?: (messageId: string) => void;
|
onReply?: (messageId: string) => void;
|
||||||
onPin?: (messageId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
message,
|
message,
|
||||||
isOwnMessage,
|
|
||||||
canDelete,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onReply,
|
onReply,
|
||||||
onPin,
|
|
||||||
}) => {
|
}) => {
|
||||||
const handleAction = (action: () => void) => {
|
const handleAction = (action: () => void) => {
|
||||||
action();
|
action();
|
||||||
@@ -63,37 +54,6 @@ export const MessageActionsModal: React.FC<MessageActionsModalProps> = ({
|
|||||||
<Copy className="h-4 w-4 mr-2" />
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
Copy Text
|
Copy Text
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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 React, { useState } from "react";
|
||||||
import { Moon, Sun, Monitor, Palette, Plus, Trash2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
Moon,
|
||||||
DropdownMenuContent,
|
Sun,
|
||||||
DropdownMenuItem,
|
Monitor,
|
||||||
DropdownMenuSeparator,
|
Palette,
|
||||||
DropdownMenuTrigger,
|
Plus,
|
||||||
DropdownMenuLabel,
|
Trash2,
|
||||||
DropdownMenuGroup,
|
Settings,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
useTheme,
|
useTheme,
|
||||||
ThemeDefinition,
|
ThemeDefinition,
|
||||||
@@ -132,101 +134,107 @@ const CreateThemeModal: React.FC<{
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<ScrollArea className="max-h-96">
|
||||||
{/* Basic Info */}
|
<div className="space-y-6 pr-4">
|
||||||
<div className="space-y-4">
|
{/* Basic Info */}
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<div className="space-y-4">
|
||||||
<Label htmlFor="theme-name" className="text-right">
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
Name
|
<Label htmlFor="theme-name" className="text-right">
|
||||||
</Label>
|
Name
|
||||||
<Input
|
</Label>
|
||||||
id="theme-name"
|
<Input
|
||||||
value={name}
|
id="theme-name"
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={name}
|
||||||
className="col-span-3"
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="My Custom Theme"
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 items-center gap-4">
|
<Separator />
|
||||||
<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">
|
{/* Color sections */}
|
||||||
<Label htmlFor="theme-mode" className="text-right">
|
<div className="space-y-4">
|
||||||
Mode
|
<h4 className="font-medium">Basic Colors</h4>
|
||||||
</Label>
|
<div className="space-y-3">
|
||||||
<Select
|
<ColorInput
|
||||||
value={mode}
|
label="Background"
|
||||||
onValueChange={(v: "light" | "dark") => setMode(v)}
|
value={colors.background}
|
||||||
>
|
onChange={(v) => updateColor("background", v)}
|
||||||
<SelectTrigger className="col-span-3">
|
/>
|
||||||
<SelectValue />
|
<ColorInput
|
||||||
</SelectTrigger>
|
label="Foreground"
|
||||||
<SelectContent>
|
value={colors.foreground}
|
||||||
<SelectItem value="light">Light</SelectItem>
|
onChange={(v) => updateColor("foreground", v)}
|
||||||
<SelectItem value="dark">Dark</SelectItem>
|
/>
|
||||||
</SelectContent>
|
<ColorInput
|
||||||
</Select>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
{/* 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>
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<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() {
|
export function ThemeSelector() {
|
||||||
const {
|
const {
|
||||||
mode,
|
mode,
|
||||||
currentTheme,
|
currentTheme,
|
||||||
// themes,
|
|
||||||
setMode,
|
setMode,
|
||||||
setTheme,
|
setTheme,
|
||||||
addCustomTheme,
|
addCustomTheme,
|
||||||
@@ -254,6 +261,7 @@ export function ThemeSelector() {
|
|||||||
getThemesForMode,
|
getThemesForMode,
|
||||||
} = useTheme();
|
} = useTheme();
|
||||||
|
|
||||||
|
const [isMainModalOpen, setIsMainModalOpen] = useState(false);
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
const lightThemes = getThemesForMode("light");
|
const lightThemes = getThemesForMode("light");
|
||||||
@@ -272,142 +280,200 @@ export function ThemeSelector() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<Dialog open={isMainModalOpen} onOpenChange={setIsMainModalOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm">
|
<Button variant="outline" size="sm">
|
||||||
{getCurrentModeIcon()}
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
<span className="ml-2">{currentTheme.name}</span>
|
Theme
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DialogTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
|
<DialogHeader>
|
||||||
<DropdownMenuSeparator />
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{getCurrentModeIcon()}
|
||||||
|
Appearance Settings
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose your preferred theme and color scheme
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Mode Selection */}
|
<div className="space-y-6">
|
||||||
<DropdownMenuGroup>
|
{/* Current Theme Display */}
|
||||||
<DropdownMenuLabel className="text-xs">Mode</DropdownMenuLabel>
|
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
||||||
<DropdownMenuItem onClick={() => setMode("light")}>
|
<div>
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
<p className="font-medium">{currentTheme.name}</p>
|
||||||
<span>Light</span>
|
{currentTheme.description && (
|
||||||
{mode === "light" && (
|
<p className="text-sm text-muted-foreground">
|
||||||
<Badge variant="secondary" className="ml-auto">
|
{currentTheme.description}
|
||||||
Active
|
</p>
|
||||||
</Badge>
|
)}
|
||||||
)}
|
</div>
|
||||||
</DropdownMenuItem>
|
<Badge variant="secondary">Active</Badge>
|
||||||
<DropdownMenuItem onClick={() => setMode("dark")}>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
{/* Mode Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
{/* Light Themes */}
|
<Label className="text-sm font-medium">Display Mode</Label>
|
||||||
{lightThemes.length > 0 && (
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<DropdownMenuGroup>
|
<Button
|
||||||
<DropdownMenuLabel className="text-xs">
|
variant={mode === "light" ? "default" : "outline"}
|
||||||
Light Themes
|
size="sm"
|
||||||
</DropdownMenuLabel>
|
onClick={() => setMode("light")}
|
||||||
{lightThemes.map((theme) => (
|
className="flex flex-col gap-1 h-auto py-3"
|
||||||
<DropdownMenuItem
|
|
||||||
key={theme.id}
|
|
||||||
onClick={() => setTheme(theme.id)}
|
|
||||||
className="justify-between"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<Sun className="h-4 w-4" />
|
||||||
<Palette className="mr-2 h-4 w-4" />
|
<span className="text-xs">Light</span>
|
||||||
<span>{theme.name}</span>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
<div className="flex items-center gap-2">
|
variant={mode === "dark" ? "default" : "outline"}
|
||||||
{currentTheme.id === theme.id && (
|
size="sm"
|
||||||
<Badge variant="secondary">Active</Badge>
|
onClick={() => setMode("dark")}
|
||||||
)}
|
className="flex flex-col gap-1 h-auto py-3"
|
||||||
{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"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<Moon className="h-4 w-4" />
|
||||||
<Palette className="mr-2 h-4 w-4" />
|
<span className="text-xs">Dark</span>
|
||||||
<span>{theme.name}</span>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
<div className="flex items-center gap-2">
|
variant={mode === "system" ? "default" : "outline"}
|
||||||
{currentTheme.id === theme.id && (
|
size="sm"
|
||||||
<Badge variant="secondary">Active</Badge>
|
onClick={() => setMode("system")}
|
||||||
)}
|
className="flex flex-col gap-1 h-auto py-3"
|
||||||
{theme.isCustom && (
|
>
|
||||||
<Button
|
<Monitor className="h-4 w-4" />
|
||||||
variant="ghost"
|
<span className="text-xs">System</span>
|
||||||
size="sm"
|
</Button>
|
||||||
className="h-auto p-1 hover:text-destructive"
|
</div>
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.stopPropagation();
|
|
||||||
removeCustomTheme(theme.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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 */}
|
<ScrollArea className="max-h-64">
|
||||||
<DropdownMenuItem onClick={() => setIsCreateModalOpen(true)}>
|
<div className="space-y-3">
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
{/* Light Themes */}
|
||||||
<span>Create Theme</span>
|
{lightThemes.length > 0 && (
|
||||||
</DropdownMenuItem>
|
<div className="space-y-2">
|
||||||
</DropdownMenuContent>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
</DropdownMenu>
|
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
|
<CreateThemeModal
|
||||||
isOpen={isCreateModalOpen}
|
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;
|
createdAt: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
replyToId?: string | null;
|
replies: MessageReply;
|
||||||
user?: BackendUser;
|
user?: BackendUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MessageReply {
|
||||||
|
id: string;
|
||||||
|
repliesToId: string;
|
||||||
|
repliesToText: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Enhanced fetch wrapper with auth and error handling
|
// Enhanced fetch wrapper with auth and error handling
|
||||||
export class ApiClient {
|
export class ApiClient {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Reply,
|
Reply,
|
||||||
Plus,
|
Plus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow, isValid, parseISO } from "date-fns";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
@@ -26,10 +26,8 @@ import { useTheme } from "@/components/theme-provider";
|
|||||||
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
|
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
|
||||||
import {
|
import {
|
||||||
useChannelMessages,
|
useChannelMessages,
|
||||||
useSendMessage,
|
|
||||||
useDeleteMessage,
|
|
||||||
usePinMessage,
|
|
||||||
useLoadMoreMessages,
|
useLoadMoreMessages,
|
||||||
|
useSendMessage,
|
||||||
} from "@/hooks/useMessages";
|
} from "@/hooks/useMessages";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useUiStore } from "@/stores/uiStore";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
@@ -37,8 +35,6 @@ import { Message } from "@/lib/api-client";
|
|||||||
|
|
||||||
// Modal imports
|
// Modal imports
|
||||||
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
|
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
|
||||||
import { EditMessageModal } from "@/components/modals/EditMessageModal";
|
|
||||||
import { PinnedMessagesModal } from "@/components/modals/PinnedMessagesModal";
|
|
||||||
|
|
||||||
// User type for message component
|
// User type for message component
|
||||||
interface MessageUser {
|
interface MessageUser {
|
||||||
@@ -57,12 +53,7 @@ interface MessageProps {
|
|||||||
currentUser: any;
|
currentUser: any;
|
||||||
replyTo?: Message;
|
replyTo?: Message;
|
||||||
replyToUser?: MessageUser;
|
replyToUser?: MessageUser;
|
||||||
onEdit?: (messageId: string) => void;
|
|
||||||
onDelete?: (messageId: string) => void;
|
|
||||||
onReply?: (messageId: string) => void;
|
onReply?: (messageId: string) => void;
|
||||||
onPin?: (messageId: string) => void;
|
|
||||||
canDelete?: boolean;
|
|
||||||
canPin?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MessageComponent: React.FC<MessageProps> = ({
|
const MessageComponent: React.FC<MessageProps> = ({
|
||||||
@@ -71,29 +62,33 @@ const MessageComponent: React.FC<MessageProps> = ({
|
|||||||
currentUser,
|
currentUser,
|
||||||
replyTo,
|
replyTo,
|
||||||
replyToUser,
|
replyToUser,
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onReply,
|
onReply,
|
||||||
onPin,
|
|
||||||
canDelete = false,
|
|
||||||
canPin = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [showActionsModal, setShowActionsModal] = useState(false);
|
const [showActionsModal, setShowActionsModal] = useState(false);
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
const formatTimestamp = (timestamp: string) => {
|
||||||
try {
|
try {
|
||||||
const date = new Date(timestamp);
|
// First try parsing as ISO string
|
||||||
if (isNaN(date.getTime())) {
|
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 "Invalid date";
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDistanceToNow(date, { addSuffix: true });
|
return formatDistanceToNow(date, { addSuffix: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error formatting timestamp:", timestamp, error);
|
console.error("Error formatting timestamp:", timestamp, error);
|
||||||
return "Invalid date";
|
return "Invalid date";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOwnMessage = currentUser?.id === message.userId;
|
const isOwnMessage = currentUser?.id === message.userId;
|
||||||
const { mode } = useTheme();
|
const { mode } = useTheme();
|
||||||
|
|
||||||
@@ -101,7 +96,7 @@ const MessageComponent: React.FC<MessageProps> = ({
|
|||||||
const username = user.username || user.userName || "Unknown User";
|
const username = user.username || user.userName || "Unknown User";
|
||||||
const displayName = user.nickname || user.nickName || username;
|
const displayName = user.nickname || user.nickName || username;
|
||||||
|
|
||||||
const isDeleted = (message as any).deleted;
|
const isDeleted = message.deleted;
|
||||||
|
|
||||||
if (isDeleted) {
|
if (isDeleted) {
|
||||||
return (
|
return (
|
||||||
@@ -111,13 +106,6 @@ const MessageComponent: React.FC<MessageProps> = ({
|
|||||||
<div className="flex-1 min-w-0">
|
<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">
|
<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
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,6 +149,22 @@ const MessageComponent: React.FC<MessageProps> = ({
|
|||||||
</div>
|
</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 */}
|
{/* Header - always show */}
|
||||||
<div className="flex items-baseline gap-2 mb-1">
|
<div className="flex items-baseline gap-2 mb-1">
|
||||||
<span className="font-semibold text-concord-primary">
|
<span className="font-semibold text-concord-primary">
|
||||||
@@ -288,11 +292,7 @@ const MessageComponent: React.FC<MessageProps> = ({
|
|||||||
onClose={() => setShowActionsModal(false)}
|
onClose={() => setShowActionsModal(false)}
|
||||||
message={message}
|
message={message}
|
||||||
isOwnMessage={isOwnMessage}
|
isOwnMessage={isOwnMessage}
|
||||||
canDelete={canDelete}
|
|
||||||
onEdit={onEdit}
|
|
||||||
onDelete={onDelete}
|
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onPin={canPin ? onPin : undefined}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -433,16 +433,12 @@ const ChatPage: React.FC = () => {
|
|||||||
|
|
||||||
// Local state hooks - called unconditionally
|
// Local state hooks - called unconditionally
|
||||||
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
|
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 [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const messagesStartRef = useRef<HTMLDivElement>(null);
|
const messagesStartRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// API mutation hooks - called unconditionally
|
// API mutation hooks - called unconditionally
|
||||||
const deleteMessageMutation = useDeleteMessage();
|
|
||||||
const pinMessageMutation = usePinMessage();
|
|
||||||
const loadMoreMessagesMutation = useLoadMoreMessages(channelId);
|
const loadMoreMessagesMutation = useLoadMoreMessages(channelId);
|
||||||
|
|
||||||
// Memoized values - called unconditionally
|
// Memoized values - called unconditionally
|
||||||
@@ -460,23 +456,16 @@ const ChatPage: React.FC = () => {
|
|||||||
return currentUser.roles.some((role) => role.instanceId === instanceId);
|
return currentUser.roles.some((role) => role.instanceId === instanceId);
|
||||||
}, [currentUser, instanceId]);
|
}, [currentUser, instanceId]);
|
||||||
|
|
||||||
const canDeleteMessages = React.useMemo(() => {
|
const sortedMessages = React.useMemo(() => {
|
||||||
if (!currentUser || !instanceId) return false;
|
if (!channelMessages) return [];
|
||||||
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 canPinMessages = React.useMemo(() => {
|
// Sort messages by createdAt timestamp (oldest first, newest last)
|
||||||
if (!currentUser || !instanceId) return false;
|
return [...channelMessages].sort((a, b) => {
|
||||||
if (currentUser.admin) return true;
|
const dateA = new Date(a.createdAt).getTime();
|
||||||
const userRole = currentUser.roles.find(
|
const dateB = new Date(b.createdAt).getTime();
|
||||||
(role) => role.instanceId === instanceId,
|
return dateA - dateB; // ascending order (oldest to newest)
|
||||||
);
|
});
|
||||||
return userRole && (userRole.role === "admin" || userRole.role === "mod");
|
}, [channelMessages]);
|
||||||
}, [currentUser, instanceId]);
|
|
||||||
|
|
||||||
// Effects - called unconditionally
|
// Effects - called unconditionally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -511,52 +500,6 @@ const ChatPage: React.FC = () => {
|
|||||||
[channelMessages],
|
[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
|
// Handle loading states
|
||||||
if (instanceLoading || messagesLoading || usersLoading) {
|
if (instanceLoading || messagesLoading || usersLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -578,7 +521,7 @@ const ChatPage: React.FC = () => {
|
|||||||
Access Denied
|
Access Denied
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-4">You don't have permission to view this server.</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -638,7 +581,6 @@ const ChatPage: React.FC = () => {
|
|||||||
|
|
||||||
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
|
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
|
||||||
|
|
||||||
console.log(channelMessages);
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
|
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
|
||||||
{/* Channel Header */}
|
{/* Channel Header */}
|
||||||
@@ -659,14 +601,6 @@ const ChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -731,12 +665,13 @@ const ChatPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="pb-4">
|
<div className="pb-4">
|
||||||
{/* Messages */}
|
{/* Messages */}
|
||||||
{channelMessages && channelMessages.length > 0 ? (
|
{sortedMessages && sortedMessages.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
{channelMessages.map((message) => {
|
{sortedMessages.map((message) => {
|
||||||
|
console.log(message);
|
||||||
const user = users?.find((u) => u.id === message.userId);
|
const user = users?.find((u) => u.id === message.userId);
|
||||||
const replyToMessage = channelMessages?.find(
|
const replyToMessage = channelMessages?.find(
|
||||||
(m) => m.id === message.replyToId,
|
(m) => m.id === (message as any).repliedMessageId,
|
||||||
);
|
);
|
||||||
const replyToUser = replyToMessage
|
const replyToUser = replyToMessage
|
||||||
? users?.find((u) => u.id === replyToMessage.userId)
|
? users?.find((u) => u.id === replyToMessage.userId)
|
||||||
@@ -751,13 +686,8 @@ const ChatPage: React.FC = () => {
|
|||||||
user={user}
|
user={user}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
replyTo={replyToMessage}
|
replyTo={replyToMessage}
|
||||||
onEdit={handleEdit}
|
|
||||||
onDelete={handleDelete}
|
|
||||||
onReply={handleReply}
|
onReply={handleReply}
|
||||||
onPin={handlePin}
|
|
||||||
replyToUser={replyToUser}
|
replyToUser={replyToUser}
|
||||||
canDelete={canDeleteMessages}
|
|
||||||
canPin={canPinMessages}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -791,25 +721,6 @@ const ChatPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const LoginPage: React.FC = () => {
|
|||||||
|
|
||||||
// Redirect if already authenticated
|
// Redirect if already authenticated
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Navigate to="/channels/@me" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
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>
|
<h1 className="text-4xl font-bold mb-4 text-concord-primary">404</h1>
|
||||||
<p className="text-xl mb-6">Page not found</p>
|
<p className="text-xl mb-6">Page not found</p>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<a href="/channels/@me">Go Home</a>
|
<a href="/">Go Home</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
Shield,
|
Shield,
|
||||||
Mic,
|
Mic,
|
||||||
|
Eye,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Moon,
|
Moon,
|
||||||
@@ -45,12 +46,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
|||||||
icon: User,
|
icon: User,
|
||||||
description: "Profile, privacy, and account settings",
|
description: "Profile, privacy, and account settings",
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
id: "security",
|
// id: "security",
|
||||||
title: "Security",
|
// title: "Security",
|
||||||
icon: Lock,
|
// icon: Lock,
|
||||||
description: "Password and security settings",
|
// description: "Password and security settings",
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
id: "appearance",
|
id: "appearance",
|
||||||
title: "Appearance",
|
title: "Appearance",
|
||||||
@@ -629,6 +630,98 @@ const AppearanceSettings: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export async function getMessagesBefore(date: string, channelId: string) {
|
|||||||
userId: message.userId!,
|
userId: message.userId!,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
deleted: message.deleted,
|
deleted: message.deleted,
|
||||||
|
createdAt: message.createdAt,
|
||||||
replies: originalMessage
|
replies: originalMessage
|
||||||
? {
|
? {
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
@@ -159,7 +160,11 @@ export async function getMessagesBefore(date: string, channelId: string) {
|
|||||||
export async function editMessage(data: PutMessage) {
|
export async function editMessage(data: PutMessage) {
|
||||||
try {
|
try {
|
||||||
const userCreds = await getUserCredentials(data.id);
|
const userCreds = await getUserCredentials(data.id);
|
||||||
if (!userCreds || userCreds.token == null || userCreds.token != data.token) {
|
if (
|
||||||
|
!userCreds ||
|
||||||
|
userCreds.token == null ||
|
||||||
|
userCreds.token != data.token
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user