| // src/components/threads/ThreadSidebar.tsx | |
| import { useState, useMemo } from "react"; | |
| import type { Thread, ThreadFilters } from "@/types/thread"; | |
| import { ThreadBadge } from "./ThreadBadge"; | |
| import { ThreadParticipantAvatars } from "./ThreadParticipantAvatars"; | |
| import { | |
| Search, Plus, Filter, Pin, Archive, CheckCircle2, X, | |
| MessageSquare, Hash, Lightbulb, Loader2, | |
| } from "lucide-react"; | |
| interface ThreadSidebarProps { | |
| threads: Thread[]; | |
| activeThreadId: string | null; | |
| unreadTotal: number; | |
| filters: ThreadFilters; | |
| onFilterChange: (filters: ThreadFilters) => void; | |
| onSelectThread: (threadId: string) => void; | |
| onCreateThread: () => void; | |
| loading: boolean; | |
| } | |
| const STATUS_FILTERS: { status: ThreadFilters["status"]; icon: typeof Pin; label: string }[] = [ | |
| { status: undefined, icon: MessageSquare, label: "All" }, | |
| { status: ["pinned"], icon: Pin, label: "Pinned" }, | |
| { status: ["active"], icon: MessageSquare, label: "Active" }, | |
| { status: ["resolved"], icon: CheckCircle2, label: "Resolved" }, | |
| { status: ["archived"], icon: Archive, label: "Archived" }, | |
| ]; | |
| export function ThreadSidebar({ | |
| threads, | |
| activeThreadId, | |
| unreadTotal, | |
| filters, | |
| onFilterChange, | |
| onSelectThread, | |
| onCreateThread, | |
| loading, | |
| }: ThreadSidebarProps) { | |
| const [searchOpen, setSearchOpen] = useState(false); | |
| const activeStatus = filters.status?.[0] ?? undefined; | |
| // Filter threads locally (status & search) | |
| const filteredThreads = useMemo(() => { | |
| let result = threads; | |
| if (activeStatus) { | |
| result = result.filter(t => t.status === activeStatus); | |
| } | |
| if (filters.searchQuery) { | |
| const q = filters.searchQuery.toLowerCase(); | |
| result = result.filter(t => t.title.toLowerCase().includes(q)); | |
| } | |
| return result; | |
| }, [threads, activeStatus, filters.searchQuery]); | |
| return ( | |
| <div className="flex h-full w-80 flex-col border-r border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between border-b border-gray-100 p-3 dark:border-gray-700"> | |
| <div className="flex items-center gap-2"> | |
| <h2 className="text-sm font-bold">Threads</h2> | |
| {unreadTotal > 0 && ( | |
| <span className="rounded-full bg-red-500 px-1.5 py-0.5 text-[10px] text-white"> | |
| {unreadTotal} | |
| </span> | |
| )} | |
| </div> | |
| <button | |
| onClick={onCreateThread} | |
| className="rounded-lg bg-primary p-1.5 text-white transition hover:bg-primary/90" | |
| aria-label="Create new thread" | |
| > | |
| <Plus className="h-4 w-4" /> | |
| </button> | |
| </div> | |
| {/* Search */} | |
| <div className="p-2"> | |
| <div className="relative"> | |
| <Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-gray-400" /> | |
| <input | |
| type="text" | |
| placeholder="Search threads..." | |
| value={filters.searchQuery ?? ""} | |
| onChange={(e) => | |
| onFilterChange({ ...filters, searchQuery: e.target.value || undefined }) | |
| } | |
| className="w-full rounded-lg bg-gray-100 py-1.5 pl-7 pr-3 text-xs focus:outline-none focus:ring-2 focus:ring-primary dark:bg-gray-700" | |
| /> | |
| </div> | |
| </div> | |
| {/* Status filter pills */} | |
| <div className="flex gap-1 overflow-x-auto px-2 pb-2"> | |
| {STATUS_FILTERS.map((sf) => ( | |
| <button | |
| key={sf.label} | |
| onClick={() => | |
| onFilterChange({ ...filters, status: sf.status }) | |
| } | |
| className={`flex items-center gap-1 whitespace-nowrap rounded-full px-2.5 py-1 text-[10px] font-medium transition ${ | |
| (activeStatus === sf.status?.[0]) || (!activeStatus && sf.status === undefined) | |
| ? "bg-primary text-white" | |
| : "bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700" | |
| }`} | |
| > | |
| <sf.icon className="h-3 w-3" /> | |
| {sf.label} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Thread list */} | |
| <div className="flex-1 overflow-y-auto"> | |
| {loading ? ( | |
| <div className="flex items-center justify-center p-4 text-center text-sm text-gray-400"> | |
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading threads... | |
| </div> | |
| ) : filteredThreads.length === 0 ? ( | |
| <div className="p-6 text-center text-gray-400"> | |
| <Hash className="mx-auto mb-2 h-8 w-8 opacity-30" /> | |
| <p className="text-sm">No threads found</p> | |
| <button onClick={onCreateThread} className="mt-1 text-xs text-primary hover:underline"> | |
| Start the first thread | |
| </button> | |
| </div> | |
| ) : ( | |
| filteredThreads.map((thread) => ( | |
| <button | |
| key={thread.id} | |
| onClick={() => onSelectThread(thread.id)} | |
| className={`w-full border-b border-gray-50 px-3 py-3 text-left transition hover:bg-gray-50 dark:border-gray-700/50 dark:hover:bg-gray-700/30 ${ | |
| activeThreadId === thread.id | |
| ? "border-l-2 border-l-primary bg-primary/5 dark:bg-primary/10" | |
| : "" | |
| }`} | |
| > | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="min-w-0 flex-1"> | |
| <div className="flex items-center gap-1.5"> | |
| {thread.status === "pinned" && ( | |
| <Pin className="h-3 w-3 text-yellow-500" /> | |
| )} | |
| <h4 className="truncate text-sm font-semibold text-gray-800 dark:text-gray-200"> | |
| {thread.title} | |
| </h4> | |
| </div> | |
| <p className="mt-0.5 text-[11px] text-gray-400"> | |
| {thread.messageCount} messages ·{" "} | |
| {new Date(thread.lastActivityAt).toLocaleDateString()} | |
| </p> | |
| {thread.participantIds && thread.participantIds.length > 0 && ( | |
| <div className="mt-1"> | |
| <ThreadParticipantAvatars | |
| participants={thread.participantIds.map((id) => ({ | |
| id, | |
| username: `User ${id}`, | |
| }))} | |
| max={3} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {thread.unreadCount > 0 && ( | |
| <ThreadBadge count={thread.unreadCount} /> | |
| )} | |
| </div> | |
| {thread.tags && thread.tags.length > 0 && ( | |
| <div className="mt-1.5 flex flex-wrap gap-1"> | |
| {thread.tags.slice(0, 3).map((tag) => ( | |
| <span | |
| key={tag} | |
| className="rounded-full bg-gray-100 px-1.5 py-0.5 text-[9px] text-gray-500 dark:bg-gray-700" | |
| > | |
| {tag} | |
| </span> | |
| ))} | |
| </div> | |
| )} | |
| </button> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
Xet Storage Details
- Size:
- 7.18 kB
- Xet hash:
- 70455ac1692b78ae2715405e84c4942c5faaf0591092c806907ab4bea3bf3c83
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.