Save workspace changes
This commit is contained in:
@@ -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(() => {
|
||||
})
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(() => {
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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.')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user