Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,18 @@
export function sendFeedAnalyticsEvent(payload) {
const endpoint = '/api/analytics/feed'
const body = JSON.stringify(payload)
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const blob = new Blob([body], { type: 'application/json' })
navigator.sendBeacon(endpoint, blob)
return
}
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
}).catch(() => {
})
}

View File

@@ -0,0 +1,124 @@
/**
* Nova Gallery Navigation Context
*
* Stores artwork list context in sessionStorage when a card is clicked,
* so the artwork page can provide prev/next navigation without page reload.
*
* Context shape:
* { source, key, ids: number[], index: number, page: string, ts: number }
*/
(function () {
'use strict';
var STORAGE_KEY = 'nav_ctx';
function getPageContext() {
var path = window.location.pathname;
var search = window.location.search;
// /tag/{slug}
var tagMatch = path.match(/^\/tag\/([^/]+)\/?$/);
if (tagMatch) return { source: 'tag', key: 'tag:' + tagMatch[1] };
// /browse/{contentType}/{category...}
var browseMatch = path.match(/^\/browse\/([^/]+)(?:\/(.+))?\/?$/);
if (browseMatch) {
var browsePart = browseMatch[1] + (browseMatch[2] ? '/' + browseMatch[2] : '');
return { source: 'browse', key: 'browse:' + browsePart };
}
// /search?q=...
if (path === '/search' || path.startsWith('/search?')) {
var q = new URLSearchParams(search).get('q') || '';
return { source: 'search', key: 'search:' + q };
}
// /@{username}
var profileMatch = path.match(/^\/@([^/]+)\/?$/);
if (profileMatch) return { source: 'profile', key: 'profile:' + profileMatch[1] };
// /members/...
if (path.startsWith('/members')) return { source: 'members', key: 'members' };
// home
if (path === '/' || path === '/home') return { source: 'home', key: 'home' };
return { source: 'page', key: 'page:' + path };
}
function collectIds() {
var cards = document.querySelectorAll('article[data-art-id]');
var ids = [];
for (var i = 0; i < cards.length; i++) {
var raw = cards[i].getAttribute('data-art-id');
var id = parseInt(raw, 10);
if (id > 0 && !isNaN(id)) ids.push(id);
}
return ids;
}
function saveContext(artId, ids, context) {
var index = ids.indexOf(artId);
if (index === -1) index = 0;
var ctx = {
source: context.source,
key: context.key,
ids: ids,
index: index,
page: window.location.href,
ts: Date.now(),
};
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ctx));
} catch (_) {
// quota exceeded or private mode — silently skip
}
}
function findArticle(el) {
var node = el;
while (node && node !== document.body) {
if (node.tagName === 'ARTICLE' && node.hasAttribute('data-art-id')) {
return node;
}
node = node.parentElement;
}
return null;
}
function init() {
// Only act on pages that have artwork cards (not the artwork detail page itself)
var cards = document.querySelectorAll('article[data-art-id]');
if (cards.length === 0) return;
// Don't inject on the artwork detail page (has #artwork-page mount)
if (document.getElementById('artwork-page')) return;
var context = getPageContext();
document.addEventListener(
'click',
function (event) {
var article = findArticle(event.target);
if (!article) return;
// Make sure click was on or inside the card's <a> link
var link = article.querySelector('a[href]');
if (!link) return;
var artId = parseInt(article.getAttribute('data-art-id'), 10);
if (!artId || isNaN(artId)) return;
var currentIds = collectIds();
saveContext(artId, currentIds, context);
},
true // capture phase: store before navigation fires
);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -0,0 +1,65 @@
async function sha256Hex(value) {
if (!window.crypto?.subtle) {
return ''
}
const encoded = new TextEncoder().encode(value)
const digest = await window.crypto.subtle.digest('SHA-256', encoded)
return Array.from(new Uint8Array(digest))
.map((part) => part.toString(16).padStart(2, '0'))
.join('')
}
function readWebglVendor() {
try {
const canvas = document.createElement('canvas')
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
if (!gl) {
return 'no-webgl'
}
const extension = gl.getExtension('WEBGL_debug_renderer_info')
if (!extension) {
return 'webgl-hidden'
}
return [
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
].join(':')
} catch {
return 'webgl-error'
}
}
export async function buildBotFingerprint() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown'
const screenSize = typeof window.screen !== 'undefined'
? `${window.screen.width}x${window.screen.height}x${window.devicePixelRatio || 1}`
: 'no-screen'
const payload = [
navigator.userAgent || 'unknown-ua',
navigator.language || 'unknown-language',
navigator.platform || 'unknown-platform',
timezone,
screenSize,
readWebglVendor(),
].join('|')
return sha256Hex(payload)
}
export async function populateBotFingerprint(form) {
if (!form) {
return ''
}
const fingerprint = await buildBotFingerprint()
const field = form.querySelector('input[name="_bot_fingerprint"]')
if (field && fingerprint !== '') {
field.value = fingerprint
}
return fingerprint
}

View File

@@ -0,0 +1,19 @@
export function sendTagInteractionEvent(payload) {
const endpoint = '/api/analytics/tags'
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
const body = JSON.stringify(payload)
fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}),
},
body,
keepalive: true,
credentials: 'same-origin',
}).catch(() => {
})
}

View File

@@ -0,0 +1,24 @@
export function emitUploadEvent(eventName, payload = {}) {
try {
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('skinbase:upload-analytics', {
detail: {
event: eventName,
payload,
timestamp: Date.now(),
},
})
)
}
const endpoint = typeof window !== 'undefined' ? window?.SKINBASE_UPLOAD_ANALYTICS_URL : null
if (endpoint && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
const body = JSON.stringify({ event: eventName, payload, ts: Date.now() })
const blob = new Blob([body], { type: 'application/json' })
navigator.sendBeacon(endpoint, blob)
}
} catch {
// analytics must remain non-blocking
}
}

View File

@@ -0,0 +1,27 @@
export function init() {
return '/api/uploads/init'
}
export function chunk() {
return '/api/uploads/chunk'
}
export function finish() {
return '/api/uploads/finish'
}
export function status(id) {
return `/api/uploads/status/${id}`
}
export function cancel() {
return '/api/uploads/cancel'
}
export function publish(id) {
return `/api/uploads/${id}/publish`
}
export function submitReview(id) {
return `/api/uploads/${id}/submit-review`
}

View File

@@ -0,0 +1,77 @@
const REASON_MAP = {
duplicate_hash: {
type: 'error',
message: 'This file already exists in Skinbase. Please upload a different file.',
},
validation_failed: {
type: 'error',
message: 'Upload validation failed. Please check the file and try again.',
},
scan_failed: {
type: 'error',
message: 'Upload scan failed. Please try another file.',
},
quota_exceeded: {
type: 'warning',
message: 'Upload limit reached. Please wait before uploading again.',
},
}
function normalizeType(value, fallback = 'error') {
const normalized = String(value || '').toLowerCase()
if (normalized === 'success' || normalized === 'warning' || normalized === 'error') return normalized
return fallback
}
export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
const status = Number(error?.response?.status || 0)
const payload = error?.response?.data || {}
const reason = String(payload?.reason || '').toLowerCase()
const mapped = REASON_MAP[reason]
const errorCode = String(error?.code || '').toUpperCase()
const rawMessage = typeof error?.message === 'string' ? error.message.trim() : ''
const timedOut = errorCode === 'ECONNABORTED' || /timeout/i.test(rawMessage)
const requestTooLarge = status === 413
const type = mapped?.type
? mapped.type
: normalizeType(payload?.type || payload?.level, requestTooLarge ? 'warning' : (status >= 500 ? 'error' : 'warning'))
const message =
(requestTooLarge ? 'Server rejected this upload chunk as too large. Retrying with smaller chunks may help, or increase Nginx/PHP upload limits.' : '') ||
(timedOut ? 'Upload request timed out before the server responded. Check Nginx/PHP-FPM body handling and try again.' : '') ||
mapped?.message ||
(typeof payload?.message === 'string' && payload.message.trim()) ||
rawMessage ||
fallback
return {
type,
message,
reason,
status,
}
}
export function mapUploadResultNotice(payload, options = {}) {
const {
fallbackType = 'success',
fallbackMessage = 'Operation completed successfully.',
} = options
const reason = String(payload?.reason || '').toLowerCase()
const mapped = REASON_MAP[reason]
const type = mapped?.type || normalizeType(payload?.type || payload?.level, fallbackType)
const message =
mapped?.message ||
(typeof payload?.message === 'string' && payload.message.trim()) ||
fallbackMessage
return {
type,
message,
reason,
status: Number(payload?.status_code || 0),
}
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { mapUploadErrorNotice, mapUploadResultNotice } from './uploadNotices'
describe('uploadNotices mapping', () => {
it('maps duplicate_hash reason to a clear error message', () => {
const notice = mapUploadErrorNotice({
response: {
status: 409,
data: {
reason: 'duplicate_hash',
message: 'Duplicate upload is not allowed. This file already exists.',
},
},
}, 'Upload failed.')
expect(notice.type).toBe('error')
expect(notice.reason).toBe('duplicate_hash')
expect(notice.message).toBe('This file already exists in Skinbase. Please upload a different file.')
})
it('keeps success messages as success', () => {
const notice = mapUploadResultNotice({
type: 'success',
message: 'Artwork published successfully.',
})
expect(notice.type).toBe('success')
expect(notice.message).toBe('Artwork published successfully.')
})
it('normalizes warning messages for queued processing', () => {
const notice = mapUploadResultNotice({
level: 'warning',
message: 'Upload received. Processing is queued.',
})
expect(notice.type).toBe('warning')
expect(notice.message).toBe('Upload received. Processing is queued.')
})
})

View File

@@ -0,0 +1,130 @@
/**
* 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'
}

View File

@@ -0,0 +1,43 @@
/**
* useNavContext
*
* Provides prev/next artwork IDs scoped to the same author via API.
*/
import { useCallback } from 'react';
// Module-level cache for API calls
const fallbackCache = new Map();
async function fetchFallback(artworkId) {
const key = String(artworkId);
if (fallbackCache.has(key)) return fallbackCache.get(key);
try {
const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
const data = await res.json();
const result = {
prevId: data.prev_id ?? null,
nextId: data.next_id ?? null,
prevUrl: data.prev_url ?? null,
nextUrl: data.next_url ?? null,
};
fallbackCache.set(key, result);
return result;
} catch {
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null };
}
}
export function useNavContext(currentArtworkId) {
/**
* Always resolve via API to guarantee same-user navigation.
*/
const getNeighbors = useCallback(async () => {
return fetchFallback(currentArtworkId);
}, [currentArtworkId]);
return { getNeighbors };
}