@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,497 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback, useRef } from 'react'
4
+ import type { ActionState, ProgressState } from './StudioContext'
5
+ import type { FileItem } from '../types'
6
+
7
+ const defaultActionState: ActionState = {
8
+ showProgress: false,
9
+ progressTitle: '',
10
+ progressState: { current: 0, total: 0, percent: 0, status: 'processing' },
11
+ showDeleteConfirm: false,
12
+ showMoveModal: false,
13
+ showSyncConfirm: false,
14
+ showProcessConfirm: false,
15
+ actionPaths: [],
16
+ syncImageCount: 0,
17
+ syncHasRemote: false,
18
+ syncHasLocal: false,
19
+ processMode: 'generate',
20
+ }
21
+
22
+ interface UseStudioActionsProps {
23
+ triggerRefresh: () => void
24
+ clearSelection: () => void
25
+ setFocusedItem: (item: FileItem | null) => void
26
+ showError: (title: string, message: string) => void
27
+ }
28
+
29
+ export function useStudioActions({
30
+ triggerRefresh,
31
+ clearSelection,
32
+ setFocusedItem,
33
+ showError,
34
+ }: UseStudioActionsProps) {
35
+ const [actionState, setActionState] = useState<ActionState>(defaultActionState)
36
+ const abortControllerRef = useRef<AbortController | null>(null)
37
+
38
+ // Helper to update progress state
39
+ const setProgressState = useCallback((update: Partial<ProgressState> | ((prev: ProgressState) => ProgressState)) => {
40
+ setActionState(prev => ({
41
+ ...prev,
42
+ progressState: typeof update === 'function'
43
+ ? update(prev.progressState)
44
+ : { ...prev.progressState, ...update }
45
+ }))
46
+ }, [])
47
+
48
+ // Request handlers (show confirmation modals)
49
+ const requestDelete = useCallback((paths: string[]) => {
50
+ setActionState(prev => ({
51
+ ...prev,
52
+ actionPaths: paths,
53
+ showDeleteConfirm: true,
54
+ }))
55
+ }, [])
56
+
57
+ const requestMove = useCallback((paths: string[]) => {
58
+ setActionState(prev => ({
59
+ ...prev,
60
+ actionPaths: paths,
61
+ showMoveModal: true,
62
+ }))
63
+ }, [])
64
+
65
+ const requestSync = useCallback((paths: string[], fileItems: FileItem[]) => {
66
+ // Calculate sync info
67
+ const imageKeys = paths.map(p => '/' + p.replace(/^public\//, ''))
68
+ let hasRemote = false
69
+ let hasLocal = false
70
+
71
+ for (const path of paths) {
72
+ const item = fileItems.find(f => f.path === path)
73
+ if (item) {
74
+ if (item.isRemote) {
75
+ hasRemote = true
76
+ } else if (!item.cdnPushed) {
77
+ hasLocal = true
78
+ }
79
+ }
80
+ }
81
+
82
+ setActionState(prev => ({
83
+ ...prev,
84
+ actionPaths: paths,
85
+ syncImageCount: imageKeys.length,
86
+ syncHasRemote: hasRemote,
87
+ syncHasLocal: hasLocal,
88
+ showSyncConfirm: true,
89
+ }))
90
+ }, [])
91
+
92
+ const requestProcess = useCallback((paths: string[]) => {
93
+ setActionState(prev => ({
94
+ ...prev,
95
+ actionPaths: paths,
96
+ showProcessConfirm: true,
97
+ processMode: 'generate', // Reset to default when opening modal
98
+ }))
99
+ }, [])
100
+
101
+ const setProcessMode = useCallback((mode: 'generate' | 'remove') => {
102
+ setActionState(prev => ({
103
+ ...prev,
104
+ processMode: mode,
105
+ }))
106
+ }, [])
107
+
108
+ // Cancel action
109
+ const cancelAction = useCallback(() => {
110
+ setActionState(prev => ({
111
+ ...prev,
112
+ showDeleteConfirm: false,
113
+ showMoveModal: false,
114
+ showSyncConfirm: false,
115
+ showProcessConfirm: false,
116
+ }))
117
+ }, [])
118
+
119
+ // Close progress modal
120
+ const closeProgress = useCallback(() => {
121
+ setActionState(defaultActionState)
122
+ }, [])
123
+
124
+ // Stop processing
125
+ const stopProcessing = useCallback(() => {
126
+ if (abortControllerRef.current) {
127
+ abortControllerRef.current.abort()
128
+ }
129
+ }, [])
130
+
131
+ // Confirm delete
132
+ const confirmDelete = useCallback(async () => {
133
+ const paths = actionState.actionPaths
134
+ setActionState(prev => ({ ...prev, showDeleteConfirm: false }))
135
+
136
+ try {
137
+ const response = await fetch('/api/studio/delete', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({ paths }),
141
+ })
142
+
143
+ if (response.ok) {
144
+ clearSelection()
145
+ setFocusedItem(null)
146
+ triggerRefresh()
147
+ } else {
148
+ const error = await response.json()
149
+ showError('Delete Failed', error.error || 'Unknown error')
150
+ }
151
+ } catch (error) {
152
+ console.error('Delete error:', error)
153
+ showError('Delete Failed', 'Delete failed. Check console for details.')
154
+ }
155
+ }, [actionState.actionPaths, clearSelection, setFocusedItem, triggerRefresh, showError])
156
+
157
+ // Confirm move
158
+ const confirmMove = useCallback(async (destination: string) => {
159
+ const paths = actionState.actionPaths
160
+ setActionState(prev => ({
161
+ ...prev,
162
+ showMoveModal: false,
163
+ showProgress: true,
164
+ progressTitle: 'Moving Files',
165
+ progressState: {
166
+ current: 0,
167
+ total: paths.length,
168
+ percent: 0,
169
+ status: 'processing',
170
+ message: 'Moving files...',
171
+ },
172
+ }))
173
+
174
+ try {
175
+ const response = await fetch('/api/studio/move', {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({ paths, destination }),
179
+ })
180
+
181
+ if (!response.ok) {
182
+ const error = await response.json()
183
+ setProgressState({
184
+ status: 'error',
185
+ message: error.error || 'Move failed',
186
+ })
187
+ return
188
+ }
189
+
190
+ const reader = response.body?.getReader()
191
+ const decoder = new TextDecoder()
192
+
193
+ if (reader) {
194
+ let buffer = ''
195
+ while (true) {
196
+ const { done, value } = await reader.read()
197
+ if (done) break
198
+
199
+ buffer += decoder.decode(value, { stream: true })
200
+ const lines = buffer.split('\n')
201
+ buffer = lines.pop() || ''
202
+
203
+ for (const line of lines) {
204
+ if (line.startsWith('data: ')) {
205
+ try {
206
+ const data = JSON.parse(line.slice(6))
207
+
208
+ if (data.type === 'start') {
209
+ setProgressState(prev => ({ ...prev, total: data.total }))
210
+ } else if (data.type === 'progress') {
211
+ setProgressState({
212
+ current: data.current,
213
+ total: data.total,
214
+ percent: Math.round((data.current / data.total) * 100),
215
+ status: 'processing',
216
+ message: data.message,
217
+ })
218
+ } else if (data.type === 'complete') {
219
+ setProgressState(prev => ({
220
+ ...prev,
221
+ status: 'complete',
222
+ message: `Moved ${data.moved} file${data.moved !== 1 ? 's' : ''}${data.errors > 0 ? `, ${data.errors} error${data.errors !== 1 ? 's' : ''}` : ''}`,
223
+ }))
224
+ if (data.errors > 0 && data.errorMessages?.length > 0) {
225
+ showError('Move Failed', data.errorMessages.join('\n'))
226
+ }
227
+ clearSelection()
228
+ setFocusedItem(null)
229
+ triggerRefresh()
230
+ } else if (data.type === 'error') {
231
+ setProgressState(prev => ({
232
+ ...prev,
233
+ status: 'error',
234
+ message: data.message || 'Unknown error',
235
+ }))
236
+ }
237
+ } catch { /* ignore parse errors */ }
238
+ }
239
+ }
240
+ }
241
+ }
242
+ } catch (error) {
243
+ console.error('Move error:', error)
244
+ setProgressState(prev => ({
245
+ ...prev,
246
+ status: 'error',
247
+ message: 'Failed to move files. Check console for details.',
248
+ }))
249
+ }
250
+ }, [actionState.actionPaths, clearSelection, setFocusedItem, triggerRefresh, showError, setProgressState])
251
+
252
+ // Confirm sync (push to CDN)
253
+ const confirmSync = useCallback(async () => {
254
+ const paths = actionState.actionPaths
255
+ const imageKeys = paths.map(p => '/' + p.replace(/^public\//, ''))
256
+
257
+ setActionState(prev => ({
258
+ ...prev,
259
+ showSyncConfirm: false,
260
+ showProgress: true,
261
+ progressTitle: 'Pushing to CDN',
262
+ progressState: {
263
+ current: 0,
264
+ total: imageKeys.length,
265
+ percent: 0,
266
+ status: 'processing',
267
+ message: 'Pushing to CDN...',
268
+ },
269
+ }))
270
+
271
+ let pushed = 0
272
+ const errors: string[] = []
273
+
274
+ for (let i = 0; i < imageKeys.length; i++) {
275
+ const imageKey = imageKeys[i]
276
+
277
+ setProgressState({
278
+ current: i + 1,
279
+ total: imageKeys.length,
280
+ percent: Math.round(((i + 1) / imageKeys.length) * 100),
281
+ status: 'processing',
282
+ message: `Pushing ${imageKey}...`,
283
+ })
284
+
285
+ try {
286
+ const response = await fetch('/api/studio/sync', {
287
+ method: 'POST',
288
+ headers: { 'Content-Type': 'application/json' },
289
+ body: JSON.stringify({ imageKeys: [imageKey] }),
290
+ })
291
+
292
+ if (response.ok) {
293
+ pushed++
294
+ } else {
295
+ const data = await response.json()
296
+ errors.push(`${imageKey}: ${data.error || 'Unknown error'}`)
297
+ }
298
+ } catch (error) {
299
+ errors.push(`${imageKey}: ${error instanceof Error ? error.message : 'Unknown error'}`)
300
+ }
301
+ }
302
+
303
+ setProgressState({
304
+ current: imageKeys.length,
305
+ total: imageKeys.length,
306
+ percent: 100,
307
+ status: errors.length > 0 ? 'error' : 'complete',
308
+ message: `Pushed ${pushed} file${pushed !== 1 ? 's' : ''}${errors.length > 0 ? `, ${errors.length} error${errors.length !== 1 ? 's' : ''}` : ''}`,
309
+ })
310
+
311
+ if (errors.length > 0) {
312
+ showError('Push Errors', errors.join('\n'))
313
+ }
314
+
315
+ clearSelection()
316
+ triggerRefresh()
317
+ }, [actionState.actionPaths, clearSelection, triggerRefresh, showError, setProgressState])
318
+
319
+ // Confirm process (generate or remove thumbnails based on mode)
320
+ const confirmProcess = useCallback(async () => {
321
+ const paths = actionState.actionPaths
322
+ const mode = actionState.processMode
323
+ const imageKeys = paths.map(p => {
324
+ const key = p.replace(/^public\//, '')
325
+ return key.startsWith('/') ? key : `/${key}`
326
+ })
327
+
328
+ const isRemove = mode === 'remove'
329
+ const endpoint = isRemove ? '/api/studio/unprocess-stream' : '/api/studio/reprocess-stream'
330
+ const progressTitle = isRemove ? 'Removing Thumbnails' : 'Generating Thumbnails'
331
+ const progressMessage = isRemove ? 'Removing thumbnails...' : 'Generating thumbnails...'
332
+
333
+ setActionState(prev => ({
334
+ ...prev,
335
+ showProcessConfirm: false,
336
+ showProgress: true,
337
+ progressTitle,
338
+ progressState: {
339
+ current: 0,
340
+ total: imageKeys.length,
341
+ percent: 0,
342
+ status: 'processing',
343
+ message: progressMessage,
344
+ },
345
+ }))
346
+
347
+ abortControllerRef.current = new AbortController()
348
+ const signal = abortControllerRef.current.signal
349
+
350
+ try {
351
+ const response = await fetch(endpoint, {
352
+ method: 'POST',
353
+ headers: { 'Content-Type': 'application/json' },
354
+ body: JSON.stringify({ imageKeys }),
355
+ signal,
356
+ })
357
+
358
+ if (!response.ok) {
359
+ const error = await response.json()
360
+ setProgressState({
361
+ current: 0,
362
+ total: imageKeys.length,
363
+ percent: 0,
364
+ status: 'error',
365
+ message: error.error || (isRemove ? 'Failed to remove thumbnails' : 'Processing failed'),
366
+ })
367
+ return
368
+ }
369
+
370
+ const reader = response.body?.getReader()
371
+ const decoder = new TextDecoder()
372
+
373
+ if (reader) {
374
+ let buffer = ''
375
+ while (true) {
376
+ const { done, value } = await reader.read()
377
+ if (done) break
378
+
379
+ buffer += decoder.decode(value, { stream: true })
380
+ const lines = buffer.split('\n')
381
+ buffer = lines.pop() || ''
382
+
383
+ for (const line of lines) {
384
+ if (line.startsWith('data: ')) {
385
+ try {
386
+ const data = JSON.parse(line.slice(6))
387
+
388
+ if (data.type === 'start') {
389
+ setProgressState(prev => ({
390
+ ...prev,
391
+ total: data.total,
392
+ }))
393
+ } else if (data.type === 'progress') {
394
+ setProgressState({
395
+ current: data.current,
396
+ total: data.total,
397
+ percent: data.percent,
398
+ status: 'processing',
399
+ message: data.message,
400
+ })
401
+ } else if (data.type === 'cleanup') {
402
+ setProgressState(prev => ({
403
+ ...prev,
404
+ status: 'cleanup',
405
+ message: data.message,
406
+ }))
407
+ } else if (data.type === 'complete') {
408
+ setProgressState({
409
+ current: data.processed,
410
+ total: data.processed,
411
+ percent: 100,
412
+ status: data.errors > 0 ? 'error' : 'complete',
413
+ message: data.message,
414
+ })
415
+ triggerRefresh()
416
+ } else if (data.type === 'error') {
417
+ setProgressState(prev => ({
418
+ ...prev,
419
+ status: 'error',
420
+ message: data.message,
421
+ }))
422
+ }
423
+ } catch { /* ignore parse errors */ }
424
+ }
425
+ }
426
+ }
427
+ }
428
+ } catch (error) {
429
+ if (signal.aborted) {
430
+ setProgressState(prev => ({
431
+ ...prev,
432
+ status: 'stopped',
433
+ message: isRemove ? 'Removal stopped by user' : 'Processing stopped by user',
434
+ }))
435
+ } else {
436
+ console.error('Processing error:', error)
437
+ setProgressState({
438
+ current: 0,
439
+ total: imageKeys.length,
440
+ percent: 0,
441
+ status: 'error',
442
+ message: isRemove ? 'Failed to remove thumbnails. Check console for details.' : 'Processing failed. Check console for details.',
443
+ })
444
+ }
445
+ } finally {
446
+ abortControllerRef.current = null
447
+ }
448
+ }, [actionState.actionPaths, actionState.processMode, triggerRefresh, setProgressState])
449
+
450
+ // Delete orphans
451
+ const deleteOrphans = useCallback(async () => {
452
+ const orphanedFiles = actionState.progressState.orphanedFiles
453
+ if (!orphanedFiles || orphanedFiles.length === 0) return
454
+
455
+ try {
456
+ const response = await fetch('/api/studio/delete-orphans', {
457
+ method: 'POST',
458
+ headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ files: orphanedFiles }),
460
+ })
461
+
462
+ if (response.ok) {
463
+ setProgressState(prev => ({
464
+ ...prev,
465
+ orphanedFiles: undefined,
466
+ message: prev.message?.replace(/Found \d+ orphaned thumbnail\(s\).*/, 'Orphaned thumbnails deleted.'),
467
+ }))
468
+ triggerRefresh()
469
+ } else {
470
+ const error = await response.json()
471
+ showError('Delete Failed', error.error || 'Failed to delete orphaned files')
472
+ }
473
+ } catch (error) {
474
+ console.error('Delete orphans error:', error)
475
+ showError('Delete Failed', 'Failed to delete orphaned files. Check console for details.')
476
+ }
477
+ }, [actionState.progressState.orphanedFiles, triggerRefresh, showError, setProgressState])
478
+
479
+ return {
480
+ actionState,
481
+ setActionState,
482
+ abortController: abortControllerRef.current,
483
+ requestDelete,
484
+ requestMove,
485
+ requestSync,
486
+ requestProcess,
487
+ setProcessMode,
488
+ cancelAction,
489
+ closeProgress,
490
+ stopProcessing,
491
+ confirmDelete,
492
+ confirmMove,
493
+ confirmSync,
494
+ confirmProcess,
495
+ deleteOrphans,
496
+ }
497
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ getWorkspace,
3
+ getPublicPath,
4
+ getDataPath,
5
+ getSrcAppPath,
6
+ getWorkspacePath,
7
+ } from './workspace'
@@ -0,0 +1,52 @@
1
+ import path from 'path'
2
+
3
+ /**
4
+ * Workspace configuration for standalone Studio.
5
+ *
6
+ * The workspace path determines where Studio looks for:
7
+ * - public/ folder (images, media)
8
+ * - _data/_studio.json (metadata)
9
+ * - src/app/ (for favicon generation)
10
+ *
11
+ * Set via STUDIO_WORKSPACE environment variable or defaults to cwd.
12
+ */
13
+
14
+ let workspacePath: string | null = null
15
+
16
+ /**
17
+ * Get the workspace path. Reads from STUDIO_WORKSPACE env var on first call.
18
+ */
19
+ export function getWorkspace(): string {
20
+ if (workspacePath === null) {
21
+ workspacePath = process.env.STUDIO_WORKSPACE || process.cwd()
22
+ }
23
+ return workspacePath
24
+ }
25
+
26
+ /**
27
+ * Get the path to the public folder.
28
+ */
29
+ export function getPublicPath(...segments: string[]): string {
30
+ return path.join(getWorkspace(), 'public', ...segments)
31
+ }
32
+
33
+ /**
34
+ * Get the path to the _data folder.
35
+ */
36
+ export function getDataPath(...segments: string[]): string {
37
+ return path.join(getWorkspace(), '_data', ...segments)
38
+ }
39
+
40
+ /**
41
+ * Get the path to the src/app folder (for favicon generation).
42
+ */
43
+ export function getSrcAppPath(...segments: string[]): string {
44
+ return path.join(getWorkspace(), 'src', 'app', ...segments)
45
+ }
46
+
47
+ /**
48
+ * Join paths relative to the workspace root.
49
+ */
50
+ export function getWorkspacePath(...segments: string[]): string {
51
+ return path.join(getWorkspace(), ...segments)
52
+ }
@@ -0,0 +1,152 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import sharp from 'sharp'
3
+ import path from 'path'
4
+ import fs from 'fs/promises'
5
+ import { getPublicPath, getSrcAppPath } from '../config'
6
+
7
+ /**
8
+ * Generate favicon variants from a source image (streaming)
9
+ *
10
+ * Takes a favicon.png or favicon.jpg and generates:
11
+ * - favicon.ico (48x48) - Classic ICO format
12
+ * - icon.png (32x32) - Standard favicon
13
+ * - apple-icon.png (180x180) - Apple touch icon
14
+ *
15
+ * All outputs are saved to src/app/ for Next.js metadata
16
+ */
17
+
18
+ const FAVICON_CONFIGS = [
19
+ { name: 'favicon.ico', size: 48 },
20
+ { name: 'icon.png', size: 32 },
21
+ { name: 'apple-icon.png', size: 180 },
22
+ ]
23
+
24
+ export async function handleGenerateFavicon(request: NextRequest) {
25
+ const encoder = new TextEncoder()
26
+
27
+ let imagePath: string
28
+ try {
29
+ const body = await request.json() as { imagePath: string }
30
+ imagePath = body.imagePath
31
+
32
+ if (!imagePath) {
33
+ return NextResponse.json({ error: 'No image path provided' }, { status: 400 })
34
+ }
35
+ } catch {
36
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 })
37
+ }
38
+
39
+ // Validate filename is favicon.png or favicon.jpg
40
+ const fileName = path.basename(imagePath).toLowerCase()
41
+ if (fileName !== 'favicon.png' && fileName !== 'favicon.jpg') {
42
+ return NextResponse.json({
43
+ error: 'Source file must be named favicon.png or favicon.jpg'
44
+ }, { status: 400 })
45
+ }
46
+
47
+ // Build full path to source file
48
+ const sourcePath = getPublicPath(imagePath.replace(/^\//, ''))
49
+
50
+ // Check if source file exists
51
+ try {
52
+ await fs.access(sourcePath)
53
+ } catch {
54
+ return NextResponse.json({ error: 'Source file not found' }, { status: 404 })
55
+ }
56
+
57
+ // Verify the source is a valid image
58
+ let metadata
59
+ try {
60
+ metadata = await sharp(sourcePath).metadata()
61
+ } catch {
62
+ return NextResponse.json({ error: 'Source file is not a valid image' }, { status: 400 })
63
+ }
64
+
65
+ // Output directory is src/app/
66
+ const outputDir = getSrcAppPath()
67
+
68
+ // Check output directory exists
69
+ try {
70
+ await fs.access(outputDir)
71
+ } catch {
72
+ return NextResponse.json({
73
+ error: 'Output directory src/app/ not found'
74
+ }, { status: 500 })
75
+ }
76
+
77
+ const stream = new ReadableStream({
78
+ async start(controller) {
79
+ const sendEvent = (data: object) => {
80
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
81
+ }
82
+
83
+ try {
84
+ const total = FAVICON_CONFIGS.length
85
+ const generated: string[] = []
86
+ const errors: string[] = []
87
+
88
+ sendEvent({
89
+ type: 'start',
90
+ total,
91
+ sourceSize: `${metadata.width}x${metadata.height}`,
92
+ })
93
+
94
+ for (let i = 0; i < FAVICON_CONFIGS.length; i++) {
95
+ const config = FAVICON_CONFIGS[i]
96
+
97
+ sendEvent({
98
+ type: 'progress',
99
+ current: i + 1,
100
+ total,
101
+ percent: Math.round(((i + 1) / total) * 100),
102
+ message: `Generating ${config.name} (${config.size}x${config.size})...`
103
+ })
104
+
105
+ try {
106
+ const outputPath = path.join(outputDir, config.name)
107
+
108
+ await sharp(sourcePath)
109
+ .resize(config.size, config.size, {
110
+ fit: 'cover',
111
+ position: 'center',
112
+ })
113
+ .png({ quality: 100 })
114
+ .toFile(outputPath)
115
+
116
+ generated.push(config.name)
117
+ } catch (error) {
118
+ console.error(`Failed to generate ${config.name}:`, error)
119
+ errors.push(config.name)
120
+ }
121
+ }
122
+
123
+ // Build completion message
124
+ let message = `Generated ${generated.length} favicon${generated.length !== 1 ? 's' : ''} to src/app/.`
125
+ if (errors.length > 0) {
126
+ message += ` ${errors.length} failed.`
127
+ }
128
+
129
+ sendEvent({
130
+ type: 'complete',
131
+ processed: generated.length,
132
+ errors: errors.length,
133
+ message,
134
+ })
135
+
136
+ controller.close()
137
+ } catch (error) {
138
+ console.error('Favicon generation error:', error)
139
+ sendEvent({ type: 'error', message: 'Failed to generate favicons' })
140
+ controller.close()
141
+ }
142
+ }
143
+ })
144
+
145
+ return new Response(stream, {
146
+ headers: {
147
+ 'Content-Type': 'text/event-stream',
148
+ 'Cache-Control': 'no-cache',
149
+ Connection: 'keep-alive',
150
+ },
151
+ })
152
+ }