@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 +72 -109
- package/dist/server/index.js +49 -9
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,157 +1,120 @@
|
|
|
1
1
|
# @gallop.software/studio
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Standalone media manager for Gallop templates. Upload, process, and sync images to Cloudflare R2 CDN.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
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
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
58
|
+
npm run studio
|
|
83
59
|
```
|
|
84
60
|
|
|
85
|
-
|
|
61
|
+
Studio opens in your browser on an available port (default 3001).
|
|
86
62
|
|
|
87
|
-
##
|
|
63
|
+
## Environment Variables
|
|
88
64
|
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
##
|
|
83
|
+
## Setting Up Cache Purge (Custom Domains)
|
|
103
84
|
|
|
104
|
-
|
|
85
|
+
If using a custom domain for your CDN:
|
|
105
86
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
94
|
+
Studio stores image metadata in `_data/_studio.json`:
|
|
121
95
|
|
|
122
96
|
```json
|
|
123
97
|
{
|
|
124
|
-
"
|
|
125
|
-
"
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
"
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
package/dist/server/index.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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.
|
|
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)};
|