230 lines
7.4 KiB
TypeScript
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>
|
|
);
|
|
}
|