diff --git a/ui/colony/src/App.tsx b/ui/colony/src/App.tsx index 5bb47a3..e6f1e8a 100644 --- a/ui/colony/src/App.tsx +++ b/ui/colony/src/App.tsx @@ -1,27 +1,28 @@ import { useCallback, useEffect, useRef, useState } from "react"; import type { Channel } from "@/types/Channel"; import type { Message } from "@/types/Message"; -import { getChannels, getMessages, getCurrentUsername } from "@/api"; +import { getChannels, getMessages } from "@/api"; import { ChannelSidebar } from "@/components/ChannelSidebar"; import { MessageItem } from "@/components/MessageItem"; import { ComposeBox } from "@/components/ComposeBox"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; export default function App() { const [channels, setChannels] = useState([]); const [activeChannelId, setActiveChannelId] = useState(null); const [messages, setMessages] = useState([]); const [replyTo, setReplyTo] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); const scrollRef = useRef(null); const prevMsgCountRef = useRef(0); const activeChannelRef = useRef(activeChannelId); - // Keep ref in sync activeChannelRef.current = activeChannelId; const loadChannels = useCallback(async () => { const chs = await getChannels(); setChannels(chs); - // Auto-select first channel only if none selected setActiveChannelId((prev) => (prev ? prev : chs[0]?.id ?? null)); }, []); @@ -30,7 +31,6 @@ export default function App() { if (!channelId) return; try { const msgs = await getMessages(channelId); - // Only update if we're still on the same channel (prevent race) if (activeChannelRef.current === channelId) { setMessages(msgs); } @@ -39,20 +39,15 @@ export default function App() { } }, []); - // Initial channel load - useEffect(() => { - loadChannels(); - }, [loadChannels]); + useEffect(() => { loadChannels(); }, [loadChannels]); - // Load messages on channel switch useEffect(() => { - setMessages([]); // Clear immediately on switch + setMessages([]); setReplyTo(null); prevMsgCountRef.current = 0; loadMessages(); }, [activeChannelId, loadMessages]); - // Auto-scroll only when NEW messages arrive (not on every poll) useEffect(() => { if (messages.length > prevMsgCountRef.current && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; @@ -60,7 +55,6 @@ export default function App() { prevMsgCountRef.current = messages.length; }, [messages]); - // Poll until WebSocket (S5) useEffect(() => { const interval = setInterval(loadMessages, 3000); return () => clearInterval(interval); @@ -68,69 +62,63 @@ export default function App() { const messagesById = new Map(messages.map((m) => [m.id, m])); const activeChannel = channels.find((c) => c.id === activeChannelId); - const [sidebarOpen, setSidebarOpen] = useState(false); + + const sidebar = ( + { + setActiveChannelId(id); + setSheetOpen(false); + }} + onChannelCreated={loadChannels} + /> + ); return ( -
- {/* Mobile sidebar overlay */} - {sidebarOpen && ( -
setSidebarOpen(false)} - onKeyDown={() => {}} - role="button" - tabIndex={-1} - /> - )} - -
- { - setActiveChannelId(id); - setSidebarOpen(false); - }} - onChannelCreated={loadChannels} - /> +
+ {/* Desktop sidebar */} +
+ {sidebar}
{/* Channel header */} -
- +
+ {/* Mobile: Sheet trigger */} + + + + + + {sidebar} + + + {activeChannel ? ( <> # - - {activeChannel.name} - + {activeChannel.name} {activeChannel.description && ( - + {activeChannel.description} )} - + {messages.length} msg ) : ( - - select a channel - + select a channel )}
{/* Messages */}
{messages.length === 0 && activeChannelId && ( -
+
no messages yet
)} @@ -138,15 +126,12 @@ export default function App() { ))}
- {/* Compose — key forces reset on channel switch */} {activeChannelId && ( void; } -const TYPES: { value: MessageType; label: string; shortLabel: string }[] = [ - { value: "text", label: "TXT", shortLabel: "T" }, - { value: "code", label: "CODE", shortLabel: "C" }, - { value: "result", label: "RES", shortLabel: "R" }, - { value: "error", label: "ERR", shortLabel: "E" }, - { value: "plan", label: "PLAN", shortLabel: "P" }, +const MSG_TYPES: { value: MessageType; label: string }[] = [ + { value: "text", label: "T" }, + { value: "code", label: "C" }, + { value: "result", label: "R" }, + { value: "error", label: "E" }, + { value: "plan", label: "P" }, ]; export function ComposeBox({ @@ -48,42 +51,33 @@ export function ComposeBox({ return (
{replyTo && ( -
+
^ #{replyTo.slice(0, 8)} - +
)}
- {/* Type selector — compact on mobile */} -
- {TYPES.map((t) => ( - + {t.label} + ))} -
+ - {/* Input — larger touch target on mobile */} - setContent(e.target.value)} onKeyDown={(e) => { @@ -91,23 +85,20 @@ export function ComposeBox({ e.preventDefault(); handleSend(); } - if (e.altKey && e.key >= "1" && e.key <= "5") { - setMsgType(TYPES[parseInt(e.key) - 1].value); - } }} placeholder="message..." disabled={sending} - className="flex-1 bg-input text-[13px] md:text-[13px] text-foreground placeholder:text-muted-foreground px-3 py-2 md:py-1.5 rounded-sm border border-border focus:outline-none focus:border-blue-500/30" + className="flex-1 h-9 md:h-8 text-sm" /> - +
); diff --git a/ui/colony/src/components/ui/avatar.tsx b/ui/colony/src/components/ui/avatar.tsx new file mode 100644 index 0000000..e92a2f4 --- /dev/null +++ b/ui/colony/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/ui/colony/src/components/ui/sheet.tsx b/ui/colony/src/components/ui/sheet.tsx new file mode 100644 index 0000000..1a2885a --- /dev/null +++ b/ui/colony/src/components/ui/sheet.tsx @@ -0,0 +1,136 @@ +import * as React from "react" +import { Dialog as SheetPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Sheet({ ...props }: SheetPrimitive.Root.Props) { + return +} + +function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) { + return +} + +function SheetClose({ ...props }: SheetPrimitive.Close.Props) { + return +} + +function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) { + return +} + +function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + showCloseButton = true, + ...props +}: SheetPrimitive.Popup.Props & { + side?: "top" | "right" | "bottom" | "left" + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: SheetPrimitive.Description.Props) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/ui/colony/src/components/ui/toggle-group.tsx b/ui/colony/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..314f894 --- /dev/null +++ b/ui/colony/src/components/ui/toggle-group.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" +import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + } +>({ + size: "default", + variant: "default", + spacing: 0, + orientation: "horizontal", +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + orientation = "horizontal", + children, + ...props +}: ToggleGroupPrimitive.Props & + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/ui/colony/src/components/ui/toggle.tsx b/ui/colony/src/components/ui/toggle.tsx new file mode 100644 index 0000000..f48e504 --- /dev/null +++ b/ui/colony/src/components/ui/toggle.tsx @@ -0,0 +1,44 @@ +"use client" + +import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-transparent", + outline: "border border-input bg-transparent hover:bg-muted", + }, + size: { + default: "h-8 min-w-8 px-2", + sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]", + lg: "h-9 min-w-9 px-2.5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Toggle({ + className, + variant = "default", + size = "default", + ...props +}: TogglePrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Toggle, toggleVariants } diff --git a/ui/colony/src/components/ui/tooltip.tsx b/ui/colony/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..69e8a82 --- /dev/null +++ b/ui/colony/src/components/ui/tooltip.tsx @@ -0,0 +1,66 @@ +"use client" + +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delay = 0, + ...props +}: TooltipPrimitive.Provider.Props) { + return ( + + ) +} + +function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { + return +} + +function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { + return +} + +function TooltipContent({ + className, + side = "top", + sideOffset = 4, + align = "center", + alignOffset = 0, + children, + ...props +}: TooltipPrimitive.Popup.Props & + Pick< + TooltipPrimitive.Positioner.Props, + "align" | "alignOffset" | "side" | "sideOffset" + >) { + return ( + + + + {children} + + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }