@gallop.software/studio 1.5.9 → 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 +84 -63
  6. package/dist/handlers/index.js.map +1 -1
  7. package/dist/handlers/index.mjs +135 -114
  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,399 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { useState } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { useStudio } from './StudioContext'
7
+ import { ConfirmModal, AlertModal } from './StudioModal'
8
+ import { colors, fontSize } from './tokens'
9
+
10
+ const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.ico', '.bmp', '.tiff', '.tif']
11
+ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v']
12
+
13
+ function isImageFile(filename: string): boolean {
14
+ const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
15
+ return IMAGE_EXTENSIONS.includes(ext)
16
+ }
17
+
18
+ function isVideoFile(filename: string): boolean {
19
+ const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
20
+ return VIDEO_EXTENSIONS.includes(ext)
21
+ }
22
+
23
+ const styles = {
24
+ panel: css`
25
+ width: 320px;
26
+ border-left: 1px solid ${colors.border};
27
+ background-color: ${colors.surface};
28
+ padding: 20px;
29
+ overflow: auto;
30
+ `,
31
+ title: css`
32
+ font-size: ${fontSize.sm};
33
+ font-weight: 600;
34
+ color: ${colors.textSecondary};
35
+ text-transform: uppercase;
36
+ letter-spacing: 0.05em;
37
+ margin: 0 0 16px 0;
38
+ `,
39
+ imageContainer: css`
40
+ background-color: ${colors.background};
41
+ border-radius: 8px;
42
+ border: 1px solid ${colors.border};
43
+ padding: 12px;
44
+ margin-bottom: 20px;
45
+ `,
46
+ image: css`
47
+ width: 100%;
48
+ height: auto;
49
+ border-radius: 6px;
50
+ `,
51
+ info: css`
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 10px;
55
+ `,
56
+ row: css`
57
+ display: flex;
58
+ justify-content: space-between;
59
+ font-size: ${fontSize.sm};
60
+ `,
61
+ label: css`
62
+ color: ${colors.textSecondary};
63
+ `,
64
+ value: css`
65
+ color: ${colors.text};
66
+ font-weight: 500;
67
+ `,
68
+ valueTruncate: css`
69
+ max-width: 140px;
70
+ white-space: nowrap;
71
+ overflow: hidden;
72
+ text-overflow: ellipsis;
73
+ `,
74
+ section: css`
75
+ padding-top: 12px;
76
+ margin-top: 4px;
77
+ border-top: 1px solid ${colors.borderLight};
78
+ `,
79
+ sectionTitle: css`
80
+ font-size: ${fontSize.xs};
81
+ font-weight: 600;
82
+ color: ${colors.textMuted};
83
+ text-transform: uppercase;
84
+ letter-spacing: 0.05em;
85
+ margin: 0 0 10px 0;
86
+ `,
87
+ cdnStatus: css`
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 8px;
91
+ font-size: ${fontSize.sm};
92
+ color: ${colors.success};
93
+ font-weight: 500;
94
+ `,
95
+ cdnIcon: css`
96
+ width: 16px;
97
+ height: 16px;
98
+ `,
99
+ copyBtn: css`
100
+ margin-top: 8px;
101
+ font-size: ${fontSize.sm};
102
+ font-weight: 500;
103
+ color: ${colors.primary};
104
+ background: none;
105
+ border: none;
106
+ cursor: pointer;
107
+ padding: 0;
108
+
109
+ &:hover {
110
+ text-decoration: underline;
111
+ }
112
+ `,
113
+ colorSwatch: css`
114
+ margin-top: 8px;
115
+ height: 32px;
116
+ border-radius: 6px;
117
+ border: 1px solid ${colors.border};
118
+ `,
119
+ emptyState: css`
120
+ display: flex;
121
+ flex-direction: column;
122
+ align-items: center;
123
+ justify-content: center;
124
+ height: 200px;
125
+ text-align: center;
126
+ `,
127
+ emptyText: css`
128
+ font-size: ${fontSize.sm};
129
+ color: ${colors.textMuted};
130
+ margin: 0;
131
+ `,
132
+ filePlaceholder: css`
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ height: 120px;
137
+ background: ${colors.background};
138
+ border-radius: 6px;
139
+ `,
140
+ fileIcon: css`
141
+ width: 56px;
142
+ height: 56px;
143
+ color: ${colors.textMuted};
144
+ `,
145
+ folderIcon: css`
146
+ width: 56px;
147
+ height: 56px;
148
+ color: #f5a623;
149
+ `,
150
+ video: css`
151
+ width: 100%;
152
+ height: auto;
153
+ border-radius: 6px;
154
+ `,
155
+ actions: css`
156
+ margin-top: 20px;
157
+ padding-top: 20px;
158
+ border-top: 1px solid ${colors.border};
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: 8px;
162
+ `,
163
+ actionBtn: css`
164
+ width: 100%;
165
+ padding: 10px 14px;
166
+ font-size: ${fontSize.base};
167
+ font-weight: 500;
168
+ background-color: ${colors.surface};
169
+ border: 1px solid ${colors.border};
170
+ border-radius: 6px;
171
+ cursor: pointer;
172
+ transition: all 0.15s ease;
173
+ color: ${colors.text};
174
+
175
+ &:hover {
176
+ background-color: ${colors.surfaceHover};
177
+ border-color: #d0d5dd;
178
+ }
179
+ `,
180
+ actionBtnDanger: css`
181
+ color: ${colors.danger};
182
+
183
+ &:hover {
184
+ background-color: ${colors.dangerLight};
185
+ border-color: ${colors.danger};
186
+ }
187
+ `,
188
+ }
189
+
190
+ export function StudioPreview() {
191
+ const { selectedItems, meta, triggerRefresh, clearSelection } = useStudio()
192
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
193
+ const [alertMessage, setAlertMessage] = useState<{ title: string; message: string } | null>(null)
194
+
195
+ const handleDeleteClick = () => {
196
+ if (selectedItems.size === 0) return
197
+ setShowDeleteConfirm(true)
198
+ }
199
+
200
+ const handleDeleteConfirm = async () => {
201
+ setShowDeleteConfirm(false)
202
+
203
+ try {
204
+ const response = await fetch('/api/studio/delete', {
205
+ method: 'POST',
206
+ headers: { 'Content-Type': 'application/json' },
207
+ body: JSON.stringify({ paths: Array.from(selectedItems) }),
208
+ })
209
+
210
+ if (response.ok) {
211
+ clearSelection()
212
+ triggerRefresh()
213
+ } else {
214
+ const error = await response.json()
215
+ setAlertMessage({
216
+ title: 'Delete Failed',
217
+ message: error.error || 'Unknown error',
218
+ })
219
+ }
220
+ } catch (error) {
221
+ console.error('Delete error:', error)
222
+ setAlertMessage({
223
+ title: 'Delete Failed',
224
+ message: 'Delete failed. Check console for details.',
225
+ })
226
+ }
227
+ }
228
+
229
+ const modals = (
230
+ <>
231
+ {showDeleteConfirm && (
232
+ <ConfirmModal
233
+ title="Delete Items"
234
+ message={`Are you sure you want to delete ${selectedItems.size} item(s)? This action cannot be undone.`}
235
+ confirmLabel="Delete"
236
+ variant="danger"
237
+ onConfirm={handleDeleteConfirm}
238
+ onCancel={() => setShowDeleteConfirm(false)}
239
+ />
240
+ )}
241
+
242
+ {alertMessage && (
243
+ <AlertModal
244
+ title={alertMessage.title}
245
+ message={alertMessage.message}
246
+ onClose={() => setAlertMessage(null)}
247
+ />
248
+ )}
249
+ </>
250
+ )
251
+
252
+ // Always show the sidebar
253
+ if (selectedItems.size === 0) {
254
+ return (
255
+ <>
256
+ {modals}
257
+ <div css={styles.panel}>
258
+ <h3 css={styles.title}>Preview</h3>
259
+ <div css={styles.emptyState}>
260
+ <p css={styles.emptyText}>Select an image to preview</p>
261
+ </div>
262
+ </div>
263
+ </>
264
+ )
265
+ }
266
+
267
+ if (selectedItems.size > 1) {
268
+ return (
269
+ <>
270
+ {modals}
271
+ <div css={styles.panel}>
272
+ <h3 css={styles.title}>{selectedItems.size} items selected</h3>
273
+ <div css={styles.actions}>
274
+ <button css={[styles.actionBtn, styles.actionBtnDanger]} onClick={handleDeleteClick}>
275
+ Delete {selectedItems.size} items
276
+ </button>
277
+ </div>
278
+ </div>
279
+ </>
280
+ )
281
+ }
282
+
283
+ const selectedPath = Array.from(selectedItems)[0]
284
+ const isFolder = !selectedPath.includes('.') || selectedPath.endsWith('/')
285
+ const filename = selectedPath.split('/').pop() || ''
286
+ const isImage = isImageFile(filename)
287
+ const isVideo = isVideoFile(filename)
288
+
289
+ // Build the meta key (with leading slash)
290
+ const imageKey = '/' + selectedPath
291
+ .replace(/^public\/images\//, '')
292
+ .replace(/^public\/originals\//, '')
293
+ .replace(/^public\//, '')
294
+
295
+ const imageData = meta?.[imageKey]
296
+
297
+ const renderPreview = () => {
298
+ if (isFolder) {
299
+ return (
300
+ <div css={styles.filePlaceholder}>
301
+ <svg css={styles.folderIcon} fill="currentColor" viewBox="0 0 24 24">
302
+ <path d="M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z" />
303
+ </svg>
304
+ </div>
305
+ )
306
+ }
307
+
308
+ if (isImage) {
309
+ return (
310
+ <img
311
+ css={styles.image}
312
+ src={selectedPath.replace('public', '')}
313
+ alt="Preview"
314
+ />
315
+ )
316
+ }
317
+
318
+ if (isVideo) {
319
+ return (
320
+ <video
321
+ css={styles.video}
322
+ src={selectedPath.replace('public', '')}
323
+ controls
324
+ muted
325
+ />
326
+ )
327
+ }
328
+
329
+ // Non-image/video file - show file icon
330
+ return (
331
+ <div css={styles.filePlaceholder}>
332
+ <svg css={styles.fileIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
333
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
334
+ </svg>
335
+ </div>
336
+ )
337
+ }
338
+
339
+ return (
340
+ <>
341
+ {modals}
342
+ <div css={styles.panel}>
343
+ <h3 css={styles.title}>Preview</h3>
344
+
345
+ <div css={styles.imageContainer}>
346
+ {renderPreview()}
347
+ </div>
348
+
349
+ <div css={styles.info}>
350
+ <InfoRow label="Filename" value={selectedPath.split('/').pop() || ''} />
351
+
352
+ {imageData && (
353
+ <>
354
+ <InfoRow
355
+ label="Dimensions"
356
+ value={imageData.o ? `${imageData.o.w}x${imageData.o.h}` : 'Unknown'}
357
+ />
358
+
359
+ {imageData.c && (
360
+ <div css={styles.section}>
361
+ <p css={styles.sectionTitle}>CDN</p>
362
+ <div css={styles.cdnStatus}>
363
+ <svg css={styles.cdnIcon} fill="currentColor" viewBox="0 0 20 20">
364
+ <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
365
+ </svg>
366
+ Pushed to CDN
367
+ </div>
368
+ </div>
369
+ )}
370
+
371
+ {imageData.b && (
372
+ <div css={styles.section}>
373
+ <InfoRow label="Blurhash" value={imageData.b} truncate />
374
+ </div>
375
+ )}
376
+ </>
377
+ )}
378
+ </div>
379
+
380
+ <div css={styles.actions}>
381
+ <button css={styles.actionBtn}>Rename</button>
382
+ <button css={[styles.actionBtn, styles.actionBtnDanger]} onClick={handleDeleteClick}>Delete</button>
383
+ </div>
384
+ </div>
385
+ </>
386
+ )
387
+ }
388
+
389
+ function InfoRow({ label, value, truncate }: { label: string; value: string; truncate?: boolean }) {
390
+ return (
391
+ <div css={styles.row}>
392
+ <span css={styles.label}>{label}</span>
393
+ <span css={[styles.value, truncate && styles.valueTruncate]} title={truncate ? value : undefined}>
394
+ {value}
395
+ </span>
396
+ </div>
397
+ )
398
+ }
399
+