@gallop.software/studio 1.5.10 → 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 +77 -55
- package/dist/handlers/index.js.map +1 -1
- package/dist/handlers/index.mjs +128 -106
- 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,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
|
+
}
|