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%
18.1 KB · 373 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import Link from "next/link";4import useSWR from "swr";5import { AlertTriangle, Check, Copy, ExternalLink, Eye, Link2, Link2Off, MessageSquare, Share2, Sparkles, User } from "lucide-react";6import type { ConversationDetail, PublicMessage, ShareLinkItem } from "@/lib/client/types";7import { api, useApi } from "@/lib/client/api";8import { useIsMobile, useCopy } from "@/lib/client/hooks";9import { ResponsiveDialog } from "@/components/ui/sheet";10import { Segmented } from "@/components/ui/segmented";11import { Button } from "@/components/ui/button";12import { Badge } from "@/components/ui/badge";13import { EmptyState, Skeleton, Spinner } from "@/components/ui/misc";14import { ConfirmDialog } from "@/components/common/confirm-dialog";15import { ProviderIcon } from "@/components/brand/provider-icon";16import { toast } from "@/components/ui/toast";17import { errorMessage } from "@/lib/client/humanize";18import { stripMarkdown } from "@/lib/search/query";19import { cn, formatRelative, truncate } from "@/lib/utils";2021/* ------------------------------------------------------------------------------------------------22 * Global hook — open the sheet from anywhere (chat header, palette, sidebar) without prop drilling.23 *24 *   const share = useShareSheet();25 *   share.open({ conversationId, title, messages })          // messages optional (fetched when absent)26 *   share.open({ conversationId, messageIds: [id1, id2] })   // preselect "Selected messages"27 *28 * `<ShareSheetHost />` must be mounted once (done in components/app/command-palette.tsx).29 * ---------------------------------------------------------------------------------------------- */30export interface ShareRequest {31  conversationId: string;32  title?: string;33  messages?: PublicMessage[];34  /** Preselect these messages and start in "Selected messages" mode. */35  messageIds?: string[];36}3738interface HostState {39  req: ShareRequest | null;40  open: boolean;41}42let hostState: HostState = { req: null, open: false };43const hostListeners = new Set<() => void>();44function setHost(next: HostState) {45  hostState = next;46  for (const l of hostListeners) l();47}48export function openShareSheet(req: ShareRequest) {49  setHost({ req, open: true });50}51export function closeShareSheet() {52  setHost({ ...hostState, open: false });53  setTimeout(() => {54    if (!hostState.open) setHost({ req: null, open: false });55  }, 320);56}57export function useShareSheet() {58  return React.useMemo(() => ({ open: openShareSheet, close: closeShareSheet }), []);59}60export function ShareSheetHost() {61  const state = React.useSyncExternalStore(62    (cb) => {63      hostListeners.add(cb);64      return () => hostListeners.delete(cb);65    },66    () => hostState,67    () => hostState,68  );69  if (!state.req) return null;70  return <ShareSheet open={state.open} onOpenChange={(o) => (o ? setHost({ ...hostState, open: true }) : closeShareSheet())} {...state.req} />;71}7273/* ------------------------------------------------------------------------------------------------74 * Sheet75 * ---------------------------------------------------------------------------------------------- */76type Mode = "all" | "selected";7778interface ShareResult {79  id: string;80  path: string;81  partial: boolean;82  messageCount: number;83  created: boolean;84}8586export interface ShareSheetProps extends ShareRequest {87  open: boolean;88  onOpenChange: (open: boolean) => void;89}9091export function ShareSheet({ open, onOpenChange, conversationId, title, messages: providedMessages, messageIds }: ShareSheetProps) {92  const isMobile = useIsMobile();93  const [mode, setMode] = React.useState<Mode>(messageIds?.length ? "selected" : "all");94  const [selected, setSelected] = React.useState<Set<string>>(() => new Set(messageIds ?? []));95  const [busy, setBusy] = React.useState(false);96  const [result, setResult] = React.useState<ShareResult | null>(null);97  const [revoking, setRevoking] = React.useState<ShareLinkItem | null>(null);9899  const detail = useApi<ConversationDetail>(open && !providedMessages ? `/api/conversations/${conversationId}` : null);100  const status = useSWR<{ share: { id: string; createdAt: string; viewCount: number } | null; shares: ShareLinkItem[] }>(open ? ["share-status", conversationId] : null, () => api(`/api/conversations/${conversationId}/actions`, { method: "POST", json: { action: "share-status" } }), { revalidateOnFocus: false });101102  const allMessages = React.useMemo(() => (providedMessages ?? detail.data?.messages ?? []).filter((m) => m.active !== false && m.status !== "streaming"), [providedMessages, detail.data]);103  const convTitle = title ?? detail.data?.conversation.title ?? "Conversation";104  const links = status.data?.shares ?? [];105  const fullLink = links.find((l) => !l.partial) ?? null;106  const origin = typeof window !== "undefined" ? window.location.origin : "";107108  const toggle = (id: string) =>109    setSelected((prev) => {110      const next = new Set(prev);111      if (next.has(id)) next.delete(id);112      else next.add(id);113      return next;114    });115  const selectAll = () => setSelected(new Set(allMessages.map((m) => m.id)));116  const selectNone = () => setSelected(new Set());117118  const create = async () => {119    setBusy(true);120    try {121      const body = mode === "selected" ? { action: "share", messageIds: allMessages.filter((m) => selected.has(m.id)).map((m) => m.id) } : { action: "share" };122      const res = await api<ShareResult>(`/api/conversations/${conversationId}/actions`, { method: "POST", json: body });123      setResult(res);124      await status.mutate();125      const url = `${origin}${res.path}`;126      try {127        await navigator.clipboard.writeText(url);128        toast.success(res.created ? "Share link created and copied" : "Share link updated and copied");129      } catch {130        toast.success(res.created ? "Share link created" : "Share link updated");131      }132    } catch (e) {133      toast.error("Could not create the link", errorMessage(e));134    } finally {135      setBusy(false);136    }137  };138139  const revoke = async (link: ShareLinkItem) => {140    await api(`/api/shares?id=${encodeURIComponent(link.id)}`, { method: "DELETE" });141    if (result?.id === link.id) setResult(null);142    await status.mutate();143    toast.success("Link revoked", "Anyone opening it now sees “unavailable”.");144  };145146  const canCreate = mode === "all" ? allMessages.length > 0 || Boolean(providedMessages) : selected.size > 0;147  const primaryLabel = mode === "all" ? (fullLink ? "Update link" : "Create link") : `Share ${selected.size} message${selected.size === 1 ? "" : "s"}`;148149  return (150    <>151      <ResponsiveDialog152        open={open}153        onOpenChange={onOpenChange}154        title="Share conversation"155        description={truncate(convTitle, 80)}156        size="md"157        snap="full"158        footer={159          <div className="flex items-center gap-2">160            <p className="hidden flex-1 text-[12px] text-fg-subtle sm:block">Links stay active until you revoke them.</p>161            <Button variant="ghost" size={isMobile ? "lg" : "md"} className={cn(isMobile && "flex-1")} onClick={() => onOpenChange(false)}>162              Done163            </Button>164            <Button variant="primary" size={isMobile ? "lg" : "md"} className={cn(isMobile && "flex-[2]")} onClick={create} loading={busy} disabled={!canCreate}>165              <Share2 /> {primaryLabel}166            </Button>167          </div>168        }169      >170        <div className="space-y-4 pt-1">171          <div className="flex items-start gap-2.5 rounded-xl bg-warning-soft/70 px-3 py-2.5 text-[13px] leading-5 text-warning">172            <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />173            <p>174              <span className="font-medium">Anyone with the link can read this.</span> Attachments are not included. The link is a frozen snapshot — later messages are not added until you update it.175            </p>176          </div>177178          <Segmented<Mode> value={mode} onChange={setMode} fill size={isMobile ? "lg" : "md"} ariaLabel="What to share" options={[{ value: "all", label: "Entire conversation", icon: <MessageSquare /> }, { value: "selected", label: "Selected messages", icon: <Check />, count: mode === "selected" ? selected.size : undefined }]} />179180          {mode === "selected" ? (181            <div className="space-y-2">182              <div className="flex items-center justify-between text-[12.5px] text-fg-muted">183                <span>184                  {selected.size} of {allMessages.length} selected185                </span>186                <span className="flex gap-1">187                  <Button variant="ghost" size="xs" onClick={selectAll} disabled={!allMessages.length}>188                    All189                  </Button>190                  <Button variant="ghost" size="xs" onClick={selectNone} disabled={!selected.size}>191                    None192                  </Button>193                </span>194              </div>195              {!providedMessages && detail.isLoading ? (196                <div className="space-y-1.5">197                  {Array.from({ length: 4 }).map((_, i) => (198                    <Skeleton key={i} className="h-14" />199                  ))}200                </div>201              ) : allMessages.length === 0 ? (202                <EmptyState title="Nothing to share yet" description="Send a message first." className="py-8" />203              ) : (204                <ul className="max-h-[42dvh] space-y-1 overflow-y-auto rounded-xl bg-bg-subtle p-1.5 scrollbar-thin md:max-h-72" aria-label="Messages">205                  {allMessages.map((m) => {206                    const on = selected.has(m.id);207                    return (208                      <li key={m.id}>209                        <label className={cn("flex min-h-[48px] cursor-pointer items-start gap-3 rounded-lg px-2.5 py-2 transition-colors", on ? "bg-bg-elevated shadow-xs" : "hover:bg-bg-elevated/60")}>210                          <input type="checkbox" checked={on} onChange={() => toggle(m.id)} className="mt-1 size-4 shrink-0 accent-[var(--accent)]" aria-label={`Include ${m.role} message`} />211                          <span className="min-w-0 flex-1">212                            <span className="flex items-center gap-1.5 text-[11.5px] font-medium text-fg-muted">213                              {m.role === "user" ? <User className="size-3" /> : <ProviderIcon provider={m.provider} size={12} />}214                              {m.role === "user" ? "You" : m.modelKey?.split("/").slice(1).join("/") || "Assistant"}215                            </span>216                            <span className="mt-0.5 line-clamp-2 block text-[13.5px] leading-5 text-fg">{stripMarkdown(m.content).slice(0, 220) || <em className="text-fg-subtle">No text</em>}</span>217                          </span>218                        </label>219                      </li>220                    );221                  })}222                </ul>223              )}224            </div>225          ) : (226            <p className="text-[13px] text-fg-muted">227              {allMessages.length ? `${allMessages.length} message${allMessages.length === 1 ? "" : "s"} will be published.` : detail.isLoading ? "Loading messages…" : "The conversation snapshot will be published."}228              {fullLink ? " A public link already exists — updating refreshes its content and keeps the same URL." : ""}229            </p>230          )}231232          {result ? <LinkBox url={`${origin}${result.path}`} label={result.partial ? `Excerpt · ${result.messageCount} message${result.messageCount === 1 ? "" : "s"}` : "Entire conversation"} highlight /> : null}233234          <section aria-label="Active links for this conversation" className="space-y-1.5">235            <h3 className="text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Active links</h3>236            {status.isLoading && !status.data ? (237              <Skeleton className="h-12" />238            ) : links.length === 0 ? (239              <p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-[12.5px] text-fg-subtle">No public link yet.</p>240            ) : (241              <ul className="divide-y divide-hairline overflow-hidden rounded-xl bg-bg-subtle">242                {links.map((l) => (243                  <ShareLinkRow key={l.id} link={l} origin={origin} onRevoke={() => setRevoking(l)} compact />244                ))}245              </ul>246            )}247          </section>248        </div>249      </ResponsiveDialog>250251      <ConfirmDialog open={Boolean(revoking)} onOpenChange={(o) => !o && setRevoking(null)} title="Revoke this link?" description="The page will show “unavailable” to anyone who opens it. You can create a new link at any time." confirmLabel="Revoke" destructive onConfirm={async () => { if (revoking) await revoke(revoking); }} />252    </>253  );254}255256/* ------------------------------------------------------------------------------------------------257 * Pieces258 * ---------------------------------------------------------------------------------------------- */259function LinkBox({ url, label, highlight }: { url: string; label?: string; highlight?: boolean }) {260  const [copied, copy] = useCopy();261  const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function";262  return (263    <div className={cn("rounded-xl border px-3 py-2.5", highlight ? "border-accent/40 bg-accent-soft/40" : "border-border bg-bg-subtle")}>264      {label ? (265        <p className="mb-1 flex items-center gap-1.5 text-[11.5px] font-medium text-fg-muted">266          <Link2 className="size-3.5" /> {label}267        </p>268      ) : null}269      <div className="flex items-center gap-2">270        <code className="min-w-0 flex-1 truncate font-mono text-[12.5px] text-fg">{url}</code>271        <Button variant="outline" size="sm" onClick={() => copy(url)} aria-label="Copy link">272          {copied ? <Check className="text-success" /> : <Copy />} {copied ? "Copied" : "Copy"}273        </Button>274      </div>275      <div className="mt-2 flex flex-wrap gap-1.5">276        <Button asChild variant="ghost" size="xs">277          <a href={url} target="_blank" rel="noopener noreferrer">278            <ExternalLink /> Open279          </a>280        </Button>281        {canNativeShare ? (282          <Button variant="ghost" size="xs" onClick={() => navigator.share({ url, title: "Shared from PolyLLM" }).catch(() => {})}>283            <Share2 /> Share…284          </Button>285        ) : null}286      </div>287    </div>288  );289}290291function ShareLinkRow({ link, origin, onRevoke, compact }: { link: ShareLinkItem; origin: string; onRevoke: () => void; compact?: boolean }) {292  const [copied, copy] = useCopy();293  const url = `${origin}${link.path}`;294  return (295    <li className={cn("flex items-center gap-3 px-3", compact ? "min-h-[52px] py-2" : "min-h-[60px] py-2.5")}>296      <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-elevated text-fg-muted">297        <Link2 className="size-4" />298      </span>299      <span className="min-w-0 flex-1">300        <span className="flex items-center gap-1.5">301          <Link href={link.path} target="_blank" className="truncate text-[13.5px] font-medium text-fg hover:underline">302            {compact ? link.partial ? "Excerpt" : "Entire conversation" : link.title}303          </Link>304          {!compact && link.partial ? <Badge variant="accent">Excerpt</Badge> : null}305        </span>306        <span className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11.5px] tabular-nums text-fg-subtle">307          <span>{formatRelative(link.createdAt)}</span>308          <span aria-hidden>·</span>309          <span className="inline-flex items-center gap-1">310            <Eye className="size-3" /> {link.viewCount} view{link.viewCount === 1 ? "" : "s"}311          </span>312          <span aria-hidden>·</span>313          <span>{link.messageCount} msg</span>314        </span>315      </span>316      <div className="flex shrink-0 items-center gap-0.5">317        <Button variant="ghost" size="icon-sm" onClick={() => copy(url)} aria-label="Copy link" className="tap">318          {copied ? <Check className="text-success" /> : <Copy />}319        </Button>320        <Button variant="ghost" size="icon-sm" onClick={onRevoke} aria-label="Revoke link" className="tap text-fg-muted hover:text-danger">321          <Link2Off />322        </Button>323      </div>324    </li>325  );326}327328/* ------------------------------------------------------------------------------------------------329 * Settings → Data: every active link of the user (mounted by workstream E).330 *331 *   <ShareLinksList />332 * ---------------------------------------------------------------------------------------------- */333export function ShareLinksList({ className }: { className?: string }) {334  const { data, isLoading, mutate } = useApi<{ shares: ShareLinkItem[] }>("/api/shares");335  const [revoking, setRevoking] = React.useState<ShareLinkItem | null>(null);336  const origin = typeof window !== "undefined" ? window.location.origin : "";337  const list = data?.shares ?? [];338339  const revoke = async (link: ShareLinkItem) => {340    try {341      await api(`/api/shares?id=${encodeURIComponent(link.id)}`, { method: "DELETE" });342      await mutate();343      toast.success("Link revoked");344    } catch (e) {345      toast.error("Could not revoke", errorMessage(e));346    }347  };348349  return (350    <div className={className}>351      {isLoading && !data ? (352        <div className="flex items-center gap-2 px-1 py-4 text-[13px] text-fg-muted">353          <Spinner /> Loading links…354        </div>355      ) : list.length === 0 ? (356        <EmptyState icon={<Sparkles />} title="No active share links" description="Share a conversation from the chat header or the command palette (⌘K → Share conversation)." />357      ) : (358        <ul className="divide-y divide-hairline overflow-hidden rounded-xl border border-border bg-bg-elevated">359          {list.map((l) => (360            <ShareLinkRow key={l.id} link={l} origin={origin} onRevoke={() => setRevoking(l)} />361          ))}362        </ul>363      )}364      {list.length ? (365        <p className="mt-2 text-[12px] text-fg-subtle">366          {list.length} active link{list.length === 1 ? "" : "s"}. Revoking a link is immediate.367        </p>368      ) : null}369      <ConfirmDialog open={Boolean(revoking)} onOpenChange={(o) => !o && setRevoking(null)} title="Revoke this link?" description={revoking ? `“${revoking.title}” will no longer be publicly readable.` : undefined} confirmLabel="Revoke" destructive onConfirm={async () => { if (revoking) await revoke(revoking); }} />370    </div>371  );372}373