@gallop.software/studio 0.1.24 → 0.1.26

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.
@@ -62,7 +62,7 @@ import { css as css2, keyframes as keyframes2 } from "@emotion/react";
62
62
 
63
63
  // src/components/StudioModal.tsx
64
64
  import { css, keyframes } from "@emotion/react";
65
- import { jsx, jsxs } from "@emotion/react/jsx-runtime";
65
+ import { Fragment, jsx, jsxs } from "@emotion/react/jsx-runtime";
66
66
  var fadeIn = keyframes`
67
67
  from { opacity: 0; }
68
68
  to { opacity: 1; }
@@ -204,9 +204,113 @@ function AlertModal({
204
204
  /* @__PURE__ */ jsx("div", { css: styles.footer, children: /* @__PURE__ */ jsx("button", { css: [styles.btn, styles.btnConfirm], onClick: onClose, children: buttonLabel }) })
205
205
  ] }) });
206
206
  }
207
+ var progressStyles = {
208
+ progressContainer: css`
209
+ margin-top: 16px;
210
+ `,
211
+ progressBar: css`
212
+ width: 100%;
213
+ height: 8px;
214
+ background-color: ${colors.background};
215
+ border-radius: 4px;
216
+ overflow: hidden;
217
+ margin-bottom: 12px;
218
+ `,
219
+ progressFill: css`
220
+ height: 100%;
221
+ background: linear-gradient(90deg, ${colors.primary}, ${colors.primaryHover});
222
+ border-radius: 4px;
223
+ transition: width 0.3s ease;
224
+ `,
225
+ progressText: css`
226
+ font-size: ${fontSize.sm};
227
+ color: ${colors.textSecondary};
228
+ margin: 0;
229
+ display: flex;
230
+ justify-content: space-between;
231
+ align-items: center;
232
+ `,
233
+ currentFile: css`
234
+ font-size: ${fontSize.xs};
235
+ color: ${colors.textMuted};
236
+ margin: 8px 0 0;
237
+ white-space: nowrap;
238
+ overflow: hidden;
239
+ text-overflow: ellipsis;
240
+ `
241
+ };
242
+ function ProgressModal({
243
+ title,
244
+ progress,
245
+ onClose,
246
+ onStop
247
+ }) {
248
+ const isComplete = progress.status === "complete";
249
+ const isError = progress.status === "error";
250
+ const isStopped = progress.status === "stopped";
251
+ const canClose = isComplete || isError || isStopped;
252
+ const isRunning = !canClose;
253
+ return /* @__PURE__ */ jsx("div", { css: styles.overlay, children: /* @__PURE__ */ jsxs("div", { css: styles.modal, onClick: (e) => e.stopPropagation(), children: [
254
+ /* @__PURE__ */ jsx("div", { css: styles.header, children: /* @__PURE__ */ jsx("h3", { css: styles.title, children: title }) }),
255
+ /* @__PURE__ */ jsx("div", { css: styles.body, children: isError ? /* @__PURE__ */ jsx("p", { css: styles.message, children: progress.message || "An error occurred" }) : isStopped ? /* @__PURE__ */ jsxs("p", { css: styles.message, children: [
256
+ "Processing stopped. Processed ",
257
+ progress.processed ?? progress.current,
258
+ " image",
259
+ (progress.processed ?? progress.current) !== 1 ? "s" : "",
260
+ " before stopping."
261
+ ] }) : isComplete ? /* @__PURE__ */ jsxs("p", { css: styles.message, children: [
262
+ "Processed ",
263
+ progress.processed,
264
+ " image",
265
+ progress.processed !== 1 ? "s" : "",
266
+ ".",
267
+ progress.orphansRemoved !== void 0 && progress.orphansRemoved > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
268
+ " Removed ",
269
+ progress.orphansRemoved,
270
+ " orphaned thumbnail",
271
+ progress.orphansRemoved !== 1 ? "s" : "",
272
+ "."
273
+ ] }) : null,
274
+ progress.errors !== void 0 && progress.errors > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
275
+ " ",
276
+ progress.errors,
277
+ " error",
278
+ progress.errors !== 1 ? "s" : "",
279
+ " occurred."
280
+ ] }) : null
281
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
282
+ /* @__PURE__ */ jsx("p", { css: styles.message, children: progress.status === "cleanup" ? "Cleaning up orphaned files..." : `Processing images...` }),
283
+ /* @__PURE__ */ jsxs("div", { css: progressStyles.progressContainer, children: [
284
+ /* @__PURE__ */ jsx("div", { css: progressStyles.progressBar, children: /* @__PURE__ */ jsx(
285
+ "div",
286
+ {
287
+ css: progressStyles.progressFill,
288
+ style: { width: `${progress.percent}%` }
289
+ }
290
+ ) }),
291
+ /* @__PURE__ */ jsxs("div", { css: progressStyles.progressText, children: [
292
+ /* @__PURE__ */ jsxs("span", { children: [
293
+ progress.current,
294
+ " of ",
295
+ progress.total
296
+ ] }),
297
+ /* @__PURE__ */ jsxs("span", { children: [
298
+ progress.percent,
299
+ "%"
300
+ ] })
301
+ ] }),
302
+ progress.currentFile && /* @__PURE__ */ jsx("p", { css: progressStyles.currentFile, title: progress.currentFile, children: progress.currentFile })
303
+ ] })
304
+ ] }) }),
305
+ /* @__PURE__ */ jsxs("div", { css: styles.footer, children: [
306
+ isRunning && onStop && /* @__PURE__ */ jsx("button", { css: [styles.btn, styles.btnDanger], onClick: onStop, children: "Stop" }),
307
+ canClose && /* @__PURE__ */ jsx("button", { css: [styles.btn, styles.btnConfirm], onClick: onClose, children: "Done" })
308
+ ] })
309
+ ] }) });
310
+ }
207
311
 
208
312
  // src/components/StudioToolbar.tsx
209
- import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "@emotion/react/jsx-runtime";
313
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "@emotion/react/jsx-runtime";
210
314
  var btnHeight = "36px";
211
315
  var spin = keyframes2`
212
316
  to { transform: rotate(360deg); }
@@ -346,11 +450,19 @@ var styles2 = {
346
450
  function StudioToolbar() {
347
451
  const { selectedItems, viewMode, setViewMode, clearSelection, currentPath, triggerRefresh, focusedItem } = useStudio();
348
452
  const fileInputRef = useRef(null);
453
+ const abortControllerRef = useRef(null);
349
454
  const [uploading, setUploading] = useState(false);
350
455
  const [refreshing, setRefreshing] = useState(false);
351
456
  const [processing, setProcessing] = useState(false);
352
457
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
353
458
  const [showProcessConfirm, setShowProcessConfirm] = useState(false);
459
+ const [showProgress, setShowProgress] = useState(false);
460
+ const [progressState, setProgressState] = useState({
461
+ current: 0,
462
+ total: 0,
463
+ percent: 0,
464
+ status: "processing"
465
+ });
354
466
  const [processCount, setProcessCount] = useState(0);
355
467
  const [processMode, setProcessMode] = useState("all");
356
468
  const [alertMessage, setAlertMessage] = useState(null);
@@ -425,12 +537,12 @@ function StudioToolbar() {
425
537
  setShowProcessConfirm(true);
426
538
  } else {
427
539
  try {
428
- const response = await fetch("/api/studio/count-unprocessed");
540
+ const response = await fetch("/api/studio/count-images");
429
541
  const data = await response.json();
430
542
  if (data.count === 0) {
431
543
  setAlertMessage({
432
- title: "All Images Processed",
433
- message: "All images in the public folder have already been processed."
544
+ title: "No Images Found",
545
+ message: "No images found in the public folder to process."
434
546
  });
435
547
  return;
436
548
  }
@@ -438,10 +550,10 @@ function StudioToolbar() {
438
550
  setProcessMode("all");
439
551
  setShowProcessConfirm(true);
440
552
  } catch (error) {
441
- console.error("Failed to count unprocessed images:", error);
553
+ console.error("Failed to count images:", error);
442
554
  setAlertMessage({
443
555
  title: "Error",
444
- message: "Failed to count unprocessed images."
556
+ message: "Failed to count images."
445
557
  });
446
558
  }
447
559
  }
@@ -449,30 +561,100 @@ function StudioToolbar() {
449
561
  const handleProcessConfirm = useCallback(async () => {
450
562
  setShowProcessConfirm(false);
451
563
  setProcessing(true);
564
+ abortControllerRef.current = new AbortController();
565
+ const signal = abortControllerRef.current.signal;
452
566
  try {
453
567
  if (processMode === "all") {
568
+ setShowProgress(true);
569
+ setProgressState({
570
+ current: 0,
571
+ total: processCount,
572
+ percent: 0,
573
+ status: "processing"
574
+ });
454
575
  const response = await fetch("/api/studio/process-all", {
455
- method: "POST"
576
+ method: "POST",
577
+ signal
456
578
  });
457
- const data = await response.json();
458
- if (response.ok) {
459
- const message = [
460
- `Processed ${data.processed?.length || 0} images.`,
461
- data.orphansRemoved?.length > 0 ? `Removed ${data.orphansRemoved.length} orphaned thumbnails.` : "",
462
- data.errors?.length > 0 ? `${data.errors.length} errors occurred.` : ""
463
- ].filter(Boolean).join(" ");
464
- setAlertMessage({
465
- title: "Processing Complete",
466
- message
467
- });
468
- triggerRefresh();
469
- } else {
470
- setAlertMessage({
471
- title: "Processing Failed",
472
- message: data.error || "Unknown error"
473
- });
579
+ if (!response.body) {
580
+ throw new Error("No response body");
581
+ }
582
+ const reader = response.body.getReader();
583
+ const decoder = new TextDecoder();
584
+ try {
585
+ while (true) {
586
+ const { done, value } = await reader.read();
587
+ if (done) break;
588
+ if (signal.aborted) {
589
+ reader.cancel();
590
+ break;
591
+ }
592
+ const text = decoder.decode(value);
593
+ const lines = text.split("\n\n").filter((line) => line.startsWith("data: "));
594
+ for (const line of lines) {
595
+ try {
596
+ const data = JSON.parse(line.replace("data: ", ""));
597
+ if (data.type === "start") {
598
+ setProgressState((prev) => ({
599
+ ...prev,
600
+ total: data.total
601
+ }));
602
+ } else if (data.type === "progress") {
603
+ setProgressState({
604
+ current: data.current,
605
+ total: data.total,
606
+ percent: data.percent,
607
+ currentFile: data.currentFile,
608
+ status: "processing"
609
+ });
610
+ } else if (data.type === "cleanup") {
611
+ setProgressState((prev) => ({
612
+ ...prev,
613
+ status: "cleanup",
614
+ currentFile: void 0
615
+ }));
616
+ } else if (data.type === "complete") {
617
+ setProgressState({
618
+ current: data.processed,
619
+ total: data.processed,
620
+ percent: 100,
621
+ status: "complete",
622
+ processed: data.processed,
623
+ orphansRemoved: data.orphansRemoved,
624
+ errors: data.errors
625
+ });
626
+ triggerRefresh();
627
+ } else if (data.type === "error") {
628
+ setProgressState((prev) => ({
629
+ ...prev,
630
+ status: "error",
631
+ message: data.message
632
+ }));
633
+ }
634
+ } catch {
635
+ }
636
+ }
637
+ }
638
+ } catch (err) {
639
+ if (signal.aborted) {
640
+ setProgressState((prev) => ({
641
+ ...prev,
642
+ status: "stopped",
643
+ processed: prev.current
644
+ }));
645
+ triggerRefresh();
646
+ } else {
647
+ throw err;
648
+ }
474
649
  }
475
650
  } else {
651
+ setShowProgress(true);
652
+ setProgressState({
653
+ current: 0,
654
+ total: processCount,
655
+ percent: 0,
656
+ status: "processing"
657
+ });
476
658
  const selectedImageKeys = Array.from(selectedItems).filter((p) => {
477
659
  const ext = p.split(".").pop()?.toLowerCase() || "";
478
660
  return ["jpg", "jpeg", "png", "gif", "webp", "svg", "ico", "bmp", "tiff", "tif"].includes(ext);
@@ -480,33 +662,59 @@ function StudioToolbar() {
480
662
  const response = await fetch("/api/studio/reprocess", {
481
663
  method: "POST",
482
664
  headers: { "Content-Type": "application/json" },
483
- body: JSON.stringify({ imageKeys: selectedImageKeys })
665
+ body: JSON.stringify({ imageKeys: selectedImageKeys }),
666
+ signal
484
667
  });
485
668
  const data = await response.json();
486
669
  if (response.ok) {
487
- setAlertMessage({
488
- title: "Processing Complete",
489
- message: `Processed ${data.processed?.length || 0} images.${data.errors?.length > 0 ? ` ${data.errors.length} errors occurred.` : ""}`
670
+ setProgressState({
671
+ current: data.processed?.length || 0,
672
+ total: data.processed?.length || 0,
673
+ percent: 100,
674
+ status: "complete",
675
+ processed: data.processed?.length || 0,
676
+ errors: data.errors?.length || 0
490
677
  });
491
678
  clearSelection();
492
679
  triggerRefresh();
493
680
  } else {
494
- setAlertMessage({
495
- title: "Processing Failed",
681
+ setProgressState({
682
+ current: 0,
683
+ total: 0,
684
+ percent: 0,
685
+ status: "error",
496
686
  message: data.error || "Unknown error"
497
687
  });
498
688
  }
499
689
  }
500
690
  } catch (error) {
501
- console.error("Processing error:", error);
502
- setAlertMessage({
503
- title: "Processing Failed",
504
- message: "Processing failed. Check console for details."
505
- });
691
+ if (signal.aborted) {
692
+ setProgressState((prev) => ({
693
+ ...prev,
694
+ status: "stopped",
695
+ processed: prev.current
696
+ }));
697
+ triggerRefresh();
698
+ } else {
699
+ console.error("Processing error:", error);
700
+ setProgressState({
701
+ current: 0,
702
+ total: 0,
703
+ percent: 0,
704
+ status: "error",
705
+ message: "Processing failed. Check console for details."
706
+ });
707
+ }
506
708
  } finally {
507
709
  setProcessing(false);
710
+ abortControllerRef.current = null;
711
+ }
712
+ }, [processMode, processCount, selectedItems, clearSelection, triggerRefresh]);
713
+ const handleStopProcessing = useCallback(() => {
714
+ if (abortControllerRef.current) {
715
+ abortControllerRef.current.abort();
508
716
  }
509
- }, [processMode, selectedItems, clearSelection, triggerRefresh]);
717
+ }, []);
510
718
  const handleDeleteClick = useCallback(() => {
511
719
  if (selectedItems.size === 0) return;
512
720
  setShowDeleteConfirm(true);
@@ -547,7 +755,7 @@ function StudioToolbar() {
547
755
  if (focusedItem) {
548
756
  return null;
549
757
  }
550
- return /* @__PURE__ */ jsxs2(Fragment, { children: [
758
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
551
759
  showDeleteConfirm && /* @__PURE__ */ jsx2(
552
760
  ConfirmModal,
553
761
  {
@@ -563,12 +771,29 @@ function StudioToolbar() {
563
771
  ConfirmModal,
564
772
  {
565
773
  title: "Process Images",
566
- message: processMode === "all" ? `Found ${processCount} unprocessed image${processCount !== 1 ? "s" : ""} in the public folder. This will generate thumbnails and remove any orphaned files from the images folder.` : `Process ${processCount} selected image${processCount !== 1 ? "s" : ""}? This will regenerate thumbnails for these files.`,
774
+ message: processMode === "all" ? `Found ${processCount} image${processCount !== 1 ? "s" : ""} in the public folder. This will regenerate all thumbnails and remove any orphaned files from the images folder.` : `Process ${processCount} selected image${processCount !== 1 ? "s" : ""}? This will regenerate thumbnails for these files.`,
567
775
  confirmLabel: processing ? "Processing..." : "Process",
568
776
  onConfirm: handleProcessConfirm,
569
777
  onCancel: () => setShowProcessConfirm(false)
570
778
  }
571
779
  ),
780
+ showProgress && /* @__PURE__ */ jsx2(
781
+ ProgressModal,
782
+ {
783
+ title: "Processing Images",
784
+ progress: progressState,
785
+ onStop: handleStopProcessing,
786
+ onClose: () => {
787
+ setShowProgress(false);
788
+ setProgressState({
789
+ current: 0,
790
+ total: 0,
791
+ percent: 0,
792
+ status: "processing"
793
+ });
794
+ }
795
+ }
796
+ ),
572
797
  alertMessage && /* @__PURE__ */ jsx2(
573
798
  AlertModal,
574
799
  {
@@ -1434,7 +1659,7 @@ function formatFileSize2(bytes) {
1434
1659
  // src/components/StudioDetailView.tsx
1435
1660
  import { useState as useState4 } from "react";
1436
1661
  import { css as css5 } from "@emotion/react";
1437
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "@emotion/react/jsx-runtime";
1662
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "@emotion/react/jsx-runtime";
1438
1663
  var IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico", ".bmp", ".tiff", ".tif"];
1439
1664
  var VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
1440
1665
  function isImageFile(filename) {
@@ -1675,7 +1900,7 @@ function StudioDetailView() {
1675
1900
  /* @__PURE__ */ jsx5("p", { css: styles5.fileName, children: focusedItem.name })
1676
1901
  ] });
1677
1902
  };
1678
- return /* @__PURE__ */ jsxs5(Fragment2, { children: [
1903
+ return /* @__PURE__ */ jsxs5(Fragment3, { children: [
1679
1904
  showDeleteConfirm && /* @__PURE__ */ jsx5(
1680
1905
  ConfirmModal,
1681
1906
  {
@@ -1757,7 +1982,7 @@ function formatFileSize3(bytes) {
1757
1982
  // src/components/StudioSettings.tsx
1758
1983
  import { useState as useState5 } from "react";
1759
1984
  import { css as css6 } from "@emotion/react";
1760
- import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs6 } from "@emotion/react/jsx-runtime";
1985
+ import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs6 } from "@emotion/react/jsx-runtime";
1761
1986
  var btnHeight2 = "36px";
1762
1987
  var styles6 = {
1763
1988
  btn: css6`
@@ -1941,7 +2166,7 @@ var styles6 = {
1941
2166
  };
1942
2167
  function StudioSettings() {
1943
2168
  const [isOpen, setIsOpen] = useState5(false);
1944
- return /* @__PURE__ */ jsxs6(Fragment3, { children: [
2169
+ return /* @__PURE__ */ jsxs6(Fragment4, { children: [
1945
2170
  /* @__PURE__ */ jsx6("button", { css: styles6.btn, onClick: () => setIsOpen(true), "aria-label": "Settings", children: /* @__PURE__ */ jsxs6(
1946
2171
  "svg",
1947
2172
  {
@@ -2222,4 +2447,4 @@ export {
2222
2447
  StudioUI,
2223
2448
  StudioUI_default as default
2224
2449
  };
2225
- //# sourceMappingURL=StudioUI-F2C4N66F.mjs.map
2450
+ //# sourceMappingURL=StudioUI-VH2C7QCI.mjs.map