@gallop.software/studio 0.1.87 → 0.1.89

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.
@@ -310,6 +310,23 @@ var progressStyles = {
310
310
  white-space: nowrap;
311
311
  overflow: hidden;
312
312
  text-overflow: ellipsis;
313
+ `,
314
+ errorList: css`
315
+ margin-top: 12px;
316
+ padding: 12px;
317
+ background: #fef2f2;
318
+ border: 1px solid #fecaca;
319
+ border-radius: 6px;
320
+ max-height: 200px;
321
+ overflow-y: auto;
322
+ `,
323
+ errorItem: css`
324
+ font-size: ${fontSize.xs};
325
+ color: #991b1b;
326
+ margin: 0 0 4px;
327
+ &:last-child {
328
+ margin-bottom: 0;
329
+ }
313
330
  `
314
331
  };
315
332
  function ProgressModal({
@@ -331,26 +348,36 @@ function ProgressModal({
331
348
  " image",
332
349
  (progress.processed ?? progress.current) !== 1 ? "s" : "",
333
350
  " before stopping."
334
- ] }) : isComplete ? /* @__PURE__ */ jsxs("p", { css: styles.message, children: [
335
- "Processed ",
336
- progress.processed,
337
- " image",
338
- progress.processed !== 1 ? "s" : "",
339
- ".",
340
- progress.orphansRemoved !== void 0 && progress.orphansRemoved > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
341
- " Removed ",
342
- progress.orphansRemoved,
343
- " orphaned thumbnail",
344
- progress.orphansRemoved !== 1 ? "s" : "",
345
- "."
346
- ] }) : null,
347
- progress.errors !== void 0 && progress.errors > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
348
- " ",
349
- progress.errors,
350
- " error",
351
- progress.errors !== 1 ? "s" : "",
352
- " occurred."
353
- ] }) : null
351
+ ] }) : isComplete ? /* @__PURE__ */ jsxs(Fragment, { children: [
352
+ /* @__PURE__ */ jsxs("p", { css: styles.message, children: [
353
+ "Processed ",
354
+ progress.processed,
355
+ " image",
356
+ progress.processed !== 1 ? "s" : "",
357
+ ".",
358
+ progress.orphansRemoved !== void 0 && progress.orphansRemoved > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
359
+ " Removed ",
360
+ progress.orphansRemoved,
361
+ " orphaned thumbnail",
362
+ progress.orphansRemoved !== 1 ? "s" : "",
363
+ "."
364
+ ] }) : null,
365
+ progress.errors !== void 0 && progress.errors > 0 ? /* @__PURE__ */ jsxs(Fragment, { children: [
366
+ " ",
367
+ progress.errors,
368
+ " error",
369
+ progress.errors !== 1 ? "s" : "",
370
+ " occurred."
371
+ ] }) : null
372
+ ] }),
373
+ progress.errorMessages && progress.errorMessages.length > 0 && /* @__PURE__ */ jsxs("div", { css: progressStyles.errorList, children: [
374
+ progress.errorMessages.slice(0, 10).map((msg, i) => /* @__PURE__ */ jsx("p", { css: progressStyles.errorItem, children: msg }, i)),
375
+ progress.errorMessages.length > 10 && /* @__PURE__ */ jsxs("p", { css: progressStyles.errorItem, children: [
376
+ "...and ",
377
+ progress.errorMessages.length - 10,
378
+ " more"
379
+ ] })
380
+ ] })
354
381
  ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
355
382
  /* @__PURE__ */ jsx("p", { css: styles.message, children: progress.status === "cleanup" ? "Cleaning up orphaned files..." : `Processing images...` }),
356
383
  /* @__PURE__ */ jsxs("div", { css: progressStyles.progressContainer, children: [
@@ -1262,7 +1289,7 @@ function StudioToolbar() {
1262
1289
  const fileInputRef = useRef(null);
1263
1290
  const abortControllerRef = useRef(null);
1264
1291
  const [uploading, setUploading] = useState3(false);
1265
- const [refreshing, setRefreshing] = useState3(false);
1292
+ const [scanning, setScanning] = useState3(false);
1266
1293
  const [processing, setProcessing] = useState3(false);
1267
1294
  const [showDeleteConfirm, setShowDeleteConfirm] = useState3(false);
1268
1295
  const [showProcessConfirm, setShowProcessConfirm] = useState3(false);
@@ -1288,10 +1315,81 @@ function StudioToolbar() {
1288
1315
  const handleUpload = useCallback(() => {
1289
1316
  fileInputRef.current?.click();
1290
1317
  }, []);
1291
- const handleRefresh = useCallback(() => {
1292
- setRefreshing(true);
1293
- triggerRefresh();
1294
- setTimeout(() => setRefreshing(false), 600);
1318
+ const handleScan = useCallback(async () => {
1319
+ setScanning(true);
1320
+ setShowProgress(true);
1321
+ setProgressState({
1322
+ current: 0,
1323
+ total: 0,
1324
+ percent: 0,
1325
+ status: "processing",
1326
+ message: "Scanning for files..."
1327
+ });
1328
+ try {
1329
+ const response = await fetch("/api/studio/scan", { method: "POST" });
1330
+ const reader = response.body?.getReader();
1331
+ if (!reader) throw new Error("No reader");
1332
+ const decoder = new TextDecoder();
1333
+ let buffer = "";
1334
+ while (true) {
1335
+ const { done, value } = await reader.read();
1336
+ if (done) break;
1337
+ buffer += decoder.decode(value, { stream: true });
1338
+ const lines = buffer.split("\n\n");
1339
+ buffer = lines.pop() || "";
1340
+ for (const line of lines) {
1341
+ if (!line.startsWith("data: ")) continue;
1342
+ const data = JSON.parse(line.slice(6));
1343
+ if (data.type === "start") {
1344
+ setProgressState({
1345
+ current: 0,
1346
+ total: data.total,
1347
+ percent: 0,
1348
+ status: "processing",
1349
+ message: `Scanning ${data.total} files...`
1350
+ });
1351
+ } else if (data.type === "progress") {
1352
+ setProgressState({
1353
+ current: data.current,
1354
+ total: data.total,
1355
+ percent: data.percent,
1356
+ status: "processing",
1357
+ currentFile: data.currentFile
1358
+ });
1359
+ } else if (data.type === "complete") {
1360
+ setProgressState({
1361
+ current: data.total || 0,
1362
+ total: data.total || 0,
1363
+ percent: 100,
1364
+ status: "complete",
1365
+ processed: data.added,
1366
+ errors: data.errors,
1367
+ message: data.renamed > 0 ? `${data.renamed} file(s) renamed due to conflicts` : void 0
1368
+ });
1369
+ triggerRefresh();
1370
+ } else if (data.type === "error") {
1371
+ setProgressState({
1372
+ current: 0,
1373
+ total: 0,
1374
+ percent: 0,
1375
+ status: "error",
1376
+ message: data.message || "Scan failed"
1377
+ });
1378
+ }
1379
+ }
1380
+ }
1381
+ } catch (error) {
1382
+ console.error("Scan error:", error);
1383
+ setProgressState({
1384
+ current: 0,
1385
+ total: 0,
1386
+ percent: 0,
1387
+ status: "error",
1388
+ message: "Scan failed"
1389
+ });
1390
+ } finally {
1391
+ setScanning(false);
1392
+ }
1295
1393
  }, [triggerRefresh]);
1296
1394
  const handleFileChange = useCallback(async (e) => {
1297
1395
  const files = e.target.files;
@@ -1704,6 +1802,7 @@ function StudioToolbar() {
1704
1802
  setShowProgress(true);
1705
1803
  let synced = 0;
1706
1804
  let errors = 0;
1805
+ const errorMessages = [];
1707
1806
  try {
1708
1807
  for (let i = 0; i < imageKeys.length; i++) {
1709
1808
  const imageKey = imageKeys[i];
@@ -1728,13 +1827,18 @@ function StudioToolbar() {
1728
1827
  return;
1729
1828
  }
1730
1829
  errors++;
1830
+ errorMessages.push(data.error || `Failed: ${imageKey}`);
1731
1831
  } else if (data.synced?.length > 0) {
1732
1832
  synced++;
1733
1833
  } else if (data.errors?.length > 0) {
1734
1834
  errors++;
1835
+ for (const errMsg of data.errors) {
1836
+ errorMessages.push(errMsg);
1837
+ }
1735
1838
  }
1736
- } catch {
1839
+ } catch (err) {
1737
1840
  errors++;
1841
+ errorMessages.push(`Network error: ${imageKey}`);
1738
1842
  }
1739
1843
  }
1740
1844
  setProgressState({
@@ -1743,7 +1847,8 @@ function StudioToolbar() {
1743
1847
  percent: 100,
1744
1848
  status: "complete",
1745
1849
  processed: synced,
1746
- errors
1850
+ errors,
1851
+ errorMessages: errorMessages.length > 0 ? errorMessages : void 0
1747
1852
  });
1748
1853
  clearSelection();
1749
1854
  triggerRefresh();
@@ -2070,12 +2175,16 @@ function StudioToolbar() {
2070
2175
  " selected",
2071
2176
  /* @__PURE__ */ jsx4("button", { css: styles4.clearBtn, onClick: clearSelection, children: "Clear" })
2072
2177
  ] }),
2073
- /* @__PURE__ */ jsx4(
2178
+ /* @__PURE__ */ jsxs4(
2074
2179
  "button",
2075
2180
  {
2076
- css: [styles4.btn, styles4.btnIconOnly],
2077
- onClick: handleRefresh,
2078
- children: /* @__PURE__ */ jsx4(RefreshIcon, { spinning: refreshing })
2181
+ css: styles4.btn,
2182
+ onClick: handleScan,
2183
+ disabled: scanning,
2184
+ children: [
2185
+ /* @__PURE__ */ jsx4(ScanIcon, { spinning: scanning }),
2186
+ "Scan"
2187
+ ]
2079
2188
  }
2080
2189
  ),
2081
2190
  /* @__PURE__ */ jsxs4("div", { css: styles4.viewToggle, children: [
@@ -2105,7 +2214,7 @@ function StudioToolbar() {
2105
2214
  function UploadIcon() {
2106
2215
  return /* @__PURE__ */ jsx4("svg", { css: styles4.icon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" }) });
2107
2216
  }
2108
- function RefreshIcon({ spinning }) {
2217
+ function ScanIcon({ spinning }) {
2109
2218
  return /* @__PURE__ */ jsx4("svg", { css: [styles4.icon, spinning && styles4.iconSpin], fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 2, d: "M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" }) });
2110
2219
  }
2111
2220
  function TrashIcon() {
@@ -2239,6 +2348,7 @@ function useFileList() {
2239
2348
  } = useStudio();
2240
2349
  const [items, setItems] = useState4([]);
2241
2350
  const [loading, setLoading] = useState4(true);
2351
+ const [metaEmpty, setMetaEmpty] = useState4(false);
2242
2352
  const isInitialLoad = useRef2(true);
2243
2353
  const lastPath = useRef2(currentPath);
2244
2354
  useEffect2(() => {
@@ -2251,10 +2361,12 @@ function useFileList() {
2251
2361
  try {
2252
2362
  const data = searchQuery && searchQuery.length >= 2 ? await studioApi.search(searchQuery) : await studioApi.list(currentPath);
2253
2363
  setItems(data.items || []);
2364
+ setMetaEmpty(data.isEmpty === true);
2254
2365
  } catch (error) {
2255
2366
  const message = error instanceof Error ? error.message : "Failed to load items";
2256
2367
  showError("Load Error", message);
2257
2368
  setItems([]);
2369
+ setMetaEmpty(false);
2258
2370
  }
2259
2371
  setLoading(false);
2260
2372
  isInitialLoad.current = false;
@@ -2306,6 +2418,7 @@ function useFileList() {
2306
2418
  items,
2307
2419
  loading,
2308
2420
  sortedItems,
2421
+ metaEmpty,
2309
2422
  // Computed
2310
2423
  isAtRoot,
2311
2424
  isSearching,
@@ -2366,6 +2479,27 @@ var styles5 = {
2366
2479
  font-size: ${fontSize.sm};
2367
2480
  }
2368
2481
  `,
2482
+ scanButton: css5`
2483
+ margin-top: 16px;
2484
+ padding: 10px 24px;
2485
+ font-size: ${fontSize.base};
2486
+ font-weight: 500;
2487
+ background: ${colors.primary};
2488
+ color: white;
2489
+ border: none;
2490
+ border-radius: 8px;
2491
+ cursor: pointer;
2492
+ transition: background 0.15s ease;
2493
+
2494
+ &:hover:not(:disabled) {
2495
+ background: ${colors.primaryHover};
2496
+ }
2497
+
2498
+ &:disabled {
2499
+ opacity: 0.6;
2500
+ cursor: not-allowed;
2501
+ }
2502
+ `,
2369
2503
  grid: css5`
2370
2504
  display: grid;
2371
2505
  grid-template-columns: 1fr;
@@ -2649,6 +2783,7 @@ function StudioFileGrid() {
2649
2783
  const {
2650
2784
  loading,
2651
2785
  sortedItems,
2786
+ metaEmpty,
2652
2787
  isAtRoot,
2653
2788
  isSearching,
2654
2789
  allItemsSelected,
@@ -2660,14 +2795,42 @@ function StudioFileGrid() {
2660
2795
  handleGenerateThumbnail,
2661
2796
  handleSelectAll
2662
2797
  } = useFileList();
2798
+ const [scanning, setScanning] = useState5(false);
2799
+ const handleScan = async () => {
2800
+ setScanning(true);
2801
+ try {
2802
+ await fetch("/api/studio/scan", { method: "POST" });
2803
+ window.location.reload();
2804
+ } catch (error) {
2805
+ console.error("Scan failed:", error);
2806
+ } finally {
2807
+ setScanning(false);
2808
+ }
2809
+ };
2663
2810
  if (loading) {
2664
2811
  return /* @__PURE__ */ jsx5("div", { css: styles5.loading, children: /* @__PURE__ */ jsx5("div", { css: styles5.spinner }) });
2665
2812
  }
2813
+ if (metaEmpty && isAtRoot) {
2814
+ return /* @__PURE__ */ jsxs5("div", { css: styles5.empty, children: [
2815
+ /* @__PURE__ */ jsx5("svg", { css: styles5.emptyIcon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx5("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.5, d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" }) }),
2816
+ /* @__PURE__ */ jsx5("p", { css: styles5.emptyText, children: "No files tracked yet" }),
2817
+ /* @__PURE__ */ jsx5("p", { css: styles5.emptyText, children: "Click Scan to discover files in your public folder" }),
2818
+ /* @__PURE__ */ jsx5(
2819
+ "button",
2820
+ {
2821
+ css: styles5.scanButton,
2822
+ onClick: handleScan,
2823
+ disabled: scanning,
2824
+ children: scanning ? "Scanning..." : "Scan for Files"
2825
+ }
2826
+ )
2827
+ ] });
2828
+ }
2666
2829
  if (sortedItems.length === 0 && isAtRoot) {
2667
2830
  return /* @__PURE__ */ jsxs5("div", { css: styles5.empty, children: [
2668
2831
  /* @__PURE__ */ jsx5("svg", { css: styles5.emptyIcon, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx5("path", { strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: 1.5, 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" }) }),
2669
2832
  /* @__PURE__ */ jsx5("p", { css: styles5.emptyText, children: "No files in this folder" }),
2670
- /* @__PURE__ */ jsx5("p", { css: styles5.emptyText, children: "Upload images to get started" })
2833
+ /* @__PURE__ */ jsx5("p", { css: styles5.emptyText, children: "Upload images or click Scan in the toolbar" })
2671
2834
  ] });
2672
2835
  }
2673
2836
  return /* @__PURE__ */ jsxs5("div", { children: [
@@ -2864,6 +3027,32 @@ var styles6 = {
2864
3027
  height: 256px;
2865
3028
  color: ${colors.textSecondary};
2866
3029
  `,
3030
+ emptyHint: css6`
3031
+ font-size: ${fontSize.sm};
3032
+ color: ${colors.textMuted};
3033
+ margin-top: 4px;
3034
+ `,
3035
+ scanButton: css6`
3036
+ margin-top: 16px;
3037
+ padding: 10px 24px;
3038
+ font-size: ${fontSize.base};
3039
+ font-weight: 500;
3040
+ background: ${colors.primary};
3041
+ color: white;
3042
+ border: none;
3043
+ border-radius: 8px;
3044
+ cursor: pointer;
3045
+ transition: background 0.15s ease;
3046
+
3047
+ &:hover:not(:disabled) {
3048
+ background: ${colors.primaryHover};
3049
+ }
3050
+
3051
+ &:disabled {
3052
+ opacity: 0.6;
3053
+ cursor: not-allowed;
3054
+ }
3055
+ `,
2867
3056
  tableWrapper: css6`
2868
3057
  background: ${colors.surface};
2869
3058
  border-radius: 8px;
@@ -3150,6 +3339,7 @@ function StudioFileList() {
3150
3339
  const {
3151
3340
  loading,
3152
3341
  sortedItems,
3342
+ metaEmpty,
3153
3343
  isAtRoot,
3154
3344
  isSearching,
3155
3345
  allItemsSelected,
@@ -3161,11 +3351,41 @@ function StudioFileList() {
3161
3351
  handleGenerateThumbnail,
3162
3352
  handleSelectAll
3163
3353
  } = useFileList();
3354
+ const [scanning, setScanning] = useState6(false);
3355
+ const handleScan = async () => {
3356
+ setScanning(true);
3357
+ try {
3358
+ await fetch("/api/studio/scan", { method: "POST" });
3359
+ window.location.reload();
3360
+ } catch (error) {
3361
+ console.error("Scan failed:", error);
3362
+ } finally {
3363
+ setScanning(false);
3364
+ }
3365
+ };
3164
3366
  if (loading) {
3165
3367
  return /* @__PURE__ */ jsx6("div", { css: styles6.loading, children: /* @__PURE__ */ jsx6("div", { css: styles6.spinner }) });
3166
3368
  }
3369
+ if (metaEmpty && isAtRoot) {
3370
+ return /* @__PURE__ */ jsxs6("div", { css: styles6.empty, children: [
3371
+ /* @__PURE__ */ jsx6("p", { children: "No files tracked yet" }),
3372
+ /* @__PURE__ */ jsx6("p", { css: styles6.emptyHint, children: "Click Scan to discover files in your public folder" }),
3373
+ /* @__PURE__ */ jsx6(
3374
+ "button",
3375
+ {
3376
+ css: styles6.scanButton,
3377
+ onClick: handleScan,
3378
+ disabled: scanning,
3379
+ children: scanning ? "Scanning..." : "Scan for Files"
3380
+ }
3381
+ )
3382
+ ] });
3383
+ }
3167
3384
  if (sortedItems.length === 0 && isAtRoot) {
3168
- return /* @__PURE__ */ jsx6("div", { css: styles6.empty, children: /* @__PURE__ */ jsx6("p", { children: "No files in this folder" }) });
3385
+ return /* @__PURE__ */ jsxs6("div", { css: styles6.empty, children: [
3386
+ /* @__PURE__ */ jsx6("p", { children: "No files in this folder" }),
3387
+ /* @__PURE__ */ jsx6("p", { css: styles6.emptyHint, children: "Upload images or click Scan in the toolbar" })
3388
+ ] });
3169
3389
  }
3170
3390
  return /* @__PURE__ */ jsx6("div", { css: styles6.tableWrapper, children: /* @__PURE__ */ jsxs6("table", { css: styles6.table, children: [
3171
3391
  /* @__PURE__ */ jsx6("thead", { children: /* @__PURE__ */ jsxs6("tr", { children: [
@@ -4661,4 +4881,4 @@ export {
4661
4881
  StudioUI,
4662
4882
  StudioUI_default as default
4663
4883
  };
4664
- //# sourceMappingURL=StudioUI-6HTM3QHM.mjs.map
4884
+ //# sourceMappingURL=StudioUI-T7FA7S7Z.mjs.map