@gallop.software/studio 2.2.3 → 2.2.5

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.
package/README.md CHANGED
@@ -1,157 +1,120 @@
1
1
  # @gallop.software/studio
2
2
 
3
- Media manager for Gallop templates. Upload, process, and sync images to Cloudflare R2 CDN.
3
+ Standalone media manager for Gallop templates. Upload, process, and sync images to Cloudflare R2 CDN.
4
4
 
5
5
  ## Features
6
6
 
7
- - **Floating button** in dev mode opens a full-screen media manager
7
+ - **Standalone dev server** - runs on its own port, doesn't affect your app
8
8
  - **Upload images** with automatic thumbnail generation
9
9
  - **Browse folders** with grid and list views
10
10
  - **Multi-select** for batch operations
11
- - **Sync to CDN** (Cloudflare R2) with automatic local cleanup
12
- - **Blurhash** and dominant color extraction
13
- - **Meta file** with full TypeScript types
11
+ - **Push to CDN** (Cloudflare R2) with automatic local cleanup
12
+ - **Cache purge** for custom CDN domains
13
+ - **Blurhash** generation for image placeholders
14
14
 
15
15
  ## Installation
16
16
 
17
17
  ```bash
18
- npm install @gallop.software/studio
18
+ npm install @gallop.software/studio --save-dev
19
19
  ```
20
20
 
21
21
  ## Quick Start
22
22
 
23
- ### 1. Add StudioProvider to your layout
24
-
25
- ```tsx
26
- // src/app/layout.tsx
27
- import { StudioProvider } from '@gallop.software/studio'
28
-
29
- export default function RootLayout({ children }) {
30
- return (
31
- <html>
32
- <body>
33
- {children}
34
- <StudioProvider />
35
- </body>
36
- </html>
37
- )
38
- }
39
- ```
40
-
41
- ### 2. Create API routes
42
-
43
- Create these files in your project:
44
-
45
- ```ts
46
- // src/app/api/studio/list/route.ts
47
- export { GET } from '@gallop.software/studio/api/list'
48
-
49
- // src/app/api/studio/upload/route.ts
50
- export { POST } from '@gallop.software/studio/api/upload'
51
-
52
- // src/app/api/studio/delete/route.ts
53
- export { POST } from '@gallop.software/studio/api/delete'
23
+ ### 1. Create `.env.studio`
54
24
 
55
- // src/app/api/studio/scan/route.ts
56
- export { GET } from '@gallop.software/studio/api/scan'
57
-
58
- // src/app/api/studio/sync/route.ts
59
- export { POST } from '@gallop.software/studio/api/sync'
60
-
61
- // src/app/api/studio/reprocess/route.ts
62
- export { POST } from '@gallop.software/studio/api/reprocess'
63
- ```
64
-
65
- ### 3. Configure Cloudflare R2 (optional)
66
-
67
- Add to your `.env.local`:
25
+ Create a `.env.studio` file in your project root:
68
26
 
69
27
  ```bash
28
+ # Dev site link (opens in new tab from Studio header)
29
+ STUDIO_DEV_SITE_URL=http://localhost:3000
30
+
31
+ # Cloudflare R2 Storage
70
32
  CLOUDFLARE_R2_ACCOUNT_ID=your_account_id
71
33
  CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key
72
34
  CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_key
73
35
  CLOUDFLARE_R2_BUCKET_NAME=your_bucket
36
+ CLOUDFLARE_R2_PUBLIC_URL=https://your-cdn.example.com
74
37
 
75
- # Default R2 URL or custom CDN domain
76
- CLOUDFLARE_R2_PUBLIC_URL=https://your-bucket.r2.dev
38
+ # Cloudflare Cache Purge (optional, for custom domains)
39
+ CLOUDFLARE_ZONE_ID=your_zone_id
40
+ CLOUDFLARE_API_TOKEN=your_api_token
77
41
  ```
78
42
 
79
- ### 4. Run your dev server
43
+ Add `.env.studio` to your `.gitignore`.
44
+
45
+ ### 2. Add script to package.json
46
+
47
+ ```json
48
+ {
49
+ "scripts": {
50
+ "studio": "studio"
51
+ }
52
+ }
53
+ ```
54
+
55
+ ### 3. Run Studio
80
56
 
81
57
  ```bash
82
- npm run dev
58
+ npm run studio
83
59
  ```
84
60
 
85
- A floating button appears in the bottom-right corner. Click to open Studio.
61
+ Studio opens in your browser on an available port (default 3001).
86
62
 
87
- ## Using Images
63
+ ## Environment Variables
88
64
 
89
- ```tsx
90
- import { meta, getImageUrl, type ImageSize } from '@gallop.software/studio'
65
+ | Variable | Required | Description |
66
+ |----------|----------|-------------|
67
+ | `STUDIO_DEV_SITE_URL` | No | URL to your dev site (shown as link in header) |
68
+ | `CLOUDFLARE_R2_ACCOUNT_ID` | For CDN | Your Cloudflare account ID |
69
+ | `CLOUDFLARE_R2_ACCESS_KEY_ID` | For CDN | R2 API access key |
70
+ | `CLOUDFLARE_R2_SECRET_ACCESS_KEY` | For CDN | R2 API secret key |
71
+ | `CLOUDFLARE_R2_BUCKET_NAME` | For CDN | R2 bucket name |
72
+ | `CLOUDFLARE_R2_PUBLIC_URL` | For CDN | Public URL for your R2 bucket or custom domain |
73
+ | `CLOUDFLARE_ZONE_ID` | For cache purge | Cloudflare zone ID for cache purge |
74
+ | `CLOUDFLARE_API_TOKEN` | For cache purge | API token with Cache Purge permission |
91
75
 
92
- // Get image metadata
93
- const hero = meta.images['hero.jpg']
94
- console.log(hero.sizes.medium.width) // 700
95
- console.log(hero.blurhash) // "LEHV6nWB..."
76
+ ## Setting Up Cloudflare R2
96
77
 
97
- // Get resolved URL (handles CDN vs local)
98
- const url = getImageUrl('hero.jpg', 'medium')
99
- // "/images/hero-700.jpg" or "https://cdn.example.com/images/hero-700.jpg"
100
- ```
78
+ 1. Go to Cloudflare Dashboard R2 → Create bucket
79
+ 2. Go to R2 → Manage R2 API Tokens → Create API Token
80
+ 3. Copy the Access Key ID and Secret Access Key
81
+ 4. Enable public access or set up a custom domain
101
82
 
102
- ## Folder Structure
83
+ ## Setting Up Cache Purge (Custom Domains)
103
84
 
104
- Studio manages these folders:
85
+ If using a custom domain for your CDN:
105
86
 
106
- ```
107
- public/
108
- ├── originals/ # Source images (uploaded here)
109
- │ └── hero.jpg
110
- └── images/ # Generated thumbnails
111
- ├── hero.jpg # Full size (optimized)
112
- ├── hero-1400.jpg # Large
113
- ├── hero-700.jpg # Medium
114
- └── hero-300.jpg # Small
115
-
116
- _data/
117
- └── _meta.json # Image metadata
118
- ```
87
+ 1. Go to Cloudflare Dashboard → select your domain → copy Zone ID from sidebar
88
+ 2. Go to Account → API Tokens → Create Token
89
+ 3. Add permission: Zone Cache Purge → Edit
90
+ 4. Copy the token value
91
+
92
+ ## Metadata
119
93
 
120
- ## Meta Schema
94
+ Studio stores image metadata in `_data/_studio.json`:
121
95
 
122
96
  ```json
123
97
  {
124
- "$schema": "https://gallop.software/schemas/studio-meta.json",
125
- "version": 1,
126
- "generatedAt": "2026-01-24T12:00:00Z",
127
- "images": {
128
- "hero.jpg": {
129
- "original": {
130
- "path": "/originals/hero.jpg",
131
- "width": 2400,
132
- "height": 1600,
133
- "fileSize": 1245000
134
- },
135
- "sizes": {
136
- "full": { "path": "/images/hero.jpg", "width": 2400, "height": 1600 },
137
- "large": { "path": "/images/hero-1400.jpg", "width": 1400, "height": 934 },
138
- "medium": { "path": "/images/hero-700.jpg", "width": 700, "height": 467 },
139
- "small": { "path": "/images/hero-300.jpg", "width": 300, "height": 200 }
140
- },
141
- "blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
142
- "dominantColor": "#a85c32",
143
- "cdn": null
144
- }
98
+ "_cdns": ["https://your-cdn.example.com"],
99
+ "/hero.jpg": {
100
+ "o": { "w": 2400, "h": 1600 },
101
+ "b": "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
102
+ "sm": { "w": 300, "h": 200 },
103
+ "md": { "w": 700, "h": 467 },
104
+ "lg": { "w": 1400, "h": 934 },
105
+ "f": { "w": 2400, "h": 1600 },
106
+ "c": 0
145
107
  }
146
108
  }
147
109
  ```
148
110
 
149
- ## CDN Workflow
150
-
151
- 1. Upload image saves to `originals/`, generates thumbnails
152
- 2. Click "Sync CDN" uploads to R2, deletes local files
153
- 3. `meta.images[key].cdn.synced` becomes `true`
154
- 4. Image component uses CDN URL automatically
111
+ | Property | Description |
112
+ |----------|-------------|
113
+ | `o` | Original dimensions `{ w, h }` |
114
+ | `b` | Blurhash string |
115
+ | `sm/md/lg/f` | Thumbnail dimensions (small/medium/large/full) |
116
+ | `c` | CDN index (references `_cdns` array) |
117
+ | `u` | Update pending flag (local file overrides cloud) |
155
118
 
156
119
  ## License
157
120
 
@@ -229,8 +229,14 @@ function isProcessed(entry) {
229
229
  async function purgeCloudflareCache(urls) {
230
230
  const zoneId = process.env.CLOUDFLARE_ZONE_ID;
231
231
  const apiToken = process.env.CLOUDFLARE_API_TOKEN;
232
- if (!zoneId || !apiToken || urls.length === 0) {
233
- return;
232
+ if (urls.length === 0) {
233
+ return { status: "success" };
234
+ }
235
+ if (!zoneId || !apiToken) {
236
+ return {
237
+ status: "not_configured",
238
+ message: "Cache purge skipped. To enable, add CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN to .env.studio"
239
+ };
234
240
  }
235
241
  try {
236
242
  const response = await fetch(
@@ -245,10 +251,20 @@ async function purgeCloudflareCache(urls) {
245
251
  }
246
252
  );
247
253
  if (!response.ok) {
248
- console.error("Cache purge failed:", await response.text());
254
+ const text = await response.text();
255
+ console.error("Cache purge failed:", text);
256
+ return {
257
+ status: "failed",
258
+ message: "Cache purge failed. Check CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN in .env.studio"
259
+ };
249
260
  }
261
+ return { status: "success", message: "Cache cleared successfully." };
250
262
  } catch (error) {
251
263
  console.error("Cache purge error:", error);
264
+ return {
265
+ status: "failed",
266
+ message: "Cache purge failed. Check your network connection."
267
+ };
252
268
  }
253
269
  }
254
270
  function getR2Client() {
@@ -1605,13 +1621,18 @@ async function handleSync(request) {
1605
1621
  for (const folder of sourceFolders) {
1606
1622
  await deleteEmptyFolders(folder);
1607
1623
  }
1624
+ let cacheMessage;
1608
1625
  if (urlsToPurge.length > 0) {
1609
- await purgeCloudflareCache(urlsToPurge);
1626
+ const cacheResult = await purgeCloudflareCache(urlsToPurge);
1627
+ if (cacheResult.message) {
1628
+ cacheMessage = cacheResult.message;
1629
+ }
1610
1630
  }
1611
1631
  return jsonResponse({
1612
1632
  success: true,
1613
1633
  pushed,
1614
- errors: errors.length > 0 ? errors : void 0
1634
+ errors: errors.length > 0 ? errors : void 0,
1635
+ cacheMessage
1615
1636
  });
1616
1637
  } catch (error) {
1617
1638
  console.error("Failed to push:", error);
@@ -1693,9 +1714,13 @@ async function handleUnprocessStream(request) {
1693
1714
  }
1694
1715
  sendEvent({ type: "cleanup", message: "Saving metadata..." });
1695
1716
  await saveMeta(meta);
1717
+ let cacheMessage = "";
1696
1718
  if (urlsToPurge.length > 0) {
1697
1719
  sendEvent({ type: "cleanup", message: "Purging CDN cache..." });
1698
- await purgeCloudflareCache(urlsToPurge);
1720
+ const cacheResult = await purgeCloudflareCache(urlsToPurge);
1721
+ if (cacheResult.message) {
1722
+ cacheMessage = ` ${cacheResult.message}`;
1723
+ }
1699
1724
  }
1700
1725
  sendEvent({ type: "cleanup", message: "Cleaning up empty folders..." });
1701
1726
  const imagesDir = getPublicPath("images");
@@ -1710,6 +1735,7 @@ async function handleUnprocessStream(request) {
1710
1735
  if (errors.length > 0) {
1711
1736
  message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
1712
1737
  }
1738
+ message += cacheMessage;
1713
1739
  sendEvent({
1714
1740
  type: "complete",
1715
1741
  processed: removed.length,
@@ -1841,14 +1867,19 @@ async function handleReprocessStream(request) {
1841
1867
  }
1842
1868
  sendEvent({ type: "cleanup", message: "Saving metadata..." });
1843
1869
  await saveMeta(meta);
1870
+ let cacheMessage = "";
1844
1871
  if (urlsToPurge.length > 0) {
1845
1872
  sendEvent({ type: "cleanup", message: "Purging CDN cache..." });
1846
- await purgeCloudflareCache(urlsToPurge);
1873
+ const cacheResult = await purgeCloudflareCache(urlsToPurge);
1874
+ if (cacheResult.message) {
1875
+ cacheMessage = ` ${cacheResult.message}`;
1876
+ }
1847
1877
  }
1848
1878
  let message = `Generated thumbnails for ${processed.length} image${processed.length !== 1 ? "s" : ""}.`;
1849
1879
  if (errors.length > 0) {
1850
1880
  message += ` ${errors.length} image${errors.length !== 1 ? "s" : ""} failed.`;
1851
1881
  }
1882
+ message += cacheMessage;
1852
1883
  sendEvent({
1853
1884
  type: "complete",
1854
1885
  processed: processed.length,
@@ -2062,9 +2093,17 @@ async function handlePushUpdatesStream(request) {
2062
2093
  await deleteEmptyFolders(path7.dirname(localPath));
2063
2094
  }
2064
2095
  await saveMeta(meta);
2096
+ let cacheMessage = "";
2065
2097
  if (urlsToPurge.length > 0) {
2066
2098
  sendEvent({ type: "cleanup", message: "Purging CDN cache..." });
2067
- await purgeCloudflareCache(urlsToPurge);
2099
+ const cacheResult = await purgeCloudflareCache(urlsToPurge);
2100
+ if (cacheResult.status === "not_configured") {
2101
+ cacheMessage = ` ${cacheResult.message}`;
2102
+ } else if (cacheResult.status === "failed") {
2103
+ cacheMessage = ` ${cacheResult.message}`;
2104
+ } else if (cacheResult.status === "success" && cacheResult.message) {
2105
+ cacheMessage = ` ${cacheResult.message}`;
2106
+ }
2068
2107
  }
2069
2108
  let message = `Pushed ${pushed.length} update${pushed.length !== 1 ? "s" : ""} to cloud.`;
2070
2109
  if (skipped.length > 0) {
@@ -2073,6 +2112,7 @@ async function handlePushUpdatesStream(request) {
2073
2112
  if (errors.length > 0) {
2074
2113
  message += ` ${errors.length} file${errors.length !== 1 ? "s" : ""} failed.`;
2075
2114
  }
2115
+ message += cacheMessage;
2076
2116
  sendEvent({
2077
2117
  type: "complete",
2078
2118
  pushed: pushed.length,
@@ -2721,7 +2761,7 @@ async function startServer(options) {
2721
2761
  const htmlPath = join(clientDir, "index.html");
2722
2762
  if (existsSync(htmlPath)) {
2723
2763
  let html = readFileSync(htmlPath, "utf-8");
2724
- const siteUrl = process.env.NEXT_PUBLIC_PRODUCTION_URL || "";
2764
+ const siteUrl = process.env.STUDIO_DEV_SITE_URL || "";
2725
2765
  const script = `<script>
2726
2766
  window.__STUDIO_WORKSPACE__ = ${JSON.stringify(workspace)};
2727
2767
  window.__STUDIO_SITE_URL__ = ${JSON.stringify(siteUrl)};