TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import { Plus, Trophy, X } from "lucide-react";4import { PromptDialog } from "@/components/common/prompt-dialog";5import { Tooltip } from "@/components/ui/tooltip";6import { useLocalStorage } from "@/lib/client/hooks";7import { BUILTIN_CRITERIA, customCriterion, type Criterion } from "@/lib/arena/scoring";8import { cn } from "@/lib/utils";910export const CRITERIA_STORAGE_KEY = "polyllm:arena-criteria";1112/** Custom criteria persisted in localStorage (`polyllm:arena-criteria`). */13export function useCustomCriteria() {14 const [stored, setStored] = useLocalStorage<{ id: string; label: string }[]>(CRITERIA_STORAGE_KEY, []);15 const custom = React.useMemo<Criterion[]>(() => stored.map((c) => customCriterion(c.label) ?? { id: c.id, label: c.label, short: c.label, ratingKey: "bestCustom", custom: true }).filter((c) => c.id), [stored]);16 const add = React.useCallback(17 (label: string): Criterion | null => {18 const c = customCriterion(label);19 if (!c) return null;20 setStored((prev) => (prev.some((p) => p.id === c.id) ? prev : [...prev, { id: c.id, label: c.label }].slice(0, 12)));21 return c;22 },23 [setStored],24 );25 const remove = React.useCallback((id: string) => setStored((prev) => prev.filter((p) => p.id !== id)), [setStored]);26 const all = React.useMemo(() => [...BUILTIN_CRITERIA, ...custom], [custom]);27 return { custom, all, add, remove };28}2930interface Props {31 criteria: Criterion[];32 /** Criterion ids won by this response. */33 won: ReadonlySet<string>;34 onToggle: (criterionId: string, on: boolean) => void;35 onAddCriterion?: (label: string) => void;36 onRemoveCriterion?: (id: string) => void;37 busy?: boolean;38 disabled?: boolean;39 className?: string;40}4142/** Criteria chips under a response. One vote per criterion per session — voting here moves the vote. */43export function VotePanel({ criteria, won, onToggle, onAddCriterion, onRemoveCriterion, busy, disabled, className }: Props) {44 const [adding, setAdding] = React.useState(false);45 return (46 <div className={cn("flex flex-wrap items-center gap-1", className)} role="group" aria-label="Vote for this response">47 {criteria.map((c) => {48 const on = won.has(c.id);49 return (50 <span key={c.id} className="group/chip relative inline-flex">51 <Tooltip content={on ? `Retract “${c.label}”` : c.label}>52 <button53 type="button"54 disabled={busy || disabled}55 aria-pressed={on}56 onClick={() => onToggle(c.id, !on)}57 className={cn(58 "tap inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[11.5px] font-medium transition-colors disabled:opacity-50",59 on ? "border-accent bg-accent-soft text-accent" : "border-border text-fg-muted hover:border-border-strong hover:text-fg",60 c.custom && onRemoveCriterion && "pr-5",61 )}62 >63 {c.id === "best" ? <Trophy className="size-3" /> : null}64 {c.short}65 </button>66 </Tooltip>67 {c.custom && onRemoveCriterion ? (68 <button type="button" onClick={() => onRemoveCriterion(c.id)} className="hover-reveal absolute right-0.5 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-subtle hover:text-danger" aria-label={`Remove criterion ${c.label}`}>69 <X className="size-3" />70 </button>71 ) : null}72 </span>73 );74 })}75 {onAddCriterion ? (76 <>77 <Tooltip content="Add a custom criterion">78 <button type="button" disabled={disabled} onClick={() => setAdding(true)} className="tap inline-flex h-7 items-center gap-1 rounded-md border border-dashed border-border px-2 text-[11.5px] font-medium text-fg-subtle transition-colors hover:border-border-strong hover:text-fg" aria-label="Add criterion">79 <Plus className="size-3" /> Criterion80 </button>81 </Tooltip>82 <PromptDialog83 open={adding}84 onOpenChange={setAdding}85 title="New criterion"86 description="Name what you are judging — e.g. “Best tone”, “Most concise”, “Follows format”. Saved on this device."87 label="Criterion"88 placeholder="Most concise"89 confirmLabel="Add"90 maxLength={40}91 onSubmit={(v) => {92 onAddCriterion(v);93 }}94 />95 </>96 ) : null}97 </div>98 );99}100