@gallop.software/studio 0.1.23 → 0.1.25

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,101 @@ 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
+ }) {
247
+ const isComplete = progress.status === "complete";
248
+ const isError = progress.status === "error";
249
+ const canClose = isComplete || isError;
250
+ return /* @__PURE__ */ jsx("div", { css: styles.overlay, onClick: canClose ? onClose : void 0, children: /* @__PURE__ */ jsxs("div", { css: styles.modal, onClick: (e) => e.stopPropagation(), children: [
251
+ /* @__PURE__ */ jsx("div", { css: styles.header, children: /* @__PURE__ */ jsx("h3", { css: styles.title, children: title }) }),
252
+ /* @__PURE__ */ jsx("div", { css: styles.body, children: isError ? /* @__PURE__ */ jsx("p", { css: styles.message, children: progress.message || "An error occurred" }) : isComplete ? /* @__PURE__ */ jsxs("p", { css: styles.message, children: [
253
+ "Processed ",
254
+ progress.processed,
255
+ " image",
256
+ progress.processed !== 1 ? "s" : "",
257
+ ".",
258
+ progress.orphansRemoved && progress.orphansRemoved > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
259
+ " Removed ",
260
+ progress.orphansRemoved,
261
+ " orphaned thumbnail",
262
+ progress.orphansRemoved !== 1 ? "s" : "",
263
+ "."
264
+ ] }),
265
+ progress.errors && progress.errors > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
266
+ " ",
267
+ progress.errors,
268
+ " error",
269
+ progress.errors !== 1 ? "s" : "",
270
+ " occurred."
271
+ ] })
272
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
273
+ /* @__PURE__ */ jsx("p", { css: styles.message, children: progress.status === "cleanup" ? "Cleaning up orphaned files..." : `Processing images...` }),
274
+ /* @__PURE__ */ jsxs("div", { css: progressStyles.progressContainer, children: [
275
+ /* @__PURE__ */ jsx("div", { css: progressStyles.progressBar, children: /* @__PURE__ */ jsx(
276
+ "div",
277
+ {
278
+ css: progressStyles.progressFill,
279
+ style: { width: `${progress.percent}%` }
280
+ }
281
+ ) }),
282
+ /* @__PURE__ */ jsxs("div", { css: progressStyles.progressText, children: [
283
+ /* @__PURE__ */ jsxs("span", { children: [
284
+ progress.current,
285
+ " of ",
286
+ progress.total
287
+ ] }),
288
+ /* @__PURE__ */ jsxs("span", { children: [
289
+ progress.percent,
290
+ "%"
291
+ ] })
292
+ ] }),
293
+ progress.currentFile && /* @__PURE__ */ jsx("p", { css: progressStyles.currentFile, title: progress.currentFile, children: progress.currentFile })
294
+ ] })
295
+ ] }) }),
296
+ canClose && /* @__PURE__ */ jsx("div", { css: styles.footer, children: /* @__PURE__ */ jsx("button", { css: [styles.btn, styles.btnConfirm], onClick: onClose, children: "Done" }) })
297
+ ] }) });
298
+ }
207
299
 
208
300
  // src/components/StudioToolbar.tsx
209
- import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "@emotion/react/jsx-runtime";
301
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "@emotion/react/jsx-runtime";
210
302
  var btnHeight = "36px";
211
303
  var spin = keyframes2`
212
304
  to { transform: rotate(360deg); }
@@ -348,7 +440,18 @@ function StudioToolbar() {
348
440
  const fileInputRef = useRef(null);
349
441
  const [uploading, setUploading] = useState(false);
350
442
  const [refreshing, setRefreshing] = useState(false);
443
+ const [processing, setProcessing] = useState(false);
351
444
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
445
+ const [showProcessConfirm, setShowProcessConfirm] = useState(false);
446
+ const [showProgress, setShowProgress] = useState(false);
447
+ const [progressState, setProgressState] = useState({
448
+ current: 0,
449
+ total: 0,
450
+ percent: 0,
451
+ status: "processing"
452
+ });
453
+ const [processCount, setProcessCount] = useState(0);
454
+ const [processMode, setProcessMode] = useState("all");
352
455
  const [alertMessage, setAlertMessage] = useState(null);
353
456
  const isInImagesFolder = currentPath === "public/images" || currentPath.startsWith("public/images/");
354
457
  const handleUpload = useCallback(() => {
@@ -402,9 +505,167 @@ function StudioToolbar() {
402
505
  }
403
506
  }
404
507
  }, [currentPath, triggerRefresh]);
405
- const handleReprocess = useCallback(() => {
406
- console.log("Reprocess clicked", selectedItems);
508
+ const handleProcessImages = useCallback(async () => {
509
+ const hasSelection2 = selectedItems.size > 0;
510
+ if (hasSelection2) {
511
+ const selectedImagePaths = Array.from(selectedItems).filter((p) => {
512
+ const ext = p.split(".").pop()?.toLowerCase() || "";
513
+ return ["jpg", "jpeg", "png", "gif", "webp", "svg", "ico", "bmp", "tiff", "tif"].includes(ext);
514
+ });
515
+ if (selectedImagePaths.length === 0) {
516
+ setAlertMessage({
517
+ title: "No Images Selected",
518
+ message: "Please select image files to process."
519
+ });
520
+ return;
521
+ }
522
+ setProcessCount(selectedImagePaths.length);
523
+ setProcessMode("selected");
524
+ setShowProcessConfirm(true);
525
+ } else {
526
+ try {
527
+ const response = await fetch("/api/studio/count-images");
528
+ const data = await response.json();
529
+ if (data.count === 0) {
530
+ setAlertMessage({
531
+ title: "No Images Found",
532
+ message: "No images found in the public folder to process."
533
+ });
534
+ return;
535
+ }
536
+ setProcessCount(data.count);
537
+ setProcessMode("all");
538
+ setShowProcessConfirm(true);
539
+ } catch (error) {
540
+ console.error("Failed to count images:", error);
541
+ setAlertMessage({
542
+ title: "Error",
543
+ message: "Failed to count images."
544
+ });
545
+ }
546
+ }
407
547
  }, [selectedItems]);
548
+ const handleProcessConfirm = useCallback(async () => {
549
+ setShowProcessConfirm(false);
550
+ setProcessing(true);
551
+ try {
552
+ if (processMode === "all") {
553
+ setShowProgress(true);
554
+ setProgressState({
555
+ current: 0,
556
+ total: processCount,
557
+ percent: 0,
558
+ status: "processing"
559
+ });
560
+ const response = await fetch("/api/studio/process-all", {
561
+ method: "POST"
562
+ });
563
+ if (!response.body) {
564
+ throw new Error("No response body");
565
+ }
566
+ const reader = response.body.getReader();
567
+ const decoder = new TextDecoder();
568
+ while (true) {
569
+ const { done, value } = await reader.read();
570
+ if (done) break;
571
+ const text = decoder.decode(value);
572
+ const lines = text.split("\n\n").filter((line) => line.startsWith("data: "));
573
+ for (const line of lines) {
574
+ try {
575
+ const data = JSON.parse(line.replace("data: ", ""));
576
+ if (data.type === "start") {
577
+ setProgressState((prev) => ({
578
+ ...prev,
579
+ total: data.total
580
+ }));
581
+ } else if (data.type === "progress") {
582
+ setProgressState({
583
+ current: data.current,
584
+ total: data.total,
585
+ percent: data.percent,
586
+ currentFile: data.currentFile,
587
+ status: "processing"
588
+ });
589
+ } else if (data.type === "cleanup") {
590
+ setProgressState((prev) => ({
591
+ ...prev,
592
+ status: "cleanup",
593
+ currentFile: void 0
594
+ }));
595
+ } else if (data.type === "complete") {
596
+ setProgressState({
597
+ current: data.processed,
598
+ total: data.processed,
599
+ percent: 100,
600
+ status: "complete",
601
+ processed: data.processed,
602
+ orphansRemoved: data.orphansRemoved,
603
+ errors: data.errors
604
+ });
605
+ triggerRefresh();
606
+ } else if (data.type === "error") {
607
+ setProgressState((prev) => ({
608
+ ...prev,
609
+ status: "error",
610
+ message: data.message
611
+ }));
612
+ }
613
+ } catch {
614
+ }
615
+ }
616
+ }
617
+ } else {
618
+ setShowProgress(true);
619
+ setProgressState({
620
+ current: 0,
621
+ total: processCount,
622
+ percent: 0,
623
+ status: "processing"
624
+ });
625
+ const selectedImageKeys = Array.from(selectedItems).filter((p) => {
626
+ const ext = p.split(".").pop()?.toLowerCase() || "";
627
+ return ["jpg", "jpeg", "png", "gif", "webp", "svg", "ico", "bmp", "tiff", "tif"].includes(ext);
628
+ }).map((p) => p.replace(/^public\//, ""));
629
+ const response = await fetch("/api/studio/reprocess", {
630
+ method: "POST",
631
+ headers: { "Content-Type": "application/json" },
632
+ body: JSON.stringify({ imageKeys: selectedImageKeys })
633
+ });
634
+ const data = await response.json();
635
+ if (response.ok) {
636
+ setProgressState({
637
+ current: data.processed?.length || 0,
638
+ total: data.processed?.length || 0,
639
+ percent: 100,
640
+ status: "complete",
641
+ processed: data.processed?.length || 0,
642
+ errors: data.errors?.length || 0
643
+ });
644
+ clearSelection();
645
+ triggerRefresh();
646
+ } else {
647
+ setProgressState({
648
+ current: 0,
649
+ total: 0,
650
+ percent: 0,
651
+ status: "error",
652
+ message: data.error || "Unknown error"
653
+ });
654
+ }
655
+ }
656
+ } catch (error) {
657
+ console.error("Processing error:", error);
658
+ setProgressState({
659
+ current: 0,
660
+ total: 0,
661
+ percent: 0,
662
+ status: "error",
663
+ message: "Processing failed. Check console for details."
664
+ });
665
+ } finally {
666
+ setProcessing(false);
667
+ }
668
+ }, [processMode, processCount, selectedItems, clearSelection, triggerRefresh]);
408
669
  const handleDeleteClick = useCallback(() => {
409
670
  if (selectedItems.size === 0) return;
410
671
  setShowDeleteConfirm(true);
@@ -445,7 +706,7 @@ function StudioToolbar() {
445
706
  if (focusedItem) {
446
707
  return null;
447
708
  }
448
- return /* @__PURE__ */ jsxs2(Fragment, { children: [
709
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
449
710
  showDeleteConfirm && /* @__PURE__ */ jsx2(
450
711
  ConfirmModal,
451
712
  {
@@ -457,6 +718,32 @@ function StudioToolbar() {
457
718
  onCancel: () => setShowDeleteConfirm(false)
458
719
  }
459
720
  ),
721
+ showProcessConfirm && /* @__PURE__ */ jsx2(
722
+ ConfirmModal,
723
+ {
724
+ title: "Process Images",
725
+ 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.`,
726
+ confirmLabel: processing ? "Processing..." : "Process",
727
+ onConfirm: handleProcessConfirm,
728
+ onCancel: () => setShowProcessConfirm(false)
729
+ }
730
+ ),
731
+ showProgress && /* @__PURE__ */ jsx2(
732
+ ProgressModal,
733
+ {
734
+ title: "Processing Images",
735
+ progress: progressState,
736
+ onClose: () => {
737
+ setShowProgress(false);
738
+ setProgressState({
739
+ current: 0,
740
+ total: 0,
741
+ percent: 0,
742
+ status: "processing"
743
+ });
744
+ }
745
+ }
746
+ ),
460
747
  alertMessage && /* @__PURE__ */ jsx2(
461
748
  AlertModal,
462
749
  {
@@ -495,11 +782,11 @@ function StudioToolbar() {
495
782
  "button",
496
783
  {
497
784
  css: styles2.btn,
498
- onClick: handleReprocess,
499
- disabled: !hasSelection,
785
+ onClick: handleProcessImages,
786
+ disabled: processing,
500
787
  children: [
501
- /* @__PURE__ */ jsx2(RefreshIcon, {}),
502
- "Reprocess"
788
+ /* @__PURE__ */ jsx2(ImageStackIcon, {}),
789
+ processing ? "Processing..." : "Process Images"
503
790
  ]
504
791
  }
505
792
  ),
@@ -591,6 +878,9 @@ function GridIcon() {
591
878
  function ListIcon() {
592
879
  return /* @__PURE__ */ jsx2("svg", { css: styles2.icon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx2("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M4 6h16M4 10h16M4 14h16M4 18h16" }) });
593
880
  }
881
+ function ImageStackIcon() {
882
+ return /* @__PURE__ */ jsx2("svg", { css: styles2.icon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx2("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" }) });
883
+ }
594
884
 
595
885
  // src/components/StudioFileGrid.tsx
596
886
  import { useEffect, useState as useState2 } from "react";
@@ -1319,7 +1609,7 @@ function formatFileSize2(bytes) {
1319
1609
  // src/components/StudioDetailView.tsx
1320
1610
  import { useState as useState4 } from "react";
1321
1611
  import { css as css5 } from "@emotion/react";
1322
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "@emotion/react/jsx-runtime";
1612
+ import { Fragment as Fragment3, jsx as jsx5, jsxs as jsxs5 } from "@emotion/react/jsx-runtime";
1323
1613
  var IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".ico", ".bmp", ".tiff", ".tif"];
1324
1614
  var VIDEO_EXTENSIONS = [".mp4", ".webm", ".mov", ".avi", ".mkv", ".m4v"];
1325
1615
  function isImageFile(filename) {
@@ -1560,7 +1850,7 @@ function StudioDetailView() {
1560
1850
  /* @__PURE__ */ jsx5("p", { css: styles5.fileName, children: focusedItem.name })
1561
1851
  ] });
1562
1852
  };
1563
- return /* @__PURE__ */ jsxs5(Fragment2, { children: [
1853
+ return /* @__PURE__ */ jsxs5(Fragment3, { children: [
1564
1854
  showDeleteConfirm && /* @__PURE__ */ jsx5(
1565
1855
  ConfirmModal,
1566
1856
  {
@@ -1642,7 +1932,7 @@ function formatFileSize3(bytes) {
1642
1932
  // src/components/StudioSettings.tsx
1643
1933
  import { useState as useState5 } from "react";
1644
1934
  import { css as css6 } from "@emotion/react";
1645
- import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs6 } from "@emotion/react/jsx-runtime";
1935
+ import { Fragment as Fragment4, jsx as jsx6, jsxs as jsxs6 } from "@emotion/react/jsx-runtime";
1646
1936
  var btnHeight2 = "36px";
1647
1937
  var styles6 = {
1648
1938
  btn: css6`
@@ -1826,7 +2116,7 @@ var styles6 = {
1826
2116
  };
1827
2117
  function StudioSettings() {
1828
2118
  const [isOpen, setIsOpen] = useState5(false);
1829
- return /* @__PURE__ */ jsxs6(Fragment3, { children: [
2119
+ return /* @__PURE__ */ jsxs6(Fragment4, { children: [
1830
2120
  /* @__PURE__ */ jsx6("button", { css: styles6.btn, onClick: () => setIsOpen(true), "aria-label": "Settings", children: /* @__PURE__ */ jsxs6(
1831
2121
  "svg",
1832
2122
  {
@@ -2107,4 +2397,4 @@ export {
2107
2397
  StudioUI,
2108
2398
  StudioUI_default as default
2109
2399
  };
2110
- //# sourceMappingURL=StudioUI-QPAHJJ64.mjs.map
2400
+ //# sourceMappingURL=StudioUI-BPOKRRW7.mjs.map