@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,473 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import React from 'react'
5
+ import { css, keyframes } from '@emotion/react'
6
+ import { colors, fontSize, fontStack, baseReset } from './tokens'
7
+
8
+ const fadeIn = keyframes`
9
+ from { opacity: 0; }
10
+ to { opacity: 1; }
11
+ `
12
+
13
+ const slideIn = keyframes`
14
+ from {
15
+ opacity: 0;
16
+ transform: translateY(-8px) scale(0.98);
17
+ }
18
+ to {
19
+ opacity: 1;
20
+ transform: translateY(0) scale(1);
21
+ }
22
+ `
23
+
24
+ const styles = {
25
+ overlay: css`
26
+ position: fixed;
27
+ inset: 0;
28
+ background-color: rgba(26, 31, 54, 0.4);
29
+ backdrop-filter: blur(4px);
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ z-index: 10000;
34
+ animation: ${fadeIn} 0.15s ease-out;
35
+ font-family: ${fontStack};
36
+ `,
37
+ modal: css`
38
+ ${baseReset}
39
+ background-color: ${colors.surface};
40
+ border-radius: 12px;
41
+ box-shadow: 0 30px 60px -12px rgba(50, 50, 93, 0.25), 0 18px 36px -18px rgba(0, 0, 0, 0.3);
42
+ max-width: 420px;
43
+ width: 90%;
44
+ animation: ${slideIn} 0.2s ease-out;
45
+ overflow: hidden;
46
+ `,
47
+ header: css`
48
+ padding: 24px 24px 0;
49
+ `,
50
+ title: css`
51
+ font-size: ${fontSize.lg};
52
+ font-weight: 600;
53
+ color: ${colors.text};
54
+ margin: 0;
55
+ letter-spacing: -0.02em;
56
+ `,
57
+ body: css`
58
+ padding: 12px 24px 24px;
59
+ `,
60
+ message: css`
61
+ font-size: ${fontSize.base};
62
+ color: ${colors.textSecondary};
63
+ margin: 0;
64
+ line-height: 1.6;
65
+ `,
66
+ footer: css`
67
+ display: flex;
68
+ justify-content: flex-end;
69
+ gap: 12px;
70
+ padding: 16px 24px;
71
+ border-top: 1px solid ${colors.border};
72
+ background-color: ${colors.background};
73
+ `,
74
+ btn: css`
75
+ padding: 10px 18px;
76
+ font-size: ${fontSize.base};
77
+ font-weight: 500;
78
+ border-radius: 6px;
79
+ cursor: pointer;
80
+ transition: all 0.15s ease;
81
+ letter-spacing: -0.01em;
82
+ `,
83
+ btnCancel: css`
84
+ background-color: ${colors.surface};
85
+ border: 1px solid ${colors.border};
86
+ color: ${colors.text};
87
+
88
+ &:hover {
89
+ background-color: ${colors.surfaceHover};
90
+ border-color: ${colors.borderHover};
91
+ }
92
+ `,
93
+ btnConfirm: css`
94
+ background-color: ${colors.primary};
95
+ border: 1px solid ${colors.primary};
96
+ color: white;
97
+
98
+ &:hover {
99
+ background-color: ${colors.primaryHover};
100
+ border-color: ${colors.primaryHover};
101
+ }
102
+ `,
103
+ btnDanger: css`
104
+ background-color: ${colors.danger};
105
+ border: 1px solid ${colors.danger};
106
+ color: white;
107
+
108
+ &:hover {
109
+ background-color: ${colors.dangerHover};
110
+ border-color: ${colors.dangerHover};
111
+ }
112
+ `,
113
+ }
114
+
115
+ interface ConfirmModalProps {
116
+ title: string
117
+ message: string
118
+ confirmLabel?: string
119
+ cancelLabel?: string
120
+ variant?: 'default' | 'danger'
121
+ onConfirm: () => void
122
+ onCancel: () => void
123
+ }
124
+
125
+ export function ConfirmModal({
126
+ title,
127
+ message,
128
+ confirmLabel = 'Confirm',
129
+ cancelLabel = 'Cancel',
130
+ variant = 'default',
131
+ onConfirm,
132
+ onCancel,
133
+ }: ConfirmModalProps) {
134
+ return (
135
+ <div css={styles.overlay} onClick={onCancel}>
136
+ <div css={styles.modal} onClick={(e) => e.stopPropagation()}>
137
+ <div css={styles.header}>
138
+ <h3 css={styles.title}>{title}</h3>
139
+ </div>
140
+ <div css={styles.body}>
141
+ <p css={styles.message}>{message}</p>
142
+ </div>
143
+ <div css={styles.footer}>
144
+ <button css={[styles.btn, styles.btnCancel]} onClick={onCancel}>
145
+ {cancelLabel}
146
+ </button>
147
+ <button
148
+ css={[styles.btn, variant === 'danger' ? styles.btnDanger : styles.btnConfirm]}
149
+ onClick={onConfirm}
150
+ >
151
+ {confirmLabel}
152
+ </button>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ )
157
+ }
158
+
159
+ interface InputModalProps {
160
+ title: string
161
+ message?: string
162
+ inputLabel?: string
163
+ defaultValue?: string
164
+ placeholder?: string
165
+ confirmLabel?: string
166
+ cancelLabel?: string
167
+ onConfirm: (value: string) => void
168
+ onCancel: () => void
169
+ }
170
+
171
+ const inputStyles = {
172
+ input: css`
173
+ width: 100%;
174
+ padding: 10px 12px;
175
+ font-size: ${fontSize.base};
176
+ border: 1px solid ${colors.border};
177
+ border-radius: 6px;
178
+ background: ${colors.surface};
179
+ color: ${colors.text};
180
+ margin-top: 12px;
181
+ transition: all 0.15s ease;
182
+
183
+ &:focus {
184
+ outline: none;
185
+ border-color: ${colors.primary};
186
+ box-shadow: 0 0 0 2px ${colors.primaryLight};
187
+ }
188
+
189
+ &::placeholder {
190
+ color: ${colors.textMuted};
191
+ }
192
+ `,
193
+ }
194
+
195
+ export function InputModal({
196
+ title,
197
+ message,
198
+ inputLabel,
199
+ defaultValue = '',
200
+ placeholder,
201
+ confirmLabel = 'Confirm',
202
+ cancelLabel = 'Cancel',
203
+ onConfirm,
204
+ onCancel,
205
+ }: InputModalProps) {
206
+ const [value, setValue] = React.useState(defaultValue)
207
+
208
+ const handleSubmit = (e: React.FormEvent) => {
209
+ e.preventDefault()
210
+ if (value.trim()) {
211
+ onConfirm(value.trim())
212
+ }
213
+ }
214
+
215
+ return (
216
+ <div css={styles.overlay} onClick={onCancel}>
217
+ <div css={styles.modal} onClick={(e) => e.stopPropagation()}>
218
+ <form onSubmit={handleSubmit}>
219
+ <div css={styles.header}>
220
+ <h3 css={styles.title}>{title}</h3>
221
+ </div>
222
+ <div css={styles.body}>
223
+ {message && <p css={styles.message}>{message}</p>}
224
+ {inputLabel && <label css={styles.message}>{inputLabel}</label>}
225
+ <input
226
+ css={inputStyles.input}
227
+ type="text"
228
+ value={value}
229
+ onChange={(e) => setValue(e.target.value)}
230
+ placeholder={placeholder}
231
+ autoFocus
232
+ />
233
+ </div>
234
+ <div css={styles.footer}>
235
+ <button type="button" css={[styles.btn, styles.btnCancel]} onClick={onCancel}>
236
+ {cancelLabel}
237
+ </button>
238
+ <button type="submit" css={[styles.btn, styles.btnConfirm]} disabled={!value.trim()}>
239
+ {confirmLabel}
240
+ </button>
241
+ </div>
242
+ </form>
243
+ </div>
244
+ </div>
245
+ )
246
+ }
247
+
248
+ interface AlertModalProps {
249
+ title: string
250
+ message: string
251
+ buttonLabel?: string
252
+ onClose: () => void
253
+ }
254
+
255
+ export function AlertModal({
256
+ title,
257
+ message,
258
+ buttonLabel = 'OK',
259
+ onClose,
260
+ }: AlertModalProps) {
261
+ return (
262
+ <div css={styles.overlay} onClick={onClose}>
263
+ <div css={styles.modal} onClick={(e) => e.stopPropagation()}>
264
+ <div css={styles.header}>
265
+ <h3 css={styles.title}>{title}</h3>
266
+ </div>
267
+ <div css={styles.body}>
268
+ <p css={styles.message}>{message}</p>
269
+ </div>
270
+ <div css={styles.footer}>
271
+ <button css={[styles.btn, styles.btnConfirm]} onClick={onClose}>
272
+ {buttonLabel}
273
+ </button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+ )
278
+ }
279
+
280
+ const progressStyles = {
281
+ progressContainer: css`
282
+ margin-top: 16px;
283
+ `,
284
+ progressBar: css`
285
+ width: 100%;
286
+ height: 8px;
287
+ background-color: ${colors.background};
288
+ border-radius: 4px;
289
+ overflow: hidden;
290
+ margin-bottom: 12px;
291
+ `,
292
+ progressFill: css`
293
+ height: 100%;
294
+ background: linear-gradient(90deg, ${colors.primary}, ${colors.primaryHover});
295
+ border-radius: 4px;
296
+ transition: width 0.3s ease;
297
+ `,
298
+ progressText: css`
299
+ font-size: ${fontSize.sm};
300
+ color: ${colors.textSecondary};
301
+ margin: 0;
302
+ display: flex;
303
+ justify-content: space-between;
304
+ align-items: center;
305
+ `,
306
+ currentFile: css`
307
+ font-size: ${fontSize.xs};
308
+ color: ${colors.textMuted};
309
+ margin: 8px 0 0;
310
+ white-space: nowrap;
311
+ overflow: hidden;
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
+ }
330
+ `,
331
+ }
332
+
333
+ export interface ProgressState {
334
+ current: number
335
+ total: number
336
+ percent: number
337
+ currentFile?: string
338
+ status: 'processing' | 'cleanup' | 'complete' | 'error' | 'stopped'
339
+ message?: string
340
+ processed?: number
341
+ alreadyProcessed?: number
342
+ orphansRemoved?: number
343
+ orphanedFiles?: string[] // List of orphaned files found during scan
344
+ errors?: number
345
+ errorMessages?: string[]
346
+ isScan?: boolean
347
+ isMove?: boolean
348
+ }
349
+
350
+ interface ProgressModalProps {
351
+ title: string
352
+ progress: ProgressState
353
+ onClose?: () => void
354
+ onStop?: () => void
355
+ onDeleteOrphans?: () => void
356
+ }
357
+
358
+ export function ProgressModal({
359
+ title,
360
+ progress,
361
+ onClose,
362
+ onDeleteOrphans,
363
+ onStop,
364
+ }: ProgressModalProps) {
365
+ const isComplete = progress.status === 'complete'
366
+ const isError = progress.status === 'error'
367
+ const isStopped = progress.status === 'stopped'
368
+ const canClose = isComplete || isError || isStopped
369
+ const isRunning = !canClose
370
+
371
+ return (
372
+ <div css={styles.overlay}>
373
+ <div css={styles.modal} onClick={(e) => e.stopPropagation()}>
374
+ <div css={styles.header}>
375
+ <h3 css={styles.title}>{title}</h3>
376
+ </div>
377
+ <div css={styles.body}>
378
+ {isError ? (
379
+ <p css={styles.message}>{progress.message || 'An error occurred'}</p>
380
+ ) : isStopped ? (
381
+ <p css={styles.message}>
382
+ Processing stopped. Processed {progress.processed ?? progress.current} image{(progress.processed ?? progress.current) !== 1 ? 's' : ''} before stopping.
383
+ </p>
384
+ ) : isComplete ? (
385
+ <>
386
+ <p css={styles.message}>
387
+ {progress.message ? (
388
+ progress.message
389
+ ) : progress.isMove ? (
390
+ <>
391
+ Moved {progress.processed} file{progress.processed !== 1 ? 's' : ''}.
392
+ {progress.errors !== undefined && progress.errors > 0 ? (
393
+ <> {progress.errors} error{progress.errors !== 1 ? 's' : ''} occurred.</>
394
+ ) : null}
395
+ </>
396
+ ) : progress.isScan ? (
397
+ <>
398
+ {progress.alreadyProcessed !== undefined && progress.alreadyProcessed > 0 ? (
399
+ <>{progress.alreadyProcessed} image{progress.alreadyProcessed !== 1 ? 's' : ''} already exist. </>
400
+ ) : null}
401
+ Scanned {progress.processed} new image{progress.processed !== 1 ? 's' : ''}.
402
+ </>
403
+ ) : (
404
+ <>
405
+ Processed {progress.processed} new image{progress.processed !== 1 ? 's' : ''}.
406
+ {progress.alreadyProcessed !== undefined && progress.alreadyProcessed > 0 ? (
407
+ <> {progress.alreadyProcessed} already processed.</>
408
+ ) : null}
409
+ </>
410
+ )}
411
+ {progress.orphansRemoved !== undefined && progress.orphansRemoved > 0 ? (
412
+ <> Removed {progress.orphansRemoved} orphaned thumbnail{progress.orphansRemoved !== 1 ? 's' : ''}.</>
413
+ ) : null}
414
+ </p>
415
+ {progress.errorMessages && progress.errorMessages.length > 0 && (
416
+ <div css={progressStyles.errorList}>
417
+ {progress.errorMessages.slice(0, 10).map((msg, i) => (
418
+ <p key={i} css={progressStyles.errorItem}>{msg}</p>
419
+ ))}
420
+ {progress.errorMessages.length > 10 && (
421
+ <p css={progressStyles.errorItem}>...and {progress.errorMessages.length - 10} more</p>
422
+ )}
423
+ </div>
424
+ )}
425
+ </>
426
+ ) : (
427
+ <>
428
+ <p css={styles.message}>
429
+ {progress.status === 'cleanup'
430
+ ? (progress.message || 'Cleaning up...')
431
+ : (progress.message || 'Processing...')}
432
+ </p>
433
+ <div css={progressStyles.progressContainer}>
434
+ <div css={progressStyles.progressBar}>
435
+ <div
436
+ css={progressStyles.progressFill}
437
+ style={{ width: `${progress.percent}%` }}
438
+ />
439
+ </div>
440
+ <div css={progressStyles.progressText}>
441
+ <span>{progress.current} of {progress.total}</span>
442
+ <span>{progress.percent}%</span>
443
+ </div>
444
+ {progress.currentFile && (
445
+ <p css={progressStyles.currentFile} title={progress.currentFile}>
446
+ {progress.currentFile}
447
+ </p>
448
+ )}
449
+ </div>
450
+ </>
451
+ )}
452
+ </div>
453
+ <div css={styles.footer}>
454
+ {isRunning && onStop && (
455
+ <button css={[styles.btn, styles.btnDanger]} onClick={onStop}>
456
+ Stop
457
+ </button>
458
+ )}
459
+ {canClose && progress.orphanedFiles && progress.orphanedFiles.length > 0 && onDeleteOrphans && (
460
+ <button css={[styles.btn, styles.btnDanger]} onClick={onDeleteOrphans}>
461
+ Delete {progress.orphanedFiles.length} Orphan{progress.orphanedFiles.length !== 1 ? 's' : ''}
462
+ </button>
463
+ )}
464
+ {canClose && (
465
+ <button css={[styles.btn, styles.btnConfirm]} onClick={onClose}>
466
+ Done
467
+ </button>
468
+ )}
469
+ </div>
470
+ </div>
471
+ </div>
472
+ )
473
+ }