@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,190 @@
1
+ import { NextRequest } from 'next/server'
2
+ import sharp from 'sharp'
3
+ import { encode } from 'blurhash'
4
+ import {
5
+ loadMeta,
6
+ saveMeta,
7
+ getOrAddCdnIndex,
8
+ getMetaEntry,
9
+ setMetaEntry,
10
+ } from './utils'
11
+ import type { Dimensions } from '../types'
12
+
13
+ /**
14
+ * Parse an image URL into base URL and path
15
+ */
16
+ function parseImageUrl(url: string): { base: string; path: string } {
17
+ const parsed = new URL(url)
18
+ // Base is protocol + host
19
+ const base = `${parsed.protocol}//${parsed.host}`
20
+ // Path is everything after
21
+ const path = parsed.pathname
22
+ return { base, path }
23
+ }
24
+
25
+ /**
26
+ * Fetch remote image and get dimensions + blurhash
27
+ */
28
+ async function processRemoteImage(url: string): Promise<{ o: Dimensions; b: string }> {
29
+ const response = await fetch(url)
30
+ if (!response.ok) {
31
+ throw new Error(`Failed to fetch: ${response.status}`)
32
+ }
33
+
34
+ const buffer = Buffer.from(await response.arrayBuffer())
35
+
36
+ const metadata = await sharp(buffer).metadata()
37
+
38
+ // Generate blurhash
39
+ const { data, info } = await sharp(buffer)
40
+ .resize(32, 32, { fit: 'inside' })
41
+ .ensureAlpha()
42
+ .raw()
43
+ .toBuffer({ resolveWithObject: true })
44
+
45
+ const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)
46
+
47
+ return {
48
+ o: { w: metadata.width || 0, h: metadata.height || 0 },
49
+ b: blurhash,
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Streaming endpoint to import images from URLs
55
+ */
56
+ export async function handleImportUrls(request: NextRequest) {
57
+ const encoder = new TextEncoder()
58
+
59
+ const stream = new ReadableStream({
60
+ async start(controller) {
61
+ const sendEvent = (data: object) => {
62
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
63
+ }
64
+
65
+ try {
66
+ const { urls } = await request.json() as { urls: string[] }
67
+
68
+ if (!urls || !Array.isArray(urls) || urls.length === 0) {
69
+ sendEvent({ type: 'error', message: 'No URLs provided' })
70
+ controller.close()
71
+ return
72
+ }
73
+
74
+ const meta = await loadMeta()
75
+ const added: string[] = []
76
+ const skipped: string[] = []
77
+ const errors: string[] = []
78
+
79
+ const total = urls.length
80
+ sendEvent({ type: 'start', total })
81
+
82
+ for (let i = 0; i < urls.length; i++) {
83
+ const url = urls[i].trim()
84
+ if (!url) continue
85
+
86
+ sendEvent({
87
+ type: 'progress',
88
+ current: i + 1,
89
+ total,
90
+ percent: Math.round(((i + 1) / total) * 100),
91
+ currentFile: url,
92
+ })
93
+
94
+ try {
95
+ // Parse URL to get base and path
96
+ const { base, path } = parseImageUrl(url)
97
+
98
+ // Check if this path already exists in meta
99
+ const existingEntry = getMetaEntry(meta, path)
100
+ if (existingEntry) {
101
+ skipped.push(path)
102
+ continue
103
+ }
104
+
105
+ // Get or add CDN URL to _cdns array
106
+ const cdnIndex = getOrAddCdnIndex(meta, base)
107
+
108
+ // Fetch and process the image
109
+ const imageData = await processRemoteImage(url)
110
+
111
+ // Add entry to meta
112
+ // Note: No thumbnail dims since this is an external image, not processed locally
113
+ setMetaEntry(meta, path, {
114
+ o: imageData.o,
115
+ b: imageData.b,
116
+ c: cdnIndex,
117
+ })
118
+
119
+ added.push(path)
120
+ } catch (error) {
121
+ console.error(`Failed to import ${url}:`, error)
122
+ errors.push(url)
123
+ }
124
+ }
125
+
126
+ await saveMeta(meta)
127
+
128
+ sendEvent({
129
+ type: 'complete',
130
+ added: added.length,
131
+ skipped: skipped.length,
132
+ errors: errors.length,
133
+ })
134
+ } catch (error) {
135
+ console.error('Import failed:', error)
136
+ sendEvent({ type: 'error', message: 'Import failed' })
137
+ } finally {
138
+ controller.close()
139
+ }
140
+ }
141
+ })
142
+
143
+ return new Response(stream, {
144
+ headers: {
145
+ 'Content-Type': 'text/event-stream',
146
+ 'Cache-Control': 'no-cache',
147
+ 'Connection': 'keep-alive',
148
+ },
149
+ })
150
+ }
151
+
152
+ /**
153
+ * Get CDN URLs for settings
154
+ */
155
+ export async function handleGetCdns() {
156
+ try {
157
+ const meta = await loadMeta()
158
+ const cdns = meta._cdns || []
159
+
160
+ return Response.json({ cdns })
161
+ } catch (error) {
162
+ console.error('Failed to get CDNs:', error)
163
+ return Response.json({ error: 'Failed to get CDNs' }, { status: 500 })
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Update CDN URLs from settings
169
+ */
170
+ export async function handleUpdateCdns(request: NextRequest) {
171
+ try {
172
+ const { cdns } = await request.json() as { cdns: string[] }
173
+
174
+ if (!Array.isArray(cdns)) {
175
+ return Response.json({ error: 'Invalid CDN array' }, { status: 400 })
176
+ }
177
+
178
+ const meta = await loadMeta()
179
+
180
+ // Normalize URLs (remove trailing slashes)
181
+ meta._cdns = cdns.map(url => url.replace(/\/$/, ''))
182
+
183
+ await saveMeta(meta)
184
+
185
+ return Response.json({ success: true, cdns: meta._cdns })
186
+ } catch (error) {
187
+ console.error('Failed to update CDNs:', error)
188
+ return Response.json({ error: 'Failed to update CDNs' }, { status: 500 })
189
+ }
190
+ }
@@ -0,0 +1,168 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+
3
+ // List handlers
4
+ import { handleList, handleSearch, handleListFolders, handleCountImages, handleFolderImages } from './list'
5
+
6
+ // File handlers
7
+ import { handleUpload, handleDelete, handleCreateFolder, handleRename, handleMove, handleMoveStream } from './files'
8
+
9
+ // Image handlers
10
+ import { handleSync, handleReprocess, handleReprocessStream, handleUnprocessStream, handleProcessAllStream, handleDownloadStream } from './images'
11
+
12
+ // Scan handler
13
+ import { handleScanStream, handleDeleteOrphans } from './scan'
14
+
15
+ // Import handlers
16
+ import { handleImportUrls, handleGetCdns, handleUpdateCdns } from './import'
17
+
18
+ // Favicon handler
19
+ import { handleGenerateFavicon } from './favicon'
20
+
21
+ /**
22
+ * Unified GET handler for all Studio API routes
23
+ */
24
+ export async function GET(request: NextRequest) {
25
+ if (process.env.NODE_ENV !== 'development') {
26
+ return NextResponse.json({ error: 'Not available in production' }, { status: 403 })
27
+ }
28
+
29
+ const pathname = request.nextUrl.pathname
30
+ const route = pathname.replace(/^\/api\/studio\/?/, '')
31
+
32
+ // Route: /api/studio/list-folders (must come before 'list' check)
33
+ if (route === 'list-folders') {
34
+ return handleListFolders()
35
+ }
36
+
37
+ // Route: /api/studio/list
38
+ if (route === 'list' || route.startsWith('list')) {
39
+ return handleList(request)
40
+ }
41
+
42
+ // Route: /api/studio/count-images
43
+ if (route === 'count-images') {
44
+ return handleCountImages()
45
+ }
46
+
47
+ // Route: /api/studio/folder-images
48
+ if (route === 'folder-images') {
49
+ return handleFolderImages(request)
50
+ }
51
+
52
+ // Route: /api/studio/search
53
+ if (route === 'search') {
54
+ return handleSearch(request)
55
+ }
56
+
57
+ // Route: /api/studio/cdns
58
+ if (route === 'cdns') {
59
+ return handleGetCdns()
60
+ }
61
+
62
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
63
+ }
64
+
65
+ /**
66
+ * Unified POST handler for all Studio API routes
67
+ */
68
+ export async function POST(request: NextRequest) {
69
+ if (process.env.NODE_ENV !== 'development') {
70
+ return NextResponse.json({ error: 'Not available in production' }, { status: 403 })
71
+ }
72
+
73
+ const pathname = request.nextUrl.pathname
74
+ const route = pathname.replace(/^\/api\/studio\/?/, '')
75
+
76
+ // Route: /api/studio/upload
77
+ if (route === 'upload') {
78
+ return handleUpload(request)
79
+ }
80
+
81
+ // Route: /api/studio/delete
82
+ if (route === 'delete') {
83
+ return handleDelete(request)
84
+ }
85
+
86
+ // Route: /api/studio/sync
87
+ if (route === 'sync') {
88
+ return handleSync(request)
89
+ }
90
+
91
+ // Route: /api/studio/reprocess
92
+ if (route === 'reprocess') {
93
+ return handleReprocess(request)
94
+ }
95
+
96
+ // Route: /api/studio/reprocess-stream (streaming)
97
+ if (route === 'reprocess-stream') {
98
+ return handleReprocessStream(request)
99
+ }
100
+
101
+ // Route: /api/studio/unprocess-stream (streaming) - remove thumbnails
102
+ if (route === 'unprocess-stream') {
103
+ return handleUnprocessStream(request)
104
+ }
105
+
106
+ // Route: /api/studio/process-all (streaming)
107
+ if (route === 'process-all') {
108
+ return handleProcessAllStream()
109
+ }
110
+
111
+ // Route: /api/studio/download-stream (streaming) - download from R2 to local
112
+ if (route === 'download-stream') {
113
+ return handleDownloadStream(request)
114
+ }
115
+
116
+ // Route: /api/studio/create-folder
117
+ if (route === 'create-folder') {
118
+ return handleCreateFolder(request)
119
+ }
120
+
121
+ // Route: /api/studio/rename
122
+ if (route === 'rename') {
123
+ return handleRename(request)
124
+ }
125
+
126
+ // Route: /api/studio/move (streaming)
127
+ if (route === 'move') {
128
+ return handleMoveStream(request)
129
+ }
130
+
131
+ // Route: /api/studio/scan (streaming)
132
+ if (route === 'scan') {
133
+ return handleScanStream()
134
+ }
135
+
136
+ // Route: /api/studio/delete-orphans
137
+ if (route === 'delete-orphans') {
138
+ return handleDeleteOrphans(request)
139
+ }
140
+
141
+ // Route: /api/studio/import (streaming)
142
+ if (route === 'import') {
143
+ return handleImportUrls(request)
144
+ }
145
+
146
+ // Route: /api/studio/cdns (update)
147
+ if (route === 'cdns') {
148
+ return handleUpdateCdns(request)
149
+ }
150
+
151
+ // Route: /api/studio/generate-favicon
152
+ if (route === 'generate-favicon') {
153
+ return handleGenerateFavicon(request)
154
+ }
155
+
156
+ return NextResponse.json({ error: 'Not found' }, { status: 404 })
157
+ }
158
+
159
+ /**
160
+ * Unified DELETE handler
161
+ */
162
+ export async function DELETE(request: NextRequest) {
163
+ if (process.env.NODE_ENV !== 'development') {
164
+ return NextResponse.json({ error: 'Not available in production' }, { status: 403 })
165
+ }
166
+
167
+ return handleDelete(request)
168
+ }