@gallop.software/studio 1.5.10 → 2.0.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.
Files changed (60) hide show
  1. package/app/api/studio/[...path]/route.ts +1 -0
  2. package/app/layout.tsx +20 -0
  3. package/app/page.tsx +82 -0
  4. package/bin/studio.mjs +110 -0
  5. package/dist/handlers/index.js +77 -55
  6. package/dist/handlers/index.js.map +1 -1
  7. package/dist/handlers/index.mjs +128 -106
  8. package/dist/handlers/index.mjs.map +1 -1
  9. package/dist/index.d.mts +14 -10
  10. package/dist/index.d.ts +14 -10
  11. package/dist/index.js +2 -177
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4 -179
  14. package/dist/index.mjs.map +1 -1
  15. package/next.config.mjs +22 -0
  16. package/package.json +18 -10
  17. package/src/components/AddNewModal.tsx +402 -0
  18. package/src/components/ErrorModal.tsx +89 -0
  19. package/src/components/R2SetupModal.tsx +400 -0
  20. package/src/components/StudioBreadcrumb.tsx +115 -0
  21. package/src/components/StudioButton.tsx +200 -0
  22. package/src/components/StudioContext.tsx +219 -0
  23. package/src/components/StudioDetailView.tsx +714 -0
  24. package/src/components/StudioFileGrid.tsx +704 -0
  25. package/src/components/StudioFileList.tsx +743 -0
  26. package/src/components/StudioFolderPicker.tsx +342 -0
  27. package/src/components/StudioModal.tsx +473 -0
  28. package/src/components/StudioPreview.tsx +399 -0
  29. package/src/components/StudioSettings.tsx +536 -0
  30. package/src/components/StudioToolbar.tsx +1448 -0
  31. package/src/components/StudioUI.tsx +731 -0
  32. package/src/components/styles/common.ts +236 -0
  33. package/src/components/tokens.ts +78 -0
  34. package/src/components/useStudioActions.tsx +497 -0
  35. package/src/config/index.ts +7 -0
  36. package/src/config/workspace.ts +52 -0
  37. package/src/handlers/favicon.ts +152 -0
  38. package/src/handlers/files.ts +784 -0
  39. package/src/handlers/images.ts +949 -0
  40. package/src/handlers/import.ts +190 -0
  41. package/src/handlers/index.ts +168 -0
  42. package/src/handlers/list.ts +627 -0
  43. package/src/handlers/scan.ts +311 -0
  44. package/src/handlers/utils/cdn.ts +234 -0
  45. package/src/handlers/utils/files.ts +64 -0
  46. package/src/handlers/utils/index.ts +4 -0
  47. package/src/handlers/utils/meta.ts +102 -0
  48. package/src/handlers/utils/thumbnails.ts +98 -0
  49. package/src/hooks/useFileList.ts +143 -0
  50. package/src/index.tsx +36 -0
  51. package/src/lib/api.ts +176 -0
  52. package/src/types.ts +119 -0
  53. package/dist/StudioUI-GJK45R3T.js +0 -6500
  54. package/dist/StudioUI-GJK45R3T.js.map +0 -1
  55. package/dist/StudioUI-QZ54STXE.mjs +0 -6500
  56. package/dist/StudioUI-QZ54STXE.mjs.map +0 -1
  57. package/dist/chunk-N6JYTJCB.js +0 -68
  58. package/dist/chunk-N6JYTJCB.js.map +0 -1
  59. package/dist/chunk-RHI3UROE.mjs +0 -68
  60. package/dist/chunk-RHI3UROE.mjs.map +0 -1
@@ -0,0 +1,536 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { useState, useEffect, useCallback } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { colors, fontSize, baseReset } from './tokens'
7
+ import { useStudio } from './StudioContext'
8
+
9
+ // Standard button height for consistency
10
+ const btnHeight = '36px'
11
+
12
+ const styles = {
13
+ btn: css`
14
+ height: ${btnHeight};
15
+ padding: 0 12px;
16
+ background: ${colors.surface};
17
+ border: 1px solid ${colors.border};
18
+ border-radius: 6px;
19
+ cursor: pointer;
20
+ transition: all 0.15s ease;
21
+ display: flex;
22
+ align-items: center;
23
+ justify-content: center;
24
+
25
+ &:hover {
26
+ background-color: ${colors.surfaceHover};
27
+ border-color: ${colors.borderHover};
28
+ }
29
+ `,
30
+ icon: css`
31
+ width: 16px;
32
+ height: 16px;
33
+ color: ${colors.textSecondary};
34
+ `,
35
+ overlay: css`
36
+ position: fixed;
37
+ top: 0;
38
+ right: 0;
39
+ bottom: 0;
40
+ left: 0;
41
+ z-index: 10000;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ background-color: rgba(26, 31, 54, 0.4);
46
+ backdrop-filter: blur(4px);
47
+ `,
48
+ panel: css`
49
+ ${baseReset}
50
+ position: relative;
51
+ background-color: ${colors.surface};
52
+ border-radius: 12px;
53
+ box-shadow: 0 30px 60px -12px rgba(50, 50, 93, 0.25), 0 18px 36px -18px rgba(0, 0, 0, 0.3);
54
+ width: 100%;
55
+ max-width: 512px;
56
+ padding: 24px;
57
+ `,
58
+ header: css`
59
+ display: flex;
60
+ align-items: center;
61
+ justify-content: space-between;
62
+ margin-bottom: 24px;
63
+ `,
64
+ title: css`
65
+ font-size: ${fontSize.xl};
66
+ font-weight: 600;
67
+ color: ${colors.text};
68
+ margin: 0;
69
+ letter-spacing: -0.02em;
70
+ `,
71
+ closeBtn: css`
72
+ padding: 6px;
73
+ background: ${colors.surface};
74
+ border: 1px solid ${colors.border};
75
+ border-radius: 6px;
76
+ cursor: pointer;
77
+ transition: all 0.15s ease;
78
+ display: flex;
79
+ align-items: center;
80
+ justify-content: center;
81
+
82
+ &:hover {
83
+ background-color: ${colors.surfaceHover};
84
+ border-color: ${colors.borderHover};
85
+ }
86
+ `,
87
+ sections: css`
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: 24px;
91
+ `,
92
+ sectionTitle: css`
93
+ font-size: ${fontSize.base};
94
+ font-weight: 600;
95
+ color: ${colors.text};
96
+ margin: 0 0 12px 0;
97
+ `,
98
+ description: css`
99
+ font-size: ${fontSize.sm};
100
+ color: ${colors.textSecondary};
101
+ margin: 0 0 12px 0;
102
+ `,
103
+ codeWrapper: css`
104
+ position: relative;
105
+ `,
106
+ code: css`
107
+ background-color: ${colors.background};
108
+ border-radius: 8px;
109
+ padding: 12px;
110
+ padding-right: 40px;
111
+ font-family: 'SF Mono', Monaco, Consolas, monospace;
112
+ font-size: ${fontSize.xs};
113
+ color: ${colors.textSecondary};
114
+ border: 1px solid ${colors.border};
115
+ overflow-x: auto;
116
+ white-space: nowrap;
117
+ `,
118
+ copyBtn: css`
119
+ position: absolute;
120
+ top: 8px;
121
+ right: 8px;
122
+ padding: 4px;
123
+ background: ${colors.surface};
124
+ border: 1px solid ${colors.border};
125
+ border-radius: 4px;
126
+ cursor: pointer;
127
+ transition: all 0.15s ease;
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+
132
+ &:hover {
133
+ background-color: ${colors.surfaceHover};
134
+ border-color: ${colors.borderHover};
135
+ }
136
+ `,
137
+ tooltip: css`
138
+ position: absolute;
139
+ bottom: 100%;
140
+ left: 50%;
141
+ transform: translateX(-50%);
142
+ background: #1a1f36;
143
+ color: white;
144
+ padding: 4px 8px;
145
+ border-radius: 4px;
146
+ font-size: 12px;
147
+ white-space: nowrap;
148
+ margin-bottom: 6px;
149
+ pointer-events: none;
150
+ z-index: 100;
151
+
152
+ &::after {
153
+ content: '';
154
+ position: absolute;
155
+ top: 100%;
156
+ left: 50%;
157
+ transform: translateX(-50%);
158
+ border: 4px solid transparent;
159
+ border-top-color: #1a1f36;
160
+ }
161
+ `,
162
+ copyIcon: css`
163
+ width: 14px;
164
+ height: 14px;
165
+ color: ${colors.textSecondary};
166
+ `,
167
+ codeLine: css`
168
+ margin: 0 0 4px 0;
169
+
170
+ &:last-child {
171
+ margin: 0;
172
+ }
173
+ `,
174
+ input: css`
175
+ width: 100%;
176
+ padding: 10px 14px;
177
+ border: 1px solid ${colors.border};
178
+ border-radius: 6px;
179
+ font-size: ${fontSize.base};
180
+ color: ${colors.text};
181
+ background: ${colors.surface};
182
+ transition: all 0.15s ease;
183
+
184
+ &:focus {
185
+ outline: none;
186
+ border-color: ${colors.primary};
187
+ box-shadow: 0 0 0 3px ${colors.primaryLight};
188
+ }
189
+
190
+ &::placeholder {
191
+ color: ${colors.textMuted};
192
+ }
193
+ `,
194
+ grid: css`
195
+ display: grid;
196
+ grid-template-columns: repeat(3, 1fr);
197
+ gap: 12px;
198
+ `,
199
+ label: css`
200
+ font-size: ${fontSize.xs};
201
+ font-weight: 500;
202
+ color: ${colors.textSecondary};
203
+ display: block;
204
+ margin-bottom: 6px;
205
+ `,
206
+ footer: css`
207
+ margin-top: 24px;
208
+ padding-top: 20px;
209
+ border-top: 1px solid ${colors.border};
210
+ display: flex;
211
+ justify-content: flex-end;
212
+ gap: 12px;
213
+ `,
214
+ cancelBtn: css`
215
+ padding: 10px 18px;
216
+ font-size: ${fontSize.base};
217
+ font-weight: 500;
218
+ color: ${colors.text};
219
+ background: ${colors.surface};
220
+ border: 1px solid ${colors.border};
221
+ border-radius: 6px;
222
+ cursor: pointer;
223
+ transition: all 0.15s ease;
224
+
225
+ &:hover {
226
+ background-color: ${colors.surfaceHover};
227
+ border-color: ${colors.borderHover};
228
+ }
229
+ `,
230
+ saveBtn: css`
231
+ padding: 10px 18px;
232
+ font-size: ${fontSize.base};
233
+ font-weight: 500;
234
+ color: white;
235
+ background-color: ${colors.primary};
236
+ border: 1px solid ${colors.primary};
237
+ border-radius: 6px;
238
+ cursor: pointer;
239
+ transition: all 0.15s ease;
240
+
241
+ &:hover {
242
+ background-color: ${colors.primaryHover};
243
+ border-color: ${colors.primaryHover};
244
+ }
245
+
246
+ &:disabled {
247
+ opacity: 0.6;
248
+ cursor: not-allowed;
249
+ }
250
+ `,
251
+ cdnList: css`
252
+ display: flex;
253
+ flex-direction: column;
254
+ gap: 8px;
255
+ `,
256
+ cdnRow: css`
257
+ display: flex;
258
+ gap: 8px;
259
+ align-items: center;
260
+ `,
261
+ cdnInput: css`
262
+ flex: 1;
263
+ padding: 8px 12px;
264
+ border: 1px solid ${colors.border};
265
+ border-radius: 6px;
266
+ font-size: ${fontSize.sm};
267
+ color: ${colors.text};
268
+ background: ${colors.surface};
269
+ transition: all 0.15s ease;
270
+
271
+ &:focus {
272
+ outline: none;
273
+ border-color: ${colors.primary};
274
+ box-shadow: 0 0 0 3px ${colors.primaryLight};
275
+ }
276
+ `,
277
+ cdnIndex: css`
278
+ font-size: ${fontSize.xs};
279
+ color: ${colors.textMuted};
280
+ width: 24px;
281
+ text-align: center;
282
+ flex-shrink: 0;
283
+ `,
284
+ cdnDeleteBtn: css`
285
+ padding: 6px;
286
+ background: none;
287
+ border: none;
288
+ cursor: pointer;
289
+ color: ${colors.textMuted};
290
+ transition: color 0.15s;
291
+ flex-shrink: 0;
292
+
293
+ &:hover {
294
+ color: ${colors.danger};
295
+ }
296
+ `,
297
+ cdnAddBtn: css`
298
+ padding: 8px 12px;
299
+ font-size: ${fontSize.sm};
300
+ font-weight: 500;
301
+ color: ${colors.primary};
302
+ background: ${colors.primaryLight};
303
+ border: 1px dashed ${colors.primary};
304
+ border-radius: 6px;
305
+ cursor: pointer;
306
+ transition: all 0.15s ease;
307
+ display: flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ gap: 6px;
311
+ margin-top: 8px;
312
+
313
+ &:hover {
314
+ background: ${colors.surface};
315
+ }
316
+ `,
317
+ warning: css`
318
+ font-size: ${fontSize.xs};
319
+ color: ${colors.danger};
320
+ margin-top: 8px;
321
+ padding: 8px 12px;
322
+ background: ${colors.dangerLight};
323
+ border-radius: 6px;
324
+ `,
325
+ }
326
+
327
+ export function StudioSettings() {
328
+ const [isOpen, setIsOpen] = useState(false)
329
+
330
+ return (
331
+ <>
332
+ <button css={styles.btn} onClick={() => setIsOpen(true)} aria-label="Settings">
333
+ <svg
334
+ css={styles.icon}
335
+ xmlns="http://www.w3.org/2000/svg"
336
+ viewBox="0 0 24 24"
337
+ fill="none"
338
+ stroke="currentColor"
339
+ strokeWidth={2}
340
+ strokeLinecap="round"
341
+ strokeLinejoin="round"
342
+ >
343
+ <circle cx="12" cy="12" r="3" />
344
+ <path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z" />
345
+ </svg>
346
+ </button>
347
+
348
+ {isOpen && <SettingsPanel onClose={() => setIsOpen(false)} />}
349
+ </>
350
+ )
351
+ }
352
+
353
+ const envTemplate = `CLOUDFLARE_R2_ACCOUNT_ID=abc123def456ghi789
354
+ CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id_here
355
+ CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_access_key_here
356
+ CLOUDFLARE_R2_BUCKET_NAME=my-images-bucket
357
+ CLOUDFLARE_R2_PUBLIC_URL=https://cdn.yourdomain.com`
358
+
359
+ function SettingsPanel({ onClose }: { onClose: () => void }) {
360
+ const { triggerRefresh } = useStudio()
361
+ const [copied, setCopied] = useState(false)
362
+ const [cdnUrls, setCdnUrls] = useState<string[]>([])
363
+ const [loading, setLoading] = useState(true)
364
+ const [saving, setSaving] = useState(false)
365
+ const [hasChanges, setHasChanges] = useState(false)
366
+
367
+ // Load CDN URLs on mount
368
+ useEffect(() => {
369
+ async function loadCdns() {
370
+ try {
371
+ const response = await fetch('/api/studio/cdns')
372
+ const data = await response.json()
373
+ setCdnUrls(data.cdns || [])
374
+ } catch (error) {
375
+ console.error('Failed to load CDN URLs:', error)
376
+ } finally {
377
+ setLoading(false)
378
+ }
379
+ }
380
+ loadCdns()
381
+ }, [])
382
+
383
+ const handleCopy = () => {
384
+ navigator.clipboard.writeText(envTemplate)
385
+ setCopied(true)
386
+ setTimeout(() => setCopied(false), 2000)
387
+ }
388
+
389
+ const handleCdnChange = useCallback((index: number, value: string) => {
390
+ setCdnUrls(prev => {
391
+ const updated = [...prev]
392
+ updated[index] = value
393
+ return updated
394
+ })
395
+ setHasChanges(true)
396
+ }, [])
397
+
398
+ const handleAddCdn = useCallback(() => {
399
+ setCdnUrls(prev => [...prev, ''])
400
+ setHasChanges(true)
401
+ }, [])
402
+
403
+ const handleDeleteCdn = useCallback((index: number) => {
404
+ setCdnUrls(prev => prev.filter((_, i) => i !== index))
405
+ setHasChanges(true)
406
+ }, [])
407
+
408
+ const handleSave = useCallback(async () => {
409
+ setSaving(true)
410
+ try {
411
+ const response = await fetch('/api/studio/cdns', {
412
+ method: 'POST',
413
+ headers: { 'Content-Type': 'application/json' },
414
+ // Preserve empty strings as placeholders to maintain indices
415
+ body: JSON.stringify({ cdns: cdnUrls }),
416
+ })
417
+
418
+ if (response.ok) {
419
+ setHasChanges(false)
420
+ triggerRefresh()
421
+ onClose()
422
+ }
423
+ } catch (error) {
424
+ console.error('Failed to save CDN URLs:', error)
425
+ } finally {
426
+ setSaving(false)
427
+ }
428
+ }, [cdnUrls, triggerRefresh, onClose])
429
+
430
+ return (
431
+ <div css={styles.overlay} onClick={onClose}>
432
+ <div css={styles.panel} onClick={(e) => e.stopPropagation()}>
433
+ <div css={styles.header}>
434
+ <h2 css={styles.title}>Settings</h2>
435
+ <button css={styles.closeBtn} onClick={onClose}>
436
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
437
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
438
+ </svg>
439
+ </button>
440
+ </div>
441
+
442
+ <div css={styles.sections}>
443
+ <section>
444
+ <h3 css={styles.sectionTitle}>CDN URLs</h3>
445
+ <p css={styles.description}>Manage CDN base URLs used by your images:</p>
446
+ {loading ? (
447
+ <p css={styles.description}>Loading...</p>
448
+ ) : (
449
+ <>
450
+ <div css={styles.cdnList}>
451
+ {cdnUrls.map((url, index) => (
452
+ <div key={index} css={styles.cdnRow}>
453
+ <span css={styles.cdnIndex}>{index}</span>
454
+ <input
455
+ css={styles.cdnInput}
456
+ type="text"
457
+ value={url}
458
+ onChange={(e) => handleCdnChange(index, e.target.value)}
459
+ placeholder="https://cdn.example.com"
460
+ />
461
+ <button
462
+ css={styles.cdnDeleteBtn}
463
+ onClick={() => handleDeleteCdn(index)}
464
+ title="Delete CDN URL"
465
+ >
466
+ <svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
467
+ <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" />
468
+ </svg>
469
+ </button>
470
+ </div>
471
+ ))}
472
+ </div>
473
+ <button css={styles.cdnAddBtn} onClick={handleAddCdn}>
474
+ <svg width="14" height="14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
475
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
476
+ </svg>
477
+ Add CDN URL
478
+ </button>
479
+ {cdnUrls.length > 0 && (
480
+ <p css={styles.warning}>
481
+ Warning: Changing CDN URLs may break image references. The index numbers correspond to image `c` values.
482
+ </p>
483
+ )}
484
+ </>
485
+ )}
486
+ </section>
487
+
488
+ <section>
489
+ <h3 css={styles.sectionTitle}>Cloudflare R2 Credentials</h3>
490
+ <p css={styles.description}>Configure in .env.local file:</p>
491
+ <div css={styles.codeWrapper}>
492
+ <button css={styles.copyBtn} onClick={handleCopy} title="Copy to clipboard">
493
+ {copied && <span css={styles.tooltip}>Copied!</span>}
494
+ <svg css={styles.copyIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
495
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
496
+ </svg>
497
+ </button>
498
+ <div css={styles.code}>
499
+ <p css={styles.codeLine}>CLOUDFLARE_R2_ACCOUNT_ID=abc123def456ghi789</p>
500
+ <p css={styles.codeLine}>CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id_here</p>
501
+ <p css={styles.codeLine}>CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_access_key_here</p>
502
+ <p css={styles.codeLine}>CLOUDFLARE_R2_BUCKET_NAME=my-images-bucket</p>
503
+ <p css={styles.codeLine}>CLOUDFLARE_R2_PUBLIC_URL=https://cdn.yourdomain.com</p>
504
+ </div>
505
+ </div>
506
+ </section>
507
+
508
+ <section>
509
+ <h3 css={styles.sectionTitle}>Thumbnail Sizes</h3>
510
+ <div css={styles.grid}>
511
+ <div>
512
+ <label css={styles.label}>Small</label>
513
+ <input css={styles.input} type="number" defaultValue={300} />
514
+ </div>
515
+ <div>
516
+ <label css={styles.label}>Medium</label>
517
+ <input css={styles.input} type="number" defaultValue={700} />
518
+ </div>
519
+ <div>
520
+ <label css={styles.label}>Large</label>
521
+ <input css={styles.input} type="number" defaultValue={1400} />
522
+ </div>
523
+ </div>
524
+ </section>
525
+ </div>
526
+
527
+ <div css={styles.footer}>
528
+ <button css={styles.cancelBtn} onClick={onClose}>Cancel</button>
529
+ <button css={styles.saveBtn} onClick={handleSave} disabled={saving || !hasChanges}>
530
+ {saving ? 'Saving...' : 'Save Changes'}
531
+ </button>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ )
536
+ }