@gallop.software/studio 1.5.10 → 2.0.1

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 +23 -0
  3. package/app/page.tsx +90 -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,400 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { useState } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { colors, fontSize } from './tokens'
7
+
8
+ const ENV_TEMPLATE = `CLOUDFLARE_R2_ACCOUNT_ID=your_account_id
9
+ CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key
10
+ CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_key
11
+ CLOUDFLARE_R2_BUCKET_NAME=your_bucket_name
12
+ CLOUDFLARE_R2_PUBLIC_URL=https://pub-xxx.r2.dev
13
+ NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL=https://pub-xxx.r2.dev`
14
+
15
+ interface R2SetupModalProps {
16
+ isOpen: boolean
17
+ onClose: () => void
18
+ }
19
+
20
+ const styles = {
21
+ overlay: css`
22
+ position: fixed;
23
+ inset: 0;
24
+ background: rgba(0, 0, 0, 0.6);
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ z-index: 1100;
29
+ padding: 20px;
30
+ `,
31
+ modal: css`
32
+ background: ${colors.surface};
33
+ border-radius: 12px;
34
+ max-width: 560px;
35
+ width: 100%;
36
+ max-height: 90vh;
37
+ overflow-y: auto;
38
+ box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
39
+ `,
40
+ header: css`
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 12px;
44
+ padding: 20px 24px;
45
+ border-bottom: 1px solid ${colors.border};
46
+ `,
47
+ icon: css`
48
+ width: 32px;
49
+ height: 32px;
50
+ color: ${colors.primary};
51
+ flex-shrink: 0;
52
+ `,
53
+ title: css`
54
+ font-size: ${fontSize.xl};
55
+ font-weight: 600;
56
+ color: ${colors.text};
57
+ margin: 0;
58
+ `,
59
+ closeBtn: css`
60
+ margin-left: auto;
61
+ background: none;
62
+ border: none;
63
+ padding: 4px;
64
+ cursor: pointer;
65
+ color: ${colors.textMuted};
66
+ border-radius: 4px;
67
+
68
+ &:hover {
69
+ color: ${colors.text};
70
+ background: ${colors.surfaceHover};
71
+ }
72
+ `,
73
+ closeIcon: css`
74
+ width: 20px;
75
+ height: 20px;
76
+ `,
77
+ content: css`
78
+ padding: 24px;
79
+ `,
80
+ intro: css`
81
+ font-size: ${fontSize.base};
82
+ color: ${colors.textSecondary};
83
+ margin: 0 0 20px 0;
84
+ line-height: 1.6;
85
+ `,
86
+ steps: css`
87
+ list-style: none;
88
+ padding: 0;
89
+ margin: 0;
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 16px;
93
+ `,
94
+ step: css`
95
+ display: flex;
96
+ gap: 12px;
97
+ `,
98
+ stepNumber: css`
99
+ width: 28px;
100
+ height: 28px;
101
+ border-radius: 50%;
102
+ background: ${colors.primaryLight};
103
+ color: ${colors.primary};
104
+ font-size: ${fontSize.sm};
105
+ font-weight: 600;
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ flex-shrink: 0;
110
+ `,
111
+ stepContent: css`
112
+ flex: 1;
113
+ padding-top: 3px;
114
+ `,
115
+ stepTitle: css`
116
+ font-size: ${fontSize.base};
117
+ font-weight: 500;
118
+ color: ${colors.text};
119
+ margin: 0 0 4px 0;
120
+ `,
121
+ stepDesc: css`
122
+ font-size: ${fontSize.sm};
123
+ color: ${colors.textSecondary};
124
+ margin: 0;
125
+ line-height: 1.5;
126
+ `,
127
+ link: css`
128
+ color: ${colors.primary};
129
+ text-decoration: none;
130
+ font-weight: 500;
131
+
132
+ &:hover {
133
+ text-decoration: underline;
134
+ }
135
+ `,
136
+ envVarsWrapper: css`
137
+ position: relative;
138
+ margin-top: 20px;
139
+ `,
140
+ envVars: css`
141
+ background: ${colors.background};
142
+ border: 1px solid ${colors.border};
143
+ border-radius: 8px;
144
+ padding: 16px;
145
+ padding-right: 48px;
146
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
147
+ font-size: 13px;
148
+ line-height: 1.8;
149
+ color: ${colors.text};
150
+ overflow-x: auto;
151
+ `,
152
+ envVar: css`
153
+ display: block;
154
+ `,
155
+ envKey: css`
156
+ color: ${colors.primary};
157
+ `,
158
+ envValue: css`
159
+ color: ${colors.textSecondary};
160
+ `,
161
+ copyBtn: css`
162
+ position: absolute;
163
+ top: 8px;
164
+ right: 8px;
165
+ width: 32px;
166
+ height: 32px;
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ background: ${colors.surface};
171
+ border: 1px solid ${colors.border};
172
+ border-radius: 6px;
173
+ cursor: pointer;
174
+ color: ${colors.textMuted};
175
+ transition: all 0.15s ease;
176
+
177
+ &:hover {
178
+ background: ${colors.surfaceHover};
179
+ color: ${colors.text};
180
+ border-color: #d0d5dd;
181
+ }
182
+ `,
183
+ copyIcon: css`
184
+ width: 16px;
185
+ height: 16px;
186
+ `,
187
+ copiedTooltip: css`
188
+ position: absolute;
189
+ top: 50%;
190
+ right: 100%;
191
+ transform: translateY(-50%);
192
+ background: #1a1f36;
193
+ color: white;
194
+ padding: 4px 8px;
195
+ border-radius: 4px;
196
+ font-size: 12px;
197
+ white-space: nowrap;
198
+ margin-right: 6px;
199
+ pointer-events: none;
200
+
201
+ &::before {
202
+ content: '';
203
+ position: absolute;
204
+ right: -4px;
205
+ top: 50%;
206
+ transform: translateY(-50%);
207
+ border-left: 4px solid #1a1f36;
208
+ border-top: 4px solid transparent;
209
+ border-bottom: 4px solid transparent;
210
+ }
211
+ `,
212
+ footer: css`
213
+ padding: 16px 24px;
214
+ border-top: 1px solid ${colors.border};
215
+ display: flex;
216
+ justify-content: flex-end;
217
+ gap: 12px;
218
+ `,
219
+ docsBtn: css`
220
+ padding: 10px 16px;
221
+ border-radius: 6px;
222
+ font-size: ${fontSize.base};
223
+ font-weight: 500;
224
+ border: 1px solid ${colors.border};
225
+ background: ${colors.surface};
226
+ color: ${colors.text};
227
+ cursor: pointer;
228
+ text-decoration: none;
229
+ display: inline-flex;
230
+ align-items: center;
231
+ gap: 6px;
232
+ transition: all 0.15s ease;
233
+
234
+ &:hover {
235
+ background: ${colors.surfaceHover};
236
+ border-color: #d0d5dd;
237
+ }
238
+ `,
239
+ doneBtn: css`
240
+ padding: 10px 20px;
241
+ border-radius: 6px;
242
+ font-size: ${fontSize.base};
243
+ font-weight: 500;
244
+ border: none;
245
+ background: ${colors.primary};
246
+ color: white;
247
+ cursor: pointer;
248
+ transition: background 0.15s ease;
249
+
250
+ &:hover {
251
+ background: ${colors.primaryHover};
252
+ }
253
+ `,
254
+ externalIcon: css`
255
+ width: 14px;
256
+ height: 14px;
257
+ `,
258
+ }
259
+
260
+ export function R2SetupModal({ isOpen, onClose }: R2SetupModalProps) {
261
+ const [copied, setCopied] = useState(false)
262
+
263
+ const handleCopy = async () => {
264
+ try {
265
+ await navigator.clipboard.writeText(ENV_TEMPLATE)
266
+ setCopied(true)
267
+ setTimeout(() => setCopied(false), 2000)
268
+ } catch (error) {
269
+ console.error('Failed to copy:', error)
270
+ }
271
+ }
272
+
273
+ if (!isOpen) return null
274
+
275
+ return (
276
+ <div css={styles.overlay} onClick={onClose}>
277
+ <div css={styles.modal} onClick={(e) => e.stopPropagation()}>
278
+ <div css={styles.header}>
279
+ <svg css={styles.icon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
280
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
281
+ </svg>
282
+ <h2 css={styles.title}>Set Up CDN Storage</h2>
283
+ <button css={styles.closeBtn} onClick={onClose}>
284
+ <svg css={styles.closeIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
285
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
286
+ </svg>
287
+ </button>
288
+ </div>
289
+
290
+ <div css={styles.content}>
291
+ <p css={styles.intro}>
292
+ Sync your images to Cloudflare R2 for faster global delivery. R2 offers generous free tier with no egress fees.
293
+ </p>
294
+
295
+ <ol css={styles.steps}>
296
+ <li css={styles.step}>
297
+ <span css={styles.stepNumber}>1</span>
298
+ <div css={styles.stepContent}>
299
+ <h4 css={styles.stepTitle}>Create a Cloudflare account</h4>
300
+ <p css={styles.stepDesc}>
301
+ Sign up at{' '}
302
+ <a css={styles.link} href="https://dash.cloudflare.com/sign-up" target="_blank" rel="noopener noreferrer">
303
+ dash.cloudflare.com
304
+ </a>
305
+ {' '}if you don't have one already.
306
+ </p>
307
+ </div>
308
+ </li>
309
+
310
+ <li css={styles.step}>
311
+ <span css={styles.stepNumber}>2</span>
312
+ <div css={styles.stepContent}>
313
+ <h4 css={styles.stepTitle}>Create an R2 bucket</h4>
314
+ <p css={styles.stepDesc}>
315
+ Go to R2 in your Cloudflare dashboard and create a new bucket. Choose a name like <code>my-images</code>.
316
+ </p>
317
+ </div>
318
+ </li>
319
+
320
+ <li css={styles.step}>
321
+ <span css={styles.stepNumber}>3</span>
322
+ <div css={styles.stepContent}>
323
+ <h4 css={styles.stepTitle}>Enable public access</h4>
324
+ <p css={styles.stepDesc}>
325
+ In bucket settings, enable "Public Access" and copy the public URL (e.g., <code>https://pub-xxx.r2.dev</code>).
326
+ </p>
327
+ </div>
328
+ </li>
329
+
330
+ <li css={styles.step}>
331
+ <span css={styles.stepNumber}>4</span>
332
+ <div css={styles.stepContent}>
333
+ <h4 css={styles.stepTitle}>Create API token</h4>
334
+ <p css={styles.stepDesc}>
335
+ Go to R2 → Manage R2 API Tokens → Create API Token. Select "Object Read & Write" permissions for your bucket.
336
+ </p>
337
+ </div>
338
+ </li>
339
+
340
+ <li css={styles.step}>
341
+ <span css={styles.stepNumber}>5</span>
342
+ <div css={styles.stepContent}>
343
+ <h4 css={styles.stepTitle}>Add environment variables</h4>
344
+ <p css={styles.stepDesc}>
345
+ Add these to your <code>.env.local</code> file:
346
+ </p>
347
+ </div>
348
+ </li>
349
+ </ol>
350
+
351
+ <div css={styles.envVarsWrapper}>
352
+ <div css={styles.envVars}>
353
+ <span css={styles.envVar}>
354
+ <span css={styles.envKey}>CLOUDFLARE_R2_ACCOUNT_ID</span>=<span css={styles.envValue}>your_account_id</span>
355
+ </span>
356
+ <span css={styles.envVar}>
357
+ <span css={styles.envKey}>CLOUDFLARE_R2_ACCESS_KEY_ID</span>=<span css={styles.envValue}>your_access_key</span>
358
+ </span>
359
+ <span css={styles.envVar}>
360
+ <span css={styles.envKey}>CLOUDFLARE_R2_SECRET_ACCESS_KEY</span>=<span css={styles.envValue}>your_secret_key</span>
361
+ </span>
362
+ <span css={styles.envVar}>
363
+ <span css={styles.envKey}>CLOUDFLARE_R2_BUCKET_NAME</span>=<span css={styles.envValue}>your_bucket_name</span>
364
+ </span>
365
+ <span css={styles.envVar}>
366
+ <span css={styles.envKey}>CLOUDFLARE_R2_PUBLIC_URL</span>=<span css={styles.envValue}>https://pub-xxx.r2.dev</span>
367
+ </span>
368
+ <span css={styles.envVar}>
369
+ <span css={styles.envKey}>NEXT_PUBLIC_CLOUDFLARE_R2_PUBLIC_URL</span>=<span css={styles.envValue}>https://pub-xxx.r2.dev</span>
370
+ </span>
371
+ </div>
372
+ <button css={styles.copyBtn} onClick={handleCopy} title="Copy to clipboard">
373
+ {copied && <span css={styles.copiedTooltip}>Copied!</span>}
374
+ <svg css={styles.copyIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
375
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
376
+ </svg>
377
+ </button>
378
+ </div>
379
+ </div>
380
+
381
+ <div css={styles.footer}>
382
+ <a
383
+ css={styles.docsBtn}
384
+ href="https://developers.cloudflare.com/r2/get-started/"
385
+ target="_blank"
386
+ rel="noopener noreferrer"
387
+ >
388
+ R2 Documentation
389
+ <svg css={styles.externalIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
390
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
391
+ </svg>
392
+ </a>
393
+ <button css={styles.doneBtn} onClick={onClose}>
394
+ Got it
395
+ </button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ )
400
+ }
@@ -0,0 +1,115 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { css } from '@emotion/react'
5
+ import { useStudio } from './StudioContext'
6
+ import { colors, fontSize } from './tokens'
7
+
8
+ const styles = {
9
+ container: css`
10
+ display: flex;
11
+ align-items: center;
12
+ gap: 8px;
13
+ padding: 10px 24px;
14
+ background-color: ${colors.surface};
15
+ border-bottom: 1px solid ${colors.borderLight};
16
+ `,
17
+ backBtn: css`
18
+ padding: 6px;
19
+ background: ${colors.surface};
20
+ border: 1px solid ${colors.border};
21
+ border-radius: 6px;
22
+ cursor: pointer;
23
+ transition: all 0.15s ease;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+
28
+ &:hover {
29
+ background-color: ${colors.surfaceHover};
30
+ border-color: ${colors.borderHover};
31
+ }
32
+ `,
33
+ backIcon: css`
34
+ width: 16px;
35
+ height: 16px;
36
+ color: ${colors.textSecondary};
37
+ `,
38
+ nav: css`
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 2px;
42
+ font-size: ${fontSize.base};
43
+ `,
44
+ item: css`
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 2px;
48
+ `,
49
+ separator: css`
50
+ color: ${colors.textMuted};
51
+ margin: 0 2px;
52
+ `,
53
+ btn: css`
54
+ padding: 4px 8px;
55
+ background: none;
56
+ border: none;
57
+ border-radius: 4px;
58
+ cursor: pointer;
59
+ transition: all 0.15s ease;
60
+ font-size: ${fontSize.base};
61
+ letter-spacing: -0.01em;
62
+
63
+ &:hover {
64
+ background-color: ${colors.surfaceHover};
65
+ }
66
+ `,
67
+ btnActive: css`
68
+ color: ${colors.text};
69
+ font-weight: 600;
70
+ `,
71
+ btnInactive: css`
72
+ color: ${colors.textSecondary};
73
+
74
+ &:hover {
75
+ color: ${colors.text};
76
+ }
77
+ `,
78
+ }
79
+
80
+ export function StudioBreadcrumb() {
81
+ const { currentPath, setCurrentPath, navigateUp } = useStudio()
82
+
83
+ const parts = currentPath.split('/').filter(Boolean)
84
+
85
+ const handleClick = (index: number) => {
86
+ const newPath = parts.slice(0, index + 1).join('/')
87
+ setCurrentPath(newPath)
88
+ }
89
+
90
+ return (
91
+ <div css={styles.container}>
92
+ {currentPath !== 'public' && (
93
+ <button css={styles.backBtn} onClick={navigateUp} aria-label="Go back">
94
+ <svg css={styles.backIcon} fill="none" stroke="currentColor" viewBox="0 0 24 24">
95
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
96
+ </svg>
97
+ </button>
98
+ )}
99
+
100
+ <nav css={styles.nav}>
101
+ {parts.map((part, index) => (
102
+ <span key={index} css={styles.item}>
103
+ {index > 0 && <span css={styles.separator}>/</span>}
104
+ <button
105
+ css={[styles.btn, index === parts.length - 1 ? styles.btnActive : styles.btnInactive]}
106
+ onClick={() => handleClick(index)}
107
+ >
108
+ {part}
109
+ </button>
110
+ </span>
111
+ ))}
112
+ </nav>
113
+ </div>
114
+ )
115
+ }
@@ -0,0 +1,200 @@
1
+ /** @jsxImportSource @emotion/react */
2
+ 'use client'
3
+
4
+ import { useState, useEffect, lazy, Suspense } from 'react'
5
+ import { css, keyframes } from '@emotion/react'
6
+ import { colors, fontStack, fontSize, baseReset } from './tokens'
7
+
8
+ // Lazy load the full Studio UI to avoid bundling in production
9
+ const StudioUI = lazy(() => import('./StudioUI'))
10
+
11
+ const spin = keyframes`
12
+ to {
13
+ transform: rotate(360deg);
14
+ }
15
+ `
16
+
17
+ const styles = {
18
+ button: css`
19
+ position: fixed;
20
+ bottom: 24px;
21
+ right: 24px;
22
+ z-index: 9998;
23
+ width: 52px;
24
+ height: 52px;
25
+ border-radius: 50%;
26
+ background: ${colors.primary};
27
+ color: white;
28
+ box-shadow: 0 4px 12px ${colors.shadowDark}, 0 1px 3px ${colors.shadow};
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ border: none;
33
+ cursor: pointer;
34
+ transition: all 0.15s ease;
35
+ font-family: ${fontStack};
36
+
37
+ &:hover {
38
+ transform: translateY(-2px);
39
+ box-shadow: 0 8px 20px ${colors.shadowDark}, 0 2px 6px ${colors.shadow};
40
+ background: ${colors.primaryHover};
41
+ }
42
+
43
+ &:active {
44
+ transform: translateY(0);
45
+ }
46
+ `,
47
+ buttonIcon: css`
48
+ width: 24px;
49
+ height: 24px;
50
+ `,
51
+ overlay: css`
52
+ position: fixed;
53
+ top: 0;
54
+ right: 0;
55
+ bottom: 0;
56
+ left: 0;
57
+ z-index: 9999;
58
+ transition: opacity 0.2s ease, visibility 0.2s ease;
59
+ `,
60
+ overlayHidden: css`
61
+ opacity: 0;
62
+ visibility: hidden;
63
+ pointer-events: none;
64
+ `,
65
+ backdrop: css`
66
+ position: absolute;
67
+ top: 0;
68
+ right: 0;
69
+ bottom: 0;
70
+ left: 0;
71
+ background-color: rgba(26, 31, 54, 0.4);
72
+ backdrop-filter: blur(4px);
73
+ `,
74
+ modal: css`
75
+ ${baseReset}
76
+ position: absolute;
77
+ top: 24px;
78
+ right: 24px;
79
+ bottom: 24px;
80
+ left: 24px;
81
+ background-color: ${colors.surface};
82
+ border-radius: 12px;
83
+ box-shadow: 0 30px 60px -12px rgba(50, 50, 93, 0.25), 0 18px 36px -18px rgba(0, 0, 0, 0.3);
84
+ display: flex;
85
+ flex-direction: column;
86
+ overflow: hidden;
87
+ `,
88
+ loading: css`
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ height: 100%;
93
+ background: ${colors.background};
94
+ font-family: ${fontStack};
95
+ `,
96
+ loadingContent: css`
97
+ display: flex;
98
+ flex-direction: column;
99
+ align-items: center;
100
+ gap: 16px;
101
+ `,
102
+ spinner: css`
103
+ width: 36px;
104
+ height: 36px;
105
+ border-radius: 50%;
106
+ border: 3px solid ${colors.border};
107
+ border-top-color: ${colors.primary};
108
+ animation: ${spin} 0.8s linear infinite;
109
+ `,
110
+ loadingText: css`
111
+ color: ${colors.textSecondary};
112
+ font-size: ${fontSize.base};
113
+ font-weight: 500;
114
+ margin: 0;
115
+ letter-spacing: -0.01em;
116
+ `,
117
+ }
118
+
119
+ /**
120
+ * Floating button that opens the Studio modal.
121
+ * Fixed position in bottom-right corner.
122
+ * Only renders in development mode.
123
+ */
124
+ export function StudioButton() {
125
+ const [mounted, setMounted] = useState(false)
126
+ const [isOpen, setIsOpen] = useState(false)
127
+ const [hasBeenOpened, setHasBeenOpened] = useState(false)
128
+
129
+ // Only render on client to avoid hydration mismatch
130
+ useEffect(() => {
131
+ setMounted(true)
132
+ }, [])
133
+
134
+ const handleOpen = () => {
135
+ setIsOpen(true)
136
+ setHasBeenOpened(true)
137
+ }
138
+
139
+ // Only render in development and on client
140
+ if (!mounted || process.env.NODE_ENV !== 'development') {
141
+ return null
142
+ }
143
+
144
+ return (
145
+ <>
146
+ {!isOpen && (
147
+ <button
148
+ css={styles.button}
149
+ onClick={handleOpen}
150
+ title="Open Studio"
151
+ aria-label="Open Studio media manager"
152
+ >
153
+ <ImageIcon />
154
+ </button>
155
+ )}
156
+
157
+ {/* Keep mounted once opened to preserve state */}
158
+ {hasBeenOpened && (
159
+ <div css={[styles.overlay, !isOpen && styles.overlayHidden]}>
160
+ <div css={styles.backdrop} onClick={() => setIsOpen(false)} />
161
+ <div css={styles.modal}>
162
+ <Suspense fallback={<LoadingState />}>
163
+ <StudioUI onClose={() => setIsOpen(false)} isVisible={isOpen} />
164
+ </Suspense>
165
+ </div>
166
+ </div>
167
+ )}
168
+ </>
169
+ )
170
+ }
171
+
172
+ function ImageIcon() {
173
+ return (
174
+ <svg
175
+ css={styles.buttonIcon}
176
+ xmlns="http://www.w3.org/2000/svg"
177
+ viewBox="0 0 24 24"
178
+ fill="none"
179
+ stroke="currentColor"
180
+ strokeWidth={2}
181
+ strokeLinecap="round"
182
+ strokeLinejoin="round"
183
+ >
184
+ <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
185
+ <circle cx="8.5" cy="8.5" r="1.5" />
186
+ <polyline points="21 15 16 10 5 21" />
187
+ </svg>
188
+ )
189
+ }
190
+
191
+ function LoadingState() {
192
+ return (
193
+ <div css={styles.loading}>
194
+ <div css={styles.loadingContent}>
195
+ <div css={styles.spinner} />
196
+ <p css={styles.loadingText}>Loading Studio...</p>
197
+ </div>
198
+ </div>
199
+ )
200
+ }