@effectnode/media 0.5.0 → 0.7.0

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.
@@ -1,664 +0,0 @@
1
- import { useEffect, useRef, useState } from "react";
2
- import {
3
- useCharacterStore,
4
- type Character,
5
- } from "../../stores/characterStore";
6
- import {
7
- useGenerationStore,
8
- type ProjectImage,
9
- } from "../../stores/generationStore";
10
- import CropTool from "./CropTool";
11
- import CharacterSheet from "./CharacterSheet";
12
-
13
- const API_BASE = `http://localhost:${(window as any).PORT}`;
14
-
15
- /** Fetch an image URL and return it as a data URL (avoids canvas tainting). */
16
- async function urlToDataUrl(url: string): Promise<string> {
17
- const res = await fetch(url);
18
- if (!res.ok) throw new Error(`Failed to load image (${res.status})`);
19
- const blob = await res.blob();
20
- return new Promise((resolve, reject) => {
21
- const reader = new FileReader();
22
- reader.onload = () => resolve(reader.result as string);
23
- reader.onerror = () => reject(new Error("Failed to read image"));
24
- reader.readAsDataURL(blob);
25
- });
26
- }
27
-
28
- interface Props {
29
- projectId: string;
30
- }
31
-
32
- function CharacterCard({ imageUrl, name }: { imageUrl: string; name: string }) {
33
- const canvasRef = useRef<HTMLCanvasElement>(null);
34
-
35
- useEffect(() => {
36
- const canvas = canvasRef.current;
37
- if (!canvas) return;
38
- const ctx = canvas.getContext("2d");
39
- if (!ctx) return;
40
-
41
- const W = 160;
42
- const H = 160;
43
- const BAR = 18;
44
- const img = new Image();
45
- img.onload = () => {
46
- ctx.clearRect(0, 0, W, H + BAR);
47
- const scale = Math.max(W / img.width, H / img.height);
48
- const dw = img.width * scale;
49
- const dh = img.height * scale;
50
- ctx.drawImage(img, (W - dw) / 2, (H - dh) / 2, dw, dh);
51
- ctx.fillStyle = "rgba(0,0,0,0.65)";
52
- ctx.fillRect(0, H, W, BAR);
53
- ctx.fillStyle = "#fff";
54
- ctx.font = "11px sans-serif";
55
- ctx.textAlign = "center";
56
- ctx.textBaseline = "middle";
57
- ctx.fillText(name, W / 2, H + BAR / 2);
58
- };
59
- img.src = imageUrl;
60
- }, [imageUrl, name]);
61
-
62
- return (
63
- <canvas
64
- ref={canvasRef}
65
- width={160}
66
- height={178}
67
- className="rounded-xl border border-ink-200"
68
- />
69
- );
70
- }
71
-
72
- export default function CharactersTab({ projectId }: Props) {
73
- const characterStore = useCharacterStore();
74
- const gen = useGenerationStore();
75
-
76
- const fileInputRef = useRef<HTMLInputElement>(null);
77
- const editFileInputRef = useRef<HTMLInputElement>(null);
78
-
79
- const [name, setName] = useState("");
80
- const [pendingImage, setPendingImage] = useState<string | null>(null);
81
- const [editingId, setEditingId] = useState<string | null>(null);
82
- const [editName, setEditName] = useState("");
83
- const [editImage, setEditImage] = useState<string | null>(null);
84
- const [uploading, setUploading] = useState(false);
85
- const [error, setError] = useState<string | null>(null);
86
- const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
87
- const [showImageModal, setShowImageModal] = useState(false);
88
- const [previewCharacter, setPreviewCharacter] = useState<Character | null>(
89
- null,
90
- );
91
-
92
- useEffect(() => {
93
- characterStore.fetchCharacters(projectId);
94
- gen.fetchProjectImages(projectId);
95
- // eslint-disable-next-line react-hooks/exhaustive-deps
96
- }, [projectId]);
97
-
98
- // Close any open modal with the Escape key
99
- useEffect(() => {
100
- const onKeyDown = (e: KeyboardEvent) => {
101
- if (e.key !== "Escape") return;
102
- setShowImageModal(false);
103
- setPreviewCharacter(null);
104
- setConfirmDeleteId(null);
105
- };
106
- window.addEventListener("keydown", onKeyDown);
107
- return () => window.removeEventListener("keydown", onKeyDown);
108
- }, []);
109
-
110
- const editing =
111
- characterStore.characters.find((c) => c.id === editingId) ?? null;
112
-
113
- const charUrl = (filename: string): string | null => {
114
- const img = gen.projectImages.find((i) => i.filename === filename);
115
- if (!img) return null;
116
- return img.url.startsWith("http") ? img.url : `${API_BASE}${img.url}`;
117
- };
118
-
119
- const sheetItems = characterStore.characters
120
- .map((c) => {
121
- const url = charUrl(c.filename);
122
- return url ? { id: c.id, name: c.name, url } : null;
123
- })
124
- .filter(
125
- (x): x is { id: string; name: string; url: string } => x !== null,
126
- );
127
-
128
- const uploadImage = async (dataUrl: string): Promise<string | null> => {
129
- const res = await fetch(`${API_BASE}/api/upload/image`, {
130
- method: "POST",
131
- headers: { "Content-Type": "application/json" },
132
- body: JSON.stringify({
133
- image: dataUrl,
134
- filename: `character-${Date.now()}.png`,
135
- projectId,
136
- }),
137
- });
138
- if (!res.ok) throw new Error(await res.text());
139
- const data = await res.json();
140
- return data.filename as string;
141
- };
142
-
143
- const finishCharacter = async (dataUrl: string) => {
144
- setUploading(true);
145
- setError(null);
146
- try {
147
- const filename = await uploadImage(dataUrl);
148
- if (!filename) throw new Error("Upload failed");
149
- await characterStore.createCharacter(projectId, name.trim(), filename);
150
- await gen.fetchProjectImages(projectId);
151
- setName("");
152
- setPendingImage(null);
153
- } catch (e) {
154
- setError(String(e));
155
- } finally {
156
- setUploading(false);
157
- }
158
- };
159
-
160
- const startEdit = (c: Character) => {
161
- setEditingId(c.id);
162
- setEditName(c.name);
163
- setEditImage(null);
164
- setPendingImage(null);
165
- setError(null);
166
- };
167
-
168
- const cancelEdit = () => {
169
- setEditingId(null);
170
- setEditImage(null);
171
- setError(null);
172
- };
173
-
174
- const saveEdit = async () => {
175
- if (!editingId) return;
176
- await characterStore.updateCharacter(editingId, projectId, {
177
- name: editName.trim(),
178
- });
179
- setEditingId(null);
180
- setEditImage(null);
181
- };
182
-
183
- const handleEditFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
184
- const file = e.target.files?.[0];
185
- if (!file) return;
186
- const reader = new FileReader();
187
- reader.onload = () => {
188
- setEditImage(reader.result as string);
189
- setError(null);
190
- };
191
- reader.readAsDataURL(file);
192
- e.target.value = "";
193
- };
194
-
195
- const handleCropApply = async (dataUrl: string) => {
196
- if (editImage) {
197
- setUploading(true);
198
- setError(null);
199
- try {
200
- const filename = await uploadImage(dataUrl);
201
- if (!filename) throw new Error("Upload failed");
202
- if (editingId) {
203
- await characterStore.updateCharacter(editingId, projectId, {
204
- filename,
205
- });
206
- }
207
- await gen.fetchProjectImages(projectId);
208
- setEditImage(null);
209
- } catch (e) {
210
- setError(String(e));
211
- } finally {
212
- setUploading(false);
213
- }
214
- } else {
215
- await finishCharacter(dataUrl);
216
- }
217
- };
218
-
219
- const handleCropCancel = () => {
220
- setPendingImage(null);
221
- setEditImage(null);
222
- };
223
-
224
- const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
225
- const file = e.target.files?.[0];
226
- if (!file) return;
227
- const reader = new FileReader();
228
- reader.onload = () => {
229
- setPendingImage(reader.result as string);
230
- setError(null);
231
- };
232
- reader.readAsDataURL(file);
233
- e.target.value = "";
234
- };
235
-
236
- const handleDelete = async () => {
237
- if (!confirmDeleteId) return;
238
- await characterStore.deleteCharacter(confirmDeleteId, projectId);
239
- setConfirmDeleteId(null);
240
- if (editingId === confirmDeleteId) {
241
- setEditingId(null);
242
- setEditImage(null);
243
- }
244
- };
245
-
246
- const selectProjectImage = async (img: ProjectImage) => {
247
- setShowImageModal(false);
248
- setError(null);
249
- try {
250
- const dataUrl = await urlToDataUrl(img.url);
251
- setPendingImage(dataUrl);
252
- } catch (e) {
253
- setError(String(e));
254
- }
255
- };
256
-
257
- // ========== SVG Icons ==========
258
-
259
- const CharacterIcon = (
260
- <svg
261
- width="18"
262
- height="18"
263
- viewBox="0 0 24 24"
264
- fill="none"
265
- stroke="currentColor"
266
- strokeWidth="2"
267
- strokeLinecap="round"
268
- strokeLinejoin="round"
269
- >
270
- <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
271
- <circle cx="12" cy="7" r="4" />
272
- </svg>
273
- );
274
-
275
- const UploadIcon = (
276
- <svg
277
- width="14"
278
- height="14"
279
- viewBox="0 0 24 24"
280
- fill="none"
281
- stroke="currentColor"
282
- strokeWidth="2"
283
- strokeLinecap="round"
284
- strokeLinejoin="round"
285
- >
286
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
287
- <polyline points="17 8 12 3 7 8" />
288
- <line x1="12" y1="3" x2="12" y2="15" />
289
- </svg>
290
- );
291
-
292
- const PencilIcon = (
293
- <svg
294
- width="12"
295
- height="12"
296
- viewBox="0 0 24 24"
297
- fill="none"
298
- stroke="currentColor"
299
- strokeWidth="2"
300
- strokeLinecap="round"
301
- strokeLinejoin="round"
302
- >
303
- <path d="M12 20h9" />
304
- <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
305
- </svg>
306
- );
307
-
308
- const TrashIcon = (
309
- <svg
310
- width="12"
311
- height="12"
312
- viewBox="0 0 24 24"
313
- fill="none"
314
- stroke="currentColor"
315
- strokeWidth="2"
316
- strokeLinecap="round"
317
- strokeLinejoin="round"
318
- >
319
- <polyline points="3 6 5 6 21 6" />
320
- <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
321
- </svg>
322
- );
323
-
324
- const ImagesIcon = (
325
- <svg
326
- width="14"
327
- height="14"
328
- viewBox="0 0 24 24"
329
- fill="none"
330
- stroke="currentColor"
331
- strokeWidth="2"
332
- strokeLinecap="round"
333
- strokeLinejoin="round"
334
- >
335
- <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
336
- <circle cx="8.5" cy="8.5" r="1.5" />
337
- <polyline points="21 15 16 10 5 21" />
338
- </svg>
339
- );
340
-
341
- const CloseIcon = (
342
- <svg
343
- width="16"
344
- height="16"
345
- viewBox="0 0 24 24"
346
- fill="none"
347
- stroke="currentColor"
348
- strokeWidth="2"
349
- strokeLinecap="round"
350
- strokeLinejoin="round"
351
- >
352
- <line x1="18" y1="6" x2="6" y2="18" />
353
- <line x1="6" y1="6" x2="18" y2="18" />
354
- </svg>
355
- );
356
-
357
- const MaximizeIcon = (
358
- <svg
359
- width="12"
360
- height="12"
361
- viewBox="0 0 24 24"
362
- fill="none"
363
- stroke="currentColor"
364
- strokeWidth="2"
365
- strokeLinecap="round"
366
- strokeLinejoin="round"
367
- >
368
- <polyline points="15 3 21 3 21 9" />
369
- <polyline points="9 21 3 21 3 15" />
370
- <line x1="21" y1="3" x2="14" y2="10" />
371
- <line x1="3" y1="21" x2="10" y2="14" />
372
- </svg>
373
- );
374
-
375
- return (
376
- <div className="flex flex-col gap-7">
377
- <div className="flex items-center gap-2">
378
- <span className="text-tiffany-600">{CharacterIcon}</span>
379
- <h2 className="text-base font-semibold text-ink-900">Characters</h2>
380
- </div>
381
-
382
- {/* Create / Edit panel */}
383
- {!editing ? (
384
- <div className="border border-ink-200 rounded-2xl p-5 flex flex-col gap-3 bg-ink-100/60">
385
- <input
386
- type="text"
387
- value={name}
388
- onChange={(e) => setName(e.target.value)}
389
- placeholder="Character name"
390
- className="px-3 py-2 bg-white border border-ink-200 rounded-xl text-sm text-ink-900 placeholder-ink-500 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30"
391
- />
392
- <div className="flex items-center gap-2">
393
- <input
394
- ref={fileInputRef}
395
- type="file"
396
- accept="image/*"
397
- onChange={handleFileChange}
398
- className="hidden"
399
- />
400
- <button
401
- onClick={() => fileInputRef.current?.click()}
402
- className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 transition-colors"
403
- >
404
- {UploadIcon}
405
- Upload Image
406
- </button>
407
- <button
408
- onClick={() => setShowImageModal(true)}
409
- className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 transition-colors"
410
- >
411
- {ImagesIcon}
412
- Select Project Image
413
- </button>
414
- </div>
415
- </div>
416
- ) : (
417
- <div className="border border-ink-200 rounded-2xl p-5 flex flex-col gap-3 bg-ink-100/60">
418
- <input
419
- type="text"
420
- value={editName}
421
- onChange={(e) => setEditName(e.target.value)}
422
- placeholder="Character name"
423
- className="px-3 py-2 bg-white border border-ink-200 rounded-xl text-sm text-ink-900 placeholder-ink-500 focus:outline-none focus:border-tiffany-500 focus:ring-2 focus:ring-tiffany-500/30"
424
- />
425
- {editing && charUrl(editing.filename) && (
426
- <img
427
- src={charUrl(editing.filename)!}
428
- alt={editing.name}
429
- className="w-16 h-16 rounded-xl object-cover border border-ink-200"
430
- />
431
- )}
432
- <div className="flex items-center gap-2">
433
- <input
434
- ref={editFileInputRef}
435
- type="file"
436
- accept="image/*"
437
- onChange={handleEditFileChange}
438
- className="hidden"
439
- />
440
- <button
441
- onClick={() => editFileInputRef.current?.click()}
442
- className="flex items-center gap-1.5 px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 bg-white text-ink-600 hover:border-ink-300 transition-colors"
443
- >
444
- {UploadIcon}
445
- Replace Image
446
- </button>
447
- <button
448
- onClick={saveEdit}
449
- className="px-3 py-2 text-xs font-medium rounded-xl bg-tiffany-500 hover:bg-tiffany-600 text-ink-950 transition-colors"
450
- >
451
- Save
452
- </button>
453
- <button
454
- onClick={cancelEdit}
455
- className="px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 transition-colors"
456
- >
457
- Cancel
458
- </button>
459
- </div>
460
- </div>
461
- )}
462
-
463
- {uploading && (
464
- <p className="text-xs text-ink-600 italic">Saving character...</p>
465
- )}
466
- {error && <p className="text-xs text-red-600">{error}</p>}
467
-
468
- {/* Crop tool (create or edit) */}
469
- {(pendingImage || editImage) && (
470
- <CropTool
471
- image={pendingImage ?? editImage!}
472
- onCancel={handleCropCancel}
473
- onApply={handleCropApply}
474
- />
475
- )}
476
-
477
- {/* Character grid */}
478
- {characterStore.loading ? (
479
- <p className="text-xs text-ink-500 italic text-center py-8">
480
- Loading characters...
481
- </p>
482
- ) : characterStore.characters.length === 0 ? (
483
- <p className="text-xs text-ink-500 italic text-center py-8 border border-dashed border-ink-200 rounded-2xl">
484
- No characters yet. Upload one.
485
- </p>
486
- ) : (
487
- <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
488
- {characterStore.characters.map((c) => {
489
- const url = charUrl(c.filename);
490
- return (
491
- <div
492
- key={c.id}
493
- className="relative group cursor-pointer"
494
- onClick={() => setPreviewCharacter(c)}
495
- title="Preview image"
496
- >
497
- {url ? (
498
- <CharacterCard imageUrl={url} name={c.name} />
499
- ) : (
500
- <div className="w-[160px] h-[178px] rounded-xl border border-ink-200 bg-ink-100 flex items-center justify-center text-ink-500">
501
- {CharacterIcon}
502
- </div>
503
- )}
504
- <div
505
- className="absolute top-1 right-1 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
506
- onClick={(e) => e.stopPropagation()}
507
- >
508
- <button
509
- onClick={() => setPreviewCharacter(c)}
510
- className="w-6 h-6 rounded-full bg-black/50 text-ink-950 flex items-center justify-center hover:bg-tiffany-600 transition-colors"
511
- title="Preview image"
512
- >
513
- {MaximizeIcon}
514
- </button>
515
- <button
516
- onClick={() => startEdit(c)}
517
- className="w-6 h-6 rounded-full bg-black/50 text-ink-950 flex items-center justify-center hover:bg-tiffany-600 transition-colors"
518
- title="Edit character"
519
- >
520
- {PencilIcon}
521
- </button>
522
- <button
523
- onClick={() => setConfirmDeleteId(c.id)}
524
- className="w-6 h-6 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-red-600 transition-colors"
525
- title="Delete character"
526
- >
527
- {TrashIcon}
528
- </button>
529
- </div>
530
- </div>
531
- );
532
- })}
533
- </div>
534
- )}
535
-
536
- {/* Character sheet (canvas 2d grid) */}
537
- <div className="flex flex-col gap-3">
538
- <div className="flex items-center gap-2">
539
- <span className="text-tiffany-600">{ImagesIcon}</span>
540
- <h3 className="text-sm font-semibold text-ink-900">
541
- Character Sheet
542
- </h3>
543
- </div>
544
- <CharacterSheet items={sheetItems} projectId={projectId} />
545
- </div>
546
-
547
- {/* Select project image modal */}
548
- {showImageModal && (
549
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
550
- <div className="bg-white rounded-3xl shadow-card p-6 w-[480px] max-w-full max-h-[80vh] flex flex-col">
551
- <div className="flex items-center justify-between mb-3">
552
- <h3 className="text-sm font-semibold text-ink-900">
553
- Select Project Image
554
- </h3>
555
- <button
556
- onClick={() => setShowImageModal(false)}
557
- className="w-6 h-6 rounded-full text-tiffany-600 hover:bg-ink-200 hover:text-ink-800 flex items-center justify-center transition-colors"
558
- title="Close"
559
- >
560
- {CloseIcon}
561
- </button>
562
- </div>
563
-
564
- {gen.projectImagesLoading ? (
565
- <p className="text-xs text-ink-500 italic text-center py-8">
566
- Loading images...
567
- </p>
568
- ) : gen.projectImages.length === 0 ? (
569
- <p className="text-xs text-ink-500 italic text-center py-8 border border-dashed border-ink-200 rounded-2xl">
570
- No project images yet. Upload or generate one first.
571
- </p>
572
- ) : (
573
- <div className="grid grid-cols-3 gap-2 overflow-y-auto pr-1">
574
- {gen.projectImages.map((img) => (
575
- <button
576
- key={`${img.source}-${img.filename}`}
577
- onClick={() => selectProjectImage(img)}
578
- className="relative rounded-xl border border-ink-200 hover:border-tiffany-500 overflow-hidden transition-colors"
579
- title={img.filename}
580
- >
581
- <img
582
- src={img.url}
583
- alt={img.filename}
584
- className="aspect-square object-cover w-full"
585
- />
586
- <span className="absolute bottom-0 left-0 right-0 bg-white/80 backdrop-blur-sm px-1 py-0.5 text-[10px] text-ink-700 truncate text-center">
587
- {img.filename}
588
- </span>
589
- </button>
590
- ))}
591
- </div>
592
- )}
593
- </div>
594
- </div>
595
- )}
596
-
597
- {/* Character image preview modal */}
598
- {previewCharacter && (
599
- <div
600
- className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-5"
601
- onClick={() => setPreviewCharacter(null)}
602
- >
603
- <div
604
- className="bg-white rounded-3xl shadow-card p-6 w-full max-w-2xl max-h-[85vh] flex flex-col"
605
- onClick={(e) => e.stopPropagation()}
606
- >
607
- <div className="flex items-center justify-between mb-3">
608
- <h3 className="text-sm font-semibold text-ink-900">
609
- {previewCharacter.name}
610
- </h3>
611
- <button
612
- onClick={() => setPreviewCharacter(null)}
613
- className="w-6 h-6 rounded-full text-tiffany-600 hover:bg-ink-200 hover:text-ink-800 flex items-center justify-center transition-colors"
614
- title="Close"
615
- >
616
- {CloseIcon}
617
- </button>
618
- </div>
619
-
620
- <div className="flex-1 min-h-0 flex items-center justify-center bg-ink-50 rounded-2xl overflow-hidden">
621
- {charUrl(previewCharacter.filename) ? (
622
- <img
623
- src={charUrl(previewCharacter.filename)!}
624
- alt={previewCharacter.name}
625
- className="max-h-[65vh] max-w-full object-contain"
626
- />
627
- ) : (
628
- <div className="text-ink-500 py-16">{CharacterIcon}</div>
629
- )}
630
- </div>
631
- </div>
632
- </div>
633
- )}
634
-
635
- {/* Delete confirmation modal */}
636
- {confirmDeleteId && (
637
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
638
- <div className="bg-white rounded-3xl shadow-card p-6 w-80">
639
- <h3 className="text-sm font-semibold text-ink-900 mb-2">
640
- Delete Character
641
- </h3>
642
- <p className="text-xs text-ink-600 mb-4">
643
- Are you sure you want to delete this character?
644
- </p>
645
- <div className="flex justify-end gap-2">
646
- <button
647
- onClick={() => setConfirmDeleteId(null)}
648
- className="px-3 py-2 text-xs font-medium rounded-xl border border-ink-200 text-ink-600 hover:bg-ink-100 transition-colors"
649
- >
650
- Cancel
651
- </button>
652
- <button
653
- onClick={handleDelete}
654
- className="px-3 py-2 text-xs font-medium rounded-xl bg-red-500 hover:bg-red-600 text-white transition-colors"
655
- >
656
- Delete
657
- </button>
658
- </div>
659
- </div>
660
- </div>
661
- )}
662
- </div>
663
- );
664
- }