This commit is contained in:
2025-09-28 07:46:11 -04:00
parent d55049dfc4
commit a5e7fa4bc6
17 changed files with 687 additions and 1096 deletions

View File

@@ -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>
);
};

View File

@@ -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}
/>
))}

View File

@@ -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 = () => {

View File

@@ -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) => (
{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>

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,7 +134,8 @@ const CreateThemeModal: React.FC<{
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<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">
@@ -181,6 +184,8 @@ const CreateThemeModal: React.FC<{
</div>
</div>
<Separator />
{/* Color sections */}
<div className="space-y-4">
<h4 className="font-medium">Basic Colors</h4>
@@ -207,6 +212,8 @@ const CreateThemeModal: React.FC<{
/>
</div>
<Separator />
<h4 className="font-medium">Sidebar Colors</h4>
<div className="space-y-3">
<ColorInput
@@ -227,6 +234,7 @@ const CreateThemeModal: React.FC<{
</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,76 +280,124 @@ 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>
<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>
{/* 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-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"
>
<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"
>
<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>
<ScrollArea className="max-h-64">
<div className="space-y-3">
{/* Light Themes */}
{lightThemes.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Light Themes
</DropdownMenuLabel>
</p>
{lightThemes.map((theme) => (
<DropdownMenuItem
<div
key={theme.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
onClick={() => setTheme(theme.id)}
className="justify-between"
>
<div className="flex items-center">
<Palette className="mr-2 h-4 w-4" />
<span>{theme.name}</span>
<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">Active</Badge>
<Badge variant="secondary" className="text-xs">
Active
</Badge>
)}
{theme.isCustom && (
<Button
variant="ghost"
size="sm"
className="h-auto p-1 hover:text-destructive"
className="h-6 w-6 p-0 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeCustomTheme(theme.id);
@@ -351,40 +407,48 @@ export function ThemeSelector() {
</Button>
)}
</div>
</DropdownMenuItem>
</div>
))}
</DropdownMenuGroup>
)}
{lightThemes.length > 0 && darkThemes.length > 0 && (
<DropdownMenuSeparator />
</div>
)}
{/* Dark Themes */}
{darkThemes.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">
<div className="space-y-2">
{lightThemes.length > 0 && <Separator />}
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Dark Themes
</DropdownMenuLabel>
</p>
{darkThemes.map((theme) => (
<DropdownMenuItem
<div
key={theme.id}
className="flex items-center justify-between p-2 rounded-md hover:bg-muted/50 cursor-pointer"
onClick={() => setTheme(theme.id)}
className="justify-between"
>
<div className="flex items-center">
<Palette className="mr-2 h-4 w-4" />
<span>{theme.name}</span>
<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">Active</Badge>
<Badge variant="secondary" className="text-xs">
Active
</Badge>
)}
{theme.isCustom && (
<Button
variant="ghost"
size="sm"
className="h-auto p-1 hover:text-destructive"
className="h-6 w-6 p-0 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
removeCustomTheme(theme.id);
@@ -394,20 +458,22 @@ export function ThemeSelector() {
</Button>
)}
</div>
</DropdownMenuItem>
</div>
))}
</DropdownMenuGroup>
</div>
)}
</div>
</ScrollArea>
</div>
</div>
<DropdownMenuSeparator />
{/* Add Custom Theme */}
<DropdownMenuItem onClick={() => setIsCreateModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<span>Create Theme</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMainModalOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CreateThemeModal
isOpen={isCreateModalOpen}

View 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);
},
});
};

View File

@@ -88,10 +88,16 @@ export interface Message {
createdAt: string;
deleted: boolean;
updatedAt: string;
replyToId?: string | null;
replies: MessageReply;
user?: BackendUser;
}
export interface MessageReply {
id: string;
repliesToId: string;
repliesToText: string;
}
// Enhanced fetch wrapper with auth and error handling
export class ApiClient {
private baseUrl: string;

View File

@@ -10,7 +10,7 @@ import {
Reply,
Plus,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { formatDistanceToNow, isValid, parseISO } from "date-fns";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -26,10 +26,8 @@ import { useTheme } from "@/components/theme-provider";
import { useInstanceDetails, useInstanceMembers } from "@/hooks/useServers";
import {
useChannelMessages,
useSendMessage,
useDeleteMessage,
usePinMessage,
useLoadMoreMessages,
useSendMessage,
} from "@/hooks/useMessages";
import { useUiStore } from "@/stores/uiStore";
import { useAuthStore } from "@/stores/authStore";
@@ -37,8 +35,6 @@ import { Message } from "@/lib/api-client";
// Modal imports
import { MessageActionsModal } from "@/components/modals/MessageActionsModal";
import { EditMessageModal } from "@/components/modals/EditMessageModal";
import { PinnedMessagesModal } from "@/components/modals/PinnedMessagesModal";
// User type for message component
interface MessageUser {
@@ -57,12 +53,7 @@ interface MessageProps {
currentUser: any;
replyTo?: Message;
replyToUser?: MessageUser;
onEdit?: (messageId: string) => void;
onDelete?: (messageId: string) => void;
onReply?: (messageId: string) => void;
onPin?: (messageId: string) => void;
canDelete?: boolean;
canPin?: boolean;
}
const MessageComponent: React.FC<MessageProps> = ({
@@ -71,29 +62,33 @@ const MessageComponent: React.FC<MessageProps> = ({
currentUser,
replyTo,
replyToUser,
onEdit,
onDelete,
onReply,
onPin,
canDelete = false,
canPin = false,
}) => {
const [isHovered, setIsHovered] = useState(false);
const [showActionsModal, setShowActionsModal] = useState(false);
const formatTimestamp = (timestamp: string) => {
try {
const date = new Date(timestamp);
if (isNaN(date.getTime())) {
// First try parsing as ISO string
let date = parseISO(timestamp);
// If that fails, try regular Date constructor
if (!isValid(date)) {
date = new Date(timestamp);
}
// Final check if date is valid
if (!isValid(date)) {
console.error("Invalid timestamp:", timestamp);
return "Invalid date";
}
return formatDistanceToNow(date, { addSuffix: true });
} catch (error) {
console.error("Error formatting timestamp:", timestamp, error);
return "Invalid date";
}
};
const isOwnMessage = currentUser?.id === message.userId;
const { mode } = useTheme();
@@ -101,7 +96,7 @@ const MessageComponent: React.FC<MessageProps> = ({
const username = user.username || user.userName || "Unknown User";
const displayName = user.nickname || user.nickName || username;
const isDeleted = (message as any).deleted;
const isDeleted = message.deleted;
if (isDeleted) {
return (
@@ -111,13 +106,6 @@ const MessageComponent: React.FC<MessageProps> = ({
<div className="flex-1 min-w-0">
<div className="text-sm text-concord-secondary italic border border-border rounded px-3 py-2 bg-concord-tertiary/50">
This message has been deleted
{(message as any).deletedBy && (
<span className="text-xs block mt-1">
Deleted by {(message as any).deletedBy}
{(message as any).deletedAt &&
`${formatTimestamp((message as any).deletedAt)}`}
</span>
)}
</div>
</div>
</div>
@@ -161,6 +149,22 @@ const MessageComponent: React.FC<MessageProps> = ({
</div>
)}
{/* Reply line and reference */}
{replyTo && replyToUser && (
<div className="flex items-center gap-2 mb-2 text-xs text-concord-secondary">
<div className="w-6 h-3 border-l-2 border-t-2 border-concord-secondary/50 rounded-tl-md ml-2" />
<span className="font-medium text-concord-primary">
{replyToUser.nickname ||
replyToUser.nickName ||
replyToUser.username ||
replyToUser.userName}
</span>
<span className="truncate max-w-xs opacity-75">
{replyTo.text.replace(/```[\s\S]*?```/g, "[code]")}
</span>
</div>
)}
{/* Header - always show */}
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-concord-primary">
@@ -288,11 +292,7 @@ const MessageComponent: React.FC<MessageProps> = ({
onClose={() => setShowActionsModal(false)}
message={message}
isOwnMessage={isOwnMessage}
canDelete={canDelete}
onEdit={onEdit}
onDelete={onDelete}
onReply={onReply}
onPin={canPin ? onPin : undefined}
/>
</>
);
@@ -433,16 +433,12 @@ const ChatPage: React.FC = () => {
// Local state hooks - called unconditionally
const [replyingTo, setReplyingTo] = useState<Message | null>(null);
const [editingMessage, setEditingMessage] = useState<Message | null>(null);
const [showPinnedMessages, setShowPinnedMessages] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesStartRef = useRef<HTMLDivElement>(null);
// API mutation hooks - called unconditionally
const deleteMessageMutation = useDeleteMessage();
const pinMessageMutation = usePinMessage();
const loadMoreMessagesMutation = useLoadMoreMessages(channelId);
// Memoized values - called unconditionally
@@ -460,23 +456,16 @@ const ChatPage: React.FC = () => {
return currentUser.roles.some((role) => role.instanceId === instanceId);
}, [currentUser, instanceId]);
const canDeleteMessages = React.useMemo(() => {
if (!currentUser || !instanceId) return false;
if (currentUser.admin) return true;
const userRole = currentUser.roles.find(
(role) => role.instanceId === instanceId,
);
return userRole && (userRole.role === "admin" || userRole.role === "mod");
}, [currentUser, instanceId]);
const sortedMessages = React.useMemo(() => {
if (!channelMessages) return [];
const canPinMessages = React.useMemo(() => {
if (!currentUser || !instanceId) return false;
if (currentUser.admin) return true;
const userRole = currentUser.roles.find(
(role) => role.instanceId === instanceId,
);
return userRole && (userRole.role === "admin" || userRole.role === "mod");
}, [currentUser, instanceId]);
// Sort messages by createdAt timestamp (oldest first, newest last)
return [...channelMessages].sort((a, b) => {
const dateA = new Date(a.createdAt).getTime();
const dateB = new Date(b.createdAt).getTime();
return dateA - dateB; // ascending order (oldest to newest)
});
}, [channelMessages]);
// Effects - called unconditionally
useEffect(() => {
@@ -511,52 +500,6 @@ const ChatPage: React.FC = () => {
[channelMessages],
);
const handleEdit = React.useCallback(
(messageId: string) => {
const message = channelMessages?.find((m) => m.id === messageId);
if (message) {
setEditingMessage(message);
}
},
[channelMessages],
);
const handleDelete = React.useCallback(
async (messageId: string) => {
if (confirm("Are you sure you want to delete this message?")) {
try {
await deleteMessageMutation.mutateAsync({
messageId,
channelId: channelId!,
});
} catch (error) {
console.error("Failed to delete message:", error);
}
}
},
[deleteMessageMutation, channelId],
);
const handlePin = React.useCallback(
async (messageId: string) => {
try {
const message = channelMessages?.find((m) => m.id === messageId);
const isPinned = (message as any)?.pinned;
await pinMessageMutation.mutateAsync({
messageId,
channelId: channelId!,
pinned: !isPinned,
});
} catch (error) {
console.error("Failed to pin/unpin message:", error);
}
},
[pinMessageMutation, channelId, channelMessages],
);
// NOW WE CAN START CONDITIONAL LOGIC AND EARLY RETURNS
// Handle loading states
if (instanceLoading || messagesLoading || usersLoading) {
return (
@@ -578,7 +521,7 @@ const ChatPage: React.FC = () => {
Access Denied
</h2>
<p className="mb-4">You don't have permission to view this server.</p>
<Button onClick={() => navigate("/channels/@me")}>Go Home</Button>
<Button onClick={() => navigate("/")}>Go Home</Button>
</div>
</div>
);
@@ -638,7 +581,6 @@ const ChatPage: React.FC = () => {
const ChannelIcon = currentChannel?.type === "voice" ? Volume2 : Hash;
console.log(channelMessages);
return (
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
{/* Channel Header */}
@@ -659,14 +601,6 @@ const ChatPage: React.FC = () => {
</div>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 interactive-hover"
onClick={() => setShowPinnedMessages(true)}
>
<Pin size={16} />
</Button>
<Button
variant="ghost"
size="icon"
@@ -731,12 +665,13 @@ const ChatPage: React.FC = () => {
<div className="pb-4">
{/* Messages */}
{channelMessages && channelMessages.length > 0 ? (
{sortedMessages && sortedMessages.length > 0 ? (
<div>
{channelMessages.map((message) => {
{sortedMessages.map((message) => {
console.log(message);
const user = users?.find((u) => u.id === message.userId);
const replyToMessage = channelMessages?.find(
(m) => m.id === message.replyToId,
(m) => m.id === (message as any).repliedMessageId,
);
const replyToUser = replyToMessage
? users?.find((u) => u.id === replyToMessage.userId)
@@ -751,13 +686,8 @@ const ChatPage: React.FC = () => {
user={user}
currentUser={currentUser}
replyTo={replyToMessage}
onEdit={handleEdit}
onDelete={handleDelete}
onReply={handleReply}
onPin={handlePin}
replyToUser={replyToUser}
canDelete={canDeleteMessages}
canPin={canPinMessages}
/>
);
})}
@@ -791,25 +721,6 @@ const ChatPage: React.FC = () => {
</div>
)}
</div>
{/* Edit Message Modal */}
{editingMessage && (
<EditMessageModal
isOpen={!!editingMessage}
onClose={() => setEditingMessage(null)}
message={editingMessage}
channelId={channelId!}
/>
)}
{/* Pinned Messages Modal */}
<PinnedMessagesModal
isOpen={showPinnedMessages}
onClose={() => setShowPinnedMessages(false)}
channelId={channelId!}
channelName={currentChannel?.name || "channel"}
canManagePins={canPinMessages ? canPinMessages : false}
/>
</div>
);
};

View File

@@ -24,7 +24,7 @@ const LoginPage: React.FC = () => {
// Redirect if already authenticated
if (isAuthenticated) {
return <Navigate to="/channels/@me" replace />;
return <Navigate to="/" replace />;
}
const handleLogin = async (e: React.FormEvent) => {

View File

@@ -7,7 +7,7 @@ const NotFoundPage: React.FC = () => {
<h1 className="text-4xl font-bold mb-4 text-concord-primary">404</h1>
<p className="text-xl mb-6">Page not found</p>
<Button asChild>
<a href="/channels/@me">Go Home</a>
<a href="/">Go Home</a>
</Button>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import {
User,
Shield,
Mic,
Eye,
Settings,
ChevronRight,
Moon,
@@ -45,12 +46,12 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
icon: User,
description: "Profile, privacy, and account settings",
},
{
id: "security",
title: "Security",
icon: Lock,
description: "Password and security settings",
},
// {
// id: "security",
// title: "Security",
// icon: Lock,
// description: "Password and security settings",
// },
{
id: "appearance",
title: "Appearance",
@@ -629,6 +630,98 @@ const AppearanceSettings: React.FC = () => {
</div>
</div>
</div>
{/* Theme Grid */}
<div className="mt-6">
<Label className="text-sm font-medium">Available Themes</Label>
<div className="mt-3 grid grid-cols-2 gap-3">
{/* Light Themes */}
{lightThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentLightTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Sun className="h-4 w-4 text-yellow-500" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
{/* Dark Themes */}
{darkThemes.map((theme) => (
<button
key={theme.id}
onClick={() => setTheme(theme.id)}
className={`p-3 rounded-lg border-2 transition-all text-left ${
currentDarkTheme.id === theme.id
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-sm">{theme.name}</span>
<Moon className="h-4 w-4 text-blue-400" />
</div>
{theme.description && (
<p className="text-xs text-muted-foreground mb-2">
{theme.description}
</p>
)}
<div className="flex gap-1">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.primary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.secondary }}
/>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.colors.accent }}
/>
</div>
</button>
))}
</div>
</div>
{/* Theme Stats */}
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{lightThemes.length}</div>
<div className="text-sm text-muted-foreground">Light Themes</div>
</div>
<div className="text-center p-3 bg-muted/50 rounded-lg">
<div className="text-lg font-semibold">{darkThemes.length}</div>
<div className="text-sm text-muted-foreground">Dark Themes</div>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -127,6 +127,7 @@ export async function getMessagesBefore(date: string, channelId: string) {
userId: message.userId!,
text: message.text,
deleted: message.deleted,
createdAt: message.createdAt,
replies: originalMessage
? {
messageId: message.id,
@@ -159,7 +160,11 @@ export async function getMessagesBefore(date: string, channelId: string) {
export async function editMessage(data: PutMessage) {
try {
const userCreds = await getUserCredentials(data.id);
if (!userCreds || userCreds.token == null || userCreds.token != data.token) {
if (
!userCreds ||
userCreds.token == null ||
userCreds.token != data.token
) {
return null;
}