Files
SkinbaseNova/resources/js/lib/uploadUtils.js
T
klevze 6b00652a20 Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
2026-09-20 14:50:12 +02:00

158 lines
6.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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())
}
export function compareCategoryNames(a, b) {
return String(a?.name || '').localeCompare(String(b?.name || ''), undefined, {
numeric: true,
sensitivity: 'base',
})
}
export function additionalCategoryOptions(roots = [], primaryId = '') {
const options = []
const seen = new Set()
const primary = String(primaryId || '')
roots.forEach((root) => {
const items = [root, ...(Array.isArray(root.children) ? root.children : [])]
items.forEach((item) => {
const id = String(item?.id || '')
if (!id || id === primary || seen.has(id)) {
return
}
seen.add(id)
options.push({ id, name: item.name || `Category ${id}` })
})
})
return options.sort(compareCategoryNames)
}
// ─── 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'
}