SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
11.2 KB · 198 lines tsx
Raw Blame History
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