@gallop.software/studio 1.2.7 → 1.3.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.
@@ -7,11 +7,24 @@ import {
7
7
  } from "./chunk-RHI3UROE.mjs";
8
8
 
9
9
  // src/components/StudioUI.tsx
10
- import { useEffect as useEffect5, useCallback as useCallback5, useState as useState10 } from "react";
10
+ import { useEffect as useEffect5, useCallback as useCallback6, useState as useState11 } from "react";
11
11
  import { css as css11 } from "@emotion/react";
12
12
 
13
13
  // src/components/StudioContext.tsx
14
14
  import { createContext, useContext } from "react";
15
+ var defaultActionState = {
16
+ showProgress: false,
17
+ progressTitle: "",
18
+ progressState: { current: 0, total: 0, percent: 0, status: "processing" },
19
+ showDeleteConfirm: false,
20
+ showMoveModal: false,
21
+ showSyncConfirm: false,
22
+ showProcessConfirm: false,
23
+ actionPaths: [],
24
+ syncImageCount: 0,
25
+ syncHasRemote: false,
26
+ syncHasLocal: false
27
+ };
15
28
  var defaultState = {
16
29
  isOpen: false,
17
30
  openStudio: () => {
@@ -65,6 +78,34 @@ var defaultState = {
65
78
  },
66
79
  fileItems: [],
67
80
  setFileItems: () => {
81
+ },
82
+ // Shared action state
83
+ actionState: defaultActionState,
84
+ // Shared action handlers
85
+ requestDelete: () => {
86
+ },
87
+ requestMove: () => {
88
+ },
89
+ requestSync: () => {
90
+ },
91
+ requestProcess: () => {
92
+ },
93
+ confirmDelete: async () => {
94
+ },
95
+ confirmMove: async () => {
96
+ },
97
+ confirmSync: async () => {
98
+ },
99
+ confirmProcess: async () => {
100
+ },
101
+ cancelAction: () => {
102
+ },
103
+ closeProgress: () => {
104
+ },
105
+ stopProcessing: () => {
106
+ },
107
+ abortController: null,
108
+ deleteOrphans: async () => {
68
109
  }
69
110
  };
70
111
  var StudioContext = createContext(defaultState);
@@ -4367,15 +4408,20 @@ var styles8 = {
4367
4408
  color: ${colors.text};
4368
4409
  text-align: left;
4369
4410
 
4370
- &:hover {
4411
+ &:hover:not(:disabled) {
4371
4412
  background-color: ${colors.surfaceHover};
4372
4413
  border-color: ${colors.borderHover};
4373
4414
  }
4415
+
4416
+ &:disabled {
4417
+ opacity: 0.5;
4418
+ cursor: not-allowed;
4419
+ }
4374
4420
  `,
4375
4421
  actionBtnDanger: css8`
4376
4422
  color: ${colors.danger};
4377
4423
 
4378
- &:hover {
4424
+ &:hover:not(:disabled) {
4379
4425
  background-color: ${colors.dangerLight};
4380
4426
  border-color: ${colors.danger};
4381
4427
  }
@@ -4387,17 +4433,23 @@ var styles8 = {
4387
4433
  `
4388
4434
  };
4389
4435
  function StudioDetailView() {
4390
- const { focusedItem, setFocusedItem, triggerRefresh, clearSelection } = useStudio();
4391
- const [showDeleteConfirm, setShowDeleteConfirm] = useState8(false);
4436
+ const {
4437
+ focusedItem,
4438
+ setFocusedItem,
4439
+ triggerRefresh,
4440
+ fileItems,
4441
+ // Shared action handlers
4442
+ requestDelete,
4443
+ requestMove,
4444
+ requestSync,
4445
+ requestProcess,
4446
+ actionState
4447
+ } = useStudio();
4392
4448
  const [showRenameModal, setShowRenameModal] = useState8(false);
4393
- const [showMoveModal, setShowMoveModal] = useState8(false);
4394
- const [showProcessConfirm, setShowProcessConfirm] = useState8(false);
4395
4449
  const [showR2SetupModal, setShowR2SetupModal] = useState8(false);
4396
- const [processProgress, setProcessProgress] = useState8(null);
4397
4450
  const [alertMessage, setAlertMessage] = useState8(null);
4398
4451
  const [showCopied, setShowCopied] = useState8(false);
4399
- const [pushing, setPushing] = useState8(false);
4400
- const [moving, setMoving] = useState8(false);
4452
+ const isActionInProgress = actionState.showProgress;
4401
4453
  if (!focusedItem) return null;
4402
4454
  const isImage = isImageFile(focusedItem.name);
4403
4455
  const isVideo = isVideoFile(focusedItem.name);
@@ -4444,177 +4496,6 @@ function StudioDetailView() {
4444
4496
  }
4445
4497
  }
4446
4498
  };
4447
- const handleDelete = async () => {
4448
- setShowDeleteConfirm(false);
4449
- try {
4450
- const response = await fetch("/api/studio/delete", {
4451
- method: "POST",
4452
- headers: { "Content-Type": "application/json" },
4453
- body: JSON.stringify({ paths: [focusedItem.path] })
4454
- });
4455
- if (response.ok) {
4456
- clearSelection();
4457
- triggerRefresh();
4458
- setFocusedItem(null);
4459
- } else {
4460
- const error = await response.json();
4461
- setAlertMessage({
4462
- title: "Delete Failed",
4463
- message: error.error || "Unknown error"
4464
- });
4465
- }
4466
- } catch (error) {
4467
- console.error("Delete error:", error);
4468
- setAlertMessage({
4469
- title: "Delete Failed",
4470
- message: "Delete failed. Check console for details."
4471
- });
4472
- }
4473
- };
4474
- const handleMove = async (destination) => {
4475
- setShowMoveModal(false);
4476
- setMoving(true);
4477
- try {
4478
- const response = await fetch("/api/studio/move", {
4479
- method: "POST",
4480
- headers: { "Content-Type": "application/json" },
4481
- body: JSON.stringify({ paths: [focusedItem.path], destination })
4482
- });
4483
- if (!response.body) {
4484
- throw new Error("No response body");
4485
- }
4486
- const reader = response.body.getReader();
4487
- const decoder = new TextDecoder();
4488
- let buffer = "";
4489
- while (true) {
4490
- const { done, value } = await reader.read();
4491
- if (done) break;
4492
- buffer += decoder.decode(value, { stream: true });
4493
- const lines = buffer.split("\n\n");
4494
- buffer = lines.pop() || "";
4495
- for (const line of lines) {
4496
- if (!line.startsWith("data: ")) continue;
4497
- try {
4498
- const data = JSON.parse(line.slice(6));
4499
- if (data.type === "complete") {
4500
- if (data.errors > 0 && data.errorMessages?.length > 0) {
4501
- setAlertMessage({
4502
- title: "Move Failed",
4503
- message: data.errorMessages.join("\n")
4504
- });
4505
- } else {
4506
- clearSelection();
4507
- triggerRefresh();
4508
- setFocusedItem(null);
4509
- }
4510
- } else if (data.type === "error") {
4511
- setAlertMessage({
4512
- title: "Move Failed",
4513
- message: data.message || "Unknown error"
4514
- });
4515
- }
4516
- } catch {
4517
- }
4518
- }
4519
- }
4520
- } catch (error) {
4521
- console.error("Move error:", error);
4522
- setAlertMessage({
4523
- title: "Move Failed",
4524
- message: "Failed to move file. Check console for details."
4525
- });
4526
- } finally {
4527
- setMoving(false);
4528
- }
4529
- };
4530
- const handleSync = async () => {
4531
- const imageKey = "/" + focusedItem.path.replace(/^public\//, "");
4532
- setPushing(true);
4533
- try {
4534
- const response = await fetch("/api/studio/sync", {
4535
- method: "POST",
4536
- headers: { "Content-Type": "application/json" },
4537
- body: JSON.stringify({ imageKeys: [imageKey] })
4538
- });
4539
- const data = await response.json();
4540
- if (response.ok) {
4541
- setAlertMessage({
4542
- title: "Push Complete",
4543
- message: "Successfully pushed to CDN."
4544
- });
4545
- triggerRefresh();
4546
- } else {
4547
- if (data.error?.includes("R2 not configured") || data.error?.includes("CLOUDFLARE_R2")) {
4548
- setShowR2SetupModal(true);
4549
- } else {
4550
- setAlertMessage({
4551
- title: "Push Failed",
4552
- message: data.error || "Failed to push to CDN."
4553
- });
4554
- }
4555
- }
4556
- } catch (error) {
4557
- console.error("Push error:", error);
4558
- setAlertMessage({
4559
- title: "Push Failed",
4560
- message: "Failed to push to CDN. Check console for details."
4561
- });
4562
- } finally {
4563
- setPushing(false);
4564
- }
4565
- };
4566
- const handleProcessImage = async () => {
4567
- setShowProcessConfirm(false);
4568
- setProcessProgress({
4569
- current: 0,
4570
- total: 1,
4571
- percent: 0,
4572
- status: "processing",
4573
- currentFile: focusedItem.name
4574
- });
4575
- try {
4576
- const imageKey = focusedItem.path.replace(/^public\//, "");
4577
- const formattedKey = imageKey.startsWith("/") ? imageKey : `/${imageKey}`;
4578
- const response = await fetch("/api/studio/reprocess", {
4579
- method: "POST",
4580
- headers: { "Content-Type": "application/json" },
4581
- body: JSON.stringify({
4582
- imageKeys: [formattedKey]
4583
- })
4584
- });
4585
- const data = await response.json();
4586
- if (!response.ok) {
4587
- throw new Error(data.error || "Processing failed");
4588
- }
4589
- if (data.processed?.length > 0) {
4590
- setProcessProgress({
4591
- current: 1,
4592
- total: 1,
4593
- percent: 100,
4594
- status: "complete",
4595
- message: `Processed ${focusedItem.name}`
4596
- });
4597
- } else if (data.errors?.length > 0) {
4598
- setProcessProgress({
4599
- current: 0,
4600
- total: 1,
4601
- percent: 0,
4602
- status: "error",
4603
- message: `Failed to process: ${data.errors.join(", ")}`
4604
- });
4605
- }
4606
- triggerRefresh();
4607
- } catch (error) {
4608
- console.error("Process error:", error);
4609
- setProcessProgress({
4610
- current: 0,
4611
- total: 1,
4612
- percent: 0,
4613
- status: "error",
4614
- message: error instanceof Error ? error.message : "Failed to process image"
4615
- });
4616
- }
4617
- };
4618
4499
  const renderMedia = () => {
4619
4500
  if (isImage) {
4620
4501
  return /* @__PURE__ */ jsx8("img", { css: styles8.image, src: imageSrc, alt: focusedItem.name });
@@ -4628,17 +4509,6 @@ function StudioDetailView() {
4628
4509
  ] });
4629
4510
  };
4630
4511
  return /* @__PURE__ */ jsxs8(Fragment4, { children: [
4631
- showDeleteConfirm && /* @__PURE__ */ jsx8(
4632
- ConfirmModal,
4633
- {
4634
- title: "Delete File",
4635
- message: `Are you sure you want to delete "${focusedItem.name}"? This action cannot be undone.`,
4636
- confirmLabel: "Delete",
4637
- variant: "danger",
4638
- onConfirm: handleDelete,
4639
- onCancel: () => setShowDeleteConfirm(false)
4640
- }
4641
- ),
4642
4512
  alertMessage && /* @__PURE__ */ jsx8(
4643
4513
  AlertModal,
4644
4514
  {
@@ -4666,33 +4536,6 @@ function StudioDetailView() {
4666
4536
  onCancel: () => setShowRenameModal(false)
4667
4537
  }
4668
4538
  ),
4669
- showMoveModal && /* @__PURE__ */ jsx8(
4670
- StudioFolderPicker,
4671
- {
4672
- selectedItems: /* @__PURE__ */ new Set([focusedItem.path]),
4673
- currentPath: focusedItem.path.split("/").slice(0, -1).join("/"),
4674
- onMove: handleMove,
4675
- onCancel: () => setShowMoveModal(false)
4676
- }
4677
- ),
4678
- showProcessConfirm && /* @__PURE__ */ jsx8(
4679
- ConfirmModal,
4680
- {
4681
- title: "Process Image",
4682
- message: `Generate thumbnails for "${focusedItem.name}"?`,
4683
- confirmLabel: "Process",
4684
- onConfirm: handleProcessImage,
4685
- onCancel: () => setShowProcessConfirm(false)
4686
- }
4687
- ),
4688
- processProgress && /* @__PURE__ */ jsx8(
4689
- ProgressModal,
4690
- {
4691
- title: "Processing Image",
4692
- progress: processProgress,
4693
- onClose: () => setProcessProgress(null)
4694
- }
4695
- ),
4696
4539
  /* @__PURE__ */ jsx8("div", { css: styles8.overlay, onClick: handleClose, children: /* @__PURE__ */ jsxs8("div", { css: styles8.container, onClick: (e) => e.stopPropagation(), children: [
4697
4540
  /* @__PURE__ */ jsxs8("div", { css: styles8.main, children: [
4698
4541
  /* @__PURE__ */ jsxs8("div", { css: styles8.headerButtons, children: [
@@ -4766,11 +4609,11 @@ function StudioDetailView() {
4766
4609
  "button",
4767
4610
  {
4768
4611
  css: styles8.actionBtn,
4769
- onClick: () => setShowMoveModal(true),
4770
- disabled: moving || focusedItem.isProtected,
4612
+ onClick: () => requestMove([focusedItem.path]),
4613
+ disabled: isActionInProgress || focusedItem.isProtected,
4771
4614
  children: [
4772
4615
  /* @__PURE__ */ jsx8("svg", { css: styles8.actionIcon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx8("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" }) }),
4773
- moving ? "Moving..." : "Move"
4616
+ "Move"
4774
4617
  ]
4775
4618
  }
4776
4619
  ),
@@ -4778,12 +4621,12 @@ function StudioDetailView() {
4778
4621
  "button",
4779
4622
  {
4780
4623
  css: styles8.actionBtn,
4781
- onClick: handleSync,
4782
- disabled: pushing || focusedItem.isProtected || focusedItem.cdnPushed && !focusedItem.isRemote,
4624
+ onClick: () => requestSync([focusedItem.path], fileItems),
4625
+ disabled: isActionInProgress || focusedItem.isProtected || focusedItem.cdnPushed && !focusedItem.isRemote,
4783
4626
  title: focusedItem.cdnPushed && !focusedItem.isRemote ? "Already in R2" : void 0,
4784
4627
  children: [
4785
4628
  /* @__PURE__ */ jsx8("svg", { css: styles8.actionIcon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx8("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" }) }),
4786
- pushing ? "Pushing..." : "Push to CDN"
4629
+ "Push to CDN"
4787
4630
  ]
4788
4631
  }
4789
4632
  ),
@@ -4791,8 +4634,8 @@ function StudioDetailView() {
4791
4634
  "button",
4792
4635
  {
4793
4636
  css: styles8.actionBtn,
4794
- onClick: () => setShowProcessConfirm(true),
4795
- disabled: focusedItem.isProtected,
4637
+ onClick: () => requestProcess([focusedItem.path]),
4638
+ disabled: isActionInProgress || focusedItem.isProtected,
4796
4639
  children: [
4797
4640
  /* @__PURE__ */ jsx8("svg", { css: styles8.actionIcon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx8("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" }) }),
4798
4641
  "Process Image"
@@ -4803,8 +4646,8 @@ function StudioDetailView() {
4803
4646
  "button",
4804
4647
  {
4805
4648
  css: [styles8.actionBtn, styles8.actionBtnDanger],
4806
- onClick: () => setShowDeleteConfirm(true),
4807
- disabled: focusedItem.isProtected,
4649
+ onClick: () => requestDelete([focusedItem.path]),
4650
+ disabled: isActionInProgress || focusedItem.isProtected,
4808
4651
  children: [
4809
4652
  /* @__PURE__ */ jsx8("svg", { css: styles8.actionIcon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx8("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" }) }),
4810
4653
  "Delete"
@@ -5388,6 +5231,413 @@ function ErrorModal() {
5388
5231
  ] }) });
5389
5232
  }
5390
5233
 
5234
+ // src/components/useStudioActions.tsx
5235
+ import { useState as useState10, useCallback as useCallback5, useRef as useRef4 } from "react";
5236
+ var defaultActionState2 = {
5237
+ showProgress: false,
5238
+ progressTitle: "",
5239
+ progressState: { current: 0, total: 0, percent: 0, status: "processing" },
5240
+ showDeleteConfirm: false,
5241
+ showMoveModal: false,
5242
+ showSyncConfirm: false,
5243
+ showProcessConfirm: false,
5244
+ actionPaths: [],
5245
+ syncImageCount: 0,
5246
+ syncHasRemote: false,
5247
+ syncHasLocal: false
5248
+ };
5249
+ function useStudioActions({
5250
+ triggerRefresh,
5251
+ clearSelection,
5252
+ setFocusedItem,
5253
+ showError
5254
+ }) {
5255
+ const [actionState, setActionState] = useState10(defaultActionState2);
5256
+ const abortControllerRef = useRef4(null);
5257
+ const setProgressState = useCallback5((update) => {
5258
+ setActionState((prev) => ({
5259
+ ...prev,
5260
+ progressState: typeof update === "function" ? update(prev.progressState) : { ...prev.progressState, ...update }
5261
+ }));
5262
+ }, []);
5263
+ const requestDelete = useCallback5((paths) => {
5264
+ setActionState((prev) => ({
5265
+ ...prev,
5266
+ actionPaths: paths,
5267
+ showDeleteConfirm: true
5268
+ }));
5269
+ }, []);
5270
+ const requestMove = useCallback5((paths) => {
5271
+ setActionState((prev) => ({
5272
+ ...prev,
5273
+ actionPaths: paths,
5274
+ showMoveModal: true
5275
+ }));
5276
+ }, []);
5277
+ const requestSync = useCallback5((paths, fileItems) => {
5278
+ const imageKeys = paths.map((p) => "/" + p.replace(/^public\//, ""));
5279
+ let hasRemote = false;
5280
+ let hasLocal = false;
5281
+ for (const path of paths) {
5282
+ const item = fileItems.find((f) => f.path === path);
5283
+ if (item) {
5284
+ if (item.isRemote) {
5285
+ hasRemote = true;
5286
+ } else if (!item.cdnPushed) {
5287
+ hasLocal = true;
5288
+ }
5289
+ }
5290
+ }
5291
+ setActionState((prev) => ({
5292
+ ...prev,
5293
+ actionPaths: paths,
5294
+ syncImageCount: imageKeys.length,
5295
+ syncHasRemote: hasRemote,
5296
+ syncHasLocal: hasLocal,
5297
+ showSyncConfirm: true
5298
+ }));
5299
+ }, []);
5300
+ const requestProcess = useCallback5((paths) => {
5301
+ setActionState((prev) => ({
5302
+ ...prev,
5303
+ actionPaths: paths,
5304
+ showProcessConfirm: true
5305
+ }));
5306
+ }, []);
5307
+ const cancelAction = useCallback5(() => {
5308
+ setActionState((prev) => ({
5309
+ ...prev,
5310
+ showDeleteConfirm: false,
5311
+ showMoveModal: false,
5312
+ showSyncConfirm: false,
5313
+ showProcessConfirm: false
5314
+ }));
5315
+ }, []);
5316
+ const closeProgress = useCallback5(() => {
5317
+ setActionState(defaultActionState2);
5318
+ }, []);
5319
+ const stopProcessing = useCallback5(() => {
5320
+ if (abortControllerRef.current) {
5321
+ abortControllerRef.current.abort();
5322
+ }
5323
+ }, []);
5324
+ const confirmDelete = useCallback5(async () => {
5325
+ const paths = actionState.actionPaths;
5326
+ setActionState((prev) => ({ ...prev, showDeleteConfirm: false }));
5327
+ try {
5328
+ const response = await fetch("/api/studio/delete", {
5329
+ method: "POST",
5330
+ headers: { "Content-Type": "application/json" },
5331
+ body: JSON.stringify({ paths })
5332
+ });
5333
+ if (response.ok) {
5334
+ clearSelection();
5335
+ setFocusedItem(null);
5336
+ triggerRefresh();
5337
+ } else {
5338
+ const error = await response.json();
5339
+ showError("Delete Failed", error.error || "Unknown error");
5340
+ }
5341
+ } catch (error) {
5342
+ console.error("Delete error:", error);
5343
+ showError("Delete Failed", "Delete failed. Check console for details.");
5344
+ }
5345
+ }, [actionState.actionPaths, clearSelection, setFocusedItem, triggerRefresh, showError]);
5346
+ const confirmMove = useCallback5(async (destination) => {
5347
+ const paths = actionState.actionPaths;
5348
+ setActionState((prev) => ({
5349
+ ...prev,
5350
+ showMoveModal: false,
5351
+ showProgress: true,
5352
+ progressTitle: "Moving Files",
5353
+ progressState: {
5354
+ current: 0,
5355
+ total: paths.length,
5356
+ percent: 0,
5357
+ status: "processing",
5358
+ message: "Moving files..."
5359
+ }
5360
+ }));
5361
+ try {
5362
+ const response = await fetch("/api/studio/move-stream", {
5363
+ method: "POST",
5364
+ headers: { "Content-Type": "application/json" },
5365
+ body: JSON.stringify({ paths, destination })
5366
+ });
5367
+ if (!response.ok) {
5368
+ const error = await response.json();
5369
+ setProgressState({
5370
+ status: "error",
5371
+ message: error.error || "Move failed"
5372
+ });
5373
+ return;
5374
+ }
5375
+ const reader = response.body?.getReader();
5376
+ const decoder = new TextDecoder();
5377
+ if (reader) {
5378
+ let buffer = "";
5379
+ while (true) {
5380
+ const { done, value } = await reader.read();
5381
+ if (done) break;
5382
+ buffer += decoder.decode(value, { stream: true });
5383
+ const lines = buffer.split("\n");
5384
+ buffer = lines.pop() || "";
5385
+ for (const line of lines) {
5386
+ if (line.startsWith("data: ")) {
5387
+ try {
5388
+ const data = JSON.parse(line.slice(6));
5389
+ if (data.type === "start") {
5390
+ setProgressState((prev) => ({ ...prev, total: data.total }));
5391
+ } else if (data.type === "progress") {
5392
+ setProgressState({
5393
+ current: data.current,
5394
+ total: data.total,
5395
+ percent: Math.round(data.current / data.total * 100),
5396
+ status: "processing",
5397
+ message: data.message
5398
+ });
5399
+ } else if (data.type === "complete") {
5400
+ setProgressState((prev) => ({
5401
+ ...prev,
5402
+ status: "complete",
5403
+ message: `Moved ${data.moved} file${data.moved !== 1 ? "s" : ""}${data.errors > 0 ? `, ${data.errors} error${data.errors !== 1 ? "s" : ""}` : ""}`
5404
+ }));
5405
+ if (data.errors > 0 && data.errorMessages?.length > 0) {
5406
+ showError("Move Failed", data.errorMessages.join("\n"));
5407
+ }
5408
+ clearSelection();
5409
+ setFocusedItem(null);
5410
+ triggerRefresh();
5411
+ } else if (data.type === "error") {
5412
+ setProgressState((prev) => ({
5413
+ ...prev,
5414
+ status: "error",
5415
+ message: data.message || "Unknown error"
5416
+ }));
5417
+ }
5418
+ } catch {
5419
+ }
5420
+ }
5421
+ }
5422
+ }
5423
+ }
5424
+ } catch (error) {
5425
+ console.error("Move error:", error);
5426
+ setProgressState((prev) => ({
5427
+ ...prev,
5428
+ status: "error",
5429
+ message: "Failed to move files. Check console for details."
5430
+ }));
5431
+ }
5432
+ }, [actionState.actionPaths, clearSelection, setFocusedItem, triggerRefresh, showError, setProgressState]);
5433
+ const confirmSync = useCallback5(async () => {
5434
+ const paths = actionState.actionPaths;
5435
+ const imageKeys = paths.map((p) => "/" + p.replace(/^public\//, ""));
5436
+ setActionState((prev) => ({
5437
+ ...prev,
5438
+ showSyncConfirm: false,
5439
+ showProgress: true,
5440
+ progressTitle: "Pushing to CDN",
5441
+ progressState: {
5442
+ current: 0,
5443
+ total: imageKeys.length,
5444
+ percent: 0,
5445
+ status: "processing",
5446
+ message: "Pushing to CDN..."
5447
+ }
5448
+ }));
5449
+ let pushed = 0;
5450
+ const errors = [];
5451
+ for (let i = 0; i < imageKeys.length; i++) {
5452
+ const imageKey = imageKeys[i];
5453
+ setProgressState({
5454
+ current: i + 1,
5455
+ total: imageKeys.length,
5456
+ percent: Math.round((i + 1) / imageKeys.length * 100),
5457
+ status: "processing",
5458
+ message: `Pushing ${imageKey}...`
5459
+ });
5460
+ try {
5461
+ const response = await fetch("/api/studio/sync", {
5462
+ method: "POST",
5463
+ headers: { "Content-Type": "application/json" },
5464
+ body: JSON.stringify({ imageKeys: [imageKey] })
5465
+ });
5466
+ if (response.ok) {
5467
+ pushed++;
5468
+ } else {
5469
+ const data = await response.json();
5470
+ errors.push(`${imageKey}: ${data.error || "Unknown error"}`);
5471
+ }
5472
+ } catch (error) {
5473
+ errors.push(`${imageKey}: ${error instanceof Error ? error.message : "Unknown error"}`);
5474
+ }
5475
+ }
5476
+ setProgressState({
5477
+ current: imageKeys.length,
5478
+ total: imageKeys.length,
5479
+ percent: 100,
5480
+ status: errors.length > 0 ? "error" : "complete",
5481
+ message: `Pushed ${pushed} file${pushed !== 1 ? "s" : ""}${errors.length > 0 ? `, ${errors.length} error${errors.length !== 1 ? "s" : ""}` : ""}`
5482
+ });
5483
+ if (errors.length > 0) {
5484
+ showError("Push Errors", errors.join("\n"));
5485
+ }
5486
+ clearSelection();
5487
+ triggerRefresh();
5488
+ }, [actionState.actionPaths, clearSelection, triggerRefresh, showError, setProgressState]);
5489
+ const confirmProcess = useCallback5(async () => {
5490
+ const paths = actionState.actionPaths;
5491
+ const imageKeys = paths.map((p) => "/" + p.replace(/^public\//, ""));
5492
+ setActionState((prev) => ({
5493
+ ...prev,
5494
+ showProcessConfirm: false,
5495
+ showProgress: true,
5496
+ progressTitle: "Processing Images",
5497
+ progressState: {
5498
+ current: 0,
5499
+ total: imageKeys.length,
5500
+ percent: 0,
5501
+ status: "processing",
5502
+ message: "Processing images..."
5503
+ }
5504
+ }));
5505
+ abortControllerRef.current = new AbortController();
5506
+ const signal = abortControllerRef.current.signal;
5507
+ try {
5508
+ const response = await fetch("/api/studio/process-stream", {
5509
+ method: "POST",
5510
+ headers: { "Content-Type": "application/json" },
5511
+ body: JSON.stringify({ imageKeys }),
5512
+ signal
5513
+ });
5514
+ if (!response.ok) {
5515
+ const error = await response.json();
5516
+ setProgressState({
5517
+ status: "error",
5518
+ message: error.error || "Processing failed"
5519
+ });
5520
+ return;
5521
+ }
5522
+ const reader = response.body?.getReader();
5523
+ const decoder = new TextDecoder();
5524
+ if (reader) {
5525
+ let buffer = "";
5526
+ while (true) {
5527
+ const { done, value } = await reader.read();
5528
+ if (done) break;
5529
+ buffer += decoder.decode(value, { stream: true });
5530
+ const lines = buffer.split("\n");
5531
+ buffer = lines.pop() || "";
5532
+ for (const line of lines) {
5533
+ if (line.startsWith("data: ")) {
5534
+ try {
5535
+ const data = JSON.parse(line.slice(6));
5536
+ if (data.type === "start") {
5537
+ setProgressState((prev) => ({
5538
+ ...prev,
5539
+ total: data.total
5540
+ }));
5541
+ } else if (data.type === "progress") {
5542
+ setProgressState({
5543
+ current: data.current,
5544
+ total: data.total,
5545
+ percent: Math.round(data.current / data.total * 100),
5546
+ status: "processing",
5547
+ message: data.message
5548
+ });
5549
+ } else if (data.type === "cleanup") {
5550
+ setProgressState((prev) => ({
5551
+ ...prev,
5552
+ status: "cleanup",
5553
+ message: data.message
5554
+ }));
5555
+ } else if (data.type === "complete") {
5556
+ setProgressState({
5557
+ current: data.processed,
5558
+ total: data.processed,
5559
+ percent: 100,
5560
+ status: "complete",
5561
+ message: `Processed ${data.processed} image${data.processed !== 1 ? "s" : ""}${data.errors > 0 ? `, ${data.errors} error${data.errors !== 1 ? "s" : ""}` : ""}`
5562
+ });
5563
+ triggerRefresh();
5564
+ } else if (data.type === "error") {
5565
+ setProgressState((prev) => ({
5566
+ ...prev,
5567
+ status: "error",
5568
+ message: data.message
5569
+ }));
5570
+ }
5571
+ } catch {
5572
+ }
5573
+ }
5574
+ }
5575
+ }
5576
+ }
5577
+ } catch (error) {
5578
+ if (signal.aborted) {
5579
+ setProgressState((prev) => ({
5580
+ ...prev,
5581
+ status: "stopped",
5582
+ message: "Processing stopped by user"
5583
+ }));
5584
+ } else {
5585
+ console.error("Processing error:", error);
5586
+ setProgressState({
5587
+ current: 0,
5588
+ total: 0,
5589
+ status: "error",
5590
+ message: "Processing failed. Check console for details."
5591
+ });
5592
+ }
5593
+ } finally {
5594
+ abortControllerRef.current = null;
5595
+ }
5596
+ }, [actionState.actionPaths, triggerRefresh, setProgressState]);
5597
+ const deleteOrphans = useCallback5(async () => {
5598
+ const orphanedFiles = actionState.progressState.orphanedFiles;
5599
+ if (!orphanedFiles || orphanedFiles.length === 0) return;
5600
+ try {
5601
+ const response = await fetch("/api/studio/delete-orphans", {
5602
+ method: "POST",
5603
+ headers: { "Content-Type": "application/json" },
5604
+ body: JSON.stringify({ files: orphanedFiles })
5605
+ });
5606
+ if (response.ok) {
5607
+ setProgressState((prev) => ({
5608
+ ...prev,
5609
+ orphanedFiles: void 0,
5610
+ message: prev.message?.replace(/Found \d+ orphaned thumbnail\(s\).*/, "Orphaned thumbnails deleted.")
5611
+ }));
5612
+ triggerRefresh();
5613
+ } else {
5614
+ const error = await response.json();
5615
+ showError("Delete Failed", error.error || "Failed to delete orphaned files");
5616
+ }
5617
+ } catch (error) {
5618
+ console.error("Delete orphans error:", error);
5619
+ showError("Delete Failed", "Failed to delete orphaned files. Check console for details.");
5620
+ }
5621
+ }, [actionState.progressState.orphanedFiles, triggerRefresh, showError, setProgressState]);
5622
+ return {
5623
+ actionState,
5624
+ setActionState,
5625
+ abortController: abortControllerRef.current,
5626
+ requestDelete,
5627
+ requestMove,
5628
+ requestSync,
5629
+ requestProcess,
5630
+ cancelAction,
5631
+ closeProgress,
5632
+ stopProcessing,
5633
+ confirmDelete,
5634
+ confirmMove,
5635
+ confirmSync,
5636
+ confirmProcess,
5637
+ deleteOrphans
5638
+ };
5639
+ }
5640
+
5391
5641
  // src/components/StudioUI.tsx
5392
5642
  import { jsx as jsx11, jsxs as jsxs11 } from "@emotion/react/jsx-runtime";
5393
5643
  var btnHeight3 = "36px";
@@ -5531,45 +5781,45 @@ var styles11 = {
5531
5781
  `
5532
5782
  };
5533
5783
  function StudioUI({ onClose, isVisible = true }) {
5534
- const [currentPath, setCurrentPathInternal] = useState10("public");
5535
- const [selectedItems, setSelectedItems] = useState10(/* @__PURE__ */ new Set());
5536
- const [lastSelectedPath, setLastSelectedPath] = useState10(null);
5537
- const [viewMode, setViewMode] = useState10("grid");
5538
- const [focusedItem, setFocusedItem] = useState10(null);
5539
- const [meta, setMeta] = useState10(null);
5540
- const [isLoading, setIsLoading] = useState10(false);
5541
- const [refreshKey, setRefreshKey] = useState10(0);
5542
- const [scanRequested, setScanRequested] = useState10(false);
5543
- const [searchQuery, setSearchQuery] = useState10("");
5544
- const [error, setError] = useState10(null);
5545
- const [fileItems, setFileItems] = useState10([]);
5546
- const [isDragging, setIsDragging] = useState10(false);
5547
- const triggerRefresh = useCallback5(() => {
5784
+ const [currentPath, setCurrentPathInternal] = useState11("public");
5785
+ const [selectedItems, setSelectedItems] = useState11(/* @__PURE__ */ new Set());
5786
+ const [lastSelectedPath, setLastSelectedPath] = useState11(null);
5787
+ const [viewMode, setViewMode] = useState11("grid");
5788
+ const [focusedItem, setFocusedItem] = useState11(null);
5789
+ const [meta, setMeta] = useState11(null);
5790
+ const [isLoading, setIsLoading] = useState11(false);
5791
+ const [refreshKey, setRefreshKey] = useState11(0);
5792
+ const [scanRequested, setScanRequested] = useState11(false);
5793
+ const [searchQuery, setSearchQuery] = useState11("");
5794
+ const [error, setError] = useState11(null);
5795
+ const [fileItems, setFileItems] = useState11([]);
5796
+ const [isDragging, setIsDragging] = useState11(false);
5797
+ const triggerRefresh = useCallback6(() => {
5548
5798
  setRefreshKey((k) => k + 1);
5549
5799
  }, []);
5550
- const triggerScan = useCallback5(() => {
5800
+ const triggerScan = useCallback6(() => {
5551
5801
  setScanRequested(true);
5552
5802
  }, []);
5553
- const clearScanRequest = useCallback5(() => {
5803
+ const clearScanRequest = useCallback6(() => {
5554
5804
  setScanRequested(false);
5555
5805
  }, []);
5556
- const showError = useCallback5((title, message) => {
5806
+ const showError = useCallback6((title, message) => {
5557
5807
  setError({ title, message });
5558
5808
  }, []);
5559
- const clearError = useCallback5(() => {
5809
+ const clearError = useCallback6(() => {
5560
5810
  setError(null);
5561
5811
  }, []);
5562
- const handleDragOver = useCallback5((e) => {
5812
+ const handleDragOver = useCallback6((e) => {
5563
5813
  e.preventDefault();
5564
5814
  e.stopPropagation();
5565
5815
  setIsDragging(true);
5566
5816
  }, []);
5567
- const handleDragLeave = useCallback5((e) => {
5817
+ const handleDragLeave = useCallback6((e) => {
5568
5818
  e.preventDefault();
5569
5819
  e.stopPropagation();
5570
5820
  setIsDragging(false);
5571
5821
  }, []);
5572
- const handleDrop = useCallback5(async (e) => {
5822
+ const handleDrop = useCallback6(async (e) => {
5573
5823
  e.preventDefault();
5574
5824
  e.stopPropagation();
5575
5825
  setIsDragging(false);
@@ -5593,19 +5843,19 @@ function StudioUI({ onClose, isVisible = true }) {
5593
5843
  }
5594
5844
  triggerRefresh();
5595
5845
  }, [currentPath, triggerRefresh]);
5596
- const navigateUp = useCallback5(() => {
5846
+ const navigateUp = useCallback6(() => {
5597
5847
  if (currentPath === "public") return;
5598
5848
  const parts = currentPath.split("/");
5599
5849
  parts.pop();
5600
5850
  setCurrentPathInternal(parts.join("/") || "public");
5601
5851
  setSelectedItems(/* @__PURE__ */ new Set());
5602
5852
  }, [currentPath]);
5603
- const setCurrentPath = useCallback5((path) => {
5853
+ const setCurrentPath = useCallback6((path) => {
5604
5854
  setCurrentPathInternal(path);
5605
5855
  setSelectedItems(/* @__PURE__ */ new Set());
5606
5856
  setFocusedItem(null);
5607
5857
  }, []);
5608
- const toggleSelection = useCallback5((path) => {
5858
+ const toggleSelection = useCallback6((path) => {
5609
5859
  setSelectedItems((prev) => {
5610
5860
  const next = new Set(prev);
5611
5861
  if (next.has(path)) {
@@ -5617,7 +5867,7 @@ function StudioUI({ onClose, isVisible = true }) {
5617
5867
  });
5618
5868
  setLastSelectedPath(path);
5619
5869
  }, []);
5620
- const selectRange = useCallback5((fromPath, toPath, allItems) => {
5870
+ const selectRange = useCallback6((fromPath, toPath, allItems) => {
5621
5871
  const fromIndex = allItems.findIndex((item) => item.path === fromPath);
5622
5872
  const toIndex = allItems.findIndex((item) => item.path === toPath);
5623
5873
  if (fromIndex === -1 || toIndex === -1) return;
@@ -5632,13 +5882,22 @@ function StudioUI({ onClose, isVisible = true }) {
5632
5882
  });
5633
5883
  setLastSelectedPath(toPath);
5634
5884
  }, []);
5635
- const selectAll = useCallback5((items) => {
5885
+ const selectAll = useCallback6((items) => {
5636
5886
  setSelectedItems(new Set(items.map((item) => item.path)));
5637
5887
  }, []);
5638
- const clearSelection = useCallback5(() => {
5888
+ const clearSelection = useCallback6(() => {
5639
5889
  setSelectedItems(/* @__PURE__ */ new Set());
5640
5890
  }, []);
5641
- const handleKeyDown = useCallback5(
5891
+ const setFocusedItemCallback = useCallback6((item) => {
5892
+ setFocusedItem(item);
5893
+ }, []);
5894
+ const actions = useStudioActions({
5895
+ triggerRefresh,
5896
+ clearSelection,
5897
+ setFocusedItem: setFocusedItemCallback,
5898
+ showError
5899
+ });
5900
+ const handleKeyDown = useCallback6(
5642
5901
  (e) => {
5643
5902
  if (e.key === "Escape") {
5644
5903
  const target = e.target;
@@ -5698,7 +5957,22 @@ function StudioUI({ onClose, isVisible = true }) {
5698
5957
  showError,
5699
5958
  clearError,
5700
5959
  fileItems,
5701
- setFileItems
5960
+ setFileItems,
5961
+ // Shared action state and handlers
5962
+ actionState: actions.actionState,
5963
+ requestDelete: actions.requestDelete,
5964
+ requestMove: actions.requestMove,
5965
+ requestSync: actions.requestSync,
5966
+ requestProcess: actions.requestProcess,
5967
+ confirmDelete: actions.confirmDelete,
5968
+ confirmMove: actions.confirmMove,
5969
+ confirmSync: actions.confirmSync,
5970
+ confirmProcess: actions.confirmProcess,
5971
+ cancelAction: actions.cancelAction,
5972
+ closeProgress: actions.closeProgress,
5973
+ stopProcessing: actions.stopProcessing,
5974
+ abortController: actions.abortController,
5975
+ deleteOrphans: actions.deleteOrphans
5702
5976
  };
5703
5977
  return /* @__PURE__ */ jsx11(StudioContext.Provider, { value: contextValue, children: /* @__PURE__ */ jsxs11("div", { css: styles11.container, children: [
5704
5978
  /* @__PURE__ */ jsxs11("div", { css: styles11.header, children: [
@@ -5735,7 +6009,57 @@ function StudioUI({ onClose, isVisible = true }) {
5735
6009
  }
5736
6010
  ),
5737
6011
  focusedItem && /* @__PURE__ */ jsx11(StudioDetailView, {}),
5738
- /* @__PURE__ */ jsx11(ErrorModal, {})
6012
+ /* @__PURE__ */ jsx11(ErrorModal, {}),
6013
+ actions.actionState.showDeleteConfirm && /* @__PURE__ */ jsx11(
6014
+ ConfirmModal,
6015
+ {
6016
+ title: "Delete Files",
6017
+ message: `Are you sure you want to delete ${actions.actionState.actionPaths.length} item${actions.actionState.actionPaths.length !== 1 ? "s" : ""}? This action cannot be undone.`,
6018
+ confirmLabel: "Delete",
6019
+ variant: "danger",
6020
+ onConfirm: actions.confirmDelete,
6021
+ onCancel: actions.cancelAction
6022
+ }
6023
+ ),
6024
+ actions.actionState.showSyncConfirm && /* @__PURE__ */ jsx11(
6025
+ ConfirmModal,
6026
+ {
6027
+ title: "Push to CDN",
6028
+ message: `Push ${actions.actionState.syncImageCount} image${actions.actionState.syncImageCount !== 1 ? "s" : ""} to Cloudflare R2?${actions.actionState.syncHasRemote ? " Remote images will be downloaded first." : ""}${actions.actionState.syncHasLocal ? " After pushing, local files will be deleted." : ""}`,
6029
+ confirmLabel: "Push",
6030
+ onConfirm: actions.confirmSync,
6031
+ onCancel: actions.cancelAction
6032
+ }
6033
+ ),
6034
+ actions.actionState.showProcessConfirm && /* @__PURE__ */ jsx11(
6035
+ ConfirmModal,
6036
+ {
6037
+ title: "Process Images",
6038
+ message: `Generate thumbnails for ${actions.actionState.actionPaths.length} image${actions.actionState.actionPaths.length !== 1 ? "s" : ""}?`,
6039
+ confirmLabel: "Process",
6040
+ onConfirm: actions.confirmProcess,
6041
+ onCancel: actions.cancelAction
6042
+ }
6043
+ ),
6044
+ actions.actionState.showMoveModal && /* @__PURE__ */ jsx11(
6045
+ StudioFolderPicker,
6046
+ {
6047
+ selectedItems: new Set(actions.actionState.actionPaths),
6048
+ currentPath,
6049
+ onMove: (destination) => actions.confirmMove(destination),
6050
+ onCancel: actions.cancelAction
6051
+ }
6052
+ ),
6053
+ actions.actionState.showProgress && /* @__PURE__ */ jsx11(
6054
+ ProgressModal,
6055
+ {
6056
+ title: actions.actionState.progressTitle,
6057
+ progress: actions.actionState.progressState,
6058
+ onStop: actions.stopProcessing,
6059
+ onDeleteOrphans: actions.deleteOrphans,
6060
+ onClose: actions.closeProgress
6061
+ }
6062
+ )
5739
6063
  ] }) });
5740
6064
  }
5741
6065
  function Breadcrumbs({ currentPath, onNavigate }) {
@@ -5780,4 +6104,4 @@ export {
5780
6104
  StudioUI,
5781
6105
  StudioUI_default as default
5782
6106
  };
5783
- //# sourceMappingURL=StudioUI-TZB57JJT.mjs.map
6107
+ //# sourceMappingURL=StudioUI-K6TIH6LF.mjs.map