nexus/ui/src/components/IconGeneratePanel.tsx

230 lines
7.4 KiB
TypeScript

import { useState } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useContentJob } from "@/hooks/useContentJob";
import { getContentJobAsset } from "@/api/contentJobs";
import type { IconSetBundle } from "@/types/content-bundles";
import { IconResultGrid } from "./IconResultGrid";
import { IconDownloadBar } from "./IconDownloadBar";
const STYLE_OPTIONS = [
{ value: "outline", label: "Outline" },
{ value: "filled", label: "Filled" },
{ value: "rounded", label: "Rounded" },
];
const COUNT_OPTIONS = [
{ value: "1", label: "1" },
{ value: "4", label: "4" },
{ value: "8", label: "8" },
{ value: "16", label: "16" },
];
interface IconGeneratePanelProps {
companyId: string;
}
export function IconGeneratePanel({ companyId }: IconGeneratePanelProps) {
const [description, setDescription] = useState("");
const [style, setStyle] = useState("outline");
const [count, setCount] = useState("4");
const [bundle, setBundle] = useState<IconSetBundle | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const job = useContentJob(companyId);
const isGenerating = job.status === "queued" || job.status === "running";
const isIdle = job.status === "idle";
async function handleSubmit() {
if (!description.trim() || isGenerating) return;
setBundle(null);
setSelectedIds(new Set());
await job.submit("icon-set", { description, style, count: parseInt(count, 10) });
}
// Fetch asset when job completes
if (job.status === "done" && job.resultAssetId && !bundle) {
void getContentJobAsset(companyId, job.resultAssetId).then(async (assetUrl) => {
const res = await fetch(assetUrl);
const text = await res.text();
try {
const parsed = JSON.parse(text) as IconSetBundle;
setBundle(parsed);
} catch {
// ignore parse error
}
});
}
function handleToggleIcon(name: string) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
}
function handleDownload(format: string) {
if (!bundle) return;
const selected = bundle.icons.filter((icon) => selectedIds.has(icon.name));
for (const icon of selected) {
let blobData: Blob;
let filename: string;
if (format === "svg") {
blobData = new Blob([icon.svgSource], { type: "image/svg+xml" });
filename = `${icon.name}.svg`;
} else {
const sizeMap: Record<string, string> = { "png-16": "16", "png-32": "32", "png-64": "64" };
const size = sizeMap[format] ?? "32";
const pngData = icon.pngs[size];
if (!pngData) continue;
const byteString = atob(pngData);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
blobData = new Blob([ab], { type: "image/png" });
filename = `${icon.name}-${size}.png`;
}
const url = URL.createObjectURL(blobData);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
}
function handleClearSelection() {
setSelectedIds(new Set());
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle className="text-xl font-semibold">Generate Icons</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="icon-description" className="text-sm font-medium">
Describe your icons
</label>
<Textarea
id="icon-description"
rows={3}
placeholder="Describe what you need..."
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isGenerating}
/>
</div>
<div className="flex gap-3">
<div className="flex flex-col gap-2 flex-1">
<label htmlFor="icon-style" className="text-sm font-medium">
Style
</label>
<Select value={style} onValueChange={setStyle} disabled={isGenerating}>
<SelectTrigger id="icon-style" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STYLE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 flex-1">
<label htmlFor="icon-count" className="text-sm font-medium">
Count
</label>
<Select value={count} onValueChange={setCount} disabled={isGenerating}>
<SelectTrigger id="icon-count" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COUNT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={isGenerating || !description.trim()}
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" aria-hidden="true" />
Generating...
</>
) : (
"Generate Icons"
)}
</Button>
{isGenerating && (
<Progress
value={job.progress}
role="progressbar"
aria-valuenow={job.progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Icon generation progress"
/>
)}
{job.status === "failed" && job.errorMessage && (
<p className="text-sm text-destructive">
Render failed {job.errorMessage}. Try again.
</p>
)}
{bundle && bundle.icons.length > 0 ? (
<>
<IconResultGrid
icons={bundle.icons}
selectedIds={selectedIds}
onToggle={handleToggleIcon}
/>
<IconDownloadBar
selectedCount={selectedIds.size}
onDownload={handleDownload}
onClear={handleClearSelection}
/>
</>
) : isIdle ? (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<p className="text-xl font-semibold">No icons yet</p>
<p className="text-sm text-muted-foreground">
Describe what you need and we'll generate a cohesive set.
</p>
</div>
) : null}
</CardContent>
</Card>
);
}