@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.
- package/app/api/studio/[...path]/route.ts +1 -0
- package/app/layout.tsx +20 -0
- package/app/page.tsx +82 -0
- package/bin/studio.mjs +110 -0
- package/dist/handlers/index.js +84 -63
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/index.mjs +135 -114
- package/dist/handlers/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -10
- package/dist/index.d.ts +14 -10
- package/dist/index.js +2 -177
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4 -179
- package/dist/index.mjs.map +1 -1
- package/next.config.mjs +22 -0
- package/package.json +18 -10
- package/src/components/AddNewModal.tsx +402 -0
- package/src/components/ErrorModal.tsx +89 -0
- package/src/components/R2SetupModal.tsx +400 -0
- package/src/components/StudioBreadcrumb.tsx +115 -0
- package/src/components/StudioButton.tsx +200 -0
- package/src/components/StudioContext.tsx +219 -0
- package/src/components/StudioDetailView.tsx +714 -0
- package/src/components/StudioFileGrid.tsx +704 -0
- package/src/components/StudioFileList.tsx +743 -0
- package/src/components/StudioFolderPicker.tsx +342 -0
- package/src/components/StudioModal.tsx +473 -0
- package/src/components/StudioPreview.tsx +399 -0
- package/src/components/StudioSettings.tsx +536 -0
- package/src/components/StudioToolbar.tsx +1448 -0
- package/src/components/StudioUI.tsx +731 -0
- package/src/components/styles/common.ts +236 -0
- package/src/components/tokens.ts +78 -0
- package/src/components/useStudioActions.tsx +497 -0
- package/src/config/index.ts +7 -0
- package/src/config/workspace.ts +52 -0
- package/src/handlers/favicon.ts +152 -0
- package/src/handlers/files.ts +784 -0
- package/src/handlers/images.ts +949 -0
- package/src/handlers/import.ts +190 -0
- package/src/handlers/index.ts +168 -0
- package/src/handlers/list.ts +627 -0
- package/src/handlers/scan.ts +311 -0
- package/src/handlers/utils/cdn.ts +234 -0
- package/src/handlers/utils/files.ts +64 -0
- package/src/handlers/utils/index.ts +4 -0
- package/src/handlers/utils/meta.ts +102 -0
- package/src/handlers/utils/thumbnails.ts +98 -0
- package/src/hooks/useFileList.ts +143 -0
- package/src/index.tsx +36 -0
- package/src/lib/api.ts +176 -0
- package/src/types.ts +119 -0
- package/dist/StudioUI-GJK45R3T.js +0 -6500
- package/dist/StudioUI-GJK45R3T.js.map +0 -1
- package/dist/StudioUI-QZ54STXE.mjs +0 -6500
- package/dist/StudioUI-QZ54STXE.mjs.map +0 -1
- package/dist/chunk-N6JYTJCB.js +0 -68
- package/dist/chunk-N6JYTJCB.js.map +0 -1
- package/dist/chunk-RHI3UROE.mjs +0 -68
- 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
|
+
}
|