@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,102 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import type { FullMeta, MetaEntry } from '../../types'
|
|
3
|
+
import { getDataPath } from '../../config'
|
|
4
|
+
|
|
5
|
+
export async function loadMeta(): Promise<FullMeta> {
|
|
6
|
+
const metaPath = getDataPath('_studio.json')
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const content = await fs.readFile(metaPath, 'utf-8')
|
|
10
|
+
return JSON.parse(content) as FullMeta
|
|
11
|
+
} catch {
|
|
12
|
+
return {}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function saveMeta(meta: FullMeta): Promise<void> {
|
|
17
|
+
const dataDir = getDataPath()
|
|
18
|
+
await fs.mkdir(dataDir, { recursive: true })
|
|
19
|
+
const metaPath = getDataPath('_studio.json')
|
|
20
|
+
|
|
21
|
+
// Ensure _cdns is at the top by creating ordered object
|
|
22
|
+
const ordered: FullMeta = {}
|
|
23
|
+
if (meta._cdns) {
|
|
24
|
+
ordered._cdns = meta._cdns
|
|
25
|
+
}
|
|
26
|
+
// Add all other entries
|
|
27
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
28
|
+
if (key !== '_cdns') {
|
|
29
|
+
ordered[key] = value
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await fs.writeFile(metaPath, JSON.stringify(ordered, null, 2))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the CDN URLs array from meta
|
|
38
|
+
*/
|
|
39
|
+
export function getCdnUrls(meta: FullMeta): string[] {
|
|
40
|
+
return meta._cdns || []
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Set the CDN URLs array in meta
|
|
45
|
+
*/
|
|
46
|
+
export function setCdnUrls(meta: FullMeta, urls: string[]): void {
|
|
47
|
+
meta._cdns = urls
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get or add a CDN URL, returning its index
|
|
52
|
+
*/
|
|
53
|
+
export function getOrAddCdnIndex(meta: FullMeta, cdnUrl: string): number {
|
|
54
|
+
if (!meta._cdns) {
|
|
55
|
+
meta._cdns = []
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Normalize URL (remove trailing slash)
|
|
59
|
+
const normalizedUrl = cdnUrl.replace(/\/$/, '')
|
|
60
|
+
|
|
61
|
+
const existingIndex = meta._cdns.indexOf(normalizedUrl)
|
|
62
|
+
if (existingIndex >= 0) {
|
|
63
|
+
return existingIndex
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Add new CDN URL
|
|
67
|
+
meta._cdns.push(normalizedUrl)
|
|
68
|
+
return meta._cdns.length - 1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Get a meta entry (excludes special keys like _cdns)
|
|
73
|
+
*/
|
|
74
|
+
export function getMetaEntry(meta: FullMeta, key: string): MetaEntry | undefined {
|
|
75
|
+
if (key.startsWith('_')) return undefined
|
|
76
|
+
const value = meta[key]
|
|
77
|
+
if (Array.isArray(value)) return undefined
|
|
78
|
+
return value as MetaEntry | undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Set a meta entry
|
|
83
|
+
*/
|
|
84
|
+
export function setMetaEntry(meta: FullMeta, key: string, entry: MetaEntry): void {
|
|
85
|
+
meta[key] = entry
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Delete a meta entry
|
|
90
|
+
*/
|
|
91
|
+
export function deleteMetaEntry(meta: FullMeta, key: string): void {
|
|
92
|
+
delete meta[key]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get all file entries (excludes special keys like _cdns)
|
|
97
|
+
*/
|
|
98
|
+
export function getFileEntries(meta: FullMeta): Array<[string, MetaEntry]> {
|
|
99
|
+
return Object.entries(meta).filter(
|
|
100
|
+
([key, value]) => !key.startsWith('_') && !Array.isArray(value)
|
|
101
|
+
) as Array<[string, MetaEntry]>
|
|
102
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import sharp from 'sharp'
|
|
4
|
+
import { encode } from 'blurhash'
|
|
5
|
+
import type { MetaEntry, Dimensions } from '../../types'
|
|
6
|
+
import { getPublicPath } from '../../config'
|
|
7
|
+
|
|
8
|
+
export const FULL_MAX_WIDTH = 2560
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SIZES: Record<string, { width: number; suffix: string; key: 'sm' | 'md' | 'lg' }> = {
|
|
11
|
+
small: { width: 300, suffix: '-sm', key: 'sm' },
|
|
12
|
+
medium: { width: 700, suffix: '-md', key: 'md' },
|
|
13
|
+
large: { width: 1400, suffix: '-lg', key: 'lg' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function processImage(
|
|
17
|
+
buffer: Buffer,
|
|
18
|
+
imageKey: string
|
|
19
|
+
): Promise<MetaEntry> {
|
|
20
|
+
const sharpInstance = sharp(buffer)
|
|
21
|
+
const metadata = await sharpInstance.metadata()
|
|
22
|
+
const originalWidth = metadata.width || 0
|
|
23
|
+
const originalHeight = metadata.height || 0
|
|
24
|
+
const ratio = originalHeight / originalWidth
|
|
25
|
+
|
|
26
|
+
// Remove leading slash for path operations
|
|
27
|
+
const keyWithoutSlash = imageKey.startsWith('/') ? imageKey.slice(1) : imageKey
|
|
28
|
+
const baseName = path.basename(keyWithoutSlash, path.extname(keyWithoutSlash))
|
|
29
|
+
const ext = path.extname(keyWithoutSlash).toLowerCase()
|
|
30
|
+
const imageDir = path.dirname(keyWithoutSlash)
|
|
31
|
+
|
|
32
|
+
const imagesPath = getPublicPath('images', imageDir === '.' ? '' : imageDir)
|
|
33
|
+
await fs.mkdir(imagesPath, { recursive: true })
|
|
34
|
+
|
|
35
|
+
const isPng = ext === '.png'
|
|
36
|
+
const outputExt = isPng ? '.png' : '.jpg'
|
|
37
|
+
|
|
38
|
+
// Build the result entry
|
|
39
|
+
const entry: MetaEntry = {
|
|
40
|
+
o: { w: originalWidth, h: originalHeight },
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Generate full size (capped at FULL_MAX_WIDTH)
|
|
44
|
+
const fullFileName = imageDir === '.' ? `${baseName}${outputExt}` : `${imageDir}/${baseName}${outputExt}`
|
|
45
|
+
const fullPath = getPublicPath('images', fullFileName)
|
|
46
|
+
|
|
47
|
+
let fullWidth = originalWidth
|
|
48
|
+
let fullHeight = originalHeight
|
|
49
|
+
|
|
50
|
+
if (originalWidth > FULL_MAX_WIDTH) {
|
|
51
|
+
fullWidth = FULL_MAX_WIDTH
|
|
52
|
+
fullHeight = Math.round(FULL_MAX_WIDTH * ratio)
|
|
53
|
+
if (isPng) {
|
|
54
|
+
await sharp(buffer).resize(fullWidth, fullHeight).png({ quality: 85 }).toFile(fullPath)
|
|
55
|
+
} else {
|
|
56
|
+
await sharp(buffer).resize(fullWidth, fullHeight).jpeg({ quality: 85 }).toFile(fullPath)
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
if (isPng) {
|
|
60
|
+
await sharp(buffer).png({ quality: 85 }).toFile(fullPath)
|
|
61
|
+
} else {
|
|
62
|
+
await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
entry.f = { w: fullWidth, h: fullHeight }
|
|
66
|
+
|
|
67
|
+
// Generate thumbnail sizes
|
|
68
|
+
for (const [, sizeConfig] of Object.entries(DEFAULT_SIZES)) {
|
|
69
|
+
const { width: maxWidth, suffix, key } = sizeConfig
|
|
70
|
+
if (originalWidth <= maxWidth) {
|
|
71
|
+
continue // Skip if original is smaller than this size
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const newHeight = Math.round(maxWidth * ratio)
|
|
75
|
+
const sizeFileName = `${baseName}${suffix}${outputExt}`
|
|
76
|
+
const sizeFilePath = imageDir === '.' ? sizeFileName : `${imageDir}/${sizeFileName}`
|
|
77
|
+
const sizePath = getPublicPath('images', sizeFilePath)
|
|
78
|
+
|
|
79
|
+
if (isPng) {
|
|
80
|
+
await sharp(buffer).resize(maxWidth, newHeight).png({ quality: 80 }).toFile(sizePath)
|
|
81
|
+
} else {
|
|
82
|
+
await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
entry[key] = { w: maxWidth, h: newHeight }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Generate blurhash
|
|
89
|
+
const { data, info } = await sharp(buffer)
|
|
90
|
+
.resize(32, 32, { fit: 'inside' })
|
|
91
|
+
.ensureAlpha()
|
|
92
|
+
.raw()
|
|
93
|
+
.toBuffer({ resolveWithObject: true })
|
|
94
|
+
|
|
95
|
+
entry.b = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)
|
|
96
|
+
|
|
97
|
+
return entry
|
|
98
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { useEffect, useState, useRef, useCallback } from 'react'
|
|
2
|
+
import { useStudio } from '../components/StudioContext'
|
|
3
|
+
import { studioApi } from '../lib/api'
|
|
4
|
+
import type { FileItem } from '../types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared hook for file list logic used by both Grid and List views
|
|
8
|
+
* Handles loading, sorting, selection, and navigation
|
|
9
|
+
*/
|
|
10
|
+
export function useFileList() {
|
|
11
|
+
const {
|
|
12
|
+
currentPath,
|
|
13
|
+
setCurrentPath,
|
|
14
|
+
navigateUp,
|
|
15
|
+
selectedItems,
|
|
16
|
+
toggleSelection,
|
|
17
|
+
selectRange,
|
|
18
|
+
lastSelectedPath,
|
|
19
|
+
selectAll,
|
|
20
|
+
clearSelection,
|
|
21
|
+
refreshKey,
|
|
22
|
+
setFocusedItem,
|
|
23
|
+
triggerRefresh,
|
|
24
|
+
triggerScan,
|
|
25
|
+
searchQuery,
|
|
26
|
+
showError,
|
|
27
|
+
setFileItems,
|
|
28
|
+
} = useStudio()
|
|
29
|
+
|
|
30
|
+
const [items, setItems] = useState<FileItem[]>([])
|
|
31
|
+
const [loading, setLoading] = useState(true)
|
|
32
|
+
const [metaEmpty, setMetaEmpty] = useState(false)
|
|
33
|
+
const isInitialLoad = useRef(true)
|
|
34
|
+
const lastPath = useRef(currentPath)
|
|
35
|
+
|
|
36
|
+
// Load items when path, refresh, or search changes
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
async function loadItems() {
|
|
39
|
+
const isPathChange = lastPath.current !== currentPath
|
|
40
|
+
if (isInitialLoad.current || isPathChange) {
|
|
41
|
+
setLoading(true)
|
|
42
|
+
}
|
|
43
|
+
lastPath.current = currentPath
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const data = searchQuery && searchQuery.length >= 2
|
|
47
|
+
? await studioApi.search(searchQuery)
|
|
48
|
+
: await studioApi.list(currentPath)
|
|
49
|
+
const loadedItems = data.items || []
|
|
50
|
+
setItems(loadedItems)
|
|
51
|
+
setFileItems(loadedItems)
|
|
52
|
+
setMetaEmpty(data.isEmpty === true)
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const message = error instanceof Error ? error.message : 'Failed to load items'
|
|
55
|
+
showError('Load Error', message)
|
|
56
|
+
setItems([])
|
|
57
|
+
setFileItems([])
|
|
58
|
+
setMetaEmpty(false)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
setLoading(false)
|
|
62
|
+
isInitialLoad.current = false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
loadItems()
|
|
66
|
+
}, [currentPath, refreshKey, searchQuery, showError, setFileItems])
|
|
67
|
+
|
|
68
|
+
// Computed values
|
|
69
|
+
const isAtRoot = currentPath === 'public'
|
|
70
|
+
const isSearching = searchQuery && searchQuery.length >= 2
|
|
71
|
+
|
|
72
|
+
// Sort items: folders first, then alphabetically
|
|
73
|
+
const sortedItems = [...items].sort((a, b) => {
|
|
74
|
+
if (a.type === 'folder' && b.type !== 'folder') return -1
|
|
75
|
+
if (a.type !== 'folder' && b.type === 'folder') return 1
|
|
76
|
+
return a.name.localeCompare(b.name)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const allItemsSelected = sortedItems.length > 0 && sortedItems.every(item => selectedItems.has(item.path))
|
|
80
|
+
const someItemsSelected = sortedItems.some(item => selectedItems.has(item.path))
|
|
81
|
+
|
|
82
|
+
// Handlers
|
|
83
|
+
const handleItemClick = useCallback((item: FileItem, e: React.MouseEvent) => {
|
|
84
|
+
if (e.shiftKey && lastSelectedPath) {
|
|
85
|
+
selectRange(lastSelectedPath, item.path, sortedItems)
|
|
86
|
+
} else {
|
|
87
|
+
toggleSelection(item.path)
|
|
88
|
+
}
|
|
89
|
+
}, [lastSelectedPath, selectRange, sortedItems, toggleSelection])
|
|
90
|
+
|
|
91
|
+
const handleOpen = useCallback((item: FileItem) => {
|
|
92
|
+
if (item.type === 'folder') {
|
|
93
|
+
setCurrentPath(item.path)
|
|
94
|
+
} else {
|
|
95
|
+
setFocusedItem(item)
|
|
96
|
+
}
|
|
97
|
+
}, [setCurrentPath, setFocusedItem])
|
|
98
|
+
|
|
99
|
+
const handleGenerateThumbnail = useCallback(async (item: FileItem) => {
|
|
100
|
+
try {
|
|
101
|
+
const imageKey = '/' + item.path.replace(/^public\//, '')
|
|
102
|
+
await studioApi.reprocess([imageKey])
|
|
103
|
+
triggerRefresh()
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : 'Failed to generate thumbnail'
|
|
106
|
+
showError('Processing Error', message)
|
|
107
|
+
}
|
|
108
|
+
}, [triggerRefresh, showError])
|
|
109
|
+
|
|
110
|
+
const handleSelectAll = useCallback(() => {
|
|
111
|
+
if (allItemsSelected) {
|
|
112
|
+
clearSelection()
|
|
113
|
+
} else {
|
|
114
|
+
selectAll(sortedItems)
|
|
115
|
+
}
|
|
116
|
+
}, [allItemsSelected, clearSelection, selectAll, sortedItems])
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
// State
|
|
120
|
+
items,
|
|
121
|
+
loading,
|
|
122
|
+
sortedItems,
|
|
123
|
+
metaEmpty,
|
|
124
|
+
|
|
125
|
+
// Computed
|
|
126
|
+
isAtRoot,
|
|
127
|
+
isSearching,
|
|
128
|
+
allItemsSelected,
|
|
129
|
+
someItemsSelected,
|
|
130
|
+
|
|
131
|
+
// Context values
|
|
132
|
+
currentPath,
|
|
133
|
+
selectedItems,
|
|
134
|
+
navigateUp,
|
|
135
|
+
|
|
136
|
+
// Handlers
|
|
137
|
+
handleItemClick,
|
|
138
|
+
handleOpen,
|
|
139
|
+
handleGenerateThumbnail,
|
|
140
|
+
handleSelectAll,
|
|
141
|
+
triggerScan,
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gallop.software/studio
|
|
3
|
+
*
|
|
4
|
+
* Standalone media manager for Gallop templates.
|
|
5
|
+
*
|
|
6
|
+
* Usage (CLI):
|
|
7
|
+
* ```bash
|
|
8
|
+
* # Run Studio for current project
|
|
9
|
+
* npx @gallop.software/studio --workspace .
|
|
10
|
+
*
|
|
11
|
+
* # Or add to package.json scripts
|
|
12
|
+
* {
|
|
13
|
+
* "scripts": {
|
|
14
|
+
* "studio": "studio --workspace ."
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Studio runs as a standalone dev server on port 3001.
|
|
20
|
+
* It manages files in your project's public/ folder and
|
|
21
|
+
* stores metadata in _data/_studio.json.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
// Types - for consumers that read _studio.json
|
|
25
|
+
export type {
|
|
26
|
+
LeanImageEntry,
|
|
27
|
+
LeanMeta,
|
|
28
|
+
FileItem,
|
|
29
|
+
StudioConfig,
|
|
30
|
+
MetaEntry,
|
|
31
|
+
FullMeta,
|
|
32
|
+
Dimensions,
|
|
33
|
+
} from './types'
|
|
34
|
+
|
|
35
|
+
// Utilities - for generating thumbnail paths
|
|
36
|
+
export { getThumbnailPath, getAllThumbnailPaths, isProcessed } from './types'
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed API client for Studio
|
|
3
|
+
* Provides type-safe methods for all Studio API endpoints
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { FileItem, LeanMeta, LeanImageEntry } from '../types'
|
|
7
|
+
|
|
8
|
+
// Response types
|
|
9
|
+
interface ListResponse {
|
|
10
|
+
items: FileItem[]
|
|
11
|
+
isEmpty?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FoldersResponse {
|
|
15
|
+
folders: { path: string; name: string; depth: number }[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CountImagesResponse {
|
|
19
|
+
count: number
|
|
20
|
+
images: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UploadResponse {
|
|
24
|
+
success: boolean
|
|
25
|
+
imageKey?: string
|
|
26
|
+
entry?: LeanImageEntry
|
|
27
|
+
path?: string
|
|
28
|
+
message?: string
|
|
29
|
+
error?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DeleteResponse {
|
|
33
|
+
success: boolean
|
|
34
|
+
deleted: string[]
|
|
35
|
+
errors?: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface PushResponse {
|
|
39
|
+
success: boolean
|
|
40
|
+
pushed: string[]
|
|
41
|
+
errors?: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ReprocessResponse {
|
|
45
|
+
success: boolean
|
|
46
|
+
processed: string[]
|
|
47
|
+
errors?: string[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface CreateFolderResponse {
|
|
51
|
+
success: boolean
|
|
52
|
+
path: string
|
|
53
|
+
error?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface RenameResponse {
|
|
57
|
+
success: boolean
|
|
58
|
+
newPath: string
|
|
59
|
+
error?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface MoveResponse {
|
|
63
|
+
success: boolean
|
|
64
|
+
moved: string[]
|
|
65
|
+
errors?: string[]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class StudioApiClient {
|
|
69
|
+
private async get<T>(url: string): Promise<T> {
|
|
70
|
+
const response = await fetch(url)
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const data = await response.json().catch(() => ({}))
|
|
73
|
+
throw new Error(data.error || `Request failed: ${response.status}`)
|
|
74
|
+
}
|
|
75
|
+
return response.json()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async post<T>(url: string, body?: object): Promise<T> {
|
|
79
|
+
const response = await fetch(url, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
82
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
83
|
+
})
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const data = await response.json().catch(() => ({}))
|
|
86
|
+
throw new Error(data.error || `Request failed: ${response.status}`)
|
|
87
|
+
}
|
|
88
|
+
return response.json()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// List handlers
|
|
92
|
+
async list(path: string = 'public'): Promise<ListResponse> {
|
|
93
|
+
return this.get(`/api/studio/list?path=${encodeURIComponent(path)}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async search(query: string): Promise<ListResponse> {
|
|
97
|
+
return this.get(`/api/studio/search?q=${encodeURIComponent(query)}`)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async listFolders(): Promise<FoldersResponse> {
|
|
101
|
+
return this.get('/api/studio/list-folders')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async countImages(): Promise<CountImagesResponse> {
|
|
105
|
+
return this.get('/api/studio/count-images')
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async folderImages(folders: string[]): Promise<CountImagesResponse> {
|
|
109
|
+
return this.get(`/api/studio/folder-images?folders=${encodeURIComponent(folders.join(','))}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// File handlers
|
|
113
|
+
async upload(file: File, targetPath: string = 'public'): Promise<UploadResponse> {
|
|
114
|
+
const formData = new FormData()
|
|
115
|
+
formData.append('file', file)
|
|
116
|
+
formData.append('path', targetPath)
|
|
117
|
+
|
|
118
|
+
const response = await fetch('/api/studio/upload', {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
body: formData,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
if (!response.ok) {
|
|
124
|
+
const data = await response.json().catch(() => ({}))
|
|
125
|
+
throw new Error(data.error || `Upload failed: ${response.status}`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return response.json()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async delete(paths: string[]): Promise<DeleteResponse> {
|
|
132
|
+
return this.post('/api/studio/delete', { paths })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async createFolder(parentPath: string, name: string): Promise<CreateFolderResponse> {
|
|
136
|
+
return this.post('/api/studio/create-folder', { parentPath, name })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async rename(oldPath: string, newName: string): Promise<RenameResponse> {
|
|
140
|
+
return this.post('/api/studio/rename', { oldPath, newName })
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async move(paths: string[], destination: string): Promise<MoveResponse> {
|
|
144
|
+
return this.post('/api/studio/move', { paths, destination })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Image handlers
|
|
148
|
+
async push(imageKeys: string[]): Promise<PushResponse> {
|
|
149
|
+
return this.post('/api/studio/sync', { imageKeys })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async reprocess(imageKeys: string[]): Promise<ReprocessResponse> {
|
|
153
|
+
return this.post('/api/studio/reprocess', { imageKeys })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Process all returns a stream, handle separately
|
|
157
|
+
processAllStream(): EventSource {
|
|
158
|
+
return new EventSource('/api/studio/process-all')
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const studioApi = new StudioApiClient()
|
|
163
|
+
|
|
164
|
+
// Export types for consumers
|
|
165
|
+
export type {
|
|
166
|
+
ListResponse,
|
|
167
|
+
FoldersResponse,
|
|
168
|
+
CountImagesResponse,
|
|
169
|
+
UploadResponse,
|
|
170
|
+
DeleteResponse,
|
|
171
|
+
PushResponse,
|
|
172
|
+
ReprocessResponse,
|
|
173
|
+
CreateFolderResponse,
|
|
174
|
+
RenameResponse,
|
|
175
|
+
MoveResponse,
|
|
176
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dimensions object {w, h}
|
|
3
|
+
*/
|
|
4
|
+
export interface Dimensions {
|
|
5
|
+
w: number
|
|
6
|
+
h: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Meta entry - works for images and non-images
|
|
11
|
+
* o: original dimensions, b: blurhash, c: CDN index
|
|
12
|
+
* sm/md/lg/f: thumbnail dimensions (presence implies processed)
|
|
13
|
+
*/
|
|
14
|
+
export interface MetaEntry {
|
|
15
|
+
o?: Dimensions // original dimensions {w, h}
|
|
16
|
+
b?: string // blurhash
|
|
17
|
+
sm?: Dimensions // small thumbnail (300px width)
|
|
18
|
+
md?: Dimensions // medium thumbnail (700px width)
|
|
19
|
+
lg?: Dimensions // large thumbnail (1400px width)
|
|
20
|
+
f?: Dimensions // full size (capped at 2560px width)
|
|
21
|
+
c?: number // CDN index - index into _cdns array
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Full meta schema including special keys
|
|
26
|
+
* _cdns: Array of CDN base URLs
|
|
27
|
+
* Other keys: file paths from public folder
|
|
28
|
+
*/
|
|
29
|
+
export interface FullMeta {
|
|
30
|
+
_cdns?: string[] // Array of CDN base URLs
|
|
31
|
+
[key: string]: MetaEntry | string[] | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Meta schema - keyed by path from public folder
|
|
36
|
+
* Example: { "/portfolio/photo.jpg": { o: {w:2400,h:1600}, b: "...", sm: {w:300,h:200}, ... } }
|
|
37
|
+
*/
|
|
38
|
+
export type LeanMeta = Record<string, MetaEntry>
|
|
39
|
+
|
|
40
|
+
// Alias for compatibility
|
|
41
|
+
export type LeanImageEntry = MetaEntry
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* File/folder item for browser
|
|
45
|
+
*/
|
|
46
|
+
export interface FileItem {
|
|
47
|
+
name: string
|
|
48
|
+
path: string
|
|
49
|
+
type: 'file' | 'folder'
|
|
50
|
+
size?: number
|
|
51
|
+
dimensions?: { width: number; height: number }
|
|
52
|
+
isProcessed?: boolean
|
|
53
|
+
cdnPushed?: boolean
|
|
54
|
+
cdnBaseUrl?: string // CDN base URL when pushed to cloud
|
|
55
|
+
isRemote?: boolean // true if CDN URL doesn't match R2 (external import)
|
|
56
|
+
isProtected?: boolean // true for images folder and its contents (cannot select/modify)
|
|
57
|
+
// Folder-specific properties
|
|
58
|
+
fileCount?: number
|
|
59
|
+
totalSize?: number
|
|
60
|
+
cloudCount?: number // Number of R2 cloud files in folder
|
|
61
|
+
remoteCount?: number // Number of remote (imported URL) files in folder
|
|
62
|
+
localCount?: number // Number of local files in folder
|
|
63
|
+
// For showing thumbnails - path to -sm version if exists
|
|
64
|
+
thumbnail?: string
|
|
65
|
+
// Whether a processed thumbnail exists
|
|
66
|
+
hasThumbnail?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Studio configuration
|
|
71
|
+
*/
|
|
72
|
+
export interface StudioConfig {
|
|
73
|
+
r2AccountId?: string
|
|
74
|
+
r2AccessKeyId?: string
|
|
75
|
+
r2SecretAccessKey?: string
|
|
76
|
+
r2BucketName?: string
|
|
77
|
+
r2PublicUrl?: string
|
|
78
|
+
thumbnailSizes?: {
|
|
79
|
+
small: number
|
|
80
|
+
medium: number
|
|
81
|
+
large: number
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get thumbnail path from original image path
|
|
87
|
+
*/
|
|
88
|
+
export function getThumbnailPath(originalPath: string, size: 'sm' | 'md' | 'lg' | 'full'): string {
|
|
89
|
+
if (size === 'full') {
|
|
90
|
+
const ext = originalPath.match(/\.\w+$/)?.[0] || '.jpg'
|
|
91
|
+
const base = originalPath.replace(/\.\w+$/, '')
|
|
92
|
+
const outputExt = ext.toLowerCase() === '.png' ? '.png' : '.jpg'
|
|
93
|
+
return `/images${base}${outputExt}`
|
|
94
|
+
}
|
|
95
|
+
const ext = originalPath.match(/\.\w+$/)?.[0] || '.jpg'
|
|
96
|
+
const base = originalPath.replace(/\.\w+$/, '')
|
|
97
|
+
const outputExt = ext.toLowerCase() === '.png' ? '.png' : '.jpg'
|
|
98
|
+
return `/images${base}-${size}${outputExt}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Get all thumbnail paths for an image
|
|
103
|
+
*/
|
|
104
|
+
export function getAllThumbnailPaths(originalPath: string): string[] {
|
|
105
|
+
return [
|
|
106
|
+
getThumbnailPath(originalPath, 'full'),
|
|
107
|
+
getThumbnailPath(originalPath, 'lg'),
|
|
108
|
+
getThumbnailPath(originalPath, 'md'),
|
|
109
|
+
getThumbnailPath(originalPath, 'sm'),
|
|
110
|
+
]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if an image entry is processed (has any thumbnail dimensions)
|
|
115
|
+
*/
|
|
116
|
+
export function isProcessed(entry: MetaEntry | undefined): boolean {
|
|
117
|
+
if (!entry) return false
|
|
118
|
+
return !!(entry.f || entry.lg || entry.md || entry.sm)
|
|
119
|
+
}
|