@gallop.software/studio 0.1.4 → 0.1.6
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/dist/{StudioUI-Z3DOQO7P.js → StudioUI-P5VY2DPS.js} +93 -12
- package/dist/StudioUI-P5VY2DPS.js.map +1 -0
- package/dist/{StudioUI-DSMTGRJ3.mjs → StudioUI-PW3ZGLXL.mjs} +93 -12
- package/dist/StudioUI-PW3ZGLXL.mjs.map +1 -0
- package/dist/handlers.js +111 -34
- package/dist/handlers.js.map +1 -1
- package/dist/handlers.mjs +111 -34
- package/dist/handlers.mjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/dist/StudioUI-DSMTGRJ3.mjs.map +0 -1
- package/dist/StudioUI-Z3DOQO7P.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/components/StudioUI.tsx","../src/components/StudioContext.tsx","../src/components/StudioToolbar.tsx","../src/components/StudioBreadcrumb.tsx","../src/components/StudioFileGrid.tsx","../src/components/StudioFileList.tsx","../src/components/StudioPreview.tsx","../src/components/StudioSettings.tsx"],"sourcesContent":["/** @jsxImportSource @emotion/react */\n'use client'\n\nimport { useEffect, useCallback, useState } from 'react'\nimport { css } from '@emotion/react'\nimport { StudioContext } from './StudioContext'\nimport { StudioToolbar } from './StudioToolbar'\nimport { StudioBreadcrumb } from './StudioBreadcrumb'\nimport { StudioFileGrid } from './StudioFileGrid'\nimport { StudioFileList } from './StudioFileList'\nimport { StudioPreview } from './StudioPreview'\nimport { StudioSettings } from './StudioSettings'\nimport type { FileItem, StudioMeta } from '../types'\n\ninterface StudioUIProps {\n onClose: () => void\n}\n\nconst styles = {\n container: css`\n display: flex;\n flex-direction: column;\n height: 100%;\n font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n `,\n header: css`\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px 24px;\n border-bottom: 1px solid #e5e7eb;\n `,\n title: css`\n font-size: 20px;\n font-weight: 600;\n color: #111827;\n margin: 0;\n `,\n headerActions: css`\n display: flex;\n align-items: center;\n gap: 8px;\n `,\n closeBtn: css`\n padding: 8px;\n background: none;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s;\n \n &:hover {\n background-color: #f3f4f6;\n }\n `,\n closeIcon: css`\n width: 20px;\n height: 20px;\n color: #6b7280;\n `,\n content: css`\n flex: 1;\n display: flex;\n overflow: hidden;\n `,\n fileBrowser: css`\n flex: 1;\n min-width: 0;\n overflow: auto;\n padding: 16px;\n `,\n}\n\n/**\n * Main Studio UI - contains all panels and manages internal state\n * Rendered inside the modal via lazy loading\n */\nexport function StudioUI({ onClose }: StudioUIProps) {\n const [currentPath, setCurrentPathInternal] = useState('public')\n const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())\n const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')\n const [meta, setMeta] = useState<StudioMeta | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [refreshKey, setRefreshKey] = useState(0)\n\n const triggerRefresh = useCallback(() => {\n setRefreshKey((k) => k + 1)\n }, [])\n\n const navigateUp = useCallback(() => {\n if (currentPath === 'public') return\n const parts = currentPath.split('/')\n parts.pop()\n setCurrentPathInternal(parts.join('/') || 'public')\n setSelectedItems(new Set())\n }, [currentPath])\n\n const setCurrentPath = useCallback((path: string) => {\n setCurrentPathInternal(path)\n setSelectedItems(new Set())\n }, [])\n\n const toggleSelection = useCallback((path: string) => {\n setSelectedItems((prev) => {\n const next = new Set(prev)\n if (next.has(path)) {\n next.delete(path)\n } else {\n next.add(path)\n }\n return next\n })\n }, [])\n\n const selectAll = useCallback((items: FileItem[]) => {\n setSelectedItems(new Set(items.map((item) => item.path)))\n }, [])\n\n const clearSelection = useCallback(() => {\n setSelectedItems(new Set())\n }, [])\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent) => {\n if (e.key === 'Escape') {\n onClose()\n }\n },\n [onClose]\n )\n\n useEffect(() => {\n document.addEventListener('keydown', handleKeyDown)\n document.body.style.overflow = 'hidden'\n return () => {\n document.removeEventListener('keydown', handleKeyDown)\n document.body.style.overflow = ''\n }\n }, [handleKeyDown])\n\n const contextValue = {\n isOpen: true,\n openStudio: () => {},\n closeStudio: onClose,\n toggleStudio: onClose,\n currentPath,\n setCurrentPath,\n navigateUp,\n selectedItems,\n toggleSelection,\n selectAll,\n clearSelection,\n viewMode,\n setViewMode,\n meta,\n setMeta,\n isLoading,\n setIsLoading,\n refreshKey,\n triggerRefresh,\n }\n\n return (\n <StudioContext.Provider value={contextValue}>\n <div css={styles.container}>\n <div css={styles.header}>\n <h1 css={styles.title}>Studio</h1>\n <div css={styles.headerActions}>\n <StudioSettings />\n <button\n css={styles.closeBtn}\n onClick={onClose}\n aria-label=\"Close Studio\"\n >\n <CloseIcon />\n </button>\n </div>\n </div>\n\n <StudioToolbar />\n <StudioBreadcrumb />\n\n <div css={styles.content}>\n <div css={styles.fileBrowser}>\n {viewMode === 'grid' ? <StudioFileGrid /> : <StudioFileList />}\n </div>\n <StudioPreview />\n </div>\n </div>\n </StudioContext.Provider>\n )\n}\n\nfunction CloseIcon() {\n return (\n <svg\n css={styles.closeIcon}\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\" />\n <line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nexport default StudioUI\n","'use client'\n\nimport { createContext, useContext } from 'react'\nimport type { FileItem, StudioMeta } from '../types'\n\n/**\n * Studio state interface\n * State is managed by StudioUI and provided to all child components\n */\nexport interface StudioState {\n isOpen: boolean\n openStudio: () => void\n closeStudio: () => void\n toggleStudio: () => void\n\n // Navigation\n currentPath: string\n setCurrentPath: (path: string) => void\n navigateUp: () => void\n\n // Selection\n selectedItems: Set<string>\n toggleSelection: (path: string) => void\n selectAll: (items: FileItem[]) => void\n clearSelection: () => void\n\n // View\n viewMode: 'grid' | 'list'\n setViewMode: (mode: 'grid' | 'list') => void\n\n // Meta\n meta: StudioMeta | null\n setMeta: (meta: StudioMeta) => void\n\n // Loading\n isLoading: boolean\n setIsLoading: (loading: boolean) => void\n\n // Refresh trigger\n refreshKey: number\n triggerRefresh: () => void\n}\n\nconst defaultState: StudioState = {\n isOpen: false,\n openStudio: () => {},\n closeStudio: () => {},\n toggleStudio: () => {},\n currentPath: 'public',\n setCurrentPath: () => {},\n navigateUp: () => {},\n selectedItems: new Set(),\n toggleSelection: () => {},\n selectAll: () => {},\n clearSelection: () => {},\n viewMode: 'grid',\n setViewMode: () => {},\n meta: null,\n setMeta: () => {},\n isLoading: false,\n setIsLoading: () => {},\n refreshKey: 0,\n triggerRefresh: () => {},\n}\n\nexport const StudioContext = createContext<StudioState>(defaultState)\n\n/**\n * Hook to access Studio state from child components\n */\nexport function useStudio() {\n return useContext(StudioContext)\n}\n","/** @jsxImportSource @emotion/react */\n'use client'\n\nimport { useCallback, useRef, useState } from 'react'\nimport { css } from '@emotion/react'\nimport { useStudio } from './StudioContext'\n\nconst styles = {\n toolbar: css`\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px 24px;\n background-color: #f9fafb;\n border-bottom: 1px solid #e5e7eb;\n `,\n left: css`\n display: flex;\n align-items: center;\n gap: 8px;\n `,\n right: css`\n display: flex;\n align-items: center;\n gap: 16px;\n `,\n btn: css`\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 12px;\n border-radius: 8px;\n font-size: 14px;\n font-weight: 500;\n background: none;\n border: none;\n cursor: pointer;\n transition: background-color 0.15s;\n \n &:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n }\n `,\n btnDefault: css`\n color: #374151;\n \n &:hover:not(:disabled) {\n background-color: white;\n }\n `,\n btnDanger: css`\n color: #dc2626;\n \n &:hover:not(:disabled) {\n background-color: #fef2f2;\n }\n `,\n icon: css`\n width: 16px;\n height: 16px;\n `,\n selectionCount: css`\n font-size: 14px;\n color: #4b5563;\n `,\n clearBtn: css`\n margin-left: 8px;\n color: #9333ea;\n background: none;\n border: none;\n cursor: pointer;\n font-size: 14px;\n \n &:hover {\n text-decoration: underline;\n }\n `,\n viewToggle: css`\n display: flex;\n align-items: center;\n background-color: white;\n border: 1px solid #e5e7eb;\n border-radius: 8px;\n overflow: hidden;\n `,\n viewBtn: css`\n padding: 8px;\n background: none;\n border: none;\n cursor: pointer;\n color: #6b7280;\n transition: all 0.15s;\n \n &:hover {\n background-color: #f9fafb;\n }\n `,\n viewBtnActive: css`\n background-color: #f3e8ff;\n color: #7c3aed;\n `,\n}\n\nexport function StudioToolbar() {\n const { selectedItems, viewMode, setViewMode, clearSelection, currentPath, triggerRefresh } = useStudio()\n const fileInputRef = useRef<HTMLInputElement>(null)\n const [uploading, setUploading] = useState(false)\n\n const handleUpload = useCallback(() => {\n fileInputRef.current?.click()\n }, [])\n\n const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {\n const files = e.target.files\n if (!files || files.length === 0) return\n\n setUploading(true)\n try {\n for (const file of Array.from(files)) {\n const formData = new FormData()\n formData.append('file', file)\n formData.append('path', currentPath)\n\n const response = await fetch('/api/studio/upload', {\n method: 'POST',\n body: formData,\n })\n\n if (!response.ok) {\n const error = await response.json()\n console.error('Upload failed:', error)\n alert(`Failed to upload ${file.name}: ${error.error || 'Unknown error'}`)\n }\n }\n triggerRefresh()\n } catch (error) {\n console.error('Upload error:', error)\n alert('Upload failed. Check console for details.')\n } finally {\n setUploading(false)\n // Reset input so same file can be uploaded again\n if (fileInputRef.current) {\n fileInputRef.current.value = ''\n }\n }\n }, [currentPath, triggerRefresh])\n\n const handleReprocess = useCallback(() => {\n console.log('Reprocess clicked', selectedItems)\n }, [selectedItems])\n\n const handleDelete = useCallback(async () => {\n if (selectedItems.size === 0) return\n if (!confirm(`Delete ${selectedItems.size} item(s)?`)) return\n\n try {\n const response = await fetch('/api/studio/delete', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ paths: Array.from(selectedItems) }),\n })\n\n if (response.ok) {\n clearSelection()\n triggerRefresh()\n } else {\n const error = await response.json()\n alert(`Delete failed: ${error.error || 'Unknown error'}`)\n }\n } catch (error) {\n console.error('Delete error:', error)\n alert('Delete failed. Check console for details.')\n }\n }, [selectedItems, clearSelection, triggerRefresh])\n\n const handleSyncCdn = useCallback(() => {\n console.log('Sync CDN clicked', selectedItems)\n }, [selectedItems])\n\n const handleScan = useCallback(() => {\n console.log('Scan clicked')\n }, [])\n\n const hasSelection = selectedItems.size > 0\n\n return (\n <div css={styles.toolbar}>\n {/* Hidden file input for upload */}\n <input\n ref={fileInputRef}\n type=\"file\"\n multiple\n accept=\"image/*\"\n onChange={handleFileChange}\n style={{ display: 'none' }}\n />\n \n <div css={styles.left}>\n <ToolbarButton \n onClick={handleUpload} \n icon=\"upload\" \n label={uploading ? 'Uploading...' : 'Upload'} \n disabled={uploading}\n />\n <ToolbarButton\n onClick={handleReprocess}\n icon=\"refresh\"\n label=\"Reprocess\"\n disabled={!hasSelection}\n />\n <ToolbarButton\n onClick={handleDelete}\n icon=\"trash\"\n label=\"Delete\"\n disabled={!hasSelection}\n variant=\"danger\"\n />\n <ToolbarButton\n onClick={handleSyncCdn}\n icon=\"cloud\"\n label=\"Sync CDN\"\n disabled={!hasSelection}\n />\n <ToolbarButton onClick={handleScan} icon=\"scan\" label=\"Scan\" />\n </div>\n\n <div css={styles.right}>\n {hasSelection && (\n <span css={styles.selectionCount}>\n {selectedItems.size} selected\n <button css={styles.clearBtn} onClick={clearSelection}>\n Clear\n </button>\n </span>\n )}\n\n <div css={styles.viewToggle}>\n <button\n css={[styles.viewBtn, viewMode === 'grid' && styles.viewBtnActive]}\n onClick={() => setViewMode('grid')}\n aria-label=\"Grid view\"\n >\n <GridIcon />\n </button>\n <button\n css={[styles.viewBtn, viewMode === 'list' && styles.viewBtnActive]}\n onClick={() => setViewMode('list')}\n aria-label=\"List view\"\n >\n <ListIcon />\n </button>\n </div>\n </div>\n </div>\n )\n}\n\ninterface ToolbarButtonProps {\n onClick: () => void\n icon: 'upload' | 'refresh' | 'trash' | 'cloud' | 'scan'\n label: string\n disabled?: boolean\n variant?: 'default' | 'danger'\n}\n\nfunction ToolbarButton({\n onClick,\n icon,\n label,\n disabled,\n variant = 'default',\n}: ToolbarButtonProps) {\n return (\n <button\n css={[styles.btn, variant === 'danger' ? styles.btnDanger : styles.btnDefault]}\n onClick={onClick}\n disabled={disabled}\n >\n <IconComponent icon={icon} />\n {label}\n </button>\n )\n}\n\nfunction IconComponent({ icon }: { icon: string }) {\n switch (icon) {\n case 'upload':\n return (\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12\" />\n </svg>\n )\n case 'refresh':\n return (\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n </svg>\n )\n case 'trash':\n return (\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16\" />\n </svg>\n )\n case 'cloud':\n return (\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12\" />\n </svg>\n )\n case 'scan':\n return (\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\" />\n </svg>\n )\n default:\n return null\n }\n}\n\nfunction GridIcon() {\n return (\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z\" />\n </svg>\n )\n}\n\nfunction ListIcon() {\n return (\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M4 6h16M4 10h16M4 14h16M4 18h16\" />\n </svg>\n )\n}\n","/** @jsxImportSource @emotion/react */\n'use client'\n\nimport { css } from '@emotion/react'\nimport { useStudio } from './StudioContext'\n\nconst styles = {\n container: css`\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 24px;\n background-color: white;\n border-bottom: 1px solid #f3f4f6;\n `,\n backBtn: css`\n padding: 4px;\n background: none;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n transition: background-color 0.15s;\n \n &:hover {\n background-color: #f3f4f6;\n }\n `,\n backIcon: css`\n width: 16px;\n height: 16px;\n color: #6b7280;\n `,\n nav: css`\n display: flex;\n align-items: center;\n gap: 4px;\n font-size: 14px;\n `,\n item: css`\n display: flex;\n align-items: center;\n gap: 4px;\n `,\n separator: css`\n color: #d1d5db;\n `,\n btn: css`\n padding: 2px 4px;\n background: none;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n transition: all 0.15s;\n \n &:hover {\n background-color: #f3f4f6;\n }\n `,\n btnActive: css`\n color: #111827;\n font-weight: 500;\n `,\n btnInactive: css`\n color: #6b7280;\n \n &:hover {\n color: #374151;\n }\n `,\n}\n\nexport function StudioBreadcrumb() {\n const { currentPath, setCurrentPath, navigateUp } = useStudio()\n\n const parts = currentPath.split('/').filter(Boolean)\n\n const handleClick = (index: number) => {\n const newPath = parts.slice(0, index + 1).join('/')\n setCurrentPath(newPath)\n }\n\n return (\n <div css={styles.container}>\n {currentPath !== 'public' && (\n <button css={styles.backBtn} onClick={navigateUp} aria-label=\"Go back\">\n <svg css={styles.backIcon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 19l-7-7 7-7\" />\n </svg>\n </button>\n )}\n\n <nav css={styles.nav}>\n {parts.map((part, index) => (\n <span key={index} css={styles.item}>\n {index > 0 && <span css={styles.separator}>/</span>}\n <button\n css={[styles.btn, index === parts.length - 1 ? styles.btnActive : styles.btnInactive]}\n onClick={() => handleClick(index)}\n >\n {part}\n </button>\n </span>\n ))}\n </nav>\n </div>\n )\n}\n","/** @jsxImportSource @emotion/react */\n'use client'\n\nimport { useEffect, useState } from 'react'\nimport { css, keyframes } from '@emotion/react'\nimport { useStudio } from './StudioContext'\nimport type { FileItem } from '../types'\n\nconst spin = keyframes`\n to { transform: rotate(360deg); }\n`\n\nconst styles = {\n loading: css`\n display: flex;\n align-items: center;\n justify-content: center;\n height: 256px;\n `,\n spinner: css`\n width: 32px;\n height: 32px;\n border-radius: 50%;\n border: 2px solid transparent;\n border-bottom-color: #9333ea;\n animation: ${spin} 1s linear infinite;\n `,\n empty: css`\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 256px;\n color: #6b7280;\n `,\n emptyIcon: css`\n width: 48px;\n height: 48px;\n margin-bottom: 16px;\n `,\n emptyText: css`\n font-size: 14px;\n margin: 0;\n `,\n grid: css`\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n gap: 16px;\n \n @media (min-width: 640px) { grid-template-columns: repeat(3, 1fr); }\n @media (min-width: 768px) { grid-template-columns: repeat(4, 1fr); }\n @media (min-width: 1024px) { grid-template-columns: repeat(5, 1fr); }\n @media (min-width: 1280px) { grid-template-columns: repeat(6, 1fr); }\n `,\n item: css`\n position: relative;\n border-radius: 8px;\n border: 2px solid transparent;\n overflow: hidden;\n cursor: pointer;\n transition: all 0.15s;\n background-color: #f9fafb;\n \n &:hover {\n border-color: #e5e7eb;\n }\n `,\n itemSelected: css`\n border-color: #a855f7;\n background-color: #faf5ff;\n `,\n checkbox: css`\n position: absolute;\n top: 8px;\n left: 8px;\n z-index: 10;\n width: 16px;\n height: 16px;\n accent-color: #9333ea;\n `,\n cdnBadge: css`\n position: absolute;\n top: 8px;\n right: 8px;\n z-index: 10;\n background-color: #dcfce7;\n color: #15803d;\n font-size: 12px;\n padding: 2px 6px;\n border-radius: 9999px;\n `,\n content: css`\n aspect-ratio: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n padding: 16px;\n `,\n folderIcon: css`\n width: 64px;\n height: 64px;\n color: #facc15;\n `,\n image: css`\n max-width: 100%;\n max-height: 100%;\n object-fit: contain;\n border-radius: 4px;\n `,\n label: css`\n padding: 6px 8px;\n background-color: white;\n border-top: 1px solid #e5e7eb;\n `,\n name: css`\n font-size: 12px;\n color: #374151;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n margin: 0;\n `,\n size: css`\n font-size: 12px;\n color: #9ca3af;\n margin: 0;\n `,\n}\n\nexport function StudioFileGrid() {\n const { currentPath, setCurrentPath, selectedItems, toggleSelection, refreshKey } = useStudio()\n const [items, setItems] = useState<FileItem[]>([])\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n async function loadItems() {\n setLoading(true)\n try {\n const response = await fetch(`/api/studio/list?path=${encodeURIComponent(currentPath)}`)\n if (response.ok) {\n const data = await response.json()\n setItems(data.items || [])\n }\n } catch (error) {\n console.error('Failed to load items:', error)\n }\n setLoading(false)\n }\n loadItems()\n }, [currentPath, refreshKey])\n\n if (loading) {\n return (\n <div css={styles.loading}>\n <div css={styles.spinner} />\n </div>\n )\n }\n\n if (items.length === 0) {\n return (\n <div css={styles.empty}>\n <svg css={styles.emptyIcon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1.5} d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n </svg>\n <p css={styles.emptyText}>No files in this folder</p>\n <p css={styles.emptyText}>Upload images to get started</p>\n </div>\n )\n }\n\n const sortedItems = [...items].sort((a, b) => {\n if (a.type === 'folder' && b.type !== 'folder') return -1\n if (a.type !== 'folder' && b.type === 'folder') return 1\n return a.name.localeCompare(b.name)\n })\n\n return (\n <div css={styles.grid}>\n {sortedItems.map((item) => (\n <GridItem\n key={item.path}\n item={item}\n isSelected={selectedItems.has(item.path)}\n onSelect={() => toggleSelection(item.path)}\n onOpen={() => {\n if (item.type === 'folder') {\n setCurrentPath(item.path)\n }\n }}\n />\n ))}\n </div>\n )\n}\n\ninterface GridItemProps {\n item: FileItem\n isSelected: boolean\n onSelect: () => void\n onOpen: () => void\n}\n\nfunction GridItem({ item, isSelected, onSelect, onOpen }: GridItemProps) {\n const isFolder = item.type === 'folder'\n\n const handleClick = () => {\n if (isFolder) {\n onOpen()\n } else {\n onSelect()\n }\n }\n\n return (\n <div css={[styles.item, isSelected && styles.itemSelected]} onClick={handleClick}>\n {/* Only show checkbox for files, not folders */}\n {!isFolder && (\n <input\n type=\"checkbox\"\n css={styles.checkbox}\n checked={isSelected}\n onChange={onSelect}\n onClick={(e) => e.stopPropagation()}\n />\n )}\n\n {item.cdnSynced && <span css={styles.cdnBadge}>CDN</span>}\n\n <div css={styles.content}>\n {isFolder ? (\n <svg css={styles.folderIcon} fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path d=\"M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z\" />\n </svg>\n ) : (\n <img\n css={styles.image}\n src={item.path.replace('public', '')}\n alt={item.name}\n loading=\"lazy\"\n />\n )}\n </div>\n\n <div css={styles.label}>\n <p css={styles.name} title={item.name}>{item.name}</p>\n {item.size && <p css={styles.size}>{formatFileSize(item.size)}</p>}\n </div>\n </div>\n )\n}\n\nfunction formatFileSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n}\n","/** @jsxImportSource @emotion/react */\n'use client'\n\nimport { useEffect, useState } from 'react'\nimport { css, keyframes } from '@emotion/react'\nimport { useStudio } from './StudioContext'\nimport type { FileItem } from '../types'\n\nconst spin = keyframes`\n to { transform: rotate(360deg); }\n`\n\nconst styles = {\n loading: css`\n display: flex;\n align-items: center;\n justify-content: center;\n height: 256px;\n `,\n spinner: css`\n width: 32px;\n height: 32px;\n border-radius: 50%;\n border: 2px solid transparent;\n border-bottom-color: #9333ea;\n animation: ${spin} 1s linear infinite;\n `,\n empty: css`\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 256px;\n color: #6b7280;\n `,\n table: css`\n width: 100%;\n border-collapse: collapse;\n `,\n th: css`\n text-align: left;\n font-size: 12px;\n color: #6b7280;\n text-transform: uppercase;\n letter-spacing: 0.05em;\n padding-bottom: 8px;\n font-weight: normal;\n `,\n thCheckbox: css`\n width: 32px;\n `,\n thSize: css`\n width: 96px;\n `,\n thDimensions: css`\n width: 128px;\n `,\n thCdn: css`\n width: 96px;\n `,\n tbody: css`\n border-top: 1px solid #f3f4f6;\n `,\n row: css`\n cursor: pointer;\n transition: background-color 0.15s;\n \n &:hover {\n background-color: #f9fafb;\n }\n `,\n rowSelected: css`\n background-color: #faf5ff;\n `,\n td: css`\n padding: 8px 0;\n border-bottom: 1px solid #f3f4f6;\n `,\n checkbox: css`\n width: 16px;\n height: 16px;\n accent-color: #9333ea;\n `,\n nameCell: css`\n display: flex;\n align-items: center;\n gap: 8px;\n `,\n folderIcon: css`\n width: 20px;\n height: 20px;\n color: #facc15;\n `,\n fileIcon: css`\n width: 20px;\n height: 20px;\n color: #9ca3af;\n `,\n name: css`\n font-size: 14px;\n color: #111827;\n `,\n meta: css`\n font-size: 14px;\n color: #6b7280;\n `,\n cdnBadge: css`\n display: inline-flex;\n align-items: center;\n gap: 4px;\n font-size: 12px;\n color: #15803d;\n `,\n cdnIcon: css`\n width: 12px;\n height: 12px;\n `,\n cdnEmpty: css`\n font-size: 12px;\n color: #9ca3af;\n `,\n}\n\nexport function StudioFileList() {\n const { currentPath, setCurrentPath, selectedItems, toggleSelection, refreshKey } = useStudio()\n const [items, setItems] = useState<FileItem[]>([])\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n async function loadItems() {\n setLoading(true)\n try {\n const response = await fetch(`/api/studio/list?path=${encodeURIComponent(currentPath)}`)\n if (response.ok) {\n const data = await response.json()\n setItems(data.items || [])\n }\n } catch (error) {\n console.error('Failed to load items:', error)\n }\n setLoading(false)\n }\n loadItems()\n }, [currentPath, refreshKey])\n\n if (loading) {\n return (\n <div css={styles.loading}>\n <div css={styles.spinner} />\n </div>\n )\n }\n\n if (items.length === 0) {\n return (\n <div css={styles.empty}>\n <p>No files in this folder</p>\n </div>\n )\n }\n\n const sortedItems = [...items].sort((a, b) => {\n if (a.type === 'folder' && b.type !== 'folder') return -1\n if (a.type !== 'folder' && b.type === 'folder') return 1\n return a.name.localeCompare(b.name)\n })\n\n return (\n <table css={styles.table}>\n <thead>\n <tr>\n <th css={[styles.th, styles.thCheckbox]}></th>\n <th css={styles.th}>Name</th>\n <th css={[styles.th, styles.thSize]}>Size</th>\n <th css={[styles.th, styles.thDimensions]}>Dimensions</th>\n <th css={[styles.th, styles.thCdn]}>CDN</th>\n </tr>\n </thead>\n <tbody css={styles.tbody}>\n {sortedItems.map((item) => (\n <ListRow\n key={item.path}\n item={item}\n isSelected={selectedItems.has(item.path)}\n onSelect={() => toggleSelection(item.path)}\n onOpen={() => {\n if (item.type === 'folder') {\n setCurrentPath(item.path)\n }\n }}\n />\n ))}\n </tbody>\n </table>\n )\n}\n\ninterface ListRowProps {\n item: FileItem\n isSelected: boolean\n onSelect: () => void\n onOpen: () => void\n}\n\nfunction ListRow({ item, isSelected, onSelect, onOpen }: ListRowProps) {\n const isFolder = item.type === 'folder'\n\n const handleClick = () => {\n if (isFolder) {\n onOpen()\n } else {\n onSelect()\n }\n }\n\n return (\n <tr css={[styles.row, isSelected && styles.rowSelected]} onClick={handleClick}>\n <td css={styles.td}>\n {/* Only show checkbox for files, not folders */}\n {!isFolder && (\n <input\n type=\"checkbox\"\n css={styles.checkbox}\n checked={isSelected}\n onChange={onSelect}\n onClick={(e) => e.stopPropagation()}\n />\n )}\n </td>\n <td css={styles.td}>\n <div css={styles.nameCell}>\n {isFolder ? (\n <svg css={styles.folderIcon} fill=\"currentColor\" viewBox=\"0 0 24 24\">\n <path d=\"M10 4H4a2 2 0 00-2 2v12a2 2 0 002 2h16a2 2 0 002-2V8a2 2 0 00-2-2h-8l-2-2z\" />\n </svg>\n ) : (\n <svg css={styles.fileIcon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1.5} d=\"M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z\" />\n </svg>\n )}\n <span css={styles.name}>{item.name}</span>\n </div>\n </td>\n <td css={[styles.td, styles.meta]}>\n {item.size ? formatFileSize(item.size) : '--'}\n </td>\n <td css={[styles.td, styles.meta]}>\n {item.dimensions ? `${item.dimensions.width}x${item.dimensions.height}` : '--'}\n </td>\n <td css={styles.td}>\n {item.cdnSynced ? (\n <span css={styles.cdnBadge}>\n <svg css={styles.cdnIcon} fill=\"currentColor\" viewBox=\"0 0 20 20\">\n <path fillRule=\"evenodd\" d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\" clipRule=\"evenodd\" />\n </svg>\n Synced\n </span>\n ) : (\n <span css={styles.cdnEmpty}>--</span>\n )}\n </td>\n </tr>\n )\n}\n\nfunction formatFileSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n}\n","/** @jsxImportSource @emotion/react */\n'use client'\n\nimport { css } from '@emotion/react'\nimport { useStudio } from './StudioContext'\n\nconst styles = {\n panel: css`\n width: 320px;\n border-left: 1px solid #e5e7eb;\n background-color: #f9fafb;\n padding: 16px;\n overflow: auto;\n `,\n title: css`\n font-size: 14px;\n font-weight: 500;\n color: #111827;\n margin: 0 0 16px 0;\n `,\n imageContainer: css`\n background-color: white;\n border-radius: 8px;\n border: 1px solid #e5e7eb;\n padding: 8px;\n margin-bottom: 16px;\n `,\n image: css`\n width: 100%;\n height: auto;\n border-radius: 4px;\n `,\n info: css`\n display: flex;\n flex-direction: column;\n gap: 12px;\n `,\n row: css`\n display: flex;\n justify-content: space-between;\n font-size: 12px;\n `,\n label: css`\n color: #6b7280;\n `,\n value: css`\n color: #111827;\n `,\n valueTruncate: css`\n max-width: 128px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n `,\n section: css`\n padding-top: 8px;\n border-top: 1px solid #e5e7eb;\n `,\n sectionTitle: css`\n font-size: 12px;\n font-weight: 500;\n color: #6b7280;\n margin: 0 0 8px 0;\n `,\n cdnStatus: css`\n display: flex;\n align-items: center;\n gap: 8px;\n font-size: 12px;\n color: #16a34a;\n `,\n cdnIcon: css`\n width: 16px;\n height: 16px;\n `,\n copyBtn: css`\n margin-top: 8px;\n font-size: 12px;\n color: #9333ea;\n background: none;\n border: none;\n cursor: pointer;\n padding: 0;\n \n &:hover {\n text-decoration: underline;\n }\n `,\n colorSwatch: css`\n margin-top: 8px;\n height: 32px;\n border-radius: 4px;\n `,\n emptyState: css`\n display: flex;\n align-items: center;\n justify-content: center;\n height: 200px;\n `,\n emptyText: css`\n font-size: 14px;\n color: #9ca3af;\n margin: 0;\n `,\n actions: css`\n margin-top: 16px;\n padding-top: 16px;\n border-top: 1px solid #e5e7eb;\n display: flex;\n flex-direction: column;\n gap: 8px;\n `,\n actionBtn: css`\n width: 100%;\n padding: 8px 12px;\n font-size: 14px;\n background-color: white;\n border: 1px solid #e5e7eb;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s;\n color: #374151;\n \n &:hover {\n background-color: #f9fafb;\n }\n `,\n actionBtnDanger: css`\n color: #dc2626;\n \n &:hover {\n background-color: #fef2f2;\n }\n `,\n}\n\nexport function StudioPreview() {\n const { selectedItems, meta, triggerRefresh, clearSelection } = useStudio()\n\n const handleDelete = async () => {\n if (selectedItems.size === 0) return\n if (!confirm(`Delete ${selectedItems.size} item(s)?`)) return\n\n try {\n const response = await fetch('/api/studio/delete', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ paths: Array.from(selectedItems) }),\n })\n\n if (response.ok) {\n clearSelection()\n triggerRefresh()\n } else {\n const error = await response.json()\n alert(`Delete failed: ${error.error || 'Unknown error'}`)\n }\n } catch (error) {\n console.error('Delete error:', error)\n alert('Delete failed. Check console for details.')\n }\n }\n\n // Always show the sidebar\n if (selectedItems.size === 0) {\n return (\n <div css={styles.panel}>\n <h3 css={styles.title}>Preview</h3>\n <div css={styles.emptyState}>\n <p css={styles.emptyText}>Select an image to preview</p>\n </div>\n </div>\n )\n }\n\n if (selectedItems.size > 1) {\n return (\n <div css={styles.panel}>\n <h3 css={styles.title}>{selectedItems.size} items selected</h3>\n <div css={styles.actions}>\n <button css={[styles.actionBtn, styles.actionBtnDanger]} onClick={handleDelete}>\n Delete {selectedItems.size} items\n </button>\n </div>\n </div>\n )\n }\n\n const selectedPath = Array.from(selectedItems)[0]\n const imageKey = selectedPath\n .replace(/^public\\/images\\//, '')\n .replace(/^public\\/originals\\//, '')\n\n const imageData = meta?.images?.[imageKey]\n\n return (\n <div css={styles.panel}>\n <h3 css={styles.title}>Preview</h3>\n\n <div css={styles.imageContainer}>\n <img\n css={styles.image}\n src={selectedPath.replace('public', '')}\n alt=\"Preview\"\n />\n </div>\n\n <div css={styles.info}>\n <InfoRow label=\"Filename\" value={selectedPath.split('/').pop() || ''} />\n\n {imageData && (\n <>\n <InfoRow\n label=\"Original\"\n value={`${imageData.original.width}x${imageData.original.height}`}\n />\n <InfoRow\n label=\"File size\"\n value={formatFileSize(imageData.original.fileSize)}\n />\n\n <div css={styles.section}>\n <p css={styles.sectionTitle}>Generated sizes</p>\n {Object.entries(imageData.sizes).map(([size, data]) => (\n <InfoRow key={size} label={size} value={`${data.width}x${data.height}`} />\n ))}\n </div>\n\n {imageData.cdn?.synced && (\n <div css={styles.section}>\n <p css={styles.sectionTitle}>CDN</p>\n <div css={styles.cdnStatus}>\n <svg css={styles.cdnIcon} fill=\"currentColor\" viewBox=\"0 0 20 20\">\n <path fillRule=\"evenodd\" d=\"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z\" clipRule=\"evenodd\" />\n </svg>\n Synced to CDN\n </div>\n <button\n css={styles.copyBtn}\n onClick={() => {\n navigator.clipboard.writeText(`${imageData.cdn?.baseUrl}${imageData.sizes.full.path}`)\n }}\n >\n Copy CDN URL\n </button>\n </div>\n )}\n\n {imageData.blurhash && (\n <div css={styles.section}>\n <InfoRow label=\"Blurhash\" value={imageData.blurhash} truncate />\n <div\n css={styles.colorSwatch}\n style={{ backgroundColor: imageData.dominantColor }}\n title={`Dominant color: ${imageData.dominantColor}`}\n />\n </div>\n )}\n </>\n )}\n </div>\n\n <div css={styles.actions}>\n <button css={styles.actionBtn}>Rename</button>\n <button css={[styles.actionBtn, styles.actionBtnDanger]} onClick={handleDelete}>Delete</button>\n </div>\n </div>\n )\n}\n\nfunction InfoRow({ label, value, truncate }: { label: string; value: string; truncate?: boolean }) {\n return (\n <div css={styles.row}>\n <span css={styles.label}>{label}</span>\n <span css={[styles.value, truncate && styles.valueTruncate]} title={truncate ? value : undefined}>\n {value}\n </span>\n </div>\n )\n}\n\nfunction formatFileSize(bytes: number): string {\n if (bytes < 1024) return `${bytes} B`\n if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`\n}\n","/** @jsxImportSource @emotion/react */\n'use client'\n\nimport { useState } from 'react'\nimport { css } from '@emotion/react'\n\nconst styles = {\n btn: css`\n padding: 8px;\n background: none;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n transition: background-color 0.15s;\n \n &:hover {\n background-color: #f3f4f6;\n }\n `,\n icon: css`\n width: 20px;\n height: 20px;\n color: #6b7280;\n `,\n overlay: css`\n position: fixed;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 10000;\n display: flex;\n align-items: center;\n justify-content: center;\n `,\n backdrop: css`\n position: absolute;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n background-color: rgba(0, 0, 0, 0.3);\n `,\n panel: css`\n position: relative;\n background-color: white;\n border-radius: 12px;\n box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);\n width: 100%;\n max-width: 512px;\n padding: 24px;\n `,\n header: css`\n display: flex;\n align-items: center;\n justify-content: space-between;\n margin-bottom: 24px;\n `,\n title: css`\n font-size: 18px;\n font-weight: 600;\n margin: 0;\n `,\n closeBtn: css`\n padding: 4px;\n background: none;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n \n &:hover {\n background-color: #f3f4f6;\n }\n `,\n sections: css`\n display: flex;\n flex-direction: column;\n gap: 24px;\n `,\n sectionTitle: css`\n font-size: 14px;\n font-weight: 500;\n color: #111827;\n margin: 0 0 12px 0;\n `,\n description: css`\n font-size: 12px;\n color: #6b7280;\n margin: 0 0 12px 0;\n `,\n code: css`\n background-color: #f9fafb;\n border-radius: 8px;\n padding: 12px;\n font-family: monospace;\n font-size: 12px;\n color: #4b5563;\n `,\n codeLine: css`\n margin: 0 0 4px 0;\n \n &:last-child {\n margin: 0;\n }\n `,\n input: css`\n width: 100%;\n padding: 8px 12px;\n border: 1px solid #e5e7eb;\n border-radius: 8px;\n font-size: 14px;\n \n &:focus {\n outline: none;\n box-shadow: 0 0 0 2px #a855f7;\n }\n `,\n grid: css`\n display: grid;\n grid-template-columns: repeat(3, 1fr);\n gap: 12px;\n `,\n label: css`\n font-size: 12px;\n color: #6b7280;\n display: block;\n margin-bottom: 4px;\n `,\n footer: css`\n margin-top: 24px;\n display: flex;\n justify-content: flex-end;\n gap: 12px;\n `,\n cancelBtn: css`\n padding: 8px 16px;\n font-size: 14px;\n color: #4b5563;\n background: none;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n \n &:hover {\n background-color: #f3f4f6;\n }\n `,\n saveBtn: css`\n padding: 8px 16px;\n font-size: 14px;\n color: white;\n background-color: #9333ea;\n border: none;\n border-radius: 8px;\n cursor: pointer;\n \n &:hover {\n background-color: #7c3aed;\n }\n `,\n}\n\nexport function StudioSettings() {\n const [isOpen, setIsOpen] = useState(false)\n\n return (\n <>\n <button css={styles.btn} onClick={() => setIsOpen(true)} aria-label=\"Settings\">\n <svg\n css={styles.icon}\n xmlns=\"http://www.w3.org/2000/svg\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n strokeWidth={2}\n strokeLinecap=\"round\"\n strokeLinejoin=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"3\" />\n <path d=\"M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z\" />\n </svg>\n </button>\n\n {isOpen && <SettingsPanel onClose={() => setIsOpen(false)} />}\n </>\n )\n}\n\nfunction SettingsPanel({ onClose }: { onClose: () => void }) {\n return (\n <div css={styles.overlay}>\n <div css={styles.backdrop} onClick={onClose} />\n\n <div css={styles.panel}>\n <div css={styles.header}>\n <h2 css={styles.title}>Settings</h2>\n <button css={styles.closeBtn} onClick={onClose}>\n <svg css={styles.icon} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\n </svg>\n </button>\n </div>\n\n <div css={styles.sections}>\n <section>\n <h3 css={styles.sectionTitle}>Cloudflare R2</h3>\n <p css={styles.description}>Configure in .env.local file:</p>\n <div css={styles.code}>\n <p css={styles.codeLine}>CLOUDFLARE_R2_ACCOUNT_ID</p>\n <p css={styles.codeLine}>CLOUDFLARE_R2_ACCESS_KEY_ID</p>\n <p css={styles.codeLine}>CLOUDFLARE_R2_SECRET_ACCESS_KEY</p>\n <p css={styles.codeLine}>CLOUDFLARE_R2_BUCKET_NAME</p>\n <p css={styles.codeLine}>CLOUDFLARE_R2_PUBLIC_URL</p>\n </div>\n </section>\n\n <section>\n <h3 css={styles.sectionTitle}>Custom CDN URL</h3>\n <p css={styles.description}>Override the default R2 URL with a custom domain:</p>\n <input css={styles.input} type=\"text\" placeholder=\"https://cdn.yourdomain.com\" />\n </section>\n\n <section>\n <h3 css={styles.sectionTitle}>Thumbnail Sizes</h3>\n <div css={styles.grid}>\n <div>\n <label css={styles.label}>Small</label>\n <input css={styles.input} type=\"number\" defaultValue={300} />\n </div>\n <div>\n <label css={styles.label}>Medium</label>\n <input css={styles.input} type=\"number\" defaultValue={700} />\n </div>\n <div>\n <label css={styles.label}>Large</label>\n <input css={styles.input} type=\"number\" defaultValue={1400} />\n </div>\n </div>\n </section>\n </div>\n\n <div css={styles.footer}>\n <button css={styles.cancelBtn} onClick={onClose}>Cancel</button>\n <button css={styles.saveBtn}>Save Changes</button>\n </div>\n </div>\n </div>\n )\n}\n"],"mappings":"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","names":["useEffect","useCallback","useState","css","css","jsx","jsxs","styles","css","useState","css","jsx","jsxs","styles","css","useState","useEffect","useState","css","keyframes","jsx","jsxs","spin","keyframes","styles","css","useState","useEffect","formatFileSize","css","jsx","jsxs","styles","css","formatFileSize","useState","css","Fragment","jsx","jsxs","styles","jsx","jsxs","styles","css","useState","useCallback","useEffect"]}
|
package/dist/handlers.js
CHANGED
|
@@ -148,7 +148,7 @@ async function handleUpload(request) {
|
|
|
148
148
|
try {
|
|
149
149
|
const formData = await request.formData();
|
|
150
150
|
const file = formData.get("file");
|
|
151
|
-
const targetPath = formData.get("path") || "public
|
|
151
|
+
const targetPath = formData.get("path") || "public";
|
|
152
152
|
if (!file) {
|
|
153
153
|
return _server.NextResponse.json({ error: "No file provided" }, { status: 400 });
|
|
154
154
|
}
|
|
@@ -157,56 +157,88 @@ async function handleUpload(request) {
|
|
|
157
157
|
const fileName = file.name;
|
|
158
158
|
const baseName = _path2.default.basename(fileName, _path2.default.extname(fileName));
|
|
159
159
|
const ext = _path2.default.extname(fileName).toLowerCase();
|
|
160
|
+
const isSvg = ext === ".svg";
|
|
160
161
|
const meta = await loadMeta();
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
if (!meta.images) {
|
|
163
|
+
meta.images = {};
|
|
164
|
+
}
|
|
165
|
+
let subDir = "";
|
|
166
|
+
if (targetPath.startsWith("public/images/")) {
|
|
167
|
+
subDir = targetPath.replace("public/images/", "");
|
|
168
|
+
} else if (targetPath.startsWith("public/originals/")) {
|
|
169
|
+
subDir = targetPath.replace("public/originals/", "");
|
|
170
|
+
}
|
|
171
|
+
const fullImageKey = subDir ? `${subDir}/${fileName}` : fileName;
|
|
163
172
|
if (meta.images[fullImageKey]) {
|
|
164
173
|
return _server.NextResponse.json(
|
|
165
174
|
{ error: `File '${fullImageKey}' already exists in meta` },
|
|
166
175
|
{ status: 409 }
|
|
167
176
|
);
|
|
168
177
|
}
|
|
169
|
-
const originalsPath = _path2.default.join(process.cwd(), "public", "originals",
|
|
178
|
+
const originalsPath = _path2.default.join(process.cwd(), "public", "originals", subDir);
|
|
170
179
|
await _fs.promises.mkdir(originalsPath, { recursive: true });
|
|
171
180
|
await _fs.promises.writeFile(_path2.default.join(originalsPath, fileName), buffer);
|
|
172
|
-
const
|
|
173
|
-
const metadata = await sharpInstance.metadata();
|
|
174
|
-
const originalWidth = metadata.width || 0;
|
|
175
|
-
const originalHeight = metadata.height || 0;
|
|
176
|
-
const imagesPath = _path2.default.join(process.cwd(), "public", "images", imageKey);
|
|
181
|
+
const imagesPath = _path2.default.join(process.cwd(), "public", "images", subDir);
|
|
177
182
|
await _fs.promises.mkdir(imagesPath, { recursive: true });
|
|
183
|
+
let originalWidth = 0;
|
|
184
|
+
let originalHeight = 0;
|
|
185
|
+
let blurhash = "";
|
|
186
|
+
let dominantColor = "#888888";
|
|
178
187
|
const sizes = {
|
|
179
|
-
full: { path: "", width:
|
|
188
|
+
full: { path: "", width: 0, height: 0 },
|
|
180
189
|
large: { path: "", width: 0, height: 0 },
|
|
181
190
|
medium: { path: "", width: 0, height: 0 },
|
|
182
191
|
small: { path: "", width: 0, height: 0 }
|
|
183
192
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
193
|
+
if (isSvg) {
|
|
194
|
+
const fullPath = _path2.default.join(imagesPath, fileName);
|
|
195
|
+
await _fs.promises.writeFile(fullPath, buffer);
|
|
196
|
+
sizes.full = { path: `/images/${subDir ? subDir + "/" : ""}${fileName}`, width: 0, height: 0 };
|
|
197
|
+
sizes.large = { ...sizes.full };
|
|
198
|
+
sizes.medium = { ...sizes.full };
|
|
199
|
+
sizes.small = { ...sizes.full };
|
|
200
|
+
} else {
|
|
201
|
+
const sharpInstance = _sharp2.default.call(void 0, buffer);
|
|
202
|
+
const metadata = await sharpInstance.metadata();
|
|
203
|
+
originalWidth = metadata.width || 0;
|
|
204
|
+
originalHeight = metadata.height || 0;
|
|
205
|
+
const outputExt = ext === ".png" ? ".png" : ".jpg";
|
|
206
|
+
const fullFileName = `${baseName}${outputExt}`;
|
|
207
|
+
const fullPath = _path2.default.join(imagesPath, fullFileName);
|
|
208
|
+
if (ext === ".png") {
|
|
209
|
+
await _sharp2.default.call(void 0, buffer).png({ quality: 85 }).toFile(fullPath);
|
|
210
|
+
} else {
|
|
211
|
+
await _sharp2.default.call(void 0, buffer).jpeg({ quality: 85 }).toFile(fullPath);
|
|
191
212
|
}
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
213
|
+
sizes.full = { path: `/images/${subDir ? subDir + "/" : ""}${fullFileName}`, width: originalWidth, height: originalHeight };
|
|
214
|
+
for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES)) {
|
|
215
|
+
if (originalWidth <= maxWidth) {
|
|
216
|
+
sizes[sizeName] = { ...sizes.full };
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const ratio = originalHeight / originalWidth;
|
|
220
|
+
const newHeight = Math.round(maxWidth * ratio);
|
|
221
|
+
const sizeFileName = `${baseName}-${maxWidth}${outputExt}`;
|
|
222
|
+
const sizePath = _path2.default.join(imagesPath, sizeFileName);
|
|
223
|
+
if (ext === ".png") {
|
|
224
|
+
await _sharp2.default.call(void 0, buffer).resize(maxWidth, newHeight).png({ quality: 80 }).toFile(sizePath);
|
|
225
|
+
} else {
|
|
226
|
+
await _sharp2.default.call(void 0, buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath);
|
|
227
|
+
}
|
|
228
|
+
sizes[sizeName] = {
|
|
229
|
+
path: `/images/${subDir ? subDir + "/" : ""}${sizeFileName}`,
|
|
230
|
+
width: maxWidth,
|
|
231
|
+
height: newHeight
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const { data, info } = await _sharp2.default.call(void 0, buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
235
|
+
blurhash = _blurhash.encode.call(void 0, new Uint8ClampedArray(data), info.width, info.height, 4, 4);
|
|
236
|
+
const { dominant } = await _sharp2.default.call(void 0, buffer).stats();
|
|
237
|
+
dominantColor = `#${dominant.r.toString(16).padStart(2, "0")}${dominant.g.toString(16).padStart(2, "0")}${dominant.b.toString(16).padStart(2, "0")}`;
|
|
202
238
|
}
|
|
203
|
-
const { data, info } = await _sharp2.default.call(void 0, buffer).resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
204
|
-
const blurhash = _blurhash.encode.call(void 0, new Uint8ClampedArray(data), info.width, info.height, 4, 4);
|
|
205
|
-
const { dominant } = await _sharp2.default.call(void 0, buffer).stats();
|
|
206
|
-
const dominantColor = `#${dominant.r.toString(16).padStart(2, "0")}${dominant.g.toString(16).padStart(2, "0")}${dominant.b.toString(16).padStart(2, "0")}`;
|
|
207
239
|
const entry = {
|
|
208
240
|
original: {
|
|
209
|
-
path: `/originals/${
|
|
241
|
+
path: `/originals/${subDir ? subDir + "/" : ""}${fileName}`,
|
|
210
242
|
width: originalWidth,
|
|
211
243
|
height: originalHeight,
|
|
212
244
|
fileSize: buffer.length
|
|
@@ -221,7 +253,8 @@ async function handleUpload(request) {
|
|
|
221
253
|
return _server.NextResponse.json({ success: true, imageKey: fullImageKey, entry });
|
|
222
254
|
} catch (error) {
|
|
223
255
|
console.error("Failed to upload:", error);
|
|
224
|
-
|
|
256
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
257
|
+
return _server.NextResponse.json({ error: `Failed to upload file: ${message}` }, { status: 500 });
|
|
225
258
|
}
|
|
226
259
|
}
|
|
227
260
|
async function handleDelete(request) {
|
|
@@ -414,7 +447,51 @@ async function loadMeta() {
|
|
|
414
447
|
const metaPath = _path2.default.join(process.cwd(), "_data", "_meta.json");
|
|
415
448
|
try {
|
|
416
449
|
const content = await _fs.promises.readFile(metaPath, "utf-8");
|
|
417
|
-
|
|
450
|
+
const parsed = JSON.parse(content);
|
|
451
|
+
if (parsed.images && typeof parsed.images === "object") {
|
|
452
|
+
return parsed;
|
|
453
|
+
} else {
|
|
454
|
+
const images = {};
|
|
455
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
456
|
+
if (key.startsWith("/images/") && typeof value === "object" && value !== null) {
|
|
457
|
+
const legacyEntry = value;
|
|
458
|
+
const sizes = {
|
|
459
|
+
full: { path: "", width: 0, height: 0 },
|
|
460
|
+
large: { path: "", width: 0, height: 0 },
|
|
461
|
+
medium: { path: "", width: 0, height: 0 },
|
|
462
|
+
small: { path: "", width: 0, height: 0 }
|
|
463
|
+
};
|
|
464
|
+
for (const [sizeName, sizeData] of Object.entries(legacyEntry)) {
|
|
465
|
+
if (sizeName === "small" || sizeName === "medium" || sizeName === "large" || sizeName === "full") {
|
|
466
|
+
sizes[sizeName] = {
|
|
467
|
+
path: sizeData.file,
|
|
468
|
+
width: sizeData.width,
|
|
469
|
+
height: sizeData.height
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const imageKey = key.replace(/^\/images\//, "");
|
|
474
|
+
images[imageKey] = {
|
|
475
|
+
original: {
|
|
476
|
+
path: key.replace("/images/", "/originals/"),
|
|
477
|
+
width: sizes.full.width,
|
|
478
|
+
height: sizes.full.height,
|
|
479
|
+
fileSize: 0
|
|
480
|
+
},
|
|
481
|
+
sizes,
|
|
482
|
+
blurhash: "",
|
|
483
|
+
dominantColor: "#888888",
|
|
484
|
+
cdn: null
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
$schema: "https://gallop.software/schemas/studio-meta.json",
|
|
490
|
+
version: 1,
|
|
491
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
492
|
+
images
|
|
493
|
+
};
|
|
494
|
+
}
|
|
418
495
|
} catch (e6) {
|
|
419
496
|
return {
|
|
420
497
|
$schema: "https://gallop.software/schemas/studio-meta.json",
|
package/dist/handlers.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["/Users/chrisb/Sites/studio/dist/handlers.js","../src/handlers.ts"],"names":[],"mappings":"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","file":"/Users/chrisb/Sites/studio/dist/handlers.js","sourcesContent":[null,"import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport { encode } from 'blurhash'\nimport { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'\nimport type { StudioMeta, ImageEntry, ImageSize, FileItem } from './types'\n\n// Default thumbnail sizes\nconst DEFAULT_SIZES = {\n small: 300,\n medium: 700,\n large: 1400,\n}\n\n/**\n * Unified GET handler for all Studio API routes\n */\nexport async function GET(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/list\n if (route === 'list' || route.startsWith('list')) {\n return handleList(request)\n }\n\n // Route: /api/studio/scan\n if (route === 'scan') {\n return handleScan()\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified POST handler for all Studio API routes\n */\nexport async function POST(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/upload\n if (route === 'upload') {\n return handleUpload(request)\n }\n\n // Route: /api/studio/delete\n if (route === 'delete') {\n return handleDelete(request)\n }\n\n // Route: /api/studio/sync\n if (route === 'sync') {\n return handleSync(request)\n }\n\n // Route: /api/studio/reprocess\n if (route === 'reprocess') {\n return handleReprocess(request)\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified DELETE handler\n */\nexport async function DELETE(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n return handleDelete(request)\n}\n\n// ============================================================================\n// Handler implementations\n// ============================================================================\n\nasync function handleList(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams\n const requestedPath = searchParams.get('path') || 'public'\n\n try {\n const safePath = requestedPath.replace(/\\.\\./g, '')\n const absolutePath = path.join(process.cwd(), safePath)\n\n if (!absolutePath.startsWith(process.cwd())) {\n return NextResponse.json({ error: 'Invalid path' }, { status: 400 })\n }\n\n const items: FileItem[] = []\n const entries = await fs.readdir(absolutePath, { withFileTypes: true })\n\n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const itemPath = path.join(safePath, entry.name)\n\n if (entry.isDirectory()) {\n items.push({\n name: entry.name,\n path: itemPath,\n type: 'folder',\n })\n } else if (isImageFile(entry.name)) {\n const stats = await fs.stat(path.join(absolutePath, entry.name))\n items.push({\n name: entry.name,\n path: itemPath,\n type: 'file',\n size: stats.size,\n })\n }\n }\n\n return NextResponse.json({ items })\n } catch (error) {\n console.error('Failed to list directory:', error)\n return NextResponse.json({ error: 'Failed to list directory' }, { status: 500 })\n }\n}\n\nasync function handleScan() {\n try {\n const meta = await loadMeta()\n\n const untrackedFiles: string[] = []\n const missingFiles: string[] = []\n const validFiles: string[] = []\n\n const imagesDir = path.join(process.cwd(), 'public', 'images')\n const trackedPaths = new Set<string>()\n\n for (const entry of Object.values(meta.images)) {\n for (const sizeData of Object.values(entry.sizes)) {\n trackedPaths.add(sizeData.path)\n }\n }\n\n async function scanDir(dir: string, relativePath: string = ''): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n \n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const fullPath = path.join(dir, entry.name)\n const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name\n\n if (entry.isDirectory()) {\n await scanDir(fullPath, relPath)\n } else if (isImageFile(entry.name)) {\n const publicPath = `/images/${relPath}`\n if (!trackedPaths.has(publicPath)) {\n untrackedFiles.push(publicPath)\n } else {\n validFiles.push(publicPath)\n }\n }\n }\n } catch {\n // Directory might not exist\n }\n }\n\n await scanDir(imagesDir)\n\n for (const [key, entry] of Object.entries(meta.images)) {\n for (const [size, sizeData] of Object.entries(entry.sizes)) {\n const filePath = path.join(process.cwd(), 'public', sizeData.path)\n try {\n await fs.access(filePath)\n } catch {\n if (!entry.cdn?.synced) {\n missingFiles.push(`${key} (${size}): ${sizeData.path}`)\n }\n }\n }\n }\n\n return NextResponse.json({\n totalInMeta: Object.keys(meta.images).length,\n validFiles: validFiles.length,\n untrackedFiles,\n missingFiles,\n })\n } catch (error) {\n console.error('Failed to scan:', error)\n return NextResponse.json({ error: 'Failed to scan' }, { status: 500 })\n }\n}\n\nasync function handleUpload(request: NextRequest) {\n try {\n const formData = await request.formData()\n const file = formData.get('file') as File | null\n const targetPath = formData.get('path') as string || 'public/originals'\n\n if (!file) {\n return NextResponse.json({ error: 'No file provided' }, { status: 400 })\n }\n\n const bytes = await file.arrayBuffer()\n const buffer = Buffer.from(bytes)\n\n const fileName = file.name\n const baseName = path.basename(fileName, path.extname(fileName))\n const ext = path.extname(fileName).toLowerCase()\n\n const meta = await loadMeta()\n\n const imageKey = targetPath\n .replace(/^public\\/originals\\/?/, '')\n .replace(/^public\\/images\\/?/, '')\n const fullImageKey = imageKey ? `${imageKey}/${fileName}` : fileName\n\n if (meta.images[fullImageKey]) {\n return NextResponse.json(\n { error: `File '${fullImageKey}' already exists in meta` },\n { status: 409 }\n )\n }\n\n // Save original\n const originalsPath = path.join(process.cwd(), 'public', 'originals', imageKey)\n await fs.mkdir(originalsPath, { recursive: true })\n await fs.writeFile(path.join(originalsPath, fileName), buffer)\n\n // Get dimensions\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n const originalWidth = metadata.width || 0\n const originalHeight = metadata.height || 0\n\n // Generate thumbnails\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageKey)\n await fs.mkdir(imagesPath, { recursive: true })\n\n const sizes: Record<ImageSize, { path: string; width: number; height: number }> = {\n full: { path: '', width: originalWidth, height: originalHeight },\n large: { path: '', width: 0, height: 0 },\n medium: { path: '', width: 0, height: 0 },\n small: { path: '', width: 0, height: 0 },\n }\n\n // Full size\n const fullPath = path.join(imagesPath, fileName)\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n sizes.full.path = `/images/${imageKey ? imageKey + '/' : ''}${fileName}`\n\n // Generate each size\n for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES) as [ImageSize, number][]) {\n if (originalWidth <= maxWidth) {\n sizes[sizeName] = { ...sizes.full }\n continue\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}-${maxWidth}${ext === '.png' ? '.png' : '.jpg'}`\n const sizePath = path.join(imagesPath, sizeFileName)\n\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n\n sizes[sizeName] = {\n path: `/images/${imageKey ? imageKey + '/' : ''}${sizeFileName}`,\n width: maxWidth,\n height: newHeight,\n }\n }\n\n // Blurhash\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n // Dominant color\n const { dominant } = await sharp(buffer).stats()\n const dominantColor = `#${dominant.r.toString(16).padStart(2, '0')}${dominant.g.toString(16).padStart(2, '0')}${dominant.b.toString(16).padStart(2, '0')}`\n\n const entry: ImageEntry = {\n original: {\n path: `/originals/${imageKey ? imageKey + '/' : ''}${fileName}`,\n width: originalWidth,\n height: originalHeight,\n fileSize: buffer.length,\n },\n sizes,\n blurhash,\n dominantColor,\n cdn: null,\n }\n\n meta.images[fullImageKey] = entry\n await saveMeta(meta)\n\n return NextResponse.json({ success: true, imageKey: fullImageKey, entry })\n } catch (error) {\n console.error('Failed to upload:', error)\n return NextResponse.json({ error: 'Failed to upload file' }, { status: 500 })\n }\n}\n\nasync function handleDelete(request: NextRequest) {\n try {\n const { paths } = await request.json() as { paths: string[] }\n\n if (!paths || !Array.isArray(paths) || paths.length === 0) {\n return NextResponse.json({ error: 'No paths provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const deleted: string[] = []\n const errors: string[] = []\n\n for (const itemPath of paths) {\n try {\n if (!itemPath.startsWith('public/')) {\n errors.push(`Invalid path: ${itemPath}`)\n continue\n }\n\n const absolutePath = path.join(process.cwd(), itemPath)\n const stats = await fs.stat(absolutePath)\n\n if (stats.isDirectory()) {\n await fs.rm(absolutePath, { recursive: true })\n \n const prefix = itemPath\n .replace(/^public\\/originals\\/?/, '')\n .replace(/^public\\/images\\/?/, '')\n \n for (const key of Object.keys(meta.images)) {\n if (key.startsWith(prefix)) {\n delete meta.images[key]\n }\n }\n } else {\n await fs.unlink(absolutePath)\n\n const imageKey = itemPath\n .replace(/^public\\/originals\\//, '')\n .replace(/^public\\/images\\//, '')\n\n if (itemPath.includes('/originals/')) {\n const entry = meta.images[imageKey]\n if (entry) {\n for (const sizeData of Object.values(entry.sizes)) {\n const sizePath = path.join(process.cwd(), 'public', sizeData.path)\n try { await fs.unlink(sizePath) } catch { /* ignore */ }\n }\n delete meta.images[imageKey]\n }\n }\n }\n\n deleted.push(itemPath)\n } catch (error) {\n console.error(`Failed to delete ${itemPath}:`, error)\n errors.push(itemPath)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n deleted,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to delete:', error)\n return NextResponse.json({ error: 'Failed to delete files' }, { status: 500 })\n }\n}\n\nasync function handleSync(request: NextRequest) {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {\n return NextResponse.json(\n { error: 'R2 not configured. Set CLOUDFLARE_R2_* environment variables.' },\n { status: 400 }\n )\n }\n\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const synced: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta.images[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}`)\n continue\n }\n\n if (entry.cdn?.synced) {\n synced.push(imageKey)\n continue\n }\n\n try {\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n const fileBuffer = await fs.readFile(localPath)\n\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: sizeData.path.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(sizeData.path),\n })\n )\n }\n\n entry.cdn = {\n synced: true,\n baseUrl: publicUrl,\n syncedAt: new Date().toISOString(),\n }\n\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n try { await fs.unlink(localPath) } catch { /* ignore */ }\n }\n\n synced.push(imageKey)\n } catch (error) {\n console.error(`Failed to sync ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n synced,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to sync:', error)\n return NextResponse.json({ error: 'Failed to sync to CDN' }, { status: 500 })\n }\n}\n\nasync function handleReprocess(request: NextRequest) {\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const processed: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta.images[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}`)\n continue\n }\n\n try {\n let buffer: Buffer\n\n const originalPath = path.join(process.cwd(), 'public', entry.original.path)\n try {\n buffer = await fs.readFile(originalPath)\n } catch {\n if (entry.cdn?.synced) {\n buffer = await downloadFromCdn(entry.original.path)\n } else {\n throw new Error('Original not found locally and not on CDN')\n }\n }\n\n const updatedEntry = await processImage(buffer, entry, imageKey)\n meta.images[imageKey] = updatedEntry\n\n if (entry.cdn?.synced) {\n await uploadToCdn(updatedEntry)\n await deleteLocalFiles(updatedEntry)\n }\n\n processed.push(imageKey)\n } catch (error) {\n console.error(`Failed to reprocess ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n processed,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to reprocess:', error)\n return NextResponse.json({ error: 'Failed to reprocess images' }, { status: 500 })\n }\n}\n\n// ============================================================================\n// Helper functions\n// ============================================================================\n\nasync function loadMeta(): Promise<StudioMeta> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n try {\n const content = await fs.readFile(metaPath, 'utf-8')\n return JSON.parse(content)\n } catch {\n return {\n $schema: 'https://gallop.software/schemas/studio-meta.json',\n version: 1,\n generatedAt: new Date().toISOString(),\n images: {},\n }\n }\n}\n\nasync function saveMeta(meta: StudioMeta): Promise<void> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n await fs.mkdir(path.join(process.cwd(), '_data'), { recursive: true })\n meta.generatedAt = new Date().toISOString()\n await fs.writeFile(metaPath, JSON.stringify(meta, null, 2))\n}\n\nfunction isImageFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase()\n return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'].includes(ext)\n}\n\nfunction getContentType(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase()\n switch (ext) {\n case '.jpg':\n case '.jpeg':\n return 'image/jpeg'\n case '.png':\n return 'image/png'\n case '.gif':\n return 'image/gif'\n case '.webp':\n return 'image/webp'\n case '.svg':\n return 'image/svg+xml'\n default:\n return 'application/octet-stream'\n }\n}\n\nasync function processImage(\n buffer: Buffer,\n entry: ImageEntry,\n imageKey: string\n): Promise<ImageEntry> {\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n const originalWidth = metadata.width || 0\n const originalHeight = metadata.height || 0\n\n const baseName = path.basename(imageKey, path.extname(imageKey))\n const ext = path.extname(imageKey).toLowerCase()\n const imageDir = path.dirname(imageKey)\n\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageDir === '.' ? '' : imageDir)\n await fs.mkdir(imagesPath, { recursive: true })\n\n const sizes: Record<ImageSize, { path: string; width: number; height: number }> = {\n full: { path: '', width: originalWidth, height: originalHeight },\n large: { path: '', width: 0, height: 0 },\n medium: { path: '', width: 0, height: 0 },\n small: { path: '', width: 0, height: 0 },\n }\n\n const fullFileName = imageDir === '.' ? `${baseName}${ext}` : `${imageDir}/${baseName}${ext}`\n const fullPath = path.join(process.cwd(), 'public', 'images', fullFileName)\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n sizes.full.path = `/images/${fullFileName}`\n\n for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES) as [ImageSize, number][]) {\n if (originalWidth <= maxWidth) {\n sizes[sizeName] = { ...sizes.full }\n continue\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}-${maxWidth}${ext === '.png' ? '.png' : '.jpg'}`\n const sizeFilePath = imageDir === '.' ? sizeFileName : `${imageDir}/${sizeFileName}`\n const sizePath = path.join(process.cwd(), 'public', 'images', sizeFilePath)\n\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n\n sizes[sizeName] = {\n path: `/images/${sizeFilePath}`,\n width: maxWidth,\n height: newHeight,\n }\n }\n\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n const { dominant } = await sharp(buffer).stats()\n const dominantColor = `#${dominant.r.toString(16).padStart(2, '0')}${dominant.g.toString(16).padStart(2, '0')}${dominant.b.toString(16).padStart(2, '0')}`\n\n return {\n ...entry,\n original: {\n ...entry.original,\n width: originalWidth,\n height: originalHeight,\n fileSize: buffer.length,\n },\n sizes,\n blurhash,\n dominantColor,\n }\n}\n\nasync function downloadFromCdn(originalPath: string): Promise<Buffer> {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {\n throw new Error('R2 not configured')\n }\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const response = await r2.send(\n new GetObjectCommand({\n Bucket: bucketName,\n Key: originalPath.replace(/^\\//, ''),\n })\n )\n\n const stream = response.Body as NodeJS.ReadableStream\n const chunks: Buffer[] = []\n for await (const chunk of stream) {\n chunks.push(Buffer.from(chunk))\n }\n return Buffer.concat(chunks)\n}\n\nasync function uploadToCdn(entry: ImageEntry): Promise<void> {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {\n throw new Error('R2 not configured')\n }\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n const fileBuffer = await fs.readFile(localPath)\n\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: sizeData.path.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(sizeData.path),\n })\n )\n }\n}\n\nasync function deleteLocalFiles(entry: ImageEntry): Promise<void> {\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n try {\n await fs.unlink(localPath)\n } catch {\n // File might not exist\n }\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["/Users/chrisb/Sites/studio/dist/handlers.js","../src/handlers.ts"],"names":[],"mappings":"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","file":"/Users/chrisb/Sites/studio/dist/handlers.js","sourcesContent":[null,"import { NextRequest, NextResponse } from 'next/server'\nimport { promises as fs } from 'fs'\nimport path from 'path'\nimport sharp from 'sharp'\nimport { encode } from 'blurhash'\nimport { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'\nimport type { StudioMeta, ImageEntry, ImageSize, FileItem } from './types'\n\n// Default thumbnail sizes\nconst DEFAULT_SIZES = {\n small: 300,\n medium: 700,\n large: 1400,\n}\n\n/**\n * Unified GET handler for all Studio API routes\n */\nexport async function GET(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/list\n if (route === 'list' || route.startsWith('list')) {\n return handleList(request)\n }\n\n // Route: /api/studio/scan\n if (route === 'scan') {\n return handleScan()\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified POST handler for all Studio API routes\n */\nexport async function POST(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n const pathname = request.nextUrl.pathname\n const route = pathname.replace(/^\\/api\\/studio\\/?/, '')\n\n // Route: /api/studio/upload\n if (route === 'upload') {\n return handleUpload(request)\n }\n\n // Route: /api/studio/delete\n if (route === 'delete') {\n return handleDelete(request)\n }\n\n // Route: /api/studio/sync\n if (route === 'sync') {\n return handleSync(request)\n }\n\n // Route: /api/studio/reprocess\n if (route === 'reprocess') {\n return handleReprocess(request)\n }\n\n return NextResponse.json({ error: 'Not found' }, { status: 404 })\n}\n\n/**\n * Unified DELETE handler\n */\nexport async function DELETE(request: NextRequest) {\n if (process.env.NODE_ENV !== 'development') {\n return NextResponse.json({ error: 'Not available in production' }, { status: 403 })\n }\n\n return handleDelete(request)\n}\n\n// ============================================================================\n// Handler implementations\n// ============================================================================\n\nasync function handleList(request: NextRequest) {\n const searchParams = request.nextUrl.searchParams\n const requestedPath = searchParams.get('path') || 'public'\n\n try {\n const safePath = requestedPath.replace(/\\.\\./g, '')\n const absolutePath = path.join(process.cwd(), safePath)\n\n if (!absolutePath.startsWith(process.cwd())) {\n return NextResponse.json({ error: 'Invalid path' }, { status: 400 })\n }\n\n const items: FileItem[] = []\n const entries = await fs.readdir(absolutePath, { withFileTypes: true })\n\n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const itemPath = path.join(safePath, entry.name)\n\n if (entry.isDirectory()) {\n items.push({\n name: entry.name,\n path: itemPath,\n type: 'folder',\n })\n } else if (isImageFile(entry.name)) {\n const stats = await fs.stat(path.join(absolutePath, entry.name))\n items.push({\n name: entry.name,\n path: itemPath,\n type: 'file',\n size: stats.size,\n })\n }\n }\n\n return NextResponse.json({ items })\n } catch (error) {\n console.error('Failed to list directory:', error)\n return NextResponse.json({ error: 'Failed to list directory' }, { status: 500 })\n }\n}\n\nasync function handleScan() {\n try {\n const meta = await loadMeta()\n\n const untrackedFiles: string[] = []\n const missingFiles: string[] = []\n const validFiles: string[] = []\n\n const imagesDir = path.join(process.cwd(), 'public', 'images')\n const trackedPaths = new Set<string>()\n\n for (const entry of Object.values(meta.images)) {\n for (const sizeData of Object.values(entry.sizes)) {\n trackedPaths.add(sizeData.path)\n }\n }\n\n async function scanDir(dir: string, relativePath: string = ''): Promise<void> {\n try {\n const entries = await fs.readdir(dir, { withFileTypes: true })\n \n for (const entry of entries) {\n if (entry.name.startsWith('.')) continue\n\n const fullPath = path.join(dir, entry.name)\n const relPath = relativePath ? `${relativePath}/${entry.name}` : entry.name\n\n if (entry.isDirectory()) {\n await scanDir(fullPath, relPath)\n } else if (isImageFile(entry.name)) {\n const publicPath = `/images/${relPath}`\n if (!trackedPaths.has(publicPath)) {\n untrackedFiles.push(publicPath)\n } else {\n validFiles.push(publicPath)\n }\n }\n }\n } catch {\n // Directory might not exist\n }\n }\n\n await scanDir(imagesDir)\n\n for (const [key, entry] of Object.entries(meta.images)) {\n for (const [size, sizeData] of Object.entries(entry.sizes)) {\n const filePath = path.join(process.cwd(), 'public', sizeData.path)\n try {\n await fs.access(filePath)\n } catch {\n if (!entry.cdn?.synced) {\n missingFiles.push(`${key} (${size}): ${sizeData.path}`)\n }\n }\n }\n }\n\n return NextResponse.json({\n totalInMeta: Object.keys(meta.images).length,\n validFiles: validFiles.length,\n untrackedFiles,\n missingFiles,\n })\n } catch (error) {\n console.error('Failed to scan:', error)\n return NextResponse.json({ error: 'Failed to scan' }, { status: 500 })\n }\n}\n\nasync function handleUpload(request: NextRequest) {\n try {\n const formData = await request.formData()\n const file = formData.get('file') as File | null\n const targetPath = formData.get('path') as string || 'public'\n\n if (!file) {\n return NextResponse.json({ error: 'No file provided' }, { status: 400 })\n }\n\n const bytes = await file.arrayBuffer()\n const buffer = Buffer.from(bytes)\n\n const fileName = file.name\n const baseName = path.basename(fileName, path.extname(fileName))\n const ext = path.extname(fileName).toLowerCase()\n\n // SVG files can't be processed by sharp for thumbnails\n const isSvg = ext === '.svg'\n\n const meta = await loadMeta()\n \n // Ensure images object exists\n if (!meta.images) {\n meta.images = {}\n }\n\n // Calculate the subdirectory within images/originals\n // If viewing public/images/photos, the subdir is \"photos\"\n let subDir = ''\n if (targetPath.startsWith('public/images/')) {\n subDir = targetPath.replace('public/images/', '')\n } else if (targetPath.startsWith('public/originals/')) {\n subDir = targetPath.replace('public/originals/', '')\n }\n \n const fullImageKey = subDir ? `${subDir}/${fileName}` : fileName\n\n if (meta.images[fullImageKey]) {\n return NextResponse.json(\n { error: `File '${fullImageKey}' already exists in meta` },\n { status: 409 }\n )\n }\n\n // Save original\n const originalsPath = path.join(process.cwd(), 'public', 'originals', subDir)\n await fs.mkdir(originalsPath, { recursive: true })\n await fs.writeFile(path.join(originalsPath, fileName), buffer)\n\n // Generate thumbnails directory\n const imagesPath = path.join(process.cwd(), 'public', 'images', subDir)\n await fs.mkdir(imagesPath, { recursive: true })\n\n let originalWidth = 0\n let originalHeight = 0\n let blurhash = ''\n let dominantColor = '#888888'\n const sizes: Record<ImageSize, { path: string; width: number; height: number }> = {\n full: { path: '', width: 0, height: 0 },\n large: { path: '', width: 0, height: 0 },\n medium: { path: '', width: 0, height: 0 },\n small: { path: '', width: 0, height: 0 },\n }\n\n if (isSvg) {\n // SVG: just copy to images folder, no processing\n const fullPath = path.join(imagesPath, fileName)\n await fs.writeFile(fullPath, buffer)\n sizes.full = { path: `/images/${subDir ? subDir + '/' : ''}${fileName}`, width: 0, height: 0 }\n sizes.large = { ...sizes.full }\n sizes.medium = { ...sizes.full }\n sizes.small = { ...sizes.full }\n } else {\n // Raster images: process with sharp\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n originalWidth = metadata.width || 0\n originalHeight = metadata.height || 0\n\n // Full size\n const outputExt = ext === '.png' ? '.png' : '.jpg'\n const fullFileName = `${baseName}${outputExt}`\n const fullPath = path.join(imagesPath, fullFileName)\n \n if (ext === '.png') {\n await sharp(buffer).png({ quality: 85 }).toFile(fullPath)\n } else {\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n }\n sizes.full = { path: `/images/${subDir ? subDir + '/' : ''}${fullFileName}`, width: originalWidth, height: originalHeight }\n\n // Generate each thumbnail size\n for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES) as [ImageSize, number][]) {\n if (originalWidth <= maxWidth) {\n sizes[sizeName] = { ...sizes.full }\n continue\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}-${maxWidth}${outputExt}`\n const sizePath = path.join(imagesPath, sizeFileName)\n\n if (ext === '.png') {\n await sharp(buffer).resize(maxWidth, newHeight).png({ quality: 80 }).toFile(sizePath)\n } else {\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n }\n\n sizes[sizeName] = {\n path: `/images/${subDir ? subDir + '/' : ''}${sizeFileName}`,\n width: maxWidth,\n height: newHeight,\n }\n }\n\n // Blurhash\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n // Dominant color\n const { dominant } = await sharp(buffer).stats()\n dominantColor = `#${dominant.r.toString(16).padStart(2, '0')}${dominant.g.toString(16).padStart(2, '0')}${dominant.b.toString(16).padStart(2, '0')}`\n }\n\n const entry: ImageEntry = {\n original: {\n path: `/originals/${subDir ? subDir + '/' : ''}${fileName}`,\n width: originalWidth,\n height: originalHeight,\n fileSize: buffer.length,\n },\n sizes,\n blurhash,\n dominantColor,\n cdn: null,\n }\n\n meta.images[fullImageKey] = entry\n await saveMeta(meta)\n\n return NextResponse.json({ success: true, imageKey: fullImageKey, entry })\n } catch (error) {\n console.error('Failed to upload:', error)\n const message = error instanceof Error ? error.message : 'Unknown error'\n return NextResponse.json({ error: `Failed to upload file: ${message}` }, { status: 500 })\n }\n}\n\nasync function handleDelete(request: NextRequest) {\n try {\n const { paths } = await request.json() as { paths: string[] }\n\n if (!paths || !Array.isArray(paths) || paths.length === 0) {\n return NextResponse.json({ error: 'No paths provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const deleted: string[] = []\n const errors: string[] = []\n\n for (const itemPath of paths) {\n try {\n if (!itemPath.startsWith('public/')) {\n errors.push(`Invalid path: ${itemPath}`)\n continue\n }\n\n const absolutePath = path.join(process.cwd(), itemPath)\n const stats = await fs.stat(absolutePath)\n\n if (stats.isDirectory()) {\n await fs.rm(absolutePath, { recursive: true })\n \n const prefix = itemPath\n .replace(/^public\\/originals\\/?/, '')\n .replace(/^public\\/images\\/?/, '')\n \n for (const key of Object.keys(meta.images)) {\n if (key.startsWith(prefix)) {\n delete meta.images[key]\n }\n }\n } else {\n await fs.unlink(absolutePath)\n\n const imageKey = itemPath\n .replace(/^public\\/originals\\//, '')\n .replace(/^public\\/images\\//, '')\n\n if (itemPath.includes('/originals/')) {\n const entry = meta.images[imageKey]\n if (entry) {\n for (const sizeData of Object.values(entry.sizes)) {\n const sizePath = path.join(process.cwd(), 'public', sizeData.path)\n try { await fs.unlink(sizePath) } catch { /* ignore */ }\n }\n delete meta.images[imageKey]\n }\n }\n }\n\n deleted.push(itemPath)\n } catch (error) {\n console.error(`Failed to delete ${itemPath}:`, error)\n errors.push(itemPath)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n deleted,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to delete:', error)\n return NextResponse.json({ error: 'Failed to delete files' }, { status: 500 })\n }\n}\n\nasync function handleSync(request: NextRequest) {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n const publicUrl = process.env.CLOUDFLARE_R2_PUBLIC_URL\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName || !publicUrl) {\n return NextResponse.json(\n { error: 'R2 not configured. Set CLOUDFLARE_R2_* environment variables.' },\n { status: 400 }\n )\n }\n\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const synced: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta.images[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}`)\n continue\n }\n\n if (entry.cdn?.synced) {\n synced.push(imageKey)\n continue\n }\n\n try {\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n const fileBuffer = await fs.readFile(localPath)\n\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: sizeData.path.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(sizeData.path),\n })\n )\n }\n\n entry.cdn = {\n synced: true,\n baseUrl: publicUrl,\n syncedAt: new Date().toISOString(),\n }\n\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n try { await fs.unlink(localPath) } catch { /* ignore */ }\n }\n\n synced.push(imageKey)\n } catch (error) {\n console.error(`Failed to sync ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n synced,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to sync:', error)\n return NextResponse.json({ error: 'Failed to sync to CDN' }, { status: 500 })\n }\n}\n\nasync function handleReprocess(request: NextRequest) {\n try {\n const { imageKeys } = await request.json() as { imageKeys: string[] }\n\n if (!imageKeys || !Array.isArray(imageKeys) || imageKeys.length === 0) {\n return NextResponse.json({ error: 'No image keys provided' }, { status: 400 })\n }\n\n const meta = await loadMeta()\n const processed: string[] = []\n const errors: string[] = []\n\n for (const imageKey of imageKeys) {\n const entry = meta.images[imageKey]\n if (!entry) {\n errors.push(`Image not found in meta: ${imageKey}`)\n continue\n }\n\n try {\n let buffer: Buffer\n\n const originalPath = path.join(process.cwd(), 'public', entry.original.path)\n try {\n buffer = await fs.readFile(originalPath)\n } catch {\n if (entry.cdn?.synced) {\n buffer = await downloadFromCdn(entry.original.path)\n } else {\n throw new Error('Original not found locally and not on CDN')\n }\n }\n\n const updatedEntry = await processImage(buffer, entry, imageKey)\n meta.images[imageKey] = updatedEntry\n\n if (entry.cdn?.synced) {\n await uploadToCdn(updatedEntry)\n await deleteLocalFiles(updatedEntry)\n }\n\n processed.push(imageKey)\n } catch (error) {\n console.error(`Failed to reprocess ${imageKey}:`, error)\n errors.push(imageKey)\n }\n }\n\n await saveMeta(meta)\n\n return NextResponse.json({\n success: true,\n processed,\n errors: errors.length > 0 ? errors : undefined,\n })\n } catch (error) {\n console.error('Failed to reprocess:', error)\n return NextResponse.json({ error: 'Failed to reprocess images' }, { status: 500 })\n }\n}\n\n// ============================================================================\n// Helper functions\n// ============================================================================\n\nasync function loadMeta(): Promise<StudioMeta> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n try {\n const content = await fs.readFile(metaPath, 'utf-8')\n const parsed = JSON.parse(content)\n \n // Handle legacy flat format (keys are image paths at root level)\n // vs new format with { images: {...} }\n if (parsed.images && typeof parsed.images === 'object') {\n // New format - already has images property\n return parsed\n } else {\n // Legacy format - convert flat structure to new format\n // Filter out metadata keys like $schema, version, generatedAt\n const images: Record<string, ImageEntry> = {}\n for (const [key, value] of Object.entries(parsed)) {\n if (key.startsWith('/images/') && typeof value === 'object' && value !== null) {\n // Convert legacy format to new format\n const legacyEntry = value as Record<string, { width: number; height: number; file: string }>\n const sizes: Record<ImageSize, { path: string; width: number; height: number }> = {\n full: { path: '', width: 0, height: 0 },\n large: { path: '', width: 0, height: 0 },\n medium: { path: '', width: 0, height: 0 },\n small: { path: '', width: 0, height: 0 },\n }\n \n for (const [sizeName, sizeData] of Object.entries(legacyEntry)) {\n if (sizeName === 'small' || sizeName === 'medium' || sizeName === 'large' || sizeName === 'full') {\n sizes[sizeName] = {\n path: sizeData.file,\n width: sizeData.width,\n height: sizeData.height,\n }\n }\n }\n \n // Extract image key from path (e.g., \"/images/banner.jpg\" -> \"banner.jpg\")\n const imageKey = key.replace(/^\\/images\\//, '')\n images[imageKey] = {\n original: {\n path: key.replace('/images/', '/originals/'),\n width: sizes.full.width,\n height: sizes.full.height,\n fileSize: 0,\n },\n sizes,\n blurhash: '',\n dominantColor: '#888888',\n cdn: null,\n }\n }\n }\n \n return {\n $schema: 'https://gallop.software/schemas/studio-meta.json',\n version: 1,\n generatedAt: new Date().toISOString(),\n images,\n }\n }\n } catch {\n return {\n $schema: 'https://gallop.software/schemas/studio-meta.json',\n version: 1,\n generatedAt: new Date().toISOString(),\n images: {},\n }\n }\n}\n\nasync function saveMeta(meta: StudioMeta): Promise<void> {\n const metaPath = path.join(process.cwd(), '_data', '_meta.json')\n await fs.mkdir(path.join(process.cwd(), '_data'), { recursive: true })\n meta.generatedAt = new Date().toISOString()\n await fs.writeFile(metaPath, JSON.stringify(meta, null, 2))\n}\n\nfunction isImageFile(filename: string): boolean {\n const ext = path.extname(filename).toLowerCase()\n return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'].includes(ext)\n}\n\nfunction getContentType(filePath: string): string {\n const ext = path.extname(filePath).toLowerCase()\n switch (ext) {\n case '.jpg':\n case '.jpeg':\n return 'image/jpeg'\n case '.png':\n return 'image/png'\n case '.gif':\n return 'image/gif'\n case '.webp':\n return 'image/webp'\n case '.svg':\n return 'image/svg+xml'\n default:\n return 'application/octet-stream'\n }\n}\n\nasync function processImage(\n buffer: Buffer,\n entry: ImageEntry,\n imageKey: string\n): Promise<ImageEntry> {\n const sharpInstance = sharp(buffer)\n const metadata = await sharpInstance.metadata()\n const originalWidth = metadata.width || 0\n const originalHeight = metadata.height || 0\n\n const baseName = path.basename(imageKey, path.extname(imageKey))\n const ext = path.extname(imageKey).toLowerCase()\n const imageDir = path.dirname(imageKey)\n\n const imagesPath = path.join(process.cwd(), 'public', 'images', imageDir === '.' ? '' : imageDir)\n await fs.mkdir(imagesPath, { recursive: true })\n\n const sizes: Record<ImageSize, { path: string; width: number; height: number }> = {\n full: { path: '', width: originalWidth, height: originalHeight },\n large: { path: '', width: 0, height: 0 },\n medium: { path: '', width: 0, height: 0 },\n small: { path: '', width: 0, height: 0 },\n }\n\n const fullFileName = imageDir === '.' ? `${baseName}${ext}` : `${imageDir}/${baseName}${ext}`\n const fullPath = path.join(process.cwd(), 'public', 'images', fullFileName)\n await sharp(buffer).jpeg({ quality: 85 }).toFile(fullPath)\n sizes.full.path = `/images/${fullFileName}`\n\n for (const [sizeName, maxWidth] of Object.entries(DEFAULT_SIZES) as [ImageSize, number][]) {\n if (originalWidth <= maxWidth) {\n sizes[sizeName] = { ...sizes.full }\n continue\n }\n\n const ratio = originalHeight / originalWidth\n const newHeight = Math.round(maxWidth * ratio)\n const sizeFileName = `${baseName}-${maxWidth}${ext === '.png' ? '.png' : '.jpg'}`\n const sizeFilePath = imageDir === '.' ? sizeFileName : `${imageDir}/${sizeFileName}`\n const sizePath = path.join(process.cwd(), 'public', 'images', sizeFilePath)\n\n await sharp(buffer).resize(maxWidth, newHeight).jpeg({ quality: 80 }).toFile(sizePath)\n\n sizes[sizeName] = {\n path: `/images/${sizeFilePath}`,\n width: maxWidth,\n height: newHeight,\n }\n }\n\n const { data, info } = await sharp(buffer)\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true })\n\n const blurhash = encode(new Uint8ClampedArray(data), info.width, info.height, 4, 4)\n\n const { dominant } = await sharp(buffer).stats()\n const dominantColor = `#${dominant.r.toString(16).padStart(2, '0')}${dominant.g.toString(16).padStart(2, '0')}${dominant.b.toString(16).padStart(2, '0')}`\n\n return {\n ...entry,\n original: {\n ...entry.original,\n width: originalWidth,\n height: originalHeight,\n fileSize: buffer.length,\n },\n sizes,\n blurhash,\n dominantColor,\n }\n}\n\nasync function downloadFromCdn(originalPath: string): Promise<Buffer> {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {\n throw new Error('R2 not configured')\n }\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n const response = await r2.send(\n new GetObjectCommand({\n Bucket: bucketName,\n Key: originalPath.replace(/^\\//, ''),\n })\n )\n\n const stream = response.Body as NodeJS.ReadableStream\n const chunks: Buffer[] = []\n for await (const chunk of stream) {\n chunks.push(Buffer.from(chunk))\n }\n return Buffer.concat(chunks)\n}\n\nasync function uploadToCdn(entry: ImageEntry): Promise<void> {\n const accountId = process.env.CLOUDFLARE_R2_ACCOUNT_ID\n const accessKeyId = process.env.CLOUDFLARE_R2_ACCESS_KEY_ID\n const secretAccessKey = process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY\n const bucketName = process.env.CLOUDFLARE_R2_BUCKET_NAME\n\n if (!accountId || !accessKeyId || !secretAccessKey || !bucketName) {\n throw new Error('R2 not configured')\n }\n\n const r2 = new S3Client({\n region: 'auto',\n endpoint: `https://${accountId}.r2.cloudflarestorage.com`,\n credentials: { accessKeyId, secretAccessKey },\n })\n\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n const fileBuffer = await fs.readFile(localPath)\n\n await r2.send(\n new PutObjectCommand({\n Bucket: bucketName,\n Key: sizeData.path.replace(/^\\//, ''),\n Body: fileBuffer,\n ContentType: getContentType(sizeData.path),\n })\n )\n }\n}\n\nasync function deleteLocalFiles(entry: ImageEntry): Promise<void> {\n for (const sizeData of Object.values(entry.sizes)) {\n const localPath = path.join(process.cwd(), 'public', sizeData.path)\n try {\n await fs.unlink(localPath)\n } catch {\n // File might not exist\n }\n }\n}\n"]}
|