spb/note-spboucher-ai
Public
1"use client";2/** Small shared bits for the research workspace (select, field, key-value editor, badges, insert helpers). */3import * as React from "react";4import { Badge } from "@/components/ui/primitives";5import { Input } from "@/components/ui/input";6import { cn } from "@/lib/utils";7import { Plus, X } from "lucide-react";8import { api } from "@/lib/client/api";9import { toast } from "@/components/ui/toast";1011export const PAPER_STATUSES = ["planning", "research", "development", "writing", "submitted", "published"] as const;12export const STATUS_TONE: Record<string, "neutral" | "accent" | "ai" | "success" | "warning" | "danger"> = { planning: "neutral", research: "accent", development: "ai", writing: "warning", submitted: "warning", published: "success" };13export const EXP_TONE: Record<string, "neutral" | "accent" | "ai" | "success" | "warning" | "danger"> = { planned: "neutral", running: "accent", done: "success", failed: "danger" };1415export function Select({ className, children, ...p }: React.SelectHTMLAttributes<HTMLSelectElement>) {16 return <select className={cn("h-8 rounded-md border border-line bg-bg-elev px-2 text-[13px] shadow-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40", className)} {...p}>{children}</select>;17}18export function Field({ label, children, className, hint }: { label: React.ReactNode; children: React.ReactNode; className?: string; hint?: string }) {19 return <label className={cn("flex min-w-0 flex-col gap-1", className)}><span className="text-[11px] font-medium uppercase tracking-wide text-fg-faint">{label}</span>{children}{hint && <span className="text-[11px] text-fg-faint">{hint}</span>}</label>;20}21export function VerifiedBadge({ v }: { v: string }) {22 return v === "doi_verified" ? <Badge tone="success">DOI verified</Badge> : v === "ai_suggested" ? <Badge tone="ai">AI suggested</Badge> : <Badge tone="neutral">Manual</Badge>;23}24export function AILabel({ model }: { model?: string | null }) { return <Badge tone="ai">AI-generated{model ? ` · ${model}` : ""}</Badge>; }2526/** Key/value editor over a flat object (numbers are kept numeric). */27export function KVEditor({ value, onChange, placeholderKey = "key", placeholderValue = "value" }: { value: Record<string, unknown>; onChange: (v: Record<string, unknown>) => void; placeholderKey?: string; placeholderValue?: string }) {28 const rows = Object.entries(value ?? {}); const [draft, setDraft] = React.useState<{ k: string; v: string }>({ k: "", v: "" });29 const commit = (k: string, v: string, oldKey?: string) => { const n: Record<string, unknown> = {}; for (const [kk, vv] of rows) { if (kk === oldKey) continue; n[kk] = vv; } if (k.trim()) n[k.trim()] = v.trim() !== "" && !isNaN(Number(v)) && /^-?\d+(\.\d+)?(e-?\d+)?$/i.test(v.trim()) ? Number(v) : v; onChange(n); };30 return (31 <div className="space-y-1">32 {rows.map(([k, v]) => <div key={k} className="flex items-center gap-1"><Input defaultValue={k} className="h-7 w-[38%] font-mono text-[12px]" onBlur={(e) => { if (e.target.value !== k) commit(e.target.value, String(v ?? ""), k); }} /><Input defaultValue={String(v ?? "")} className="h-7 flex-1 font-mono text-[12px]" onBlur={(e) => { if (e.target.value !== String(v ?? "")) commit(k, e.target.value, k); }} /><button className="grid size-7 shrink-0 place-items-center rounded text-fg-faint hover:bg-bg-sunk hover:text-danger" onClick={() => { const n = { ...value }; delete n[k]; onChange(n); }} aria-label="Remove"><X className="size-3.5" /></button></div>)}33 <div className="flex items-center gap-1"><Input value={draft.k} placeholder={placeholderKey} className="h-7 w-[38%] font-mono text-[12px]" onChange={(e) => setDraft({ ...draft, k: e.target.value })} onKeyDown={(e) => { if (e.key === "Enter" && draft.k.trim()) { commit(draft.k, draft.v); setDraft({ k: "", v: "" }); } }} /><Input value={draft.v} placeholder={placeholderValue} className="h-7 flex-1 font-mono text-[12px]" onChange={(e) => setDraft({ ...draft, v: e.target.value })} onKeyDown={(e) => { if (e.key === "Enter" && draft.k.trim()) { commit(draft.k, draft.v); setDraft({ k: "", v: "" }); } }} /><button className="grid size-7 shrink-0 place-items-center rounded text-fg-faint hover:bg-bg-sunk hover:text-fg" disabled={!draft.k.trim()} onClick={() => { commit(draft.k, draft.v); setDraft({ k: "", v: "" }); }} aria-label="Add"><Plus className="size-3.5" /></button></div>34 </div>35 );36}3738/** Insert Markdown into the open page editor as an AI block (PageView listens for this event). */39export function insertMarkdown(md: string) { window.dispatchEvent(new CustomEvent("note:insert-markdown", { detail: md })); }40/** Save Markdown as a new note (wrapped in an AI block so provenance stays visible). */41export async function saveAsNote(md: string, title: string, model?: string | null) {42 const { markdownToDoc } = await import("@/lib/editor/markdown-client");43 const p = await api("/api/pages", { method: "POST", json: { title: title.slice(0, 120) || "AI research note", section: "research", content: { type: "doc", content: [{ type: "aiBlock", attrs: { model: model ?? "assistant", at: new Date().toISOString() }, content: markdownToDoc(md) }] } } });44 toast("Saved as a note", { tone: "success" }); return p as { id: string };45}46export const copy = (s: string, msg = "Copied") => { navigator.clipboard.writeText(s).then(() => toast(msg)).catch(() => toast("Copy failed", { tone: "danger" })); };47export const fmtBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);48export const shortAuthors = (a: string[] | null | undefined) => { const ln = (a ?? []).map((x) => x.trim().split(/\s+/).pop() ?? ""); return ln.length === 0 ? "—" : ln.length === 1 ? ln[0] : ln.length === 2 ? `${ln[0]} & ${ln[1]}` : `${ln[0]} et al.`; };49export const dateInput = (d: string | Date | null | undefined) => (d ? new Date(d).toISOString().slice(0, 10) : "");50