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%
5.5 KB · 125 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Check, Copy, ExternalLink, Eye, Link2, ShieldAlert } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Spinner } from "@/components/ui/misc";7import { toast } from "@/components/ui/toast";8import { api, useApi } from "@/lib/client/api";9import { useCopy } from "@/lib/client/hooks";10import { formatRelative } from "@/lib/utils";1112interface ShareStatus {13  share: { id: string; createdAt: string; viewCount: number } | null;14}1516/**17 * Public link for an Arena session. Shows a privacy warning before the first link is created; afterwards the18 * URL, view count, refresh and revoke. Bottom sheet on phones, dialog on desktop.19 */20export function ArenaShareSheet({ sessionId, open, onOpenChange }: { sessionId: string | null; open: boolean; onOpenChange: (o: boolean) => void }) {21  const status = useApi<ShareStatus>(open && sessionId ? `/api/arena/${sessionId}/share` : null);22  const [busy, setBusy] = React.useState(false);23  const [copied, copy] = useCopy();24  const share = status.data?.share ?? null;25  const url = share ? `${typeof window !== "undefined" ? window.location.origin : ""}/share/arena/${share.id}` : null;2627  const create = async () => {28    if (!sessionId) return;29    setBusy(true);30    try {31      const { share: created } = await api<{ share: ShareStatus["share"] }>(`/api/arena/${sessionId}/share`, { method: "POST" });32      await status.mutate({ share: created }, { revalidate: false });33      toast.success(share ? "Snapshot refreshed" : "Public link created", "Anyone with the link can read this comparison.");34    } catch (e) {35      toast.error("Could not create the link", (e as Error).message);36    } finally {37      setBusy(false);38    }39  };40  const revoke = async () => {41    if (!sessionId) return;42    setBusy(true);43    try {44      await api(`/api/arena/${sessionId}/share`, { method: "DELETE" });45      await status.mutate({ share: null }, { revalidate: false });46      toast.success("Link revoked", "The public page now returns 404.");47    } catch (e) {48      toast.error("Could not revoke", (e as Error).message);49    } finally {50      setBusy(false);51    }52  };5354  return (55    <ResponsiveDialog56      open={open}57      onOpenChange={onOpenChange}58      title="Share this comparison"59      description="A frozen public snapshot — prompt, system prompt, responses, metrics and votes."60      size="sm"61      footer={62        share ? (63          <div className="flex items-center gap-2">64            <Button variant="danger-soft" size="sm" onClick={revoke} loading={busy}>65              Revoke link66            </Button>67            <Button variant="ghost" size="sm" onClick={create} disabled={busy} className="ml-auto">68              Refresh snapshot69            </Button>70          </div>71        ) : (72          <div className="flex items-center justify-end gap-2">73            <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>74              Cancel75            </Button>76            <Button variant="accent" size="sm" onClick={create} loading={busy} disabled={status.isLoading}>77              <Link2 /> Create public link78            </Button>79          </div>80        )81      }82    >83      {status.isLoading && !status.data ? (84        <div className="flex items-center gap-2 py-4 text-[13px] text-fg-muted">85          <Spinner /> Checking existing links…86        </div>87      ) : share && url ? (88        <div className="space-y-3 py-1">89          <div className="flex items-center gap-2 rounded-lg border border-border bg-bg-subtle px-3 py-2">90            <Link2 className="size-4 shrink-0 text-fg-subtle" />91            <span className="min-w-0 flex-1 truncate font-mono text-[12.5px]">{url}</span>92            <Button variant="ghost" size="icon-sm" aria-label="Copy link" onClick={() => copy(url)}>93              {copied ? <Check className="text-success" /> : <Copy />}94            </Button>95            <Button asChild variant="ghost" size="icon-sm" aria-label="Open link">96              <a href={url} target="_blank" rel="noopener noreferrer">97                <ExternalLink />98              </a>99            </Button>100          </div>101          <p className="flex items-center gap-2 text-[12.5px] text-fg-muted">102            <Eye className="size-3.5" /> {share.viewCount} view{share.viewCount === 1 ? "" : "s"} · created {formatRelative(share.createdAt)}103          </p>104          <p className="text-[12px] text-fg-subtle">Votes cast after sharing are not reflected until you refresh the snapshot.</p>105        </div>106      ) : (107        <div className="space-y-3 py-1">108          <div className="flex gap-3 rounded-lg border border-warning/40 bg-warning-soft px-3 py-2.5 text-[13px] text-fg">109            <ShieldAlert className="mt-0.5 size-4 shrink-0 text-warning" />110            <div className="space-y-1">111              <p className="font-medium">Before you share</p>112              <ul className="list-disc space-y-0.5 pl-4 text-[12.5px] text-fg-muted">113                <li>The prompt, system prompt and every response are published as-is — check for personal data or secrets.</li>114                <li>Attachments are never published; only their count is shown.</li>115                <li>Model names are revealed (Blind Arena sessions are marked as blind).</li>116                <li>The page is public but not indexed; anyone with the link can read it. You can revoke it anytime.</li>117              </ul>118            </div>119          </div>120        </div>121      )}122    </ResponsiveDialog>123  );124}125