@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,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
+ }