/** * Shared utilities for the upload system. * These are pure functions – no React, no side effects. */ // ─── Constants ──────────────────────────────────────────────────────────────── export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']) export const IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']) export const ARCHIVE_EXTENSIONS = new Set(['zip', 'rar', '7z', 'tar', 'gz']) export const ARCHIVE_MIME = new Set([ 'application/zip', 'application/x-zip-compressed', 'application/x-rar-compressed', 'application/vnd.rar', 'application/x-7z-compressed', 'application/x-tar', 'application/gzip', 'application/x-gzip', 'application/octet-stream', ]) export const PRIMARY_IMAGE_MAX_BYTES = 50 * 1024 * 1024 // 50 MB export const PRIMARY_ARCHIVE_MAX_BYTES = 200 * 1024 * 1024 // 200 MB export const SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024 // 10 MB // ─── Type detection ─────────────────────────────────────────────────────────── export function getExtension(fileName = '') { const parts = String(fileName).toLowerCase().split('.') return parts.length > 1 ? parts.pop() : '' } /** * @param {File} file * @returns {'image' | 'archive' | 'unsupported'} */ export function detectFileType(file) { if (!file) return 'unknown' const extension = getExtension(file.name) const mime = String(file.type || '').toLowerCase() if (IMAGE_MIME.has(mime) || IMAGE_EXTENSIONS.has(extension)) return 'image' if (ARCHIVE_MIME.has(mime) || ARCHIVE_EXTENSIONS.has(extension)) return 'archive' return 'unsupported' } // ─── Formatting ─────────────────────────────────────────────────────────────── export function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes <= 0) return '—' if (bytes < 1024) return `${bytes} B` const kb = bytes / 1024 if (kb < 1024) return `${kb.toFixed(1)} KB` const mb = kb / 1024 if (mb < 1024) return `${mb.toFixed(1)} MB` return `${(mb / 1024).toFixed(2)} GB` } // ─── Image utils ────────────────────────────────────────────────────────────── /** * @param {File} file * @returns {Promise<{width: number, height: number}>} */ export function readImageDimensions(file) { return new Promise((resolve, reject) => { const blobUrl = URL.createObjectURL(file) const img = new Image() img.onload = () => { resolve({ width: img.naturalWidth, height: img.naturalHeight }) URL.revokeObjectURL(blobUrl) } img.onerror = () => { reject(new Error('image_read_failed')) URL.revokeObjectURL(blobUrl) } img.src = blobUrl }) } // ─── Category tree ──────────────────────────────────────────────────────────── export function buildCategoryTree(contentTypes = []) { const rootsById = new Map() contentTypes.forEach((type) => { const categories = Array.isArray(type?.categories) ? type.categories : [] categories.forEach((category) => { if (!category?.id) return const key = String(category.id) if (!rootsById.has(key)) { rootsById.set(key, { id: key, name: category.name || `Category ${category.id}`, children: [], }) } const root = rootsById.get(key) const children = Array.isArray(category?.children) ? category.children : [] children.forEach((child) => { if (!child?.id) return const exists = root.children.some((item) => String(item.id) === String(child.id)) if (!exists) { root.children.push({ id: String(child.id), name: child.name || `Subcategory ${child.id}` }) } }) }) }) return Array.from(rootsById.values()) } // ─── Content type helpers ───────────────────────────────────────────────────── export function getContentTypeValue(type) { if (!type) return '' return String(type.id ?? type.key ?? type.slug ?? type.name ?? '') } export function getContentTypeVisualKey(type) { const raw = String(type?.slug || type?.name || type?.key || '').toLowerCase() const normalized = raw.replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '') if (normalized.includes('wallpaper')) return 'wallpapers' if (normalized.includes('skin')) return 'skins' if (normalized.includes('photo')) return 'photography' return 'other' } // ─── Processing status helpers ──────────────────────────────────────────────── export function getProcessingTransparencyLabel(processingStatus, machineState) { if (!['processing', 'finishing', 'publishing'].includes(machineState)) return '' const normalized = String(processingStatus || '').toLowerCase() if (normalized === 'generating_preview') return 'Generating preview' if (['processed', 'ready', 'published', 'queued', 'publish_ready'].includes(normalized)) { return 'Preparing for publish' } return 'Analyzing content' }