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:
2025-10-07 15:55:24 -04:00
parent 24a99900b1
commit 2f91713c11
17 changed files with 190 additions and 509 deletions

View File

@@ -38,11 +38,6 @@ const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
if (isConnectedToThisChannel) { if (isConnectedToThisChannel) {
leaveChannel(); leaveChannel();
} else if (currentUser && token) { } else if (currentUser && token) {
console.log({
channelId: channel.id,
currentUser: currentUser.id,
token: token,
});
joinChannel(channel.id, currentUser.id, token); joinChannel(channel.id, currentUser.id, token);
} }
} }

View File

@@ -18,7 +18,7 @@ const CategoryHeader: React.FC<CategoryHeaderProps> = ({
return ( return (
<Button <Button
variant="ghost" 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={() => { onClick={() => {
onToggle(); onToggle();
}} }}

View File

@@ -2,6 +2,7 @@ import { Component, ErrorInfo, ReactNode } from "react";
import { AlertTriangle, RotateCcw } from "lucide-react"; import { AlertTriangle, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Card } from "../ui/card";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -48,8 +49,8 @@ class ErrorBoundary extends Component<Props, State> {
} }
return ( return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4"> <div className="min-h-screen bg-concord-primary flex items-center justify-center p-4">
<div className="max-w-md w-full space-y-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"> <Alert className="border-red-500 bg-red-950/50">
<AlertTriangle className="h-4 w-4 text-red-500" /> <AlertTriangle className="h-4 w-4 text-red-500" />
<AlertTitle className="text-red-400"> <AlertTitle className="text-red-400">
@@ -82,7 +83,7 @@ class ErrorBoundary extends Component<Props, State> {
{/* Error details in development */} {/* Error details in development */}
{process.env.NODE_ENV === "development" && this.state.error && ( {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"> <summary className="cursor-pointer text-red-400 font-medium mb-2">
Error Details (Development) Error Details (Development)
</summary> </summary>
@@ -107,7 +108,7 @@ class ErrorBoundary extends Component<Props, State> {
</div> </div>
</details> </details>
)} )}
</div> </Card>
</div> </div>
); );
} }

View File

@@ -39,7 +39,7 @@ const AppLayout: React.FC = () => {
<VoiceConnectionManager /> <VoiceConnectionManager />
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */} {/* 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 /> <ServerSidebar />
</div> </div>
{/* Channel Sidebar - Only shown when in a server context and not collapsed */} {/* Channel Sidebar - Only shown when in a server context and not collapsed */}
@@ -68,7 +68,7 @@ const AppLayout: React.FC = () => {
</div> </div>
{/* Member List - Only shown when in a channel and member list is enabled */} {/* Member List - Only shown when in a channel and member list is enabled */}
{showMemberList && shouldShowChannelSidebar && ( {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 /> <MemberList />
</div> </div>
)} )}

View File

@@ -1,12 +1,12 @@
import React from "react"; import React from "react";
import { useParams } from "react-router"; 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 { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; 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"; import { CreateChannelModal } from "@/components/modals/CreateChannelModal";
const ChannelSidebar: React.FC = () => { const ChannelSidebar: React.FC = () => {
const { instanceId } = useParams(); const { instanceId } = useParams();
@@ -14,8 +14,8 @@ const ChannelSidebar: React.FC = () => {
useInstanceDetails(instanceId); useInstanceDetails(instanceId);
const categories = instance?.categories; const categories = instance?.categories;
const { const {
toggleMemberList, showCreateChannel,
showMemberList, closeCreateChannel,
openCreateChannel, openCreateChannel,
openServerSettings, openServerSettings,
} = useUiStore(); } = useUiStore();
@@ -45,11 +45,11 @@ const ChannelSidebar: React.FC = () => {
<div className="sidebar-secondary flex-1"> <div className="sidebar-secondary flex-1">
<ScrollArea className=""> <ScrollArea className="">
{/* Server Header */} {/* 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"> <div className="flex items-center space-x-2 flex-1 min-w-0">
<Button <Button
variant="ghost" 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} onClick={openServerSettings}
> >
<span className="truncate">{instance.name}</span> <span className="truncate">{instance.name}</span>
@@ -83,20 +83,15 @@ const ChannelSidebar: React.FC = () => {
<Plus size={16} className="mr-1" /> <Plus size={16} className="mr-1" />
Add Channel Add Channel
</Button> </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>
</div> </div>
</ScrollArea> </ScrollArea>
<CreateChannelModal /> <CreateChannelModal
<CreateCategoryModal /> isOpen={showCreateChannel}
onClose={closeCreateChannel}
categories={categories}
instanceId={instance.id}
/>
</div> </div>
); );
}; };

View File

@@ -206,16 +206,16 @@ const MemberList: React.FC = () => {
); );
return ( 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 */} {/* Header */}
<div className="px-4 py-3 border-b border-concord-primary flex items-center justify-between"> <div className="px-6 py-4 pb-5 border-b border-concord flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center space-x-2">
<UserIcon size={20} className="text-concord-primary" /> <UserIcon size={20} className="h-5 w-5 text-concord-secondary" />
<p className="text-sm font-semibold text-concord-primary tracking-wide"> <p className="font-semibold text-xl text-concord-primary tracking-wide">
Members Members
</p> </p>
</div> </div>
<p className="text-sm font-medium text-concord-secondary tracking-wide"> <p className="font-medium text-concord-secondary tracking-wide">
{members.length} {members.length}
</p> </p>
</div> </div>

View File

@@ -51,17 +51,17 @@ const ServerSidebar: React.FC = () => {
return ( return (
<TooltipProvider> <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 */} {/* Home/DM Button */}
<Tooltip key={"home-server"}> <Tooltip key={"home-server"}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" 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" !instanceId || instanceId === "@me"
? "bg-primary text-primary-foreground" ? "rounded-xl border-primary bg-primary/10 border-2"
: "hover:bg-primary/10" : "rounded-2xl hover:rounded-xl border hover:border-primary/50"
}`} }`}
onClick={handleHomeClick} onClick={handleHomeClick}
> >
@@ -72,10 +72,8 @@ const ServerSidebar: React.FC = () => {
<p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p> <p>{isGlobalAdmin(currentUser) ? "Admin Dashboard" : "Home"}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
{/* Separator */} {/* 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 */} {/* Server List */}
<div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2"> <div className="flex-1 flex flex-col overflow-y-auto scrollbar-thin scrollbar-thumb-border space-y-2">
{isLoading ? ( {isLoading ? (
@@ -116,26 +114,25 @@ const ServerSidebar: React.FC = () => {
)} )}
</div> </div>
) : null} ) : 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> </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> </div>
</TooltipProvider> </TooltipProvider>
); );

View File

@@ -133,7 +133,7 @@ const UserPanel: React.FC = () => {
useVoiceStore(); useVoiceStore();
return ( 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 */} {/* User Info */}
<UserAvatar user={user} size="md" /> <UserAvatar user={user} size="md" />
<div className="ml-2 flex-1 min-w-0 text-left"> <div className="ml-2 flex-1 min-w-0 text-left">

View File

@@ -1,9 +1,8 @@
// src/components/message/MessageInput.tsx
import { Message } from "@/lib/api-client"; 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 { useSendMessage } from "@/hooks/useMessages";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface MessageUser { interface MessageUser {
id: string; id: string;
@@ -27,24 +26,21 @@ export const MessageInput: React.FC<MessageInputProps> = ({
replyingToUser, replyingToUser,
}) => { }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
// Use the API hook for sending messages // Use the API hook for sending messages
const sendMessageMutation = useSendMessage(); const sendMessageMutation = useSendMessage();
// Auto-resize textarea and focus when replying // Auto-resize textarea (using direct DOM access as a fallback, no ref needed)
useEffect(() => { useEffect(() => {
if (textareaRef.current) { const textarea = document.getElementById(
textareaRef.current.style.height = "auto"; "message-input-textarea",
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`; ) as HTMLTextAreaElement | null;
if (textarea) {
// Focus the input when a reply is initiated textarea.style.height = "auto";
if (replyingTo) { textarea.style.height = `${textarea.scrollHeight}px`;
textareaRef.current.focus();
}
} }
}, [content, replyingTo]); }, [content]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -71,7 +67,7 @@ export const MessageInput: React.FC<MessageInputProps> = ({
}; };
return ( return (
<div className="px-4 pb-4"> <div className="px-2 pb-2">
{replyingTo && replyingToUser && ( {replyingTo && replyingToUser && (
<div className="mb-2 p-3 bg-concord-secondary rounded-lg border border-b-0 border-border"> <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"> <div className="flex items-center justify-between mb-1">
@@ -97,26 +93,15 @@ export const MessageInput: React.FC<MessageInputProps> = ({
)} )}
<form ref={formRef} onSubmit={handleSubmit}> <form ref={formRef} onSubmit={handleSubmit}>
<div className="relative"> <Textarea
<textarea id="message-input-textarea" // Unique ID for DOM targeting
ref={textareaRef} value={content}
value={content} onChange={(e) => setContent(e.target.value)}
onChange={(e) => setContent(e.target.value)} onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} placeholder={`Message #${channelName || "channel"}`}
placeholder={`Message #${channelName || "channel"}`} disabled={sendMessageMutation.isPending}
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"
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>
</form> </form>
</div> </div>
); );

View File

@@ -11,39 +11,58 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Hash, Volume2, Loader2 } from "lucide-react"; import { Hash, Volume2, Loader2 } from "lucide-react";
import { useCreateChannel } from "@/hooks/useServers"; 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 { interface CreateChannelModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
instanceId: string; // Changed to use instanceId instead of categories prop instanceId: string;
defaultCategoryId?: string; categories: CategoryWithChannels[] | undefined;
} }
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
isOpen, isOpen,
onClose, onClose,
defaultCategoryId, categories,
}) => { }) => {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [type, setType] = useState<"text" | "voice">("text"); const [type, setType] = useState<"text" | "voice">("text");
const [categoryId, setCategoryId] = useState(defaultCategoryId || ""); const [categoryId, setCategoryId] = useState("");
const createChannelMutation = useCreateChannel(); const createChannelMutation = useCreateChannel();
// Reset form when modal opens/closes // Reset form when modal opens or closes
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
setName(""); setName("");
setDescription(""); setDescription("");
setType("text"); setType("text");
setCategoryId(defaultCategoryId || ""); setCategoryId("");
} else {
setCategoryId("");
} }
}, [isOpen, defaultCategoryId]); }, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); 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 { try {
await createChannelMutation.mutateAsync({ await createChannelMutation.mutateAsync({
@@ -53,17 +72,25 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
categoryId, categoryId,
}); });
// Reset form // Reset form after successful creation
setName(""); setName("");
setDescription(""); setDescription("");
setType("text"); setType("text");
setCategoryId(defaultCategoryId || ""); setCategoryId(""); // Reset to default or empty
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("Failed to create channel:", 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 ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[400px]"> <DialogContent className="sm:max-w-[400px]">
@@ -72,12 +99,13 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
{/* Channel Type Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Channel Type</Label> <Label>Channel Type</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button" type="button"
variant={type === "text" ? "default" : "outline"} variant={type === "text" ? "secondary" : "ghost"}
onClick={() => setType("text")} onClick={() => setType("text")}
className="flex-1" className="flex-1"
> >
@@ -86,7 +114,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
</Button> </Button>
<Button <Button
type="button" type="button"
variant={type === "voice" ? "default" : "outline"} variant={type === "voice" ? "secondary" : "ghost"}
onClick={() => setType("voice")} onClick={() => setType("voice")}
className="flex-1" className="flex-1"
> >
@@ -96,6 +124,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
</div> </div>
</div> </div>
{/* Channel Name Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="channel-name">Channel Name</Label> <Label htmlFor="channel-name">Channel Name</Label>
<Input <Input
@@ -107,6 +136,35 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
/> />
</div> </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"> <div className="space-y-2">
<Label htmlFor="channel-description">Description</Label> <Label htmlFor="channel-description">Description</Label>
<Textarea <Textarea
@@ -118,20 +176,12 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
/> />
</div> </div>
{/* Action Buttons */}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button <Button type="submit" disabled={isFormInvalid}>
type="submit"
disabled={
!name.trim() ||
!categoryId ||
createChannelMutation.isPending ||
categoryId === "loading" ||
categoryId === "no-categories"
}
>
{createChannelMutation.isPending ? ( {createChannelMutation.isPending ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />

View File

@@ -1,30 +1,6 @@
import React, { useState } from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Instance } from "@/types/database"; 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 { interface ServerIconProps {
server: Instance; server: Instance;
@@ -47,17 +23,17 @@ const ServerIcon: React.FC<ServerIconProps> = ({
}; };
return ( return (
<div className="relative group"> <div className="relative group w-12">
{/* Active indicator */} {/* Active indicator - Positioned outside to the left */}
<div <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" isActive ? "h-10 rounded-xl" : "rounded-r h-2 group-hover:h-5"
}`} }`}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`w-12 h-12 ml-3 transition-all duration-200 ${ className={`w-12 h-12 transition-all duration-200 ${
isActive isActive
? "rounded-xl border-primary bg-primary/10 border-2" ? "rounded-xl border-primary bg-primary/10 border-2"
: "rounded-2xl hover:rounded-xl border hover:border-primary/50" : "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; export default ServerIcon;

View File

@@ -156,12 +156,6 @@ export const useEditMessage = () => {
} }
// TODO: Replace with actual API call when available // TODO: Replace with actual API call when available
console.log(
"Editing message:",
data.messageId,
"New content:",
data.content,
);
return { return {
success: true, success: true,
@@ -207,10 +201,6 @@ export const usePinMessage = () => {
} }
// TODO: Replace with actual API call when available // TODO: Replace with actual API call when available
console.log(
`${data.pinned ? "Pinning" : "Unpinning"} message:`,
data.messageId,
);
return { return {
success: true, success: true,

View File

@@ -256,6 +256,8 @@ export class ApiClient {
requestingUserId: string; requestingUserId: string;
requestingUserToken: string; requestingUserToken: string;
}): Promise<Channel> { }): Promise<Channel> {
console.log(data);
return this.request<Channel>("/api/channel", { return this.request<Channel>("/api/channel", {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),

View File

@@ -4,30 +4,10 @@ import App from "./App.tsx";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import "./index.css"; import "./index.css";
function printPayload(data: unknown) {
console.log(data);
}
const socket = io("http://localhost:3000"); 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( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App socket={socket} /> <App socket={socket} />
</React.StrictMode>, </React.StrictMode>,
); );
// Use contextBridge
window.ipcRenderer.on("main-process-message", (_event, message) => {
console.log(message);
});

View File

@@ -76,6 +76,44 @@ const ChatPage: React.FC = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [channelMessages]); }, [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 // Event handlers
const handleLoadMore = React.useCallback(async () => { const handleLoadMore = React.useCallback(async () => {
if (!channelMessages || channelMessages.length === 0 || isLoadingMore) if (!channelMessages || channelMessages.length === 0 || isLoadingMore)
@@ -214,10 +252,10 @@ const ChatPage: React.FC = () => {
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 */}
<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"> <div className="flex items-center space-x-2">
<ChannelIcon size={20} className="text-concord-secondary" /> <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} {currentChannel?.name}
</span> </span>
{currentChannel?.description && ( {currentChannel?.description && (
@@ -275,7 +313,6 @@ const ChatPage: React.FC = () => {
{sortedMessages && sortedMessages.length > 0 ? ( {sortedMessages && sortedMessages.length > 0 ? (
<div> <div>
{sortedMessages.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.replies?.repliesToId, (m) => m.id === message.replies?.repliesToId,

View File

@@ -72,16 +72,6 @@ const AccountSettings: React.FC = () => {
try { try {
// TODO: Implement actual profile update API call // 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 // Update local state
// updateUser({ // updateUser({
@@ -595,7 +585,7 @@ const SettingsPage: React.FC = () => {
</div> </div>
{/* Content */} {/* 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> <div className="p-6 flex w-full">{renderSettingsContent()}</div>
</ScrollArea> </ScrollArea>
</div> </div>

View File

@@ -71,7 +71,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
* @returns The configured RTCPeerConnection instance. * @returns The configured RTCPeerConnection instance.
*/ */
const createPeerConnection = (targetUserId: string): RTCPeerConnection => { const createPeerConnection = (targetUserId: string): RTCPeerConnection => {
console.log(`Creating peer connection for: ${targetUserId}`);
const { iceServers, localStream, socket, peerConnections } = get(); const { iceServers, localStream, socket, peerConnections } = get();
const peerConnection = new RTCPeerConnection({ iceServers }); const peerConnection = new RTCPeerConnection({ iceServers });
@@ -95,7 +94,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
// Handle incoming remote tracks // Handle incoming remote tracks
peerConnection.ontrack = (event) => { peerConnection.ontrack = (event) => {
console.log(`Received remote track from: ${targetUserId}`);
set((state) => { set((state) => {
const newStreams = new Map(state.remoteStreams); const newStreams = new Map(state.remoteStreams);
newStreams.set(targetUserId, event.streams[0]); newStreams.set(targetUserId, event.streams[0]);
@@ -105,9 +103,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
// For debugging connection state // For debugging connection state
peerConnection.onconnectionstatechange = () => { peerConnection.onconnectionstatechange = () => {
console.log(
`Connection state change for ${targetUserId}: ${peerConnection.connectionState}`,
);
if ( if (
peerConnection.connectionState === "disconnected" || peerConnection.connectionState === "disconnected" ||
peerConnection.connectionState === "failed" peerConnection.connectionState === "failed"
@@ -128,10 +123,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
connectedUserIds: string[]; connectedUserIds: string[];
iceServers: IceServerConfig[]; iceServers: IceServerConfig[];
}) => { }) => {
console.log(
"Successfully joined voice channel. Users:",
data.connectedUserIds,
);
set({ set({
iceServers: data.iceServers, iceServers: data.iceServers,
isConnecting: false, isConnecting: false,
@@ -150,7 +141,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
}; };
const onUserLeft = (data: { userId: string }) => { const onUserLeft = (data: { userId: string }) => {
console.log(`User ${data.userId} left the channel.`);
cleanupPeerConnection(data.userId); cleanupPeerConnection(data.userId);
}; };
@@ -158,7 +148,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
senderUserId: string; senderUserId: string;
sdp: RTCSessionDescriptionInit; sdp: RTCSessionDescriptionInit;
}) => { }) => {
console.log("Received WebRTC offer from:", data.senderUserId);
const peerConnection = createPeerConnection(data.senderUserId); const peerConnection = createPeerConnection(data.senderUserId);
await peerConnection.setRemoteDescription( await peerConnection.setRemoteDescription(
new RTCSessionDescription(data.sdp), new RTCSessionDescription(data.sdp),
@@ -175,7 +164,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
senderUserId: string; senderUserId: string;
sdp: RTCSessionDescriptionInit; sdp: RTCSessionDescriptionInit;
}) => { }) => {
console.log("Received WebRTC answer from:", data.senderUserId);
const peerConnection = get().peerConnections.get(data.senderUserId); const peerConnection = get().peerConnections.get(data.senderUserId);
if (peerConnection) { if (peerConnection) {
await peerConnection.setRemoteDescription( await peerConnection.setRemoteDescription(
@@ -271,7 +259,6 @@ export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
get(); get();
if (!socket || !activeVoiceChannelId) return; if (!socket || !activeVoiceChannelId) return;
console.log(`Leaving voice channel: ${activeVoiceChannelId}`);
socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId }); socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId });
// Clean up all event listeners // Clean up all event listeners