@weirdfingers/baseboards 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/templates/api/src/boards/__init__.py +1 -1
- package/templates/web/package.json +1 -1
- package/templates/web/src/app/boards/[boardId]/page.tsx +2 -2
- package/templates/web/src/app/lineage/[generationId]/page.tsx +2 -0
- package/templates/web/src/components/boards/ArtifactInputSlots.tsx +1 -1
- package/templates/web/src/components/boards/ArtifactPreview.tsx +34 -0
- package/templates/web/src/components/boards/GenerationGrid.tsx +112 -9
- package/templates/web/src/components/boards/GenerationInput.tsx +17 -3
- package/templates/web/src/components/boards/GeneratorSelector.tsx +7 -33
- package/templates/web/src/components/ui/use-toast.ts +1 -0
- package/templates/web/src/hooks/useGeneratorMRU.ts +57 -0
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@radix-ui/react-slot": "^1.2.3",
|
|
17
17
|
"@radix-ui/react-toast": "^1.2.15",
|
|
18
18
|
"@tailwindcss/postcss": "^4.1.13",
|
|
19
|
-
"@weirdfingers/boards": "^0.9.
|
|
19
|
+
"@weirdfingers/boards": "^0.9.2",
|
|
20
20
|
"class-variance-authority": "^0.7.1",
|
|
21
21
|
"clsx": "^2.0.0",
|
|
22
22
|
"graphql": "^16.11.0",
|
|
@@ -20,7 +20,6 @@ export default function BoardPage() {
|
|
|
20
20
|
|
|
21
21
|
const {
|
|
22
22
|
board,
|
|
23
|
-
loading: boardLoading,
|
|
24
23
|
error: boardError,
|
|
25
24
|
refresh: refreshBoard,
|
|
26
25
|
updateBoard,
|
|
@@ -182,7 +181,7 @@ export default function BoardPage() {
|
|
|
182
181
|
}
|
|
183
182
|
|
|
184
183
|
// Handle loading state
|
|
185
|
-
if (
|
|
184
|
+
if (!board) {
|
|
186
185
|
return (
|
|
187
186
|
<div className="flex items-center justify-center min-h-screen">
|
|
188
187
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
|
@@ -320,6 +319,7 @@ export default function BoardPage() {
|
|
|
320
319
|
<div className="mb-8">
|
|
321
320
|
<GenerationGrid
|
|
322
321
|
generations={generations}
|
|
322
|
+
boardId={boardId}
|
|
323
323
|
onGenerationClick={() => {
|
|
324
324
|
// TODO: Open generation detail modal
|
|
325
325
|
}}
|
|
@@ -140,6 +140,7 @@ function AncestryTree({ node, currentGenerationId }: AncestryTreeProps) {
|
|
|
140
140
|
</p>
|
|
141
141
|
</div>
|
|
142
142
|
{node.generation.thumbnailUrl && (
|
|
143
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
143
144
|
<img
|
|
144
145
|
src={node.generation.thumbnailUrl}
|
|
145
146
|
alt="Thumbnail"
|
|
@@ -208,6 +209,7 @@ function DescendantTree({ node, currentGenerationId }: DescendantTreeProps) {
|
|
|
208
209
|
</p>
|
|
209
210
|
</div>
|
|
210
211
|
{node.generation.thumbnailUrl && (
|
|
212
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
211
213
|
<img
|
|
212
214
|
src={node.generation.thumbnailUrl}
|
|
213
215
|
alt="Thumbnail"
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
RotateCcw,
|
|
15
15
|
GitBranch,
|
|
16
16
|
Trash2,
|
|
17
|
+
Film,
|
|
17
18
|
} from "lucide-react";
|
|
18
19
|
import Image from "next/image";
|
|
19
20
|
import { useRouter } from "next/navigation";
|
|
@@ -23,6 +24,9 @@ import {
|
|
|
23
24
|
DropdownMenuItem,
|
|
24
25
|
DropdownMenuTrigger,
|
|
25
26
|
DropdownMenuSeparator,
|
|
27
|
+
DropdownMenuSub,
|
|
28
|
+
DropdownMenuSubContent,
|
|
29
|
+
DropdownMenuSubTrigger,
|
|
26
30
|
} from "@/components/ui/dropdown-menu";
|
|
27
31
|
|
|
28
32
|
interface ArtifactPreviewProps {
|
|
@@ -37,6 +41,7 @@ interface ArtifactPreviewProps {
|
|
|
37
41
|
onDownload?: () => void;
|
|
38
42
|
onPreview?: () => void;
|
|
39
43
|
onDelete?: () => void;
|
|
44
|
+
onExtractFrame?: (position: "first" | "last") => void;
|
|
40
45
|
artifactId?: string;
|
|
41
46
|
prompt?: string | null;
|
|
42
47
|
}
|
|
@@ -53,6 +58,7 @@ export function ArtifactPreview({
|
|
|
53
58
|
onDownload,
|
|
54
59
|
onPreview,
|
|
55
60
|
onDelete,
|
|
61
|
+
onExtractFrame,
|
|
56
62
|
artifactId,
|
|
57
63
|
prompt,
|
|
58
64
|
}: ArtifactPreviewProps) {
|
|
@@ -406,6 +412,34 @@ export function ArtifactPreview({
|
|
|
406
412
|
Download
|
|
407
413
|
</DropdownMenuItem>
|
|
408
414
|
)}
|
|
415
|
+
{isComplete && artifactType === "VIDEO" && onExtractFrame && (
|
|
416
|
+
<DropdownMenuSub>
|
|
417
|
+
<DropdownMenuSubTrigger className="cursor-pointer">
|
|
418
|
+
<Film className="w-4 h-4 mr-2" />
|
|
419
|
+
Extract Frame
|
|
420
|
+
</DropdownMenuSubTrigger>
|
|
421
|
+
<DropdownMenuSubContent>
|
|
422
|
+
<DropdownMenuItem
|
|
423
|
+
onClick={(e) => {
|
|
424
|
+
e.stopPropagation();
|
|
425
|
+
onExtractFrame("first");
|
|
426
|
+
}}
|
|
427
|
+
className="cursor-pointer"
|
|
428
|
+
>
|
|
429
|
+
First Frame
|
|
430
|
+
</DropdownMenuItem>
|
|
431
|
+
<DropdownMenuItem
|
|
432
|
+
onClick={(e) => {
|
|
433
|
+
e.stopPropagation();
|
|
434
|
+
onExtractFrame("last");
|
|
435
|
+
}}
|
|
436
|
+
className="cursor-pointer"
|
|
437
|
+
>
|
|
438
|
+
Last Frame
|
|
439
|
+
</DropdownMenuItem>
|
|
440
|
+
</DropdownMenuSubContent>
|
|
441
|
+
</DropdownMenuSub>
|
|
442
|
+
)}
|
|
409
443
|
{onDelete && (
|
|
410
444
|
<>
|
|
411
445
|
<DropdownMenuSeparator />
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
import {
|
|
1
|
+
import { useState, useCallback } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useGeneratorSelection,
|
|
4
|
+
useGeneration,
|
|
5
|
+
useMultiUpload,
|
|
6
|
+
ArtifactType,
|
|
7
|
+
} from "@weirdfingers/boards";
|
|
3
8
|
import { ArtifactPreview } from "./ArtifactPreview";
|
|
4
9
|
import { useToast } from "@/components/ui/use-toast";
|
|
5
10
|
import {
|
|
@@ -25,29 +30,118 @@ interface Generation {
|
|
|
25
30
|
|
|
26
31
|
interface GenerationGridProps {
|
|
27
32
|
generations: Generation[];
|
|
33
|
+
boardId: string;
|
|
28
34
|
onGenerationClick?: (generation: Generation) => void;
|
|
29
35
|
onRemoveSuccess?: () => void;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export function GenerationGrid({
|
|
33
39
|
generations,
|
|
40
|
+
boardId,
|
|
34
41
|
onGenerationClick,
|
|
35
42
|
onRemoveSuccess: onRemoveSuccess,
|
|
36
43
|
}: GenerationGridProps) {
|
|
37
44
|
const { canArtifactBeAdded, addArtifactToSlot } = useGeneratorSelection();
|
|
38
45
|
const { deleteGeneration } = useGeneration();
|
|
46
|
+
const { uploadMultiple } = useMultiUpload();
|
|
39
47
|
const { toast } = useToast();
|
|
40
48
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
41
49
|
const [generationToDelete, setGenerationToDelete] =
|
|
42
50
|
useState<Generation | null>(null);
|
|
51
|
+
const [isExtractingFrame, setIsExtractingFrame] = useState(false);
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
const handleExtractFrame = useCallback(
|
|
54
|
+
async (generation: Generation, position: "first" | "last") => {
|
|
55
|
+
if (!generation.storageUrl || isExtractingFrame) return;
|
|
56
|
+
|
|
57
|
+
setIsExtractingFrame(true);
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Create a video element to load the video
|
|
61
|
+
const video = document.createElement("video");
|
|
62
|
+
video.crossOrigin = "anonymous";
|
|
63
|
+
video.preload = "metadata";
|
|
64
|
+
|
|
65
|
+
// Wait for video metadata to load
|
|
66
|
+
await new Promise<void>((resolve, reject) => {
|
|
67
|
+
video.onloadedmetadata = () => resolve();
|
|
68
|
+
video.onerror = () => reject(new Error("Failed to load video"));
|
|
69
|
+
video.src = generation.storageUrl!;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Seek to the desired position
|
|
73
|
+
const targetTime = position === "first" ? 0 : video.duration - 0.1;
|
|
74
|
+
video.currentTime = Math.max(0, targetTime);
|
|
75
|
+
|
|
76
|
+
// Wait for the video to seek to the frame
|
|
77
|
+
await new Promise<void>((resolve, reject) => {
|
|
78
|
+
video.onseeked = () => resolve();
|
|
79
|
+
video.onerror = () => reject(new Error("Failed to seek video"));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Create a canvas and draw the video frame
|
|
83
|
+
const canvas = document.createElement("canvas");
|
|
84
|
+
canvas.width = video.videoWidth;
|
|
85
|
+
canvas.height = video.videoHeight;
|
|
86
|
+
const ctx = canvas.getContext("2d");
|
|
87
|
+
|
|
88
|
+
if (!ctx) {
|
|
89
|
+
throw new Error("Failed to get canvas context");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
93
|
+
|
|
94
|
+
// Convert canvas to blob
|
|
95
|
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
96
|
+
canvas.toBlob(
|
|
97
|
+
(blob) => {
|
|
98
|
+
if (blob) {
|
|
99
|
+
resolve(blob);
|
|
100
|
+
} else {
|
|
101
|
+
reject(new Error("Failed to create image blob"));
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
"image/png",
|
|
105
|
+
1.0
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Create a File object from the blob
|
|
110
|
+
const fileName = `${position}-frame-${generation.id}.png`;
|
|
111
|
+
const file = new File([blob], fileName, { type: "image/png" });
|
|
112
|
+
|
|
113
|
+
// Upload the extracted frame as a new image generation
|
|
114
|
+
const results = await uploadMultiple([
|
|
115
|
+
{
|
|
116
|
+
boardId,
|
|
117
|
+
artifactType: ArtifactType.IMAGE,
|
|
118
|
+
source: file,
|
|
119
|
+
userDescription: `${position === "first" ? "First" : "Last"} frame extracted from video`,
|
|
120
|
+
parentGenerationId: generation.id,
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
if (results.length > 0) {
|
|
125
|
+
toast({
|
|
126
|
+
title: "Frame extracted",
|
|
127
|
+
description: `${position === "first" ? "First" : "Last"} frame has been saved as a new image.`,
|
|
128
|
+
});
|
|
129
|
+
onRemoveSuccess?.(); // Refresh the grid to show the new generation
|
|
130
|
+
}
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error("Failed to extract frame:", error);
|
|
133
|
+
toast({
|
|
134
|
+
title: "Failed to extract frame",
|
|
135
|
+
description:
|
|
136
|
+
error instanceof Error ? error.message : "An unknown error occurred",
|
|
137
|
+
variant: "destructive",
|
|
138
|
+
});
|
|
139
|
+
} finally {
|
|
140
|
+
setIsExtractingFrame(false);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
[boardId, uploadMultiple, toast, onRemoveSuccess, isExtractingFrame]
|
|
144
|
+
);
|
|
51
145
|
|
|
52
146
|
const handleDownload = async (generation: Generation) => {
|
|
53
147
|
if (!generation.storageUrl) return;
|
|
@@ -128,6 +222,14 @@ export function GenerationGrid({
|
|
|
128
222
|
}
|
|
129
223
|
};
|
|
130
224
|
|
|
225
|
+
if (generations.length === 0) {
|
|
226
|
+
return (
|
|
227
|
+
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
228
|
+
<p>No generations yet. Create your first one below!</p>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
131
233
|
return (
|
|
132
234
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
133
235
|
{generations.map((generation) => {
|
|
@@ -150,6 +252,7 @@ export function GenerationGrid({
|
|
|
150
252
|
onDownload={() => handleDownload(generation)}
|
|
151
253
|
onPreview={() => handlePreview(generation)}
|
|
152
254
|
onDelete={() => handleDeleteClick(generation)}
|
|
255
|
+
onExtractFrame={(position) => handleExtractFrame(generation, position)}
|
|
153
256
|
/>
|
|
154
257
|
);
|
|
155
258
|
})}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useGeneratorSelection,
|
|
8
8
|
} from "@weirdfingers/boards";
|
|
9
9
|
import { GeneratorSelector, GeneratorInfo } from "./GeneratorSelector";
|
|
10
|
+
import { useGeneratorMRU } from "@/hooks/useGeneratorMRU";
|
|
10
11
|
import { ArtifactInputSlots } from "./ArtifactInputSlots";
|
|
11
12
|
|
|
12
13
|
interface Generation {
|
|
@@ -42,6 +43,8 @@ export function GenerationInput({
|
|
|
42
43
|
setSelectedArtifacts
|
|
43
44
|
} = useGeneratorSelection();
|
|
44
45
|
|
|
46
|
+
const { lastUsedGenerator } = useGeneratorMRU();
|
|
47
|
+
|
|
45
48
|
const [prompt, setPrompt] = useState("");
|
|
46
49
|
const [attachedImage, setAttachedImage] = useState<Generation | null>(null);
|
|
47
50
|
const [showSettings, setShowSettings] = useState(false);
|
|
@@ -49,10 +52,21 @@ export function GenerationInput({
|
|
|
49
52
|
|
|
50
53
|
// Initialize selected generator if not set
|
|
51
54
|
useEffect(() => {
|
|
52
|
-
if (
|
|
53
|
-
|
|
55
|
+
if (selectedGenerator || generators.length === 0) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Try to load from MRU
|
|
60
|
+
if (lastUsedGenerator) {
|
|
61
|
+
const match = generators.find(g => g.name === lastUsedGenerator);
|
|
62
|
+
if (match) {
|
|
63
|
+
setSelectedGenerator(match);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
54
66
|
}
|
|
55
|
-
|
|
67
|
+
|
|
68
|
+
setSelectedGenerator(generators[0]);
|
|
69
|
+
}, [generators, selectedGenerator, setSelectedGenerator, lastUsedGenerator]);
|
|
56
70
|
|
|
57
71
|
const artifactSlots = useMemo(() => {
|
|
58
72
|
if (!parsedSchema) return [];
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useState, useMemo, useEffect, useDeferredValue, useRef } from "react";
|
|
4
4
|
import type { JSONSchema7 } from "@weirdfingers/boards";
|
|
5
5
|
import { useGeneratorSelection } from "@weirdfingers/boards";
|
|
6
|
+
import { Zap, Search, Check } from "lucide-react";
|
|
6
7
|
import {
|
|
7
8
|
DropdownMenu,
|
|
8
9
|
DropdownMenuContent,
|
|
9
10
|
DropdownMenuItem,
|
|
10
11
|
DropdownMenuTrigger,
|
|
11
12
|
} from "@/components/ui/dropdown-menu";
|
|
12
|
-
import {
|
|
13
|
+
import { useGeneratorMRU } from "@/hooks/useGeneratorMRU";
|
|
13
14
|
|
|
14
15
|
export interface GeneratorInfo {
|
|
15
16
|
name: string;
|
|
@@ -24,37 +25,20 @@ interface GeneratorSelectorProps {
|
|
|
24
25
|
onSelect: (generator: GeneratorInfo) => void;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
const MRU_STORAGE_KEY = "boards-generator-mru";
|
|
28
|
-
const MRU_MAX_SIZE = 3;
|
|
29
|
-
|
|
30
28
|
export function GeneratorSelector({
|
|
31
29
|
generators,
|
|
32
30
|
selectedGenerator,
|
|
33
31
|
onSelect,
|
|
34
32
|
}: GeneratorSelectorProps) {
|
|
35
33
|
const { setSelectedGenerator } = useGeneratorSelection();
|
|
34
|
+
const { mruGenerators, addGeneratorToMRU } = useGeneratorMRU();
|
|
35
|
+
|
|
36
36
|
const [searchInput, setSearchInput] = useState("");
|
|
37
37
|
const deferredSearch = useDeferredValue(searchInput);
|
|
38
38
|
const [selectedTypes, setSelectedTypes] = useState<Set<string>>(new Set());
|
|
39
|
-
const [mruGenerators, setMruGenerators] = useState<string[]>([]);
|
|
40
39
|
const [isOpen, setIsOpen] = useState(false);
|
|
41
40
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
42
41
|
|
|
43
|
-
// Load MRU from localStorage on mount
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
try {
|
|
46
|
-
const stored = localStorage.getItem(MRU_STORAGE_KEY);
|
|
47
|
-
if (stored) {
|
|
48
|
-
const parsed = JSON.parse(stored);
|
|
49
|
-
if (Array.isArray(parsed)) {
|
|
50
|
-
setMruGenerators(parsed);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
} catch (error) {
|
|
54
|
-
console.error("Failed to load MRU generators:", error);
|
|
55
|
-
}
|
|
56
|
-
}, []);
|
|
57
|
-
|
|
58
42
|
// Focus search input when dropdown opens
|
|
59
43
|
useEffect(() => {
|
|
60
44
|
if (isOpen) {
|
|
@@ -123,7 +107,7 @@ export function GeneratorSelector({
|
|
|
123
107
|
|
|
124
108
|
const getGeneratorIcon = (name: string) => {
|
|
125
109
|
// You can customize icons per generator here
|
|
126
|
-
return <Zap className="w-4 h-4" />;
|
|
110
|
+
return <Zap className="w-4 h-4" aria-label={name} />;
|
|
127
111
|
};
|
|
128
112
|
|
|
129
113
|
const handleSelect = (generator: GeneratorInfo) => {
|
|
@@ -132,17 +116,7 @@ export function GeneratorSelector({
|
|
|
132
116
|
onSelect(generator);
|
|
133
117
|
|
|
134
118
|
// Update MRU
|
|
135
|
-
|
|
136
|
-
generator.name,
|
|
137
|
-
...mruGenerators.filter((name) => name !== generator.name),
|
|
138
|
-
].slice(0, MRU_MAX_SIZE);
|
|
139
|
-
|
|
140
|
-
setMruGenerators(newMru);
|
|
141
|
-
try {
|
|
142
|
-
localStorage.setItem(MRU_STORAGE_KEY, JSON.stringify(newMru));
|
|
143
|
-
} catch (error) {
|
|
144
|
-
console.error("Failed to save MRU generators:", error);
|
|
145
|
-
}
|
|
119
|
+
addGeneratorToMRU(generator.name);
|
|
146
120
|
};
|
|
147
121
|
|
|
148
122
|
const toggleType = (type: string) => {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
const MRU_STORAGE_KEY = "boards-generator-mru";
|
|
4
|
+
const MRU_MAX_SIZE = 3;
|
|
5
|
+
|
|
6
|
+
export function useGeneratorMRU() {
|
|
7
|
+
const [mruGenerators, setMruGenerators] = useState<string[]>([]);
|
|
8
|
+
|
|
9
|
+
// Load MRU from localStorage on mount
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
try {
|
|
12
|
+
if (typeof window === "undefined") return;
|
|
13
|
+
|
|
14
|
+
const stored = localStorage.getItem(MRU_STORAGE_KEY);
|
|
15
|
+
if (stored) {
|
|
16
|
+
const parsed = JSON.parse(stored);
|
|
17
|
+
// Robustness check: Ensure it's an array of strings
|
|
18
|
+
if (
|
|
19
|
+
Array.isArray(parsed) &&
|
|
20
|
+
parsed.every((item) => typeof item === "string")
|
|
21
|
+
) {
|
|
22
|
+
setMruGenerators(parsed);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error("Failed to load MRU generators:", error);
|
|
27
|
+
// Fallback to empty list is implicit via initial state
|
|
28
|
+
}
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const addGeneratorToMRU = useCallback((generatorName: string) => {
|
|
32
|
+
setMruGenerators((prev) => {
|
|
33
|
+
// Create new list with generator at the front, removing duplicates
|
|
34
|
+
const newMru = [
|
|
35
|
+
generatorName,
|
|
36
|
+
...prev.filter((name) => name !== generatorName),
|
|
37
|
+
].slice(0, MRU_MAX_SIZE);
|
|
38
|
+
|
|
39
|
+
// Persist to localStorage
|
|
40
|
+
try {
|
|
41
|
+
if (typeof window !== "undefined") {
|
|
42
|
+
localStorage.setItem(MRU_STORAGE_KEY, JSON.stringify(newMru));
|
|
43
|
+
}
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error("Failed to save MRU generators:", error);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return newMru;
|
|
49
|
+
});
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
mruGenerators,
|
|
54
|
+
lastUsedGenerator: mruGenerators.length > 0 ? mruGenerators[0] : undefined,
|
|
55
|
+
addGeneratorToMRU,
|
|
56
|
+
};
|
|
57
|
+
}
|