learning_ai_notes/web/src/components/ArtifactPanel.tsx
saravanakumardb1 2408f43426 feat(web/ui5+ui7): migrate 12 components to @bytelyst/ui primitives
Finishes UI5 and kicks off UI7 by migrating the remaining form-heavy
components plus the note-detail right-rail panels. Drops legacy class
matches from 92 → 67 (-25) and raw interactive controls from 38 → 25
(-13). Ratchet baseline updated to the new floor.

Components migrated:

UI5 finish:
- NoteEditor.tsx — surface-card wrapper → Card, title input → Input,
  Tiptap editor className updated to use border + bg classes instead
  of input-shell. Toolbar buttons left as raw (intentional, tightly
  styled icon controls).
- SmartActionsPanel.tsx — result panel surface-muted → Tailwind
  bg-[var(--nl-surface-muted)] utility.
- ArtifactPanel.tsx — section→Card, badge→Badge, all three input-shell
  inputs/selects/textareas→Input/Select/Textarea, surface-muted form
  shell + per-artifact row → Tailwind bg-utility, raw <button> Open
  → Button.
- CommandPalette.tsx — surface-card command sheet → Tailwind layered
  classes, search input → Input (now ref-forwarded), kind badge → Badge.

UI7 component pass:
- MetadataPanel.tsx — section→Card, tag badge→Badge.
- LinkedNotesPanel.tsx — section→Card, surface-muted link row →
  Tailwind bg-utility with hover state.
- PalaceStats.tsx — section→Card, inline styles → Tailwind utilities.
- ExtractedTasksPanel.tsx — surface-muted row → Tailwind.
- NoteVersionsPanel.tsx — all three section/surface-card variants →
  Card + raw button → preserved (interactive disclosure).
- Pagination.tsx — raw <button> Previous/Next → Button, surface-muted
  → built-in secondary variant.
- TaskReviewPanel.tsx — full migration: section→Card, badge→Badge,
  input-shell + textarea + raw button → Input/Textarea/Button.
- SurveyBanner.tsx — survey answer input-shell → Input.

Adapter changes:
- web/src/components/ui/Primitives.tsx — Input and Textarea now use
  React.forwardRef so callers like CommandPalette can attach refs.

Verified:
- pnpm --filter @notelett/web run typecheck: passes
- pnpm --filter @notelett/web test: 96/96 still pass
- pnpm run audit:ui:ratchet: at new baseline (25/67/0/0)
- pnpm run audit:ui: legacy class matches now in dashboard / search /
  workspaces / notes-detail / palace / chat pages (UI6/UI7 page targets)
2026-05-23 01:33:48 -07:00

194 lines
6.6 KiB
TypeScript

"use client";
import { useRef, useState } from "react";
import { StateNotice } from "@/components/StateNotice";
import { Badge, Button, Card, Input, Select, Textarea } from "@/components/ui/Primitives";
import { getArtifactReadUrl, uploadArtifact } from "@/lib/blob-client";
import { toast } from "@/lib/toast";
import { getEmptyState, toUserFacingState, type UserFacingState } from "@/lib/user-facing-states";
import type { ArtifactSummary } from "@/lib/types";
export function ArtifactPanel({
artifacts,
onCreate,
isCreating = false,
}: {
artifacts: ArtifactSummary[];
onCreate: (input: {
title: string;
artifactType: "file" | "summary" | "extraction" | "citation" | "export";
description?: string;
blobPath?: string;
}) => Promise<void>;
isCreating?: boolean;
}) {
const [openingId, setOpeningId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [title, setTitle] = useState("");
const [artifactType, setArtifactType] = useState<"file" | "summary" | "extraction" | "citation" | "export">("file");
const [description, setDescription] = useState("");
const [blobPath, setBlobPath] = useState("");
const [noticeState, setNoticeState] = useState<UserFacingState | null>(null);
async function handleOpenArtifact(artifact: ArtifactSummary) {
if (!artifact.blobPath) {
return;
}
try {
setOpeningId(artifact.id);
const url = await getArtifactReadUrl(artifact.blobPath);
window.open(url, "_blank", "noopener,noreferrer");
} finally {
setOpeningId(null);
}
}
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
setNoticeState(null);
try {
const path = `artifacts/${crypto.randomUUID()}/${file.name}`;
const result = await uploadArtifact(file, path);
await onCreate({
title: file.name,
artifactType: "file",
description: `Uploaded file (${(result.sizeBytes / 1024).toFixed(1)} KB)`,
blobPath: result.blobPath,
});
toast.success("File uploaded");
} catch (err) {
const state = toUserFacingState(err, "blob");
setNoticeState(state);
toast.error(state.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleCreateArtifact() {
if (!title.trim()) {
return;
}
await onCreate({
title: title.trim(),
artifactType,
description: description.trim() || undefined,
blobPath: blobPath.trim() || undefined,
});
setTitle("");
setArtifactType("file");
setDescription("");
setBlobPath("");
setNoticeState(null);
}
return (
<Card padding="md" className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<div className="font-bold">Artifacts</div>
<Badge variant="info">blob-backed view</Badge>
</div>
<div className="grid gap-3 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-3">
<Input
id="artifact-title"
label="Add artifact"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Artifact title"
aria-label="Artifact title"
/>
<div className="grid gap-3 [grid-template-columns:minmax(140px,180px)_minmax(0,1fr)]">
<Select
value={artifactType}
onChange={(event) => setArtifactType(event.target.value as "file" | "summary" | "extraction" | "citation" | "export")}
aria-label="Artifact type"
options={[
{ value: "file", label: "file" },
{ value: "summary", label: "summary" },
{ value: "extraction", label: "extraction" },
{ value: "citation", label: "citation" },
{ value: "export", label: "export" },
]}
/>
<Input
value={blobPath}
onChange={(event) => setBlobPath(event.target.value)}
placeholder="Optional blob path"
aria-label="Artifact blob path"
/>
</div>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Description"
aria-label="Artifact description"
className="min-h-[96px] resize-y"
/>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-2)" }}>
<input ref={fileInputRef} type="file" hidden onChange={handleFileUpload} />
<Button
type="button"
variant="secondary"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
>
{uploading ? "Uploading…" : "Upload file"}
</Button>
<Button
type="button"
disabled={isCreating}
onClick={() => {
void handleCreateArtifact();
}}
>
{isCreating ? "Adding…" : "Add artifact"}
</Button>
</div>
</div>
{noticeState ? (
<StateNotice
state={noticeState}
compact
onAction={noticeState.actionLabel ? () => fileInputRef.current?.click() : undefined}
/>
) : null}
{artifacts.length === 0 ? (
<StateNotice state={getEmptyState("blob", "artifacts")} compact />
) : null}
{artifacts.map((artifact) => (
<div key={artifact.id} className="flex items-center justify-between gap-3 rounded-[var(--nl-radius-sm)] bg-[color:var(--nl-surface-muted)] p-3">
<div className="grid gap-1">
<strong>{artifact.name}</strong>
<span className="text-[color:var(--nl-text-secondary)]">{artifact.type}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[color:var(--nl-text-secondary)]">{artifact.status}</span>
{artifact.blobPath ? (
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
void handleOpenArtifact(artifact);
}}
disabled={openingId === artifact.id}
aria-label={`Open artifact: ${artifact.name}`}
>
{openingId === artifact.id ? "Opening…" : "Open"}
</Button>
) : null}
</div>
</div>
))}
</Card>
);
}