ui: consistency across pages and better autofocus
- Consistency achieved across all pages on header items, icons, and margins / positions. Kinda hacked together quite a bit, may need to focus on a consistent style guide across components via utility classes - Autofocus on MessageInput. Refs were giving problems, ended up using DOM queries and focus is now achieved on ChatPage load and when replying to a user. Not ideal and should be changed to Refs again at some point - CreateChannelModal now loads categories, still fails the actual POST request though - Removed extrenuous console.log statements - Laid groundwork for permissions system - New more consistent look and feel overall
This commit is contained in:
@@ -38,11 +38,6 @@ const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
|
||||
if (isConnectedToThisChannel) {
|
||||
leaveChannel();
|
||||
} else if (currentUser && token) {
|
||||
console.log({
|
||||
channelId: channel.id,
|
||||
currentUser: currentUser.id,
|
||||
token: token,
|
||||
});
|
||||
joinChannel(channel.id, currentUser.id, token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const CategoryHeader: React.FC<CategoryHeaderProps> = ({
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-between p-4 h-6 text-md text-primary-foreground font-semibold interactive-hover uppercase tracking-wide group"
|
||||
className="w-full justify-between p-4 h-6 text-md text-concord-primary font-semibold interactive-hover uppercase tracking-wide group"
|
||||
onClick={() => {
|
||||
onToggle();
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, ErrorInfo, ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card } from "../ui/card";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -48,8 +49,8 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full space-y-4">
|
||||
<div className="min-h-screen bg-concord-primary flex items-center justify-center p-4">
|
||||
<Card className="max-w-md bg-concord-secondary border-concord w-full space-y-4">
|
||||
<Alert className="border-red-500 bg-red-950/50">
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||
<AlertTitle className="text-red-400">
|
||||
@@ -82,7 +83,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
{/* Error details in development */}
|
||||
{process.env.NODE_ENV === "development" && this.state.error && (
|
||||
<details className="mt-4 p-3 bg-gray-800 rounded-lg text-sm">
|
||||
<details className="mt-4 p-3 bg-concord-secondary rounded-lg text-sm">
|
||||
<summary className="cursor-pointer text-red-400 font-medium mb-2">
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
@@ -107,7 +108,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const AppLayout: React.FC = () => {
|
||||
<VoiceConnectionManager />
|
||||
|
||||
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
|
||||
<div className="relative w-[72px] sidebar-primary flex-shrink-0">
|
||||
<div className="relative min-w-1/16 sidebar-primary flex-shrink-0">
|
||||
<ServerSidebar />
|
||||
</div>
|
||||
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
|
||||
@@ -68,7 +68,7 @@ const AppLayout: React.FC = () => {
|
||||
</div>
|
||||
{/* Member List - Only shown when in a channel and member list is enabled */}
|
||||
{showMemberList && shouldShowChannelSidebar && (
|
||||
<div className="flex-0 sidebar-secondary order-l border-sidebar">
|
||||
<div className="flex-0 min-w-1/7 sidebar-secondary order-l border-sidebar">
|
||||
<MemberList />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { ChevronDown, Plus, Users } from "lucide-react";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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";
|
||||
import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
|
||||
|
||||
const ChannelSidebar: React.FC = () => {
|
||||
const { instanceId } = useParams();
|
||||
@@ -14,8 +14,8 @@ const ChannelSidebar: React.FC = () => {
|
||||
useInstanceDetails(instanceId);
|
||||
const categories = instance?.categories;
|
||||
const {
|
||||
toggleMemberList,
|
||||
showMemberList,
|
||||
showCreateChannel,
|
||||
closeCreateChannel,
|
||||
openCreateChannel,
|
||||
openServerSettings,
|
||||
} = useUiStore();
|
||||
@@ -45,11 +45,11 @@ const ChannelSidebar: React.FC = () => {
|
||||
<div className="sidebar-secondary flex-1">
|
||||
<ScrollArea className="">
|
||||
{/* Server Header */}
|
||||
<div className="flex items-center justify-between border-b border-concord-primary shadow-sm px-4 py-3">
|
||||
<div className="flex items-center justify-between border-b border-concord-primary shadow-sm px-6 py-4">
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex items-center justify-between w-full h-8 font-semibold text-concord-primary hover:bg-concord-tertiary"
|
||||
className="flex items-center justify-between w-full h-8 font-semibold text-concord-primary text-xl hover:bg-concord-tertiary"
|
||||
onClick={openServerSettings}
|
||||
>
|
||||
<span className="truncate">{instance.name}</span>
|
||||
@@ -83,20 +83,15 @@ const ChannelSidebar: React.FC = () => {
|
||||
<Plus size={16} className="mr-1" />
|
||||
Add Channel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-8 w-8 flex-shrink-0 ${showMemberList ? "text-interactive-active" : "interactive-hover"}`}
|
||||
onClick={toggleMemberList}
|
||||
>
|
||||
<Users size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
<CreateChannelModal
|
||||
isOpen={showCreateChannel}
|
||||
onClose={closeCreateChannel}
|
||||
categories={categories}
|
||||
instanceId={instance.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -206,16 +206,16 @@ const MemberList: React.FC = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 border-l border-concord-primary h-full bg-concord-secondary">
|
||||
<div className="flex flex-col flex-grow-1 w-full border-l border-concord h-full sidebar-secondary">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon size={20} className="text-concord-primary" />
|
||||
<p className="text-sm font-semibold text-concord-primary tracking-wide">
|
||||
<div className="px-6 py-4 pb-5 border-b border-concord flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<UserIcon size={20} className="h-5 w-5 text-concord-secondary" />
|
||||
<p className="font-semibold text-xl text-concord-primary tracking-wide">
|
||||
Members
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-concord-secondary tracking-wide">
|
||||
<p className="font-medium text-concord-secondary tracking-wide">
|
||||
{members.length}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -51,17 +51,17 @@ const ServerSidebar: React.FC = () => {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="sidebar-primary flex flex-col items-center h-full space-y-2">
|
||||
<div className="sidebar-primary flex flex-col items-center h-full space-y-2 w-full">
|
||||
{/* Home/DM Button */}
|
||||
<Tooltip key={"home-server"}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-10 h-10 p-0 mt-2 ml-2 rounded-full transition-all duration-200 ${
|
||||
className={`w-12 h-12 mt-2 transition-all duration-200 ${
|
||||
!instanceId || instanceId === "@me"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-primary/10"
|
||||
? "rounded-xl border-primary bg-primary/10 border-2"
|
||||
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
|
||||
}`}
|
||||
onClick={handleHomeClick}
|
||||
>
|
||||
@@ -72,10 +72,8 @@ const ServerSidebar: React.FC = () => {
|
||||
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-full ml-0 h-0.5 bg-border rounded-full" />
|
||||
|
||||
<div className="w-full h-0.5 bg-border rounded-full" />{" "}
|
||||
{/* Server List */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2">
|
||||
{isLoading ? (
|
||||
@@ -116,26 +114,25 @@ const ServerSidebar: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Add Server Button - Only show if user can create servers */}
|
||||
{canCreateServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 mb-4 h-12 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
|
||||
onClick={handleCreateServer}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Server Button - Only show if user can create servers */}
|
||||
{canCreateServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-12 h-12 ml-3 rounded-2xl hover:rounded-xl bg-concord-secondary hover:bg-green-600 text-green-500 hover:text-white transition-all duration-200"
|
||||
onClick={handleCreateServer}
|
||||
>
|
||||
<Plus size={24} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p>Add a Server</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -133,7 +133,7 @@ const UserPanel: React.FC = () => {
|
||||
useVoiceStore();
|
||||
|
||||
return (
|
||||
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
||||
<div className="user-panel flex items-center p-3 bg-concord-tertiary border-t border-sidebar min-h-16 rounded-xl m-2">
|
||||
{/* User Info */}
|
||||
<UserAvatar user={user} size="md" />
|
||||
<div className="ml-2 flex-1 min-w-0 text-left">
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// src/components/message/MessageInput.tsx
|
||||
|
||||
import { Message } from "@/lib/api-client";
|
||||
import { useState, useRef, useEffect } from "react"; // Keep useRef
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useSendMessage } from "@/hooks/useMessages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface MessageUser {
|
||||
id: string;
|
||||
@@ -27,24 +26,21 @@ export const MessageInput: React.FC<MessageInputProps> = ({
|
||||
replyingToUser,
|
||||
}) => {
|
||||
const [content, setContent] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
// Use the API hook for sending messages
|
||||
const sendMessageMutation = useSendMessage();
|
||||
|
||||
// Auto-resize textarea and focus when replying
|
||||
// Auto-resize textarea (using direct DOM access as a fallback, no ref needed)
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
|
||||
|
||||
// Focus the input when a reply is initiated
|
||||
if (replyingTo) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
const textarea = document.getElementById(
|
||||
"message-input-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}
|
||||
}, [content, replyingTo]);
|
||||
}, [content]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -71,7 +67,7 @@ export const MessageInput: React.FC<MessageInputProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="px-2 pb-2">
|
||||
{replyingTo && replyingToUser && (
|
||||
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -97,26 +93,15 @@ export const MessageInput: React.FC<MessageInputProps> = ({
|
||||
)}
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Message #${channelName || "channel"}`}
|
||||
disabled={sendMessageMutation.isPending}
|
||||
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 disabled:opacity-50"
|
||||
style={{
|
||||
minHeight: "44px",
|
||||
maxHeight: "200px",
|
||||
}}
|
||||
/>
|
||||
<div className="absolute right-3 bottom-3 text-xs text-concord-secondary">
|
||||
{sendMessageMutation.isPending
|
||||
? "Sending..."
|
||||
: "Press Enter to send • Shift+Enter for new line"}
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
id="message-input-textarea" // Unique ID for DOM targeting
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Message #${channelName || "channel"}`}
|
||||
disabled={sendMessageMutation.isPending}
|
||||
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 disabled:opacity-50 min-h-8 max-h-56"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,39 +11,58 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Hash, Volume2, Loader2 } from "lucide-react";
|
||||
import { useCreateChannel } from "@/hooks/useServers";
|
||||
import { CategoryWithChannels } from "@/types";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CreateChannelModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
instanceId: string; // Changed to use instanceId instead of categories prop
|
||||
defaultCategoryId?: string;
|
||||
instanceId: string;
|
||||
categories: CategoryWithChannels[] | undefined;
|
||||
}
|
||||
|
||||
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
defaultCategoryId,
|
||||
categories,
|
||||
}) => {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState(defaultCategoryId || "");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
const createChannelMutation = useCreateChannel();
|
||||
|
||||
// Reset form when modal opens/closes
|
||||
// Reset form when modal opens or closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
setCategoryId("");
|
||||
} else {
|
||||
setCategoryId("");
|
||||
}
|
||||
}, [isOpen, defaultCategoryId]);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !categoryId) return;
|
||||
// Basic validation: ensure name is not empty and a category is selected
|
||||
if (!name.trim() || !categoryId || categoryId === "no-categories") {
|
||||
console.warn("Channel name and a valid category are required.");
|
||||
toast("Error", {
|
||||
description: "Channel name and a valid category are required.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createChannelMutation.mutateAsync({
|
||||
@@ -53,17 +72,25 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
categoryId,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
// Reset form after successful creation
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId(defaultCategoryId || "");
|
||||
setCategoryId(""); // Reset to default or empty
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create channel:", error);
|
||||
toast("Error", { description: <p>{`${error}`}</p> });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to determine if the form is in a valid state for submission
|
||||
const isFormInvalid =
|
||||
!name.trim() || // Name is required and cannot be just whitespace
|
||||
!categoryId || // Category must be selected
|
||||
categoryId === "no-categories" || // Handle the "no categories available" placeholder
|
||||
createChannelMutation.isPending; // Disable while mutation is in progress
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
@@ -72,12 +99,13 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Channel Type Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Channel Type</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "text" ? "default" : "outline"}
|
||||
variant={type === "text" ? "secondary" : "ghost"}
|
||||
onClick={() => setType("text")}
|
||||
className="flex-1"
|
||||
>
|
||||
@@ -86,7 +114,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={type === "voice" ? "default" : "outline"}
|
||||
variant={type === "voice" ? "secondary" : "ghost"}
|
||||
onClick={() => setType("voice")}
|
||||
className="flex-1"
|
||||
>
|
||||
@@ -96,6 +124,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel Name Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
@@ -107,6 +136,35 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-category">Category</Label>
|
||||
<Select
|
||||
value={categoryId}
|
||||
onValueChange={(value) => setCategoryId(value)}
|
||||
required
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories && categories.length > 0 ? (
|
||||
categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
// Display this option if there are no categories
|
||||
<SelectItem value="no-categories" disabled>
|
||||
No categories available
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Channel Description Textarea */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description</Label>
|
||||
<Textarea
|
||||
@@ -118,20 +176,12 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
!categoryId ||
|
||||
createChannelMutation.isPending ||
|
||||
categoryId === "loading" ||
|
||||
categoryId === "no-categories"
|
||||
}
|
||||
>
|
||||
<Button type="submit" disabled={isFormInvalid}>
|
||||
{createChannelMutation.isPending ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -1,30 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Instance } from "@/types/database";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
} from "../ui/dialog";
|
||||
import { DialogDescription, DialogTitle } from "@radix-ui/react-dialog";
|
||||
import { Label } from "../ui/label";
|
||||
import { Input } from "../ui/input";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Category } from "@/types";
|
||||
import { useUiStore } from "@/stores/uiStore";
|
||||
import {
|
||||
useCreateCategory,
|
||||
useCreateChannel,
|
||||
useCreateInstance,
|
||||
} from "@/hooks/useServers";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
} from "../ui/select";
|
||||
|
||||
interface ServerIconProps {
|
||||
server: Instance;
|
||||
@@ -47,17 +23,17 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
{/* Active indicator */}
|
||||
<div className="relative group w-12">
|
||||
{/* Active indicator - Positioned outside to the left */}
|
||||
<div
|
||||
className={`absolute left-0 top-1/2 transform -translate-y-1/2 w-1 bg-accent-foreground rounded transition-all duration-200 ${
|
||||
className={`absolute -left-2 top-1/2 transform -translate-y-1/2 w-1 bg-accent-foreground rounded transition-all duration-200 ${
|
||||
isActive ? "h-10 rounded-xl" : "rounded-r h-2 group-hover:h-5"
|
||||
}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`w-12 h-12 ml-3 transition-all duration-200 ${
|
||||
className={`w-12 h-12 transition-all duration-200 ${
|
||||
isActive
|
||||
? "rounded-xl border-primary bg-primary/10 border-2"
|
||||
: "rounded-2xl hover:rounded-xl border hover:border-primary/50"
|
||||
@@ -80,308 +56,4 @@ const ServerIcon: React.FC<ServerIconProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Create Server Modal
|
||||
export const CreateServerModal: React.FC = () => {
|
||||
const { showCreateServer, closeCreateServer } = useUiStore();
|
||||
const { mutate: createInstance, isPending } = useCreateInstance();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
createInstance(
|
||||
{
|
||||
name: name.trim(),
|
||||
icon: icon.trim() || undefined,
|
||||
// description: description.trim() || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create server:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIcon("");
|
||||
closeCreateServer();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateServer} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new server to chat with friends and communities.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter server name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="server-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this server about?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-icon">Server Icon URL (Optional)</Label>
|
||||
<Input
|
||||
id="server-icon"
|
||||
type="url"
|
||||
value={icon}
|
||||
onChange={(e) => setIcon(e.target.value)}
|
||||
placeholder="https://example.com/icon.png"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Server"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Channel Modal
|
||||
export const CreateChannelModal: React.FC = () => {
|
||||
const { showCreateChannel, closeCreateChannel } = useUiStore();
|
||||
const { mutate: createChannel, isPending } = useCreateChannel();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState<"text" | "voice">("text");
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
|
||||
// You'd need to get categories for the current instance
|
||||
// This is a simplified version
|
||||
const categories: Category[] = []; // Get from context or props
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && categoryId) {
|
||||
createChannel(
|
||||
{
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
categoryId,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create channel:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setType("text");
|
||||
setCategoryId("");
|
||||
closeCreateChannel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showCreateChannel} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new text or voice channel in this server.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-type">Channel Type</Label>
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(value: "text" | "voice") => setType(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">Text Channel</SelectItem>
|
||||
<SelectItem value="voice">Voice Channel</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-category">Category</Label>
|
||||
<Select value={categoryId} onValueChange={setCategoryId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter channel name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="channel-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="What's this channel for?"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// Create Category Modal
|
||||
export const CreateCategoryModal: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { mutate: createCategory, isPending } = useCreateCategory();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim() && instanceId) {
|
||||
createCategory(
|
||||
{
|
||||
name: name.trim(),
|
||||
instanceId,
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to create category:", error);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setName("");
|
||||
setInstanceId("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a Category</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new category to organize your channels.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category-name">Category Name</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || !name.trim()}>
|
||||
{isPending ? "Creating..." : "Create Category"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminModals: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<CreateServerModal />
|
||||
<CreateChannelModal />
|
||||
<CreateCategoryModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default ServerIcon;
|
||||
|
||||
@@ -156,12 +156,6 @@ export const useEditMessage = () => {
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call when available
|
||||
console.log(
|
||||
"Editing message:",
|
||||
data.messageId,
|
||||
"New content:",
|
||||
data.content,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -207,10 +201,6 @@ export const usePinMessage = () => {
|
||||
}
|
||||
|
||||
// TODO: Replace with actual API call when available
|
||||
console.log(
|
||||
`${data.pinned ? "Pinning" : "Unpinning"} message:`,
|
||||
data.messageId,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -256,6 +256,8 @@ export class ApiClient {
|
||||
requestingUserId: string;
|
||||
requestingUserToken: string;
|
||||
}): Promise<Channel> {
|
||||
console.log(data);
|
||||
|
||||
return this.request<Channel>("/api/channel", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -4,30 +4,10 @@ import App from "./App.tsx";
|
||||
import { io } from "socket.io-client";
|
||||
import "./index.css";
|
||||
|
||||
function printPayload(data: unknown) {
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
const socket = io("http://localhost:3000");
|
||||
socket.on("connect", () => {
|
||||
console.log("connected!");
|
||||
socket.emit("ping", "world");
|
||||
});
|
||||
socket.on("pong", () => {
|
||||
console.log("pong");
|
||||
});
|
||||
|
||||
socket.on("joined-voicechannel", printPayload);
|
||||
socket.on("user-joined-voicechannel", printPayload);
|
||||
socket.on("error-voicechannel", printPayload);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App socket={socket} />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
// Use contextBridge
|
||||
window.ipcRenderer.on("main-process-message", (_event, message) => {
|
||||
console.log(message);
|
||||
});
|
||||
|
||||
@@ -76,6 +76,44 @@ const ChatPage: React.FC = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [channelMessages]);
|
||||
|
||||
// Auto-focus on channel change or initial load (using DOM query)
|
||||
useEffect(() => {
|
||||
if (!currentUser) return; // Skip if input isn't rendered
|
||||
|
||||
let retryCount = 0;
|
||||
const maxRetries = 10;
|
||||
const retryInterval = 100; // ms
|
||||
|
||||
const focusInput = () => {
|
||||
retryCount++;
|
||||
const textarea = document.getElementById(
|
||||
"message-input-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
} else if (retryCount < maxRetries) {
|
||||
setTimeout(focusInput, retryInterval);
|
||||
}
|
||||
};
|
||||
|
||||
focusInput();
|
||||
}, [channelId, currentUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replyingTo) return; // Skip if no reply
|
||||
|
||||
const focusInput = () => {
|
||||
const textarea = document.getElementById(
|
||||
"message-input-textarea",
|
||||
) as HTMLTextAreaElement | null;
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
}
|
||||
};
|
||||
|
||||
focusInput();
|
||||
}, [replyingTo]);
|
||||
|
||||
// Event handlers
|
||||
const handleLoadMore = React.useCallback(async () => {
|
||||
if (!channelMessages || channelMessages.length === 0 || isLoadingMore)
|
||||
@@ -214,10 +252,10 @@ const ChatPage: React.FC = () => {
|
||||
return (
|
||||
<div className="flex flex-col flex-shrink h-full bg-concord-primary">
|
||||
{/* Channel Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-concord bg-concord-secondary">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-concord bg-concord-secondary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<ChannelIcon size={20} className="text-concord-secondary" />
|
||||
<span className="font-semibold text-concord-primary">
|
||||
<span className="font-semibold text-xl text-concord-primary">
|
||||
{currentChannel?.name}
|
||||
</span>
|
||||
{currentChannel?.description && (
|
||||
@@ -275,7 +313,6 @@ const ChatPage: React.FC = () => {
|
||||
{sortedMessages && sortedMessages.length > 0 ? (
|
||||
<div>
|
||||
{sortedMessages.map((message) => {
|
||||
console.log(message);
|
||||
const user = users?.find((u) => u.id === message.userId);
|
||||
const replyToMessage = channelMessages?.find(
|
||||
(m) => m.id === message.replies?.repliesToId,
|
||||
|
||||
@@ -72,16 +72,6 @@ const AccountSettings: React.FC = () => {
|
||||
|
||||
try {
|
||||
// TODO: Implement actual profile update API call
|
||||
// await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
// console.log("Updating profile:", { username, nickname, bio });
|
||||
// const updatedUser = await userClient.updateProfile({
|
||||
// userId: user.id,
|
||||
// username: username.trim(),
|
||||
// nickname: nickname.trim() || null,
|
||||
// bio: bio.trim() || null,
|
||||
// token: authStore.token
|
||||
// });
|
||||
|
||||
// Update local state
|
||||
// updateUser({
|
||||
@@ -595,7 +585,7 @@ const SettingsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea className="min-h-0 w-full">
|
||||
<ScrollArea className="min-h-0 w-full bg-concord-primary h-full">
|
||||
<div className="p-6 flex w-full">{renderSettingsContent()}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
@@ -71,7 +71,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
* @returns The configured RTCPeerConnection instance.
|
||||
*/
|
||||
const createPeerConnection = (targetUserId: string): RTCPeerConnection => {
|
||||
console.log(`Creating peer connection for: ${targetUserId}`);
|
||||
const { iceServers, localStream, socket, peerConnections } = get();
|
||||
|
||||
const peerConnection = new RTCPeerConnection({ iceServers });
|
||||
@@ -95,7 +94,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
|
||||
// Handle incoming remote tracks
|
||||
peerConnection.ontrack = (event) => {
|
||||
console.log(`Received remote track from: ${targetUserId}`);
|
||||
set((state) => {
|
||||
const newStreams = new Map(state.remoteStreams);
|
||||
newStreams.set(targetUserId, event.streams[0]);
|
||||
@@ -105,9 +103,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
|
||||
// For debugging connection state
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log(
|
||||
`Connection state change for ${targetUserId}: ${peerConnection.connectionState}`,
|
||||
);
|
||||
if (
|
||||
peerConnection.connectionState === "disconnected" ||
|
||||
peerConnection.connectionState === "failed"
|
||||
@@ -128,10 +123,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
connectedUserIds: string[];
|
||||
iceServers: IceServerConfig[];
|
||||
}) => {
|
||||
console.log(
|
||||
"Successfully joined voice channel. Users:",
|
||||
data.connectedUserIds,
|
||||
);
|
||||
set({
|
||||
iceServers: data.iceServers,
|
||||
isConnecting: false,
|
||||
@@ -150,7 +141,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
};
|
||||
|
||||
const onUserLeft = (data: { userId: string }) => {
|
||||
console.log(`User ${data.userId} left the channel.`);
|
||||
cleanupPeerConnection(data.userId);
|
||||
};
|
||||
|
||||
@@ -158,7 +148,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
senderUserId: string;
|
||||
sdp: RTCSessionDescriptionInit;
|
||||
}) => {
|
||||
console.log("Received WebRTC offer from:", data.senderUserId);
|
||||
const peerConnection = createPeerConnection(data.senderUserId);
|
||||
await peerConnection.setRemoteDescription(
|
||||
new RTCSessionDescription(data.sdp),
|
||||
@@ -175,7 +164,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
senderUserId: string;
|
||||
sdp: RTCSessionDescriptionInit;
|
||||
}) => {
|
||||
console.log("Received WebRTC answer from:", data.senderUserId);
|
||||
const peerConnection = get().peerConnections.get(data.senderUserId);
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(
|
||||
@@ -271,7 +259,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||
get();
|
||||
if (!socket || !activeVoiceChannelId) return;
|
||||
|
||||
console.log(`Leaving voice channel: ${activeVoiceChannelId}`);
|
||||
socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId });
|
||||
|
||||
// Clean up all event listeners
|
||||
|
||||
Reference in New Issue
Block a user