TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { useRouter, useSearchParams } from "next/navigation";5import { Archive, ArchiveRestore, CheckCircle2, CircleOff, FileText, FolderKanban, MessageSquare, MessageSquarePlus, Pencil, Plus, Search, Trash2, WandSparkles } from "lucide-react";6import { useApp, invalidateProjects, invalidateConversations } from "@/components/app/store";7import { api, useApi } from "@/lib/client/api";8import { errorMessage } from "@/lib/client/humanize";9import { useIsMobile, useLongPress } from "@/lib/client/hooks";10import { Button } from "@/components/ui/button";11import { Input } from "@/components/ui/input";12import { Segmented } from "@/components/ui/segmented";13import { EmptyState, Skeleton } from "@/components/ui/misc";14import { toast } from "@/components/ui/toast";15import { ConfirmDialog } from "@/components/common/confirm-dialog";16import type { ActionSheetItem } from "@/components/ui/sheet";17import type { PublicProject } from "@/lib/client/types";18import { formatRelative, cn } from "@/lib/utils";19import { WorkspacePageHeader, WorkspacePageBody } from "./page-header";20import { ProjectFormSheet } from "./project-form-sheet";21import { ProjectIcon } from "./project-icon";22import { RowMenu } from "./row-menu";2324type Scope = "active" | "archived";2526export function ProjectsView() {27 const router = useRouter();28 const params = useSearchParams();29 const { activeProjectId, setActiveProjectId } = useApp();30 const { data, isLoading, error, mutate } = useApi<{ projects: PublicProject[] }>("/api/projects?archived=1");31 const [scope, setScope] = React.useState<Scope>("active");32 const [q, setQ] = React.useState("");33 const [form, setForm] = React.useState<{ open: boolean; project: PublicProject | null }>({ open: false, project: null });34 const [deleting, setDeleting] = React.useState<PublicProject | null>(null);3536 // `/app/projects?new=1` (sidebar "+") opens the create sheet once.37 const wantsNew = params.get("new") === "1";38 React.useEffect(() => {39 if (!wantsNew) return;40 // eslint-disable-next-line react-hooks/set-state-in-effect41 setForm({ open: true, project: null });42 router.replace("/app/projects");43 }, [wantsNew, router]);4445 const all = React.useMemo(() => data?.projects ?? [], [data]);46 const counts = React.useMemo(() => ({ active: all.filter((p) => !p.archived).length, archived: all.filter((p) => p.archived).length }), [all]);47 const list = React.useMemo(() => {48 const s = q.trim().toLowerCase();49 return all.filter((p) => (scope === "archived" ? p.archived : !p.archived)).filter((p) => (s ? [p.name, p.description ?? ""].some((x) => x.toLowerCase().includes(s)) : true));50 }, [all, scope, q]);5152 const patch = async (p: PublicProject, body: Record<string, unknown>, ok: string) => {53 try {54 await api(`/api/projects/${p.id}`, { method: "PATCH", json: body });55 await Promise.all([mutate(), invalidateProjects()]);56 toast.success(ok);57 } catch (e) {58 toast.error("Could not update project", errorMessage(e));59 }60 };61 const remove = async (p: PublicProject) => {62 try {63 await api(`/api/projects/${p.id}`, { method: "DELETE" });64 if (activeProjectId === p.id) setActiveProjectId(null);65 await Promise.all([mutate(), invalidateProjects(), invalidateConversations()]);66 toast.success("Project deleted", "Its chats were kept.");67 } catch (e) {68 toast.error("Could not delete project", errorMessage(e));69 throw e;70 }71 };72 const newChat = (p: PublicProject) => {73 setActiveProjectId(p.id);74 router.push("/app/chat");75 };7677 const itemsFor = (p: PublicProject): (ActionSheetItem | "separator")[] => [78 { key: "open", label: "Open", icon: <FolderKanban />, onSelect: () => router.push(`/app/projects/${p.id}`) },79 { key: "chat", label: "New chat in this project", icon: <MessageSquarePlus />, onSelect: () => newChat(p), disabled: p.archived },80 activeProjectId === p.id ? { key: "inactive", label: "Clear active project", icon: <CircleOff />, onSelect: () => setActiveProjectId(null) } : { key: "active", label: "Set as active project", icon: <CheckCircle2 />, onSelect: () => setActiveProjectId(p.id), disabled: p.archived },81 { key: "edit", label: "Edit", icon: <Pencil />, onSelect: () => setForm({ open: true, project: p }) },82 "separator",83 { key: "archive", label: p.archived ? "Unarchive" : "Archive", icon: p.archived ? <ArchiveRestore /> : <Archive />, onSelect: () => void patch(p, { archived: !p.archived }, p.archived ? "Project restored" : "Project archived") },84 { key: "delete", label: "Delete", icon: <Trash2 />, destructive: true, onSelect: () => setDeleting(p) },85 ];8687 return (88 <div className="flex h-full min-h-0 flex-col">89 <WorkspacePageHeader90 title="Projects"91 subtitle={all.length ? `${counts.active} active${counts.archived ? ` · ${counts.archived} archived` : ""}` : undefined}92 actions={93 <Button size="sm" onClick={() => setForm({ open: true, project: null })}>94 <Plus /> <span className="hidden sm:inline">New project</span>95 <span className="sm:hidden">New</span>96 </Button>97 }98 />99 <WorkspacePageBody>100 {all.length > 0 ? (101 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">102 <Segmented ariaLabel="Project scope" value={scope} onChange={setScope} options={[{ value: "active", label: "Active", count: counts.active }, { value: "archived", label: "Archived", count: counts.archived }]} />103 {all.length > 4 ? (104 <div className="relative sm:ml-auto sm:w-64">105 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />106 <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search projects…" className="h-10 pl-8 sm:h-9" aria-label="Search projects" />107 </div>108 ) : null}109 </div>110 ) : null}111112 <div className={cn(all.length > 0 && "mt-4")}>113 {isLoading && !data ? (114 <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">115 {Array.from({ length: 6 }).map((_, i) => (116 <li key={i}>117 <Skeleton className="h-36 rounded-xl" />118 </li>119 ))}120 </ul>121 ) : error ? (122 <EmptyState title="Could not load projects" description={errorMessage(error)} action={<Button variant="outline" onClick={() => mutate()}>Retry</Button>} />123 ) : all.length === 0 ? (124 <EmptyState125 icon={<FolderKanban />}126 title="No projects yet"127 description="Group chats, files, prompts and instructions. New chats in a project start with its system instructions and preferred models."128 action={129 <Button onClick={() => setForm({ open: true, project: null })}>130 <Plus /> Create your first project131 </Button>132 }133 />134 ) : list.length === 0 ? (135 <EmptyState title={scope === "archived" ? "No archived projects" : "No matches"} description={q ? `Nothing matches “${q}”.` : scope === "archived" ? "Archived projects are hidden from the sidebar and the switcher." : undefined} />136 ) : (137 <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">138 {list.map((p) => (139 <ProjectCard key={p.id} project={p} active={p.id === activeProjectId} items={itemsFor(p)} onNewChat={() => newChat(p)} />140 ))}141 </ul>142 )}143 </div>144 </WorkspacePageBody>145146 <ProjectFormSheet open={form.open} onOpenChange={(o) => setForm((s) => ({ ...s, open: o }))} project={form.project} onSaved={(p) => { void mutate(); if (!form.project) router.push(`/app/projects/${p.id}`); }} />147 <ConfirmDialog open={deleting !== null} onOpenChange={(o) => !o && setDeleting(null)} destructive title={`Delete “${deleting?.name ?? ""}”?`} description={`Chats stay in your history (${deleting?.conversationCount ?? 0}); the project's files (${deleting?.fileCount ?? 0}) are deleted and its prompts (${deleting?.promptCount ?? 0}) are detached.`} confirmLabel="Delete project" onConfirm={() => (deleting ? remove(deleting) : Promise.resolve())} />148 </div>149 );150}151152function ProjectCard({ project: p, active, items, onNewChat }: { project: PublicProject; active: boolean; items: (ActionSheetItem | "separator")[]; onNewChat: () => void }) {153 const isMobile = useIsMobile();154 const [menu, setMenu] = React.useState(false);155 const press = useLongPress({ onLongPress: () => setMenu(true), disabled: !isMobile });156 return (157 <li className={cn("group relative flex h-full flex-col rounded-xl border bg-bg-elevated p-4 transition-colors hover:border-border-strong", active ? "border-accent/60 shadow-[0_0_0_1px_var(--accent-soft)]" : "border-border", p.archived && "opacity-70")} {...press}>158 <div className="flex items-start gap-3">159 <Link href={`/app/projects/${p.id}`} className="shrink-0 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" aria-hidden tabIndex={-1}>160 <ProjectIcon icon={p.icon} color={p.color} size="lg" />161 </Link>162 <div className="min-w-0 flex-1">163 <h3 className="flex items-center gap-2 text-[15px] font-semibold leading-6">164 <Link href={`/app/projects/${p.id}`} className="truncate after:absolute after:inset-0 after:content-[''] focus-visible:outline-none">165 {p.name}166 </Link>167 {active ? <span className="relative z-10 inline-flex shrink-0 items-center gap-1 rounded-full bg-accent-soft px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent">Active</span> : null}168 {p.archived ? <span className="relative z-10 shrink-0 rounded-full bg-bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-fg-subtle">Archived</span> : null}169 </h3>170 {p.description ? <p className="mt-0.5 line-clamp-2 text-[13px] text-fg-muted">{p.description}</p> : <p className="mt-0.5 text-[13px] text-fg-subtle">No description</p>}171 </div>172 <div className="relative z-10">173 <RowMenu items={items} title={p.name} open={menu} onOpenChange={setMenu} />174 </div>175 </div>176 <div className="mt-4 flex items-center gap-3 text-[12px] text-fg-muted tabular-nums">177 <span className="inline-flex items-center gap-1" title="Chats">178 <MessageSquare className="size-3.5 text-fg-subtle" /> {p.conversationCount}179 </span>180 <span className="inline-flex items-center gap-1" title="Files">181 <FileText className="size-3.5 text-fg-subtle" /> {p.fileCount}182 </span>183 <span className="inline-flex items-center gap-1" title="Prompts">184 <WandSparkles className="size-3.5 text-fg-subtle" /> {p.promptCount}185 </span>186 <span className="ml-auto text-fg-subtle">{formatRelative(p.updatedAt)}</span>187 </div>188 {!p.archived ? (189 <div className="relative z-10 mt-3 border-t border-border pt-3">190 <Button size="sm" variant="secondary" className="w-full sm:w-auto" onClick={onNewChat}>191 <MessageSquarePlus /> New chat here192 </Button>193 </div>194 ) : null}195 </li>196 );197}198