Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View File

@@ -1,3 +1,65 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.form-label {
@apply block text-sm text-soft mb-1;
}
.form-input {
@apply w-full bg-deep border border-nebula-500/30 rounded-lg px-4 py-2
text-white placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-accent;
}
.form-textarea {
@apply w-full bg-deep border border-nebula-500/30 rounded-lg px-4 py-2
text-white resize-none
focus:outline-none focus:ring-2 focus:ring-accent;
}
.form-file {
@apply w-full text-sm text-soft
file:bg-panel file:border-0 file:px-4 file:py-2
file:rounded-lg file:text-white
hover:file:bg-nebula-600/40;
}
.btn-primary {
@apply bg-accent text-deep px-6 py-2 rounded-lg
font-medium hover:brightness-110 transition;
}
.btn-secondary {
@apply bg-nebula-500/30 text-white px-5 py-2 rounded-lg
hover:bg-nebula-500/50 transition;
}
@layer components {
/* Ensure plain inputs, textareas and selects match the dark form styles
so we don't end up with white backgrounds + white text. */
input,
input[type="text"],
input[type="email"],
input[type="password"],
textarea,
select {
@apply bg-deep text-white border border-nebula-500/30 rounded-lg px-4 py-2
placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent;
}
/* Keep file inputs styled separately (file controls use .form-file class).
This prevents the native file button from inheriting the same padding. */
input[type="file"] {
@apply bg-transparent text-soft p-0;
}
}
@layer base {
*,:before,:after {
box-sizing: border-box;
border: 0 solid transparent;
}
}

View File

@@ -0,0 +1,6 @@
import React from 'react'
import AdminUploadQueue from '../../components/admin/AdminUploadQueue'
export default function UploadQueuePage() {
return <AdminUploadQueue />
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,3 +2,8 @@ import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
if (csrfToken) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken;
}

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react'
export default function AdminUploadQueue() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notes, setNotes] = useState({})
const loadPending = async () => {
setLoading(true)
setError('')
try {
const response = await window.axios.get('/api/admin/uploads/pending')
setItems(Array.isArray(response?.data?.data) ? response.data.data : [])
} catch (loadError) {
setError(loadError?.response?.data?.message || 'Failed to load moderation queue.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadPending()
}, [])
const moderate = async (id, action) => {
try {
const payload = { note: String(notes[id] || '') }
await window.axios.post(`/api/admin/uploads/${id}/${action}`, payload)
setItems((prev) => prev.filter((item) => item.id !== id))
} catch (moderateError) {
setError(moderateError?.response?.data?.message || `Failed to ${action} upload.`)
}
}
return (
<section aria-label="Moderation queue" className="mx-auto w-full max-w-5xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Pending Upload Moderation</h2>
<button type="button" onClick={loadPending} className="rounded-lg border border-white/20 px-3 py-1 text-xs text-white">
Refresh
</button>
</div>
{loading ? <p role="status" className="text-sm text-white/70">Loading</p> : null}
{error ? <p role="alert" className="mb-3 text-sm text-rose-200">{error}</p> : null}
{!loading && items.length === 0 ? <p role="status" className="text-sm text-white/60">No pending uploads.</p> : null}
<ul className="space-y-3">
{items.map((item) => (
<li key={item.id} aria-label={`Pending upload ${item.id}`} className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-sm font-medium text-white">{item.title || '(untitled upload)'}</div>
<div className="mt-1 text-xs text-white/65">{item.type} · {item.id}</div>
{item.preview_path ? <div className="mt-1 text-xs text-white/55">Preview: {item.preview_path}</div> : null}
</div>
<div className="w-full max-w-sm space-y-2">
<input
type="text"
aria-label={`Moderation note for ${item.id}`}
value={notes[item.id] || ''}
onChange={(event) => setNotes((prev) => ({ ...prev, [item.id]: event.target.value }))}
placeholder="Moderation note"
className="w-full rounded-lg border border-white/15 bg-white/10 px-3 py-2 text-xs text-white"
/>
<div className="flex gap-2">
<button
type="button"
aria-label={`Approve upload ${item.id}`}
onClick={() => moderate(item.id, 'approve')}
className="rounded-lg bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Approve
</button>
<button
type="button"
aria-label={`Reject upload ${item.id}`}
onClick={() => moderate(item.id, 'reject')}
className="rounded-lg bg-rose-500 px-3 py-2 text-xs font-semibold text-white"
>
Reject
</button>
</div>
</div>
</div>
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,112 @@
import React from 'react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import AdminUploadQueue from './AdminUploadQueue'
function makePendingUpload(overrides = {}) {
return {
id: '11111111-1111-1111-1111-111111111111',
title: 'Neon Skyline',
type: 'image',
preview_path: 'tmp/drafts/1111/preview.webp',
...overrides,
}
}
describe('AdminUploadQueue', () => {
beforeEach(() => {
window.axios = {
get: vi.fn(),
post: vi.fn(),
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('renders pending list with accessible controls', async () => {
const upload = makePendingUpload()
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
render(<AdminUploadQueue />)
expect(screen.getByRole('heading', { name: 'Pending Upload Moderation' })).not.toBeNull()
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
expect(within(item).getByText('Neon Skyline')).not.toBeNull()
expect(within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` })).not.toBeNull()
expect(within(item).getByRole('button', { name: `Approve upload ${upload.id}` })).not.toBeNull()
expect(within(item).getByRole('button', { name: `Reject upload ${upload.id}` })).not.toBeNull()
})
it('approves upload and removes it from queue', async () => {
const upload = makePendingUpload()
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
await userEvent.click(within(item).getByRole('button', { name: `Approve upload ${upload.id}` }))
await waitFor(() => {
expect(screen.queryByRole('listitem', { name: `Pending upload ${upload.id}` })).toBeNull()
})
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/uploads/${upload.id}/approve`, { note: '' })
})
it('rejects upload with note and removes it from queue', async () => {
const upload = makePendingUpload({ id: '22222222-2222-2222-2222-222222222222', title: 'Retro Pack' })
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
window.axios.post.mockResolvedValueOnce({ data: { success: true } })
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
await userEvent.type(
within(item).getByRole('textbox', { name: `Moderation note for ${upload.id}` }),
'Needs better quality screenshots'
)
await userEvent.click(within(item).getByRole('button', { name: `Reject upload ${upload.id}` }))
await waitFor(() => {
expect(screen.queryByRole('listitem', { name: `Pending upload ${upload.id}` })).toBeNull()
})
expect(window.axios.post).toHaveBeenCalledWith(`/api/admin/uploads/${upload.id}/reject`, {
note: 'Needs better quality screenshots',
})
})
it('shows API failure message and keeps item when moderation action fails', async () => {
const upload = makePendingUpload({ id: '33333333-3333-3333-3333-333333333333' })
window.axios.get.mockResolvedValueOnce({ data: { data: [upload] } })
window.axios.post.mockRejectedValueOnce({
response: { data: { message: 'Moderation API failed.' } },
})
render(<AdminUploadQueue />)
const item = await screen.findByRole('listitem', { name: `Pending upload ${upload.id}` })
await userEvent.click(within(item).getByRole('button', { name: `Approve upload ${upload.id}` }))
const alert = await screen.findByRole('alert')
expect(alert.textContent).toContain('Moderation API failed.')
expect(screen.getByRole('listitem', { name: `Pending upload ${upload.id}` })).not.toBeNull()
})
it('shows empty state when no pending uploads exist', async () => {
window.axios.get.mockResolvedValueOnce({ data: { data: [] } })
render(<AdminUploadQueue />)
const empty = await screen.findByText('No pending uploads.')
expect(empty).not.toBeNull()
expect(screen.queryAllByRole('listitem').length).toBe(0)
})
})

View File

@@ -0,0 +1,498 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
const DEFAULT_MAX_TAGS = 15
const DEFAULT_MIN_LENGTH = 2
const DEFAULT_MAX_LENGTH = 32
const DEBOUNCE_MS = 300
const MAX_SUGGESTIONS = 8
function normalizeTag(rawTag) {
const raw = String(rawTag ?? '').trim().toLowerCase()
if (!raw) return ''
const noSpaces = raw.replace(/\s+/g, '-')
const cleaned = noSpaces.replace(/[^a-z0-9_-]/g, '')
const compact = cleaned.replace(/-+/g, '-').replace(/_+/g, '_').replace(/^[-_]+|[-_]+$/g, '')
return compact.slice(0, DEFAULT_MAX_LENGTH)
}
function parseTagList(input) {
if (Array.isArray(input)) return input
if (typeof input !== 'string') return []
return input
.split(/[\n,]+/)
.map((item) => item.trim())
.filter(Boolean)
}
function validateTag(tag, selectedTags, minLength, maxLength, maxTags) {
if (selectedTags.length >= maxTags) return 'Max tags reached'
if (tag.length < minLength) return 'Tag too short'
if (tag.length > maxLength) return 'Tag too long'
if (!/^[a-z0-9_-]+$/.test(tag)) return 'Invalid tag format'
if (selectedTags.includes(tag)) return 'Duplicate tag'
return null
}
function toSuggestionItems(raw) {
if (!Array.isArray(raw)) return []
return raw
.map((item) => {
if (typeof item === 'string') {
return { key: item, label: item, tag: item, usageCount: null, isAi: false }
}
const tag = item?.slug || item?.name || item?.tag || ''
if (!tag) return null
return {
key: item?.id ?? tag,
label: item?.name || item?.tag || item?.slug || tag,
tag,
usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null,
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
}
})
.filter(Boolean)
}
function TagPillList({ tags, onRemove, disabled }) {
return (
<div className="min-h-[3rem] rounded-xl border border-white/10 bg-white/5 p-2">
<div className="flex flex-wrap gap-2">
{tags.length === 0 && (
<span className="px-2 py-1 text-xs text-white/50">No tags selected</span>
)}
{tags.map((tag) => (
<span
key={tag}
className="group inline-flex max-w-full items-center gap-2 rounded-full border border-white/20 bg-slate-900/80 px-3 py-1.5 text-xs text-slate-100 transition-all duration-150 ease-in-out"
title={tag}
>
<span className="truncate">{tag}</span>
<button
type="button"
onClick={() => onRemove(tag)}
disabled={disabled}
className="rounded-full p-0.5 text-slate-300 transition-colors duration-150 ease-in-out hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Remove tag ${tag}`}
>
</button>
</span>
))}
</div>
</div>
)
}
function SearchInput({
inputValue,
onInputChange,
onKeyDown,
onPaste,
onFocus,
disabled,
expanded,
listboxId,
placeholder,
}) {
return (
<input
value={inputValue}
onChange={(event) => onInputChange(event.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onFocus={onFocus}
disabled={disabled}
type="text"
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white placeholder:text-white/45 focus:border-sky-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
placeholder={placeholder}
aria-label="Tag input"
aria-autocomplete="list"
aria-haspopup="listbox"
aria-expanded={expanded}
aria-controls={listboxId}
/>
)
}
function HighlightedMatch({ label, query }) {
if (!query) return <>{label}</>
const lowerLabel = label.toLowerCase()
const lowerQuery = query.toLowerCase()
const start = lowerLabel.indexOf(lowerQuery)
if (start === -1) return <>{label}</>
const end = start + query.length
const before = label.slice(0, start)
const match = label.slice(start, end)
const after = label.slice(end)
return (
<>
{before}
<span className="font-semibold text-sky-300">{match}</span>
{after}
</>
)
}
function SuggestionDropdown({
isOpen,
loading,
error,
suggestions,
highlightedIndex,
onSelect,
query,
listboxId,
}) {
if (!isOpen) return null
return (
<div className="rounded-xl border border-white/10 bg-slate-950/95">
<ul id={listboxId} role="listbox" className="max-h-56 overflow-auto py-1">
{loading && (
<li className="px-3 py-2 text-xs text-white/60">Searching tags</li>
)}
{!loading && error && (
<li className="px-3 py-2 text-xs text-amber-200">Tag search unavailable</li>
)}
{!loading && !error && suggestions.length === 0 && (
<li className="px-3 py-2 text-xs text-white/50">No suggestions</li>
)}
{!loading && !error && suggestions.map((item, index) => {
const active = highlightedIndex === index
return (
<li
key={item.key}
role="option"
aria-selected={active}
className={`flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm transition-colors duration-150 ease-in-out ${active ? 'bg-sky-500/20 text-white' : 'text-white/85 hover:bg-white/10'}`}
onMouseDown={(event) => {
event.preventDefault()
onSelect(item.tag)
}}
>
<div className="min-w-0">
<span className="truncate"><HighlightedMatch label={item.label} query={query} /></span>
{item.isAi && (
<span className="ml-2 rounded-full border border-purple-400/40 bg-purple-400/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-purple-200">
AI
</span>
)}
</div>
{typeof item.usageCount === 'number' && (
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span>
)}
</li>
)
})}
</ul>
</div>
)
}
function SuggestedTagsPanel({ items, selectedTags, onAdd, disabled }) {
const filtered = useMemo(
() => items.filter((item) => !selectedTags.includes(normalizeTag(item.tag))),
[items, selectedTags]
)
if (filtered.length === 0) return null
return (
<div className="rounded-xl border border-purple-400/20 bg-purple-500/5 p-3">
<div className="mb-2 text-xs uppercase tracking-wide text-purple-200">AI Suggested tags</div>
<div className="flex flex-wrap gap-2">
{filtered.map((item) => (
<button
key={item.key}
type="button"
onClick={() => onAdd(item.tag)}
disabled={disabled}
className="inline-flex items-center gap-1 rounded-full border border-purple-300/40 bg-purple-400/10 px-3 py-1.5 text-xs text-purple-100 transition-all duration-150 ease-in-out hover:bg-purple-400/20 disabled:cursor-not-allowed disabled:opacity-60"
>
{item.label}
<span className="text-[11px]">+</span>
</button>
))}
</div>
</div>
)
}
function StatusHints({ error, count, maxTags }) {
return (
<div className="flex items-center justify-between gap-3 text-xs">
<span className={error ? 'text-amber-200' : 'text-white/55'} role="status" aria-live="polite">
{error || 'Type and press Enter, comma, or Tab to add'}
</span>
<span className="text-white/50">{count}/{maxTags}</span>
</div>
)
}
export default function TagInput({
value,
onChange,
suggestedTags = [],
disabled = false,
maxTags = DEFAULT_MAX_TAGS,
minLength = DEFAULT_MIN_LENGTH,
maxLength = DEFAULT_MAX_LENGTH,
placeholder = 'Type tags…',
searchEndpoint = '/api/tags/search',
popularEndpoint = '/api/tags/popular',
}) {
const selectedTags = useMemo(() => parseTagList(value).map(normalizeTag).filter(Boolean), [value])
const [inputValue, setInputValue] = useState('')
const [suggestions, setSuggestions] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [searchError, setSearchError] = useState(false)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(-1)
const queryCacheRef = useRef(new Map())
const abortControllerRef = useRef(null)
const debounceTimerRef = useRef(null)
const listboxId = useMemo(() => `tag-input-listbox-${Math.random().toString(16).slice(2)}`, [])
const aiSuggestedItems = useMemo(() => toSuggestionItems(suggestedTags), [suggestedTags])
const updateTags = useCallback((nextTags) => {
const unique = Array.from(new Set(nextTags.map(normalizeTag).filter(Boolean)))
onChange(unique)
}, [onChange])
const addTag = useCallback((rawTag) => {
const normalized = normalizeTag(rawTag)
const validation = validateTag(normalized, selectedTags, minLength, maxLength, maxTags)
if (validation) {
setError(validation)
return false
}
setError('')
updateTags([...selectedTags, normalized])
setInputValue('')
setIsOpen(false)
setHighlightedIndex(-1)
return true
}, [selectedTags, minLength, maxLength, maxTags, updateTags])
const removeTag = useCallback((tagToRemove) => {
setError('')
updateTags(selectedTags.filter((tag) => tag !== tagToRemove))
}, [selectedTags, updateTags])
const applyPastedTags = useCallback((rawText) => {
const parts = parseTagList(rawText)
if (parts.length === 0) return
let next = [...selectedTags]
for (const part of parts) {
const normalized = normalizeTag(part)
const validation = validateTag(normalized, next, minLength, maxLength, maxTags)
if (!validation) {
next.push(normalized)
}
}
next = Array.from(new Set(next))
updateTags(next)
setInputValue('')
setError('')
}, [selectedTags, minLength, maxLength, maxTags, updateTags])
const runSearch = useCallback(async (query) => {
const normalizedQuery = normalizeTag(query)
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const cacheKey = normalizedQuery || '__popular__'
if (queryCacheRef.current.has(cacheKey)) {
const cached = queryCacheRef.current.get(cacheKey)
setSuggestions(cached)
setSearchError(false)
setIsOpen(true)
return
}
const controller = new AbortController()
abortControllerRef.current = controller
setLoading(true)
setSearchError(false)
try {
const url = normalizedQuery
? `${searchEndpoint}?q=${encodeURIComponent(normalizedQuery)}`
: popularEndpoint
const res = await window.axios.get(url, { signal: controller.signal })
const items = toSuggestionItems(res?.data?.data || [])
.filter((item) => !selectedTags.includes(normalizeTag(item.tag)))
.slice(0, MAX_SUGGESTIONS)
queryCacheRef.current.set(cacheKey, items)
setSuggestions(items)
setHighlightedIndex(items.length > 0 ? 0 : -1)
setIsOpen(true)
} catch (requestError) {
if (controller.signal.aborted) return
setSuggestions([])
setSearchError(true)
setIsOpen(true)
} finally {
if (!controller.signal.aborted) {
setLoading(false)
}
}
}, [searchEndpoint, popularEndpoint, selectedTags])
useEffect(() => {
const query = inputValue.trim()
if (debounceTimerRef.current) {
window.clearTimeout(debounceTimerRef.current)
}
debounceTimerRef.current = window.setTimeout(() => {
runSearch(query)
}, DEBOUNCE_MS)
return () => {
if (debounceTimerRef.current) {
window.clearTimeout(debounceTimerRef.current)
}
}
}, [inputValue, runSearch])
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])
const handleInputKeyDown = useCallback((event) => {
if (event.key === 'Escape') {
setIsOpen(false)
setHighlightedIndex(-1)
return
}
if (event.key === 'Backspace' && inputValue.length === 0 && selectedTags.length > 0) {
removeTag(selectedTags[selectedTags.length - 1])
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!isOpen || suggestions.length === 0) return
setHighlightedIndex((prev) => {
if (prev < 0) return 0
return Math.min(prev + 1, suggestions.length - 1)
})
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (!isOpen || suggestions.length === 0) return
setHighlightedIndex((prev) => {
if (prev <= 0) return 0
return prev - 1
})
return
}
const shouldCommit = event.key === 'Enter' || event.key === ',' || event.key === 'Tab'
if (!shouldCommit) return
if (event.key === 'Tab' && !isOpen && inputValue.trim() === '') {
return
}
event.preventDefault()
if ((event.key === 'Enter' || event.key === 'Tab') && isOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
addTag(suggestions[highlightedIndex].tag)
return
}
const candidate = inputValue.trim().replace(/,$/, '')
if (!candidate) return
addTag(candidate)
}, [inputValue, selectedTags, removeTag, isOpen, suggestions, highlightedIndex, addTag])
const handlePaste = useCallback((event) => {
const raw = event.clipboardData?.getData('text')
if (!raw || !raw.includes(',')) return
event.preventDefault()
applyPastedTags(raw)
}, [applyPastedTags])
const handleFocus = useCallback(() => {
if (inputValue.trim() !== '') return
runSearch('')
}, [inputValue, runSearch])
return (
<div className="flex h-full flex-col gap-3" data-testid="tag-input-root">
<TagPillList tags={selectedTags} onRemove={removeTag} disabled={disabled} />
<SearchInput
inputValue={inputValue}
onInputChange={(next) => {
setInputValue(next)
setError('')
}}
onKeyDown={handleInputKeyDown}
onPaste={handlePaste}
onFocus={handleFocus}
disabled={disabled}
expanded={isOpen}
listboxId={listboxId}
placeholder={placeholder}
/>
<SuggestionDropdown
isOpen={isOpen}
loading={loading}
error={searchError}
suggestions={suggestions}
highlightedIndex={highlightedIndex}
onSelect={addTag}
query={inputValue.trim()}
listboxId={listboxId}
/>
<SuggestedTagsPanel
items={aiSuggestedItems}
selectedTags={selectedTags}
onAdd={addTag}
disabled={disabled}
/>
<StatusHints error={error} count={selectedTags.length} maxTags={maxTags} />
</div>
)
}

View File

@@ -0,0 +1,113 @@
import React from 'react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import TagInput from './TagInput'
function Harness({ initial = [] }) {
const [tags, setTags] = React.useState(initial)
return (
<TagInput
value={tags}
onChange={setTags}
suggestedTags={['sunset', 'city']}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
/>
)
}
describe('TagInput', () => {
beforeEach(() => {
window.axios = {
get: vi.fn(async (url) => {
if (url.startsWith('/api/tags/search')) {
return {
data: {
data: [
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 },
{ id: 2, name: 'city', slug: 'city', usage_count: 30 },
],
},
}
}
return {
data: {
data: [
{ id: 3, name: 'popular', slug: 'popular', usage_count: 99 },
],
},
}
}),
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('adds and removes tags', async () => {
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.type(input, 'night,')
expect(screen.getByText('night')).not.toBeNull()
await userEvent.click(screen.getByRole('button', { name: 'Remove tag night' }))
expect(screen.queryByText('night')).toBeNull()
})
it('supports keyboard suggestion accept with tab', async () => {
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.type(input, 'city')
await waitFor(() => {
expect(screen.getAllByRole('option').length).toBeGreaterThan(0)
})
await userEvent.keyboard('{Tab}')
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
})
it('supports comma-separated paste', async () => {
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.click(input)
await userEvent.paste('art, city, night')
expect(screen.getByText('art')).not.toBeNull()
expect(screen.getByText('city')).not.toBeNull()
expect(screen.getByText('night')).not.toBeNull()
})
it('handles API failure gracefully', async () => {
window.axios.get = vi.fn(async () => {
throw new Error('network')
})
render(<Harness />)
const input = screen.getByLabelText('Tag input')
await userEvent.type(input, 'cyber')
await waitFor(() => {
expect(screen.getByText('Tag search unavailable')).not.toBeNull()
})
})
it('enforces max tags limit', async () => {
render(<Harness initial={['a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', 'a8', 'a9', 'a10', 'a11', 'a12', 'a13', 'a14', 'a15']} />)
const input = screen.getByLabelText('Tag input')
await userEvent.type(input, 'overflow{enter}')
expect(screen.getByText('Max tags reached')).not.toBeNull()
expect(screen.queryByText('overflow')).toBeNull()
})
})

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useMemo, useRef } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
export default function ScreenshotUploader({
title = 'Archive screenshots',
description = 'Screenshot requirement placeholder for archive uploads',
visible = false,
files = [],
perFileErrors = [],
errors = [],
invalid = false,
showLooksGood = false,
looksGoodText = 'Looks good',
onFilesChange,
min = 1,
max = 5,
}) {
const inputRef = useRef(null)
const prefersReducedMotion = useReducedMotion()
const quickTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const previewItems = useMemo(() => files.map((file) => ({
file,
url: URL.createObjectURL(file),
})), [files])
useEffect(() => {
return () => {
previewItems.forEach((item) => URL.revokeObjectURL(item.url))
}
}, [previewItems])
if (!visible) return null
const emitFiles = (fileList, merge = false) => {
const incoming = Array.from(fileList || [])
const next = merge ? [...files, ...incoming] : incoming
if (typeof onFilesChange === 'function') {
onFilesChange(next.slice(0, max))
}
}
const removeAt = (index) => {
const next = files.filter((_, idx) => idx !== index)
if (typeof onFilesChange === 'function') {
onFilesChange(next)
}
}
return (
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_28px_rgba(0,0,0,0.3)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/40' : 'border-amber-300/25 from-amber-500/10 to-slate-900/40'}`}>
{/* Intended props: screenshots, minResolution, maxFileSizeMb, required, onChange, onRemove, error */}
<div className={`rounded-lg border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-amber-300/30 bg-black/20'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-100">{title} <span className="text-red-200">(Required)</span></h3>
<span className="rounded-full border border-amber-200/35 bg-amber-500/15 px-2.5 py-1 text-xs text-amber-100">{Math.min(files.length, max)}/{max} screenshots</span>
</div>
<p className="mt-1 text-sm text-amber-100/85">{description}</p>
<div className="mt-3 rounded-lg border border-amber-200/20 bg-amber-500/10 px-3 py-3 text-xs text-amber-50/90">
<p className="font-semibold">Why we need screenshots</p>
<p className="mt-1">Screenshots provide a visual thumbnail and help AI analysis/moderation before archive contents are published.</p>
<p className="mt-2 text-amber-100/85">Rules: JPG/PNG/WEBP · 1280×720 minimum · 10MB max each · {min} to {max} files.</p>
</div>
<div
className={`mt-3 rounded-lg border-2 border-dashed p-4 text-center transition-colors ${invalid ? 'border-red-300/45 bg-red-500/10' : 'border-white/20 bg-white/5 hover:border-amber-300/45 hover:bg-amber-500/5'}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
emitFiles(event.dataTransfer?.files, true)
}}
>
<p className="text-sm text-white/85">Drop screenshots here or click to browse</p>
<button
type="button"
className="mt-2 rounded-md border border-white/20 bg-white/10 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
onClick={() => inputRef.current?.click()}
>
Browse screenshots
</button>
<input
ref={inputRef}
type="file"
className="hidden"
aria-label="Screenshot file input"
multiple
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
onChange={(event) => emitFiles(event.target.files, true)}
/>
</div>
<div className="mt-3 text-xs text-white/70">
{files.length} selected · minimum {min}, maximum {max}
</div>
{showLooksGood && (
<div className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
<span aria-hidden="true"></span>
<span>{looksGoodText}</span>
</div>
)}
{previewItems.length > 0 && (
<ul className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<AnimatePresence initial={false}>
{previewItems.map((item, index) => (
<motion.li
layout={!prefersReducedMotion}
key={`${item.file.name}-${index}`}
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.96 }}
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
transition={quickTransition}
className="rounded-lg border border-white/50 bg-white/5 p-2 text-xs"
>
<div className="overflow-hidden rounded-md border border-white/50 bg-black/25">
<img src={item.url} alt={`Screenshot ${index + 1}`} className="h-24 w-full object-cover" />
</div>
<div className="mt-2 truncate text-white/90">{item.file.name}</div>
<div className="mt-1 text-white/55">{Math.round(item.file.size / 1024)} KB</div>
{perFileErrors[index] && <div className="mt-1 rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1 text-red-200">{perFileErrors[index]}</div>}
<button
type="button"
onClick={() => removeAt(index)}
className="mt-2 rounded-md border border-white/20 bg-white/5 px-2.5 py-1 text-[11px] text-white/80 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
>
Remove
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
{errors.length > 0 && (
<ul className="mt-3 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
{error}
</li>
))}
</ul>
)}
{invalid && (
<p className="mt-3 text-xs text-red-200">Continue is blocked until screenshot requirements are valid.</p>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,152 @@
import React, { useEffect, useState } from 'react'
export default function UploadActions({
step = 1,
canStart = false,
canContinue = false,
canPublish = false,
canGoBack = false,
canReset = true,
canCancel = false,
canRetry = false,
isUploading = false,
isProcessing = false,
isPublishing = false,
isCancelling = false,
disableReason = 'Complete required fields',
onStart,
onContinue,
onPublish,
onBack,
onCancel,
onReset,
onRetry,
onSaveDraft,
showSaveDraft = false,
mobileSticky = true,
resetLabel = 'Reset',
}) {
const [confirmCancel, setConfirmCancel] = useState(false)
useEffect(() => {
if (!confirmCancel) return
const timer = window.setTimeout(() => setConfirmCancel(false), 3200)
return () => window.clearTimeout(timer)
}, [confirmCancel])
const handleCancel = () => {
if (!canCancel || isCancelling) return
if (!confirmCancel) {
setConfirmCancel(true)
return
}
setConfirmCancel(false)
onCancel?.()
}
const renderPrimary = () => {
if (step === 1) {
const disabled = !canStart || isUploading || isProcessing || isCancelling
const label = isUploading ? 'Uploading…' : isProcessing ? 'Processing…' : 'Start upload'
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Start upload'}
onClick={() => onStart?.()}
className={`rounded-lg px-5 py-2.5 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/75 ${disabled ? 'cursor-not-allowed bg-emerald-700/55 text-white/75' : 'bg-emerald-500 text-white hover:bg-emerald-400 shadow-[0_10px_28px_rgba(16,185,129,0.32)] ring-1 ring-emerald-200/40'}`}
>
{label}
</button>
)
}
if (step === 2) {
const disabled = !canContinue
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Continue to Publish'}
onClick={() => onContinue?.()}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/75 ${disabled ? 'cursor-not-allowed bg-sky-700/45 text-sky-100/75' : 'bg-sky-400 text-slate-950 hover:bg-sky-300 shadow-[0_10px_28px_rgba(56,189,248,0.28)] ring-1 ring-sky-100/45'}`}
>
Continue to Publish
</button>
)
}
const disabled = !canPublish || isPublishing
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Publish artwork'}
onClick={() => onPublish?.()}
className={`rounded-lg px-4 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/75 ${disabled ? 'cursor-not-allowed bg-emerald-700/45 text-emerald-100/75' : 'bg-emerald-400 text-slate-950 shadow-[0_0_0_1px_rgba(167,243,208,0.85),0_0_24px_rgba(52,211,153,0.45)] hover:bg-emerald-300'}`}
>
{isPublishing ? 'Publishing…' : 'Publish'}
</button>
)
}
return (
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20' : ''} rounded-xl border border-white/10 bg-slate-950/80 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur sm:p-4 lg:static lg:shadow-none`}>
<div className="flex flex-wrap items-center justify-end gap-2.5">
{canGoBack && (
<button
type="button"
onClick={() => onBack?.()}
className="rounded-lg border border-white/30 bg-white/10 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
Back
</button>
)}
{showSaveDraft && (
<button
type="button"
onClick={() => onSaveDraft?.()}
className="rounded-lg border border-purple-300/45 bg-purple-500/20 px-3.5 py-2 text-sm font-medium text-purple-50 transition hover:bg-purple-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-purple-300/70"
>
Save draft
</button>
)}
{step === 1 && canCancel && (
<button
type="button"
onClick={handleCancel}
disabled={isCancelling}
title={confirmCancel ? 'Click again to confirm cancel' : 'Cancel current upload'}
className="rounded-lg border border-amber-300/45 bg-amber-500/20 px-3.5 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/75 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCancelling ? 'Cancelling…' : confirmCancel ? 'Cancel upload?' : 'Cancel'}
</button>
)}
{canRetry && (
<button
type="button"
onClick={() => onRetry?.()}
className="rounded-lg border border-amber-300/45 bg-amber-500/20 px-3.5 py-2 text-sm font-medium text-amber-50 transition hover:bg-amber-500/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/75"
>
Retry
</button>
)}
{canReset && (
<button
type="button"
onClick={() => onReset?.()}
className="rounded-lg border border-white/30 bg-white/10 px-3.5 py-2 text-sm font-medium text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
{resetLabel}
</button>
)}
{renderPrimary()}
</div>
</footer>
)
}

View File

@@ -0,0 +1,199 @@
import React, { useRef, useState } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
function getExtension(fileName = '') {
const parts = String(fileName).toLowerCase().split('.')
return parts.length > 1 ? parts.pop() : ''
}
function detectPrimaryType(file) {
if (!file) return 'unknown'
const extension = getExtension(file.name)
const mime = String(file.type || '').toLowerCase()
const imageExt = new Set(['jpg', 'jpeg', 'png', 'webp'])
const archiveExt = new Set(['zip', 'rar', '7z', 'tar', 'gz'])
const imageMime = new Set(['image/jpeg', 'image/png', 'image/webp'])
const archiveMime = 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',
])
if (imageMime.has(mime) || imageExt.has(extension)) return 'image'
if (archiveMime.has(mime) || archiveExt.has(extension)) return 'archive'
return 'unsupported'
}
export default function UploadDropzone({
title = 'Upload file',
description = 'Drop file here or click to browse',
fileName = '',
fileHint = 'No file selected yet',
previewUrl = '',
fileMeta = null,
errors = [],
invalid = false,
showLooksGood = false,
looksGoodText = 'Looks good',
locked = false,
onPrimaryFileChange,
onValidationResult,
}) {
const [dragging, setDragging] = useState(false)
const inputRef = useRef(null)
const prefersReducedMotion = useReducedMotion()
const dragTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const emitFile = (file) => {
const detectedType = detectPrimaryType(file)
if (typeof onPrimaryFileChange === 'function') {
onPrimaryFileChange(file, { detectedType })
}
if (typeof onValidationResult === 'function') {
onValidationResult({ file, detectedType })
}
}
return (
<section className={`rounded-xl border bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/50'}`}>
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
<motion.div
data-testid="upload-dropzone"
role="button"
aria-disabled={locked ? 'true' : 'false'}
tabIndex={locked ? -1 : 0}
onClick={() => {
if (locked) return
inputRef.current?.click()
}}
onKeyDown={(event) => {
if (locked) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(event) => {
if (locked) return
event.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
if (locked) return
event.preventDefault()
setDragging(false)
const droppedFile = event.dataTransfer?.files?.[0]
if (droppedFile) emitFile(droppedFile)
}}
animate={prefersReducedMotion ? undefined : {
scale: dragging ? 1.01 : 1,
borderColor: invalid ? 'rgba(252,165,165,0.7)' : dragging ? 'rgba(103,232,249,0.9)' : 'rgba(56,189,248,0.35)',
backgroundColor: invalid ? 'rgba(23,68,68,0.10)' : dragging ? 'rgba(6,182,212,0.20)' : 'rgba(14,165,233,0.05)',
}}
transition={dragTransition}
className={`group rounded-xl border-2 border-dashed border-white/50 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
>
{previewUrl ? (
<div className="mt-2 grid place-items-center">
<div className="relative w-full max-w-[520px]">
<img src={previewUrl} alt="Selected preview" className="mx-auto max-h-64 w-auto rounded-lg object-contain" />
<div className="pointer-events-none absolute bottom-2 right-2 rounded-full bg-black/40 px-2 py-1 text-xs text-white/90">Click to replace</div>
</div>
</div>
) : (
<>
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full border border-sky-400/60 bg-sky-500/12 text-sky-100 shadow-sm">
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
<path d="M7 10l5-5 5 5" />
<path d="M12 5v10" />
</svg>
</div>
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
<p className="mt-1 text-xs text-soft">{description}</p>
<p className="mt-1 text-xs text-soft">Accepted: JPG, JPEG, PNG, WEBP, ZIP, RAR, 7Z, TAR, GZ</p>
<p className="text-xs text-soft">Max size: images 50MB · archives 200MB</p>
<span className={`btn-secondary mt-3 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
Click to browse files
</span>
</>
)}
<input
ref={inputRef}
type="file"
className="hidden"
aria-label="Upload file input"
disabled={locked}
accept=".jpg,.jpeg,.png,.webp,.zip,.rar,.7z,.tar,.gz,image/jpeg,image/png,image/webp"
onChange={(event) => {
const selectedFile = event.target.files?.[0]
if (selectedFile) {
emitFile(selectedFile)
}
}}
/>
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
<div className="mt-3 rounded-lg border border-white/50 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
<div className="font-medium text-white/85">Selected file</div>
<div className="mt-1 truncate">{fileName || fileHint}</div>
{fileMeta && (
<div className="mt-1 flex flex-wrap gap-2 text-xs text-white/60">
<span>Type: <span className="text-white/80">{fileMeta.type || '—'}</span></span>
<span>·</span>
<span>Size: <span className="text-white/80">{fileMeta.size || '—'}</span></span>
<span>·</span>
<span>Resolution: <span className="text-white/80">{fileMeta.resolution || '—'}</span></span>
</div>
)}
</div>
)}
{showLooksGood && (
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
<span aria-hidden="true"></span>
<span>{looksGoodText}</span>
</div>
)}
<AnimatePresence initial={false}>
{errors.length > 0 && (
<motion.div
key="dropzone-errors"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={dragTransition}
className="mt-4 rounded-lg border border-red-300/40 bg-red-500/10 p-3 text-left"
>
<p className="text-xs font-semibold uppercase tracking-wide text-red-100">Please fix the following</p>
<ul className="mt-2 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
{error}
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</section>
)
}

View File

@@ -0,0 +1,76 @@
import React from 'react'
export default function UploadPreview({
title = 'Preview',
description = 'Live artwork preview placeholder',
previewUrl = '',
isArchive = false,
metadata = {
resolution: '—',
size: '—',
type: '—',
},
warnings = [],
errors = [],
invalid = false,
}) {
return (
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/45'}`}>
{/* Intended props: file, previewUrl, isArchive, dimensions, fileSize, format, warning */}
<div className={`rounded-xl border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-white/50 bg-black/25'}`}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<span className="rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] text-white/65">
{isArchive ? 'Archive' : 'Image'}
</span>
</div>
<p className="mt-1 text-sm text-white/65">{description}</p>
<div className="mt-4 flex flex-col md:flex-row gap-4 items-start">
<div className="w-40 h-40 rounded-lg overflow-hidden bg-black/40 ring-1 ring-white/10 flex items-center justify-center">
{previewUrl && !isArchive ? (
<img src={previewUrl} alt="Upload preview" className="max-w-full max-h-full object-contain" />
) : (
<span className="text-sm text-soft">{isArchive ? 'Archive selected' : 'Image preview placeholder'}</span>
)}
</div>
<div className="flex-1 space-y-2 text-sm">
<div>
<span className="text-soft">Type</span>
<span className="text-white ml-2">{metadata.type}</span>
</div>
<div>
<span className="text-soft">Size</span>
<span className="text-white ml-2">{metadata.size}</span>
</div>
<div>
<span className="text-soft">Resolution</span>
<span className="text-white ml-2">{metadata.resolution}</span>
</div>
{errors.length > 0 && (
<ul className="space-y-1" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="text-red-400 text-xs">
{error}
</li>
))}
</ul>
)}
</div>
</div>
{warnings.length > 0 && (
<ul className="mt-4 space-y-1 text-xs text-amber-100" role="status" aria-live="polite">
{warnings.map((warning, index) => (
<li key={`${warning}-${index}`} className="rounded-md border border-amber-300/35 bg-amber-500/10 px-2 py-1">
{warning}
</li>
))}
</ul>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,152 @@
import React from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
export default function UploadProgress({
title = 'Upload Artwork',
description = 'Preload → Details → Publish',
progress = 24,
status = 'Idle',
state,
processingStatus,
processingLabel = '',
isCancelling = false,
error = '',
onRetry,
onReset,
}) {
const prefersReducedMotion = useReducedMotion()
const getRecoveryHint = () => {
const text = String(error || '').toLowerCase()
if (!text) return ''
if (text.includes('network') || text.includes('timeout') || text.includes('failed to fetch')) {
return 'Your connection may be unstable. Retry now or wait a moment and try again.'
}
if (text.includes('busy') || text.includes('unavailable') || text.includes('503') || text.includes('server')) {
return 'The server looks busy right now. Waiting 2030 seconds before retrying can help.'
}
if (text.includes('validation') || text.includes('invalid') || text.includes('too large') || text.includes('format')) {
return 'Please review the file requirements, then update your selection and try again.'
}
return 'You can retry now, or reset this upload and start again with the same files.'
}
const recoveryHint = getRecoveryHint()
const resolvedStatus = (() => {
if (isCancelling) return 'Processing'
if (state === 'error') return 'Error'
if (processingStatus === 'ready') return 'Ready'
if (state === 'uploading') return 'Uploading'
if (state === 'processing' || state === 'finishing' || state === 'publishing') return 'Processing'
if (status) return status
return 'Idle'
})()
const statusTheme = {
Idle: 'border-slate-400/35 bg-slate-400/15 text-slate-200',
Uploading: 'border-sky-400/35 bg-sky-400/15 text-sky-100',
Processing: 'border-amber-400/35 bg-amber-400/15 text-amber-100',
Ready: 'border-emerald-400/35 bg-emerald-400/15 text-emerald-100',
Error: 'border-red-400/35 bg-red-400/15 text-red-100',
}
const statusColors = {
Idle: { borderColor: 'rgba(148,163,184,0.35)', backgroundColor: 'rgba(148,163,184,0.15)', color: 'rgb(226,232,240)' },
Uploading: { borderColor: 'rgba(56,189,248,0.35)', backgroundColor: 'rgba(56,189,248,0.15)', color: 'rgb(224,242,254)' },
Processing: { borderColor: 'rgba(251,191,36,0.35)', backgroundColor: 'rgba(251,191,36,0.15)', color: 'rgb(254,243,199)' },
Ready: { borderColor: 'rgba(52,211,153,0.35)', backgroundColor: 'rgba(52,211,153,0.15)', color: 'rgb(209,250,229)' },
Error: { borderColor: 'rgba(248,113,113,0.35)', backgroundColor: 'rgba(248,113,113,0.15)', color: 'rgb(254,226,226)' },
}
const quickTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const stepLabels = ['Preload', 'Details', 'Publish']
const stepIndex = progress >= 100 ? 2 : progress >= 34 ? 1 : 0
return (
<header className="rounded-xl border border-white/50 bg-gradient-to-br from-slate-900/80 to-slate-900/50 p-5 shadow-[0_12px_40px_rgba(0,0,0,0.35)] sm:p-6">
{/* Intended props: step, steps, phase, badge, progress, statusMessage */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">{title}</h1>
<p className="mt-1 text-sm text-white/65">{description}</p>
</div>
<motion.span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusTheme[resolvedStatus] || statusTheme.Idle}`}
animate={statusColors[resolvedStatus] || statusColors.Idle}
transition={quickTransition}
>
{resolvedStatus}
</motion.span>
</div>
<div className="mt-4 flex items-center gap-2 overflow-x-auto">
{stepLabels.map((label, idx) => {
const active = idx <= stepIndex
return (
<div key={label} className="flex items-center gap-2">
<span className={`rounded-full border px-3 py-1 text-xs ${active ? 'border-emerald-400/40 bg-emerald-400/20 text-emerald-100' : 'border-white/15 bg-white/5 text-white/55'}`}>
{label}
</span>
{idx < stepLabels.length - 1 && <span className="text-white/30"></span>}
</div>
)
})}
</div>
<div className="mt-4">
<div className="h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full"
style={{
width: `${Math.max(0, Math.min(100, progress))}%`,
background: 'linear-gradient(90deg,#38bdf8,#06b6d4,#34d399)',
transition: prefersReducedMotion ? 'none' : 'width 200ms ease-out',
}}
/>
</div>
<p className="mt-2 text-right text-xs text-white/55">{Math.round(progress)}%</p>
</div>
<AnimatePresence initial={false}>
{(state === 'processing' || state === 'finishing' || state === 'publishing' || isCancelling) && (
<motion.div
key="processing-note"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={quickTransition}
className="mt-3 rounded-lg border border-cyan-300/25 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-100"
>
{processingLabel || 'Analyzing content'} you can continue editing details while processing finishes.
</motion.div>
)}
</AnimatePresence>
<AnimatePresence initial={false}>
{error && (
<motion.div
key="progress-error"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={quickTransition}
className="mt-3 rounded-lg border border-rose-200/25 bg-rose-400/8 px-3 py-2"
>
<p className="text-sm font-medium text-rose-100">Something went wrong while uploading.</p>
<p className="mt-1 text-xs text-rose-100/90">You can retry safely. {error}</p>
{recoveryHint && <p className="mt-1 text-xs text-rose-100/80">{recoveryHint}</p>}
<div className="mt-2 flex flex-wrap gap-2">
<button type="button" onClick={onRetry} className="rounded-md border border-rose-200/35 bg-rose-400/10 px-2.5 py-1 text-xs text-rose-100 transition hover:bg-rose-400/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200/75">Retry</button>
<button type="button" onClick={onReset} className="rounded-md border border-white/25 bg-white/10 px-2.5 py-1 text-xs text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60">Reset</button>
</div>
</motion.div>
)}
</AnimatePresence>
</header>
)
}

View File

@@ -0,0 +1,96 @@
import React from 'react'
import TagInput from '../tags/TagInput'
export default function UploadSidebar({
title = 'Artwork details',
description = 'Complete metadata before publishing',
showHeader = true,
metadata,
suggestedTags = [],
errors = {},
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleRights,
}) {
return (
<aside className="rounded-2xl border border-white/7 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_10px_24px_rgba(0,0,0,0.22)] sm:p-7">
{showHeader && (
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-white/65">{description}</p>
</div>
)}
<div className="space-y-5">
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Basics</h4>
<p className="mt-1 text-xs text-white/60">Add a clear title and short description.</p>
</div>
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-white/90">Title <span className="text-red-300">*</span></span>
<input
id="upload-sidebar-title"
value={metadata.title}
onChange={(event) => onChangeTitle?.(event.target.value)}
className={`mt-2 w-full rounded-xl border bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 ${errors.title ? 'border-red-300/60 focus:ring-red-300/70' : 'border-white/15 focus:ring-sky-300/70'}`}
placeholder="Give your artwork a clear title"
/>
{errors.title && <p className="mt-1 text-xs text-red-200">{errors.title}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-white/90">Description</span>
<textarea
id="upload-sidebar-description"
value={metadata.description}
onChange={(event) => onChangeDescription?.(event.target.value)}
rows={5}
className="mt-2 w-full rounded-xl border border-white/15 bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-sky-300/70"
placeholder="Describe your artwork (Markdown supported)."
/>
</label>
</div>
</section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Tags</h4>
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
</div>
<TagInput
value={metadata.tags}
onChange={(nextTags) => onChangeTags?.(nextTags)}
suggestedTags={suggestedTags}
maxTags={15}
minLength={2}
maxLength={32}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
placeholder="Type tags (e.g. cyberpunk, city)"
/>
</section>
<section className="rounded-xl border border-white/10 bg-white/[0.03] p-4">
<label className="flex items-start gap-3 text-sm text-white/90">
<input
id="upload-sidebar-rights"
type="checkbox"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
className="mt-0.5 h-5 w-5 rounded-md border border-white/30 bg-slate-900/70 text-emerald-400 accent-emerald-500 focus:ring-2 focus:ring-emerald-400/40"
/>
<span>
I confirm I own the rights to this content. <span className="text-red-300">*</span>
<span className="mt-1 block text-xs text-white/60">Required before publishing.</span>
{errors.rights && <span className="mt-1 block text-xs text-red-200">{errors.rights}</span>}
</span>
</label>
</section>
</div>
</aside>
)
}

View File

@@ -0,0 +1,53 @@
import React from 'react'
export default function UploadStepper({ steps = [], activeStep = 1, highestUnlockedStep = 1, onStepClick }) {
const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep))
return (
<nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4">
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto sm:gap-3">
{steps.map((step, index) => {
const number = index + 1
const isActive = number === safeActive
const isComplete = number < safeActive
const isLocked = number > highestUnlockedStep
const canNavigate = number < safeActive && !isLocked
const baseBtn = 'inline-flex items-center gap-2 rounded-full border px-2.5 py-1.5 text-xs sm:px-3'
const stateClass = isActive
? 'border-sky-300/80 bg-sky-500/30 text-white shadow-[0_8px_24px_rgba(14,165,233,0.12)]'
: isComplete
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25'
: isLocked
? 'cursor-default border-white/50 bg-white/5 text-white/40'
: 'border-white/10 bg-white/5 text-white/80 hover:bg-white/10'
const circleClass = isComplete
? 'border-emerald-300/60 bg-emerald-500/20 text-emerald-100'
: isActive
? 'border-sky-300/60 bg-sky-500/30 text-white'
: 'border-white/20 bg-white/5 text-white/80'
return (
<li key={step.key} className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={() => canNavigate && onStepClick?.(number)}
disabled={isLocked}
aria-disabled={isLocked ? 'true' : 'false'}
aria-current={isActive ? 'step' : undefined}
className={`${baseBtn} ${stateClass}`}
>
<span className={`grid h-5 w-5 place-items-center rounded-full border text-[11px] ${circleClass}`}>
{isComplete ? '✓' : number}
</span>
<span className="whitespace-nowrap">{step.label}</span>
</button>
{index < steps.length - 1 && <span className="text-white/50"></span>}
</li>
)
})}
</ol>
</nav>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,310 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act } from 'react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UploadWizard from '../UploadWizard'
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false } = {}) {
window.axios = {
post: vi.fn((url, payload, config = {}) => {
if (url === '/api/uploads/init') {
if (initError) return Promise.reject(initError)
return Promise.resolve({
data: {
session_id: 'session-1',
upload_token: 'token-1',
},
})
}
if (url === '/api/uploads/chunk') {
if (holdChunk) {
return new Promise((resolve, reject) => {
if (config?.signal?.aborted) {
reject({ name: 'CanceledError', code: 'ERR_CANCELED' })
return
}
config?.signal?.addEventListener?.('abort', () => reject({ name: 'CanceledError', code: 'ERR_CANCELED' }))
setTimeout(() => resolve({ data: { received_bytes: 1024, progress: 55 } }), 20)
})
}
const offset = Number(payload?.get?.('offset') || 0)
const chunkSize = Number(payload?.get?.('chunk_size') || 0)
const totalSize = Number(payload?.get?.('total_size') || 1)
const received = Math.min(totalSize, offset + chunkSize)
return Promise.resolve({
data: {
received_bytes: received,
progress: Math.round((received / totalSize) * 100),
},
})
}
if (url === '/api/uploads/finish') {
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
}
if (url === '/api/uploads/session-1/publish') {
return Promise.resolve({ data: { success: true, status: 'published' } })
}
if (url === '/api/uploads/cancel') {
return Promise.resolve({ data: { success: true, status: 'cancelled' } })
}
return Promise.reject(new Error(`Unhandled POST ${url}`))
}),
get: vi.fn((url) => {
if (url === '/api/uploads/status/session-1') {
return Promise.resolve({
data: {
id: 'session-1',
processing_state: statusValue,
status: statusValue,
},
})
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
}),
}
}
async function flushUi() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
}
async function renderWizard(props = {}) {
await act(async () => {
render(<UploadWizard {...props} />)
})
await flushUi()
}
async function uploadPrimary(file) {
await act(async () => {
const input = screen.getByLabelText('Upload file input')
await userEvent.upload(input, file)
})
await flushUi()
}
async function uploadScreenshot(file) {
await act(async () => {
const input = await screen.findByLabelText('Screenshot file input')
await userEvent.upload(input, file)
})
await flushUi()
}
async function completeStep1ToReady() {
await uploadPrimary(new File(['img'], 'ready.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /continue to publish/i })).not.toBeNull()
})
}
describe('UploadWizard step flow', () => {
let originalImage
let originalScrollIntoView
let consoleErrorSpy
beforeEach(() => {
window.URL.createObjectURL = vi.fn(() => `blob:${Math.random().toString(16).slice(2)}`)
window.URL.revokeObjectURL = vi.fn()
originalImage = global.Image
originalScrollIntoView = Element.prototype.scrollIntoView
Element.prototype.scrollIntoView = vi.fn()
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const text = args.map((arg) => String(arg)).join(' ')
if (text.includes('not configured to support act')) return
if (text.includes('not wrapped in act')) return
console.warn(...args)
})
global.Image = class MockImage {
set src(_value) {
this.naturalWidth = 1920
this.naturalHeight = 1080
setTimeout(() => {
if (typeof this.onload === 'function') this.onload()
}, 0)
}
}
})
afterEach(() => {
global.Image = originalImage
Element.prototype.scrollIntoView = originalScrollIntoView
consoleErrorSpy?.mockRestore()
cleanup()
vi.restoreAllMocks()
})
it('renders 3-step stepper', () => {
installAxiosStubs()
return renderWizard({ initialDraftId: 301 }).then(() => {
expect(screen.getByRole('navigation', { name: /upload steps/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /1 upload/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /2 details/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /3 publish/i })).not.toBeNull()
})
})
it('marks locked steps with aria-disabled and blocks click', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 307 })
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
const detailsStep = within(stepper).getByRole('button', { name: /2 details/i })
const publishStep = within(stepper).getByRole('button', { name: /3 publish/i })
expect(detailsStep.getAttribute('aria-disabled')).toBe('true')
expect(publishStep.getAttribute('aria-disabled')).toBe('true')
await act(async () => {
await userEvent.click(detailsStep)
})
expect(screen.getByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
expect(screen.queryByText(/add details/i)).toBeNull()
})
it('keeps step 2 hidden until step 1 upload is ready', async () => {
installAxiosStubs({ statusValue: 'processing' })
await renderWizard({ initialDraftId: 302 })
expect(screen.queryByText(/artwork details/i)).toBeNull()
await uploadPrimary(new File(['img'], 'x.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(screen.queryByRole('button', { name: /continue to publish/i })).toBeNull()
})
expect(screen.queryByText(/artwork details/i)).toBeNull()
})
it('requires archive screenshot before start upload enables', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 303 })
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
const start = await screen.findByRole('button', { name: /start upload/i })
await waitFor(() => {
expect(start.disabled).toBe(true)
})
await uploadScreenshot(new File(['shot'], 'screen.png', { type: 'image/png' }))
await waitFor(() => {
expect(start.disabled).toBe(false)
})
})
it('allows navigation back to completed previous step', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 304 })
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
await act(async () => {
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
})
expect(await screen.findByText(/upload your artwork file/i)).not.toBeNull()
})
it('triggers scroll-to-top behavior on step change', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 308 })
const scrollSpy = Element.prototype.scrollIntoView
const initialCalls = scrollSpy.mock.calls.length
await completeStep1ToReady()
await waitFor(() => {
expect(scrollSpy.mock.calls.length).toBeGreaterThan(initialCalls)
})
})
it('shows publish only on step 3 and only after ready_to_publish path', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 305, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
await act(async () => {
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'My Art')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /root category/i }), '10')
await userEvent.selectOptions(screen.getByRole('combobox', { name: /subcategory/i }), '11')
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(screen.getByText(/your artwork is live/i)).not.toBeNull()
})
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
})
it('keeps mobile sticky action bar visible class', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })
const bar = screen.getByTestId('wizard-action-bar')
expect((bar.className || '').includes('sticky')).toBe(true)
expect((bar.className || '').includes('bottom-0')).toBe(true)
})
it('locks step 1 file input after upload and unlocks after reset', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 309 })
await completeStep1ToReady()
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
await act(async () => {
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
})
await waitFor(() => {
const dropzoneButton = screen.getByTestId('upload-dropzone')
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
})
expect(screen.getByText(/file is locked after upload\. reset to change\./i)).not.toBeNull()
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))
})
await waitFor(() => {
const unlockedDropzone = screen.getByTestId('upload-dropzone')
expect(unlockedDropzone.getAttribute('aria-disabled')).toBe('false')
})
})
})

View File

@@ -0,0 +1,130 @@
import React, { useEffect, useMemo, useState } from 'react'
function toImageFiles(files) {
return Array.from(files || []).filter((file) => String(file.type || '').startsWith('image/'))
}
export default function ScreenshotUploader({
files = [],
onChange,
min = 1,
max = 5,
required = false,
error = '',
}) {
const [dragging, setDragging] = useState(false)
const previews = useMemo(
() => files.map((file) => ({ file, url: window.URL.createObjectURL(file) })),
[files]
)
useEffect(() => {
return () => {
previews.forEach((preview) => window.URL.revokeObjectURL(preview.url))
}
}, [previews])
const mergeFiles = (incomingFiles) => {
const next = [...files, ...toImageFiles(incomingFiles)].slice(0, max)
if (typeof onChange === 'function') {
onChange(next)
}
}
const replaceFiles = (incomingFiles) => {
const next = toImageFiles(incomingFiles).slice(0, max)
if (typeof onChange === 'function') {
onChange(next)
}
}
const removeAt = (index) => {
const next = files.filter((_, idx) => idx !== index)
if (typeof onChange === 'function') {
onChange(next)
}
}
const move = (from, to) => {
if (to < 0 || to >= files.length) return
const next = [...files]
const [picked] = next.splice(from, 1)
next.splice(to, 0, picked)
if (typeof onChange === 'function') {
onChange(next)
}
}
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<label className="mb-2 block text-sm text-white/80">
Archive screenshots {required ? <span className="text-rose-200">(required)</span> : null}
</label>
<div
onDrop={(event) => {
event.preventDefault()
setDragging(false)
mergeFiles(event.dataTransfer?.files)
}}
onDragOver={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
className={`rounded-xl border-2 border-dashed p-3 text-center text-xs transition ${dragging ? 'border-sky-300 bg-sky-500/10' : 'border-white/20 bg-white/5'}`}
>
<p className="text-white/80">Drag & drop screenshots here</p>
<p className="mt-1 text-white/55">Minimum {min}, maximum {max}</p>
</div>
<input
aria-label="Archive screenshots input"
type="file"
multiple
accept="image/*"
className="mt-3 block w-full text-xs text-white/80"
onChange={(event) => replaceFiles(event.target.files)}
/>
{error ? <p className="mt-2 text-xs text-rose-200">{error}</p> : null}
{previews.length > 0 ? (
<ul className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-3">
{previews.map((preview, index) => (
<li key={`${preview.file.name}-${index}`} className="rounded-lg border border-white/10 bg-black/20 p-2">
<img src={preview.url} alt={`Screenshot ${index + 1}`} className="h-20 w-full rounded object-cover" />
<div className="mt-2 truncate text-[11px] text-white/70">{preview.file.name}</div>
<div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => move(index, index - 1)}
disabled={index === 0}
className="rounded border border-white/20 px-2 py-1 text-[11px] text-white disabled:opacity-40"
>
Up
</button>
<button
type="button"
onClick={() => move(index, index + 1)}
disabled={index === previews.length - 1}
className="rounded border border-white/20 px-2 py-1 text-[11px] text-white disabled:opacity-40"
>
Down
</button>
<button
type="button"
onClick={() => removeAt(index)}
className="rounded border border-rose-300/40 px-2 py-1 text-[11px] text-rose-100"
>
Remove
</button>
</div>
</li>
))}
</ul>
) : null}
</div>
)
}

View File

@@ -0,0 +1,640 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import TagInput from '../tags/TagInput'
import ScreenshotUploader from './ScreenshotUploader'
const STEP_PRELOAD = 1
const STEP_DETAILS = 2
const STEP_PUBLISH = 3
const AUTOSAVE_INTERVAL_MS = 10_000
const STATUS_POLL_INTERVAL_MS = 3_000
const TERMINAL_PROCESSING_STEPS = new Set(['ready', 'rejected', 'published'])
const MIN_ARCHIVE_SCREENSHOTS = 1
const MAX_ARCHIVE_SCREENSHOTS = 5
function processingStepLabel(state) {
switch (state) {
case 'pending_scan':
return 'Pending scan'
case 'scanning':
return 'Scanning'
case 'generating_preview':
return 'Generating preview'
case 'analyzing_tags':
return 'Analyzing tags'
case 'ready':
return 'Ready'
case 'rejected':
return 'Rejected'
case 'published':
return 'Published'
default:
return 'Processing'
}
}
function isArchiveFile(file) {
if (!file) return false
const mime = String(file.type || '').toLowerCase()
if (
[
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/x-tar',
'application/x-gzip',
'application/octet-stream',
].includes(mime)
) {
return true
}
return /\.(zip|rar|7z|tar|gz)$/i.test(file.name || '')
}
function flattenCategories(contentTypes) {
const result = []
for (const type of contentTypes || []) {
const roots = Array.isArray(type.categories) ? type.categories : []
for (const root of roots) {
result.push({
id: root.id,
label: `${type.name} / ${root.name}`,
})
const children = Array.isArray(root.children) ? root.children : []
for (const child of children) {
result.push({
id: child.id,
label: `${type.name} / ${root.name} / ${child.name}`,
})
}
}
}
return result
}
function detailsStorageKey(uploadId) {
return `sb.upload.wizard.details.${uploadId}`
}
function makeClientSlug(title) {
const base = String(title || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return base || 'artwork'
}
export default function UploadWizard({
initialDraftId = null,
contentTypes = [],
suggestedTags = [],
onPublished,
}) {
const [step, setStep] = useState(initialDraftId ? STEP_DETAILS : STEP_PRELOAD)
const [uploadId, setUploadId] = useState(initialDraftId)
const [mainFile, setMainFile] = useState(null)
const [screenshots, setScreenshots] = useState([])
const [dragging, setDragging] = useState(false)
const [progress, setProgress] = useState(0)
const [details, setDetails] = useState({
title: '',
category_id: '',
tags: [],
description: '',
license: '',
nsfw: false,
})
const [autosaveDirty, setAutosaveDirty] = useState(false)
const [lastSavedAt, setLastSavedAt] = useState(null)
const [previewPath, setPreviewPath] = useState(null)
const [finalPath, setFinalPath] = useState(null)
const [loadingPreload, setLoadingPreload] = useState(false)
const [loadingAutosave, setLoadingAutosave] = useState(false)
const [loadingPublish, setLoadingPublish] = useState(false)
const [processingStatus, setProcessingStatus] = useState(null)
const [processingError, setProcessingError] = useState('')
const [screenshotValidationMessage, setScreenshotValidationMessage] = useState('')
const [errorMessage, setErrorMessage] = useState('')
const [successMessage, setSuccessMessage] = useState('')
const autosaveTimerRef = useRef(null)
const statusPollTimerRef = useRef(null)
const lastActionRef = useRef(null)
const categoryOptions = useMemo(() => flattenCategories(contentTypes), [contentTypes])
const archiveMode = useMemo(() => isArchiveFile(mainFile), [mainFile])
const urlPreviewSlug = useMemo(() => makeClientSlug(details.title), [details.title])
const archiveScreenshotsValid = useMemo(() => {
if (!archiveMode) return true
return screenshots.length >= MIN_ARCHIVE_SCREENSHOTS && screenshots.length <= MAX_ARCHIVE_SCREENSHOTS
}, [archiveMode, screenshots.length])
useEffect(() => {
if (!uploadId) return
const raw = window.localStorage.getItem(detailsStorageKey(uploadId))
if (!raw) return
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
setDetails((prev) => ({ ...prev, ...parsed }))
}
} catch {
// ignore invalid local state
}
}, [uploadId])
useEffect(() => {
if (!uploadId) return
window.localStorage.setItem(detailsStorageKey(uploadId), JSON.stringify(details))
setAutosaveDirty(true)
}, [details, uploadId])
useEffect(() => {
if (uploadId) {
window.localStorage.setItem('sb.upload.wizard.lastDraft', uploadId)
}
}, [uploadId])
useEffect(() => {
if (initialDraftId || uploadId) return
const lastDraft = window.localStorage.getItem('sb.upload.wizard.lastDraft')
if (lastDraft) {
setUploadId(lastDraft)
setStep(STEP_DETAILS)
setSuccessMessage('Resumed unfinished draft.')
}
}, [initialDraftId, uploadId])
const handleDrop = useCallback((event) => {
event.preventDefault()
setDragging(false)
setErrorMessage('')
const files = Array.from(event.dataTransfer?.files || [])
if (!files.length) return
const [first, ...rest] = files
setMainFile(first)
if (isArchiveFile(first)) {
setScreenshots(rest.filter((file) => file.type?.startsWith('image/')).slice(0, MAX_ARCHIVE_SCREENSHOTS))
} else {
setScreenshots([])
}
}, [])
const handleMainSelected = useCallback((event) => {
const file = event.target.files?.[0]
if (!file) return
setErrorMessage('')
setMainFile(file)
if (!isArchiveFile(file)) {
setScreenshots([])
setScreenshotValidationMessage('')
}
}, [])
const handleScreenshotsChanged = useCallback((files) => {
const next = Array.from(files || []).slice(0, MAX_ARCHIVE_SCREENSHOTS)
setScreenshots(next)
if (next.length >= MIN_ARCHIVE_SCREENSHOTS) {
setScreenshotValidationMessage('')
}
}, [])
useEffect(() => {
if (!archiveMode) {
setScreenshotValidationMessage('')
return
}
if (screenshots.length === 0) {
setScreenshotValidationMessage(`At least ${MIN_ARCHIVE_SCREENSHOTS} screenshot is required for archives.`)
return
}
if (screenshots.length > MAX_ARCHIVE_SCREENSHOTS) {
setScreenshotValidationMessage(`Maximum ${MAX_ARCHIVE_SCREENSHOTS} screenshots allowed.`)
return
}
setScreenshotValidationMessage('')
}, [archiveMode, screenshots.length])
const fetchProcessingStatus = useCallback(async () => {
if (!uploadId) return
try {
const response = await window.axios.get(`/api/uploads/${uploadId}/status`)
const payload = response.data || null
setProcessingStatus(payload)
setProcessingError('')
} catch (error) {
const message = error?.response?.data?.message || 'Status polling failed.'
setProcessingError(message)
}
}, [uploadId])
const runPreload = useCallback(async () => {
if (!mainFile) {
setErrorMessage('Please select a main file first.')
return
}
if (archiveMode && !archiveScreenshotsValid) {
setScreenshotValidationMessage(`At least ${MIN_ARCHIVE_SCREENSHOTS} screenshot is required for archives.`)
return
}
setLoadingPreload(true)
setErrorMessage('')
setSuccessMessage('')
lastActionRef.current = 'preload'
try {
const formData = new FormData()
formData.append('main', mainFile)
if (archiveMode) {
screenshots.slice(0, MAX_ARCHIVE_SCREENSHOTS).forEach((file) => formData.append('screenshots[]', file))
}
const response = await window.axios.post('/api/uploads/preload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => {
const total = Number(event.total || 0)
const loaded = Number(event.loaded || 0)
if (total > 0) {
setProgress(Math.round((loaded / total) * 100))
}
},
})
const payload = response.data || {}
setUploadId(payload.upload_id || null)
setPreviewPath(payload.preview_path || null)
setProcessingStatus(null)
setProcessingError('')
setStep(STEP_DETAILS)
setSuccessMessage('Draft created. Fill in details next.')
} catch (error) {
const message =
error?.response?.data?.message ||
error?.response?.data?.errors?.main?.[0] ||
'Preload failed. Try again.'
setErrorMessage(message)
} finally {
setLoadingPreload(false)
}
}, [archiveMode, archiveScreenshotsValid, mainFile, screenshots])
const runAutosave = useCallback(async () => {
if (!uploadId || !autosaveDirty) return
setLoadingAutosave(true)
setErrorMessage('')
lastActionRef.current = 'autosave'
try {
await window.axios.post(`/api/uploads/${uploadId}/autosave`, {
title: details.title || null,
category_id: details.category_id || null,
tags: details.tags || [],
description: details.description || null,
license: details.license || null,
nsfw: Boolean(details.nsfw),
})
setAutosaveDirty(false)
setLastSavedAt(new Date())
} catch (error) {
const message = error?.response?.data?.message || 'Autosave failed. Your local draft is preserved.'
setErrorMessage(message)
} finally {
setLoadingAutosave(false)
}
}, [autosaveDirty, details, uploadId])
useEffect(() => {
if (step !== STEP_DETAILS || !uploadId) return
autosaveTimerRef.current = window.setInterval(() => {
runAutosave()
}, AUTOSAVE_INTERVAL_MS)
return () => {
if (autosaveTimerRef.current) {
window.clearInterval(autosaveTimerRef.current)
autosaveTimerRef.current = null
}
}
}, [runAutosave, step, uploadId])
useEffect(() => {
if (!uploadId || step < STEP_DETAILS) return
if (processingStatus?.processing_state && TERMINAL_PROCESSING_STEPS.has(processingStatus.processing_state)) {
return
}
fetchProcessingStatus()
statusPollTimerRef.current = window.setInterval(() => {
fetchProcessingStatus()
}, STATUS_POLL_INTERVAL_MS)
return () => {
if (statusPollTimerRef.current) {
window.clearInterval(statusPollTimerRef.current)
statusPollTimerRef.current = null
}
}
}, [fetchProcessingStatus, processingStatus?.processing_state, step, uploadId])
const runPublish = useCallback(async () => {
if (!uploadId) return
setLoadingPublish(true)
setErrorMessage('')
setSuccessMessage('')
lastActionRef.current = 'publish'
try {
if (autosaveDirty) {
await runAutosave()
}
const response = await window.axios.post(`/api/uploads/${uploadId}/publish`)
const payload = response.data || {}
setFinalPath(payload.final_path || null)
setProcessingStatus((prev) => ({
...(prev || {}),
id: uploadId,
status: payload.status || 'published',
is_scanned: true,
preview_ready: true,
has_tags: true,
processing_state: 'published',
}))
setStep(STEP_PUBLISH)
setSuccessMessage('Upload published successfully.')
if (typeof onPublished === 'function') {
onPublished(payload)
}
} catch (error) {
const message = error?.response?.data?.message || 'Publish failed. Resolve issues and retry.'
setErrorMessage(message)
} finally {
setLoadingPublish(false)
}
}, [autosaveDirty, onPublished, runAutosave, uploadId])
const retryLastAction = useCallback(() => {
if (lastActionRef.current === 'preload') return runPreload()
if (lastActionRef.current === 'autosave') return runAutosave()
if (lastActionRef.current === 'publish') return runPublish()
return undefined
}, [runAutosave, runPreload, runPublish])
return (
<div className="mx-auto w-full max-w-4xl rounded-2xl border border-white/10 bg-slate-900/60 p-4 md:p-6">
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs md:text-sm">
<span className={`rounded-full px-3 py-1 ${step >= STEP_PRELOAD ? 'bg-emerald-500/30 text-emerald-100' : 'bg-white/10 text-white/60'}`}>1. Preload</span>
<span className={`rounded-full px-3 py-1 ${step >= STEP_DETAILS ? 'bg-emerald-500/30 text-emerald-100' : 'bg-white/10 text-white/60'}`}>2. Details</span>
<span className={`rounded-full px-3 py-1 ${step >= STEP_PUBLISH ? 'bg-emerald-500/30 text-emerald-100' : 'bg-white/10 text-white/60'}`}>3. Publish</span>
</div>
{errorMessage && (
<div className="mb-4 rounded-xl border border-rose-400/30 bg-rose-500/10 p-3 text-sm text-rose-100">
<div>{errorMessage}</div>
<button type="button" onClick={retryLastAction} className="mt-2 rounded-lg border border-rose-300/40 px-2 py-1 text-xs hover:bg-rose-500/20">
Retry last action
</button>
</div>
)}
{successMessage && (
<div className="mb-4 rounded-xl border border-emerald-400/30 bg-emerald-500/10 p-3 text-sm text-emerald-100">
{successMessage}
</div>
)}
{processingStatus && (
<div className="mb-4 rounded-xl border border-sky-400/30 bg-sky-500/10 p-3 text-sm text-sky-100">
<div className="font-medium">Processing status: {processingStepLabel(processingStatus.processing_state)}</div>
<div className="mt-1 text-xs text-sky-100/80">
status={processingStatus.status}; scanned={processingStatus.is_scanned ? 'yes' : 'no'}; preview={processingStatus.preview_ready ? 'ready' : 'pending'}; tags={processingStatus.has_tags ? 'ready' : 'pending'}
</div>
</div>
)}
{processingError && (
<div className="mb-4 rounded-xl border border-amber-400/30 bg-amber-500/10 p-3 text-xs text-amber-100">
{processingError}
</div>
)}
{step === STEP_PRELOAD && (
<div className="space-y-4">
<div
onDrop={handleDrop}
onDragOver={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
className={`rounded-2xl border-2 border-dashed p-6 text-center transition ${dragging ? 'border-sky-300 bg-sky-500/10' : 'border-white/20 bg-white/5'}`}
>
<p className="text-sm text-white/80">Drag & drop main file here</p>
<p className="mt-1 text-xs text-white/55">or choose from your device</p>
<input aria-label="Main upload file" type="file" className="mt-4 block w-full text-xs text-white/80" onChange={handleMainSelected} />
{mainFile && <p className="mt-2 text-xs text-emerald-200">Main: {mainFile.name}</p>}
</div>
{archiveMode && (
<ScreenshotUploader
files={screenshots}
onChange={handleScreenshotsChanged}
min={MIN_ARCHIVE_SCREENSHOTS}
max={MAX_ARCHIVE_SCREENSHOTS}
required
error={screenshotValidationMessage}
/>
)}
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<div className="mb-2 text-xs text-white/60">Upload progress</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full bg-sky-400 transition-all" style={{ width: `${progress}%` }} />
</div>
<div className="mt-2 text-xs text-white/60">{progress}%</div>
</div>
<button
type="button"
onClick={runPreload}
disabled={loadingPreload || !mainFile || (archiveMode && !archiveScreenshotsValid)}
className="w-full rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black disabled:cursor-not-allowed disabled:opacity-60"
>
{loadingPreload ? 'Preloading…' : 'Start preload'}
</button>
</div>
)}
{step >= STEP_DETAILS && (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm text-white/80">Title</label>
<input
type="text"
value={details.title}
onChange={(event) => setDetails((prev) => ({ ...prev, title: event.target.value }))}
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
placeholder="Name your artwork"
/>
</div>
<div>
<label className="mb-2 block text-sm text-white/80">Category</label>
<select
value={details.category_id}
onChange={(event) => setDetails((prev) => ({ ...prev, category_id: event.target.value }))}
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
>
<option value="">Select category</option>
{categoryOptions.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</div>
</div>
<div>
<label className="mb-2 block text-sm text-white/80">Tags</label>
<TagInput
value={details.tags}
onChange={(value) => setDetails((prev) => ({ ...prev, tags: value }))}
suggestedTags={suggestedTags}
/>
</div>
<div>
<label className="mb-2 block text-sm text-white/80">Description</label>
<textarea
rows={4}
value={details.description}
onChange={(event) => setDetails((prev) => ({ ...prev, description: event.target.value }))}
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
placeholder="Tell the story behind this upload"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm text-white/80">License</label>
<input
type="text"
value={details.license}
onChange={(event) => setDetails((prev) => ({ ...prev, license: event.target.value }))}
className="w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-sm text-white"
placeholder="e.g. cc-by"
/>
</div>
<label className="mt-7 inline-flex items-center gap-2 text-sm text-white/80">
<input
type="checkbox"
checked={details.nsfw}
onChange={(event) => setDetails((prev) => ({ ...prev, nsfw: event.target.checked }))}
/>
NSFW
</label>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-white/60">
{loadingAutosave ? 'Autosaving…' : lastSavedAt ? `Last saved: ${lastSavedAt.toLocaleTimeString()}` : 'Autosave every 10s'}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={runAutosave}
disabled={loadingAutosave || !uploadId}
className="rounded-xl border border-white/20 px-3 py-2 text-sm text-white disabled:opacity-60"
>
Save now
</button>
<button
type="button"
onClick={() => setStep(STEP_PUBLISH)}
disabled={!uploadId}
className="rounded-xl bg-sky-500 px-3 py-2 text-sm font-semibold text-black disabled:opacity-60"
>
Continue to publish
</button>
</div>
</div>
</div>
)}
{step >= STEP_PUBLISH && (
<div className="mt-6 space-y-4 rounded-2xl border border-white/10 bg-white/5 p-4">
<h3 className="text-base font-semibold text-white">Publish</h3>
<div className="grid grid-cols-1 gap-3 text-sm text-white/80 md:grid-cols-2">
<div><span className="text-white/50">Upload ID:</span> {uploadId || '—'}</div>
<div><span className="text-white/50">Title:</span> {details.title || '—'}</div>
<div className="md:col-span-2"><span className="text-white/50">URL preview:</span> /artwork/{urlPreviewSlug}</div>
<div><span className="text-white/50">Category:</span> {details.category_id || '—'}</div>
<div><span className="text-white/50">Tags:</span> {(details.tags || []).join(', ') || '—'}</div>
<div className="md:col-span-2"><span className="text-white/50">Preview path:</span> {previewPath || 'Will be resolved by backend pipeline'}</div>
{finalPath && <div className="md:col-span-2"><span className="text-white/50">Final path:</span> {finalPath}</div>}
</div>
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-3 text-xs text-amber-100">
Final validation and file move happen on backend. This step only calls the publish endpoint.
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={() => setStep(STEP_DETAILS)}
className="rounded-xl border border-white/20 px-3 py-2 text-sm text-white"
>
Back to details
</button>
<button
type="button"
onClick={runPublish}
disabled={loadingPublish || !uploadId}
className="rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black disabled:opacity-60"
>
{loadingPublish ? 'Publishing…' : 'Publish now'}
</button>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,113 @@
import React from 'react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UploadWizard from './UploadWizard'
describe('UploadWizard Step 1 archive screenshot UX', () => {
beforeEach(() => {
window.localStorage.clear()
window.URL.createObjectURL = vi.fn(() => 'blob:preview')
window.URL.revokeObjectURL = vi.fn()
window.axios = {
post: vi.fn(async (url) => {
if (url === '/api/uploads/preload') {
return {
data: {
upload_id: 'draft-1',
preview_path: 'tmp/drafts/draft-1/preview.webp',
},
}
}
return { data: {} }
}),
get: vi.fn(async () => ({
data: {
processing_state: 'ready',
status: 'draft',
is_scanned: true,
preview_ready: true,
has_tags: true,
},
})),
}
})
afterEach(() => {
vi.restoreAllMocks()
})
it('blocks archive preload without screenshot', async () => {
render(<UploadWizard contentTypes={[]} suggestedTags={[]} />)
const mainInput = screen.getByLabelText('Main upload file')
const archiveFile = new File(['archive'], 'pack.zip', { type: 'application/zip' })
await userEvent.upload(mainInput, archiveFile)
await waitFor(() => {
expect(screen.getByText(/Archive screenshots/i)).not.toBeNull()
})
expect(screen.getByText('At least 1 screenshot is required for archives.')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Start preload' }).hasAttribute('disabled')).toBe(true)
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/preload', expect.anything(), expect.anything())
})
it('allows archive preload when screenshot exists and sends screenshots[]', async () => {
render(<UploadWizard contentTypes={[]} suggestedTags={[]} />)
const mainInput = screen.getByLabelText('Main upload file')
const archiveFile = new File(['archive'], 'pack.zip', { type: 'application/zip' })
await userEvent.upload(mainInput, archiveFile)
const screenshotInput = await screen.findByLabelText('Archive screenshots input')
const screenshotFile = new File(['image'], 'shot-1.png', { type: 'image/png' })
await userEvent.upload(screenshotInput, screenshotFile)
const preloadButton = screen.getByRole('button', { name: 'Start preload' })
await waitFor(() => {
expect(preloadButton.hasAttribute('disabled')).toBe(false)
})
await userEvent.click(preloadButton)
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/preload',
expect.any(FormData),
expect.any(Object)
)
})
const preloadCall = window.axios.post.mock.calls.find(([url]) => url === '/api/uploads/preload')
const sentFormData = preloadCall?.[1]
expect(sentFormData).toBeInstanceOf(FormData)
expect(sentFormData.getAll('screenshots[]').length).toBe(1)
})
it('bypasses screenshot uploader for image upload', async () => {
render(<UploadWizard contentTypes={[]} suggestedTags={[]} />)
const mainInput = screen.getByLabelText('Main upload file')
const imageFile = new File(['image'], 'photo.jpg', { type: 'image/jpeg' })
await userEvent.upload(mainInput, imageFile)
expect(screen.queryByText('Archive screenshots (required)')).toBeNull()
const preloadButton = screen.getByRole('button', { name: 'Start preload' })
expect(preloadButton.hasAttribute('disabled')).toBe(false)
await userEvent.click(preloadButton)
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/preload',
expect.any(FormData),
expect.any(Object)
)
})
})
})

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,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,23 @@
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`
}

View File

@@ -0,0 +1 @@
globalThis.IS_REACT_ACT_ENVIRONMENT = true

17
resources/js/upload.jsx Normal file
View File

@@ -0,0 +1,17 @@
import './bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import UploadPage from './Pages/Upload/Index'
const pages = {
'Upload/Index': UploadPage,
}
createInertiaApp({
resolve: (name) => pages[name],
setup({ el, App, props }) {
const root = createRoot(el)
root.render(<App {...props} />)
},
})

View File

@@ -0,0 +1,266 @@
@extends('layouts.nova')
@php
use App\Banner;
use App\Models\Category;
use Illuminate\Pagination\LengthAwarePaginator;
// Determine a sensible category/context for this artwork so the
// legacy layout (sidebar + hero) can be rendered similarly to
// category listing pages.
$category = $artwork->categories->first() ?? null;
$contentType = $category ? $category->contentType : null;
if ($contentType) {
$rootCategories = Category::where('content_type_id', $contentType->id)
->whereNull('parent_id')
->orderBy('sort_order')
->get();
} else {
$rootCategories = collect();
}
$subcategories = $category ? $category->children()->orderBy('sort_order')->get() : collect();
// Provide an empty paginator to satisfy any shared pagination partials
$artworks = new LengthAwarePaginator([], 0, 24, 1, ['path' => request()->url()]);
@endphp
@section('content')
<div class="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
<div class="pt-0">
<div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]">
<!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm">
<div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</span>
<span class="text-sm text-white/90">Menu</span>
</button>
<div class="mt-6 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
@foreach($rootCategories as $root)
<li>
<a class="flex items-center gap-2 hover:text-white" href="{{ $root->url }}"><span class="opacity-70">📁</span> {{ $root->name }}</a>
</li>
@endforeach
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
@foreach($subcategories as $sub)
<li><a class="hover:text-white {{ $category && $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li>
@endforeach
</ul>
</div>
</div>
</aside>
<!-- MAIN -->
<main class="flex-1">
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 opacity-35"></div>
<div class="relative px-6 py-8 md:px-10 md:py-10">
<div class="text-sm text-neutral-400">
@if($contentType)
<a class="hover:text-white" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
@endif
@if($category)
@foreach ($category->breadcrumbs as $crumb)
<span class="opacity-50"></span> <a class="hover:text-white" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@endforeach
@endif
</div>
@php
$breadcrumbs = $category ? (is_array($category->breadcrumbs) ? $category->breadcrumbs : [$category]) : [];
$headerCategory = !empty($breadcrumbs) ? end($breadcrumbs) : ($category ?? null);
@endphp
<h1 class="mt-2 text-3xl md:text-4xl font-semibold tracking-tight text-white/95">{{ $headerCategory->name ?? $artwork->title }}</h1>
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
<div class="p-5 md:p-6">
<div class="text-lg font-semibold text-white/90">{{ $artwork->title }}</div>
<p class="mt-2 text-sm leading-6 text-neutral-400">{!! $artwork->description ?? ($headerCategory->description ?? ($contentType->name ?? 'Artwork')) !!}</p>
</div>
</section>
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
</div>
<!-- Artwork detail -->
<section class="px-6 pb-10 md:px-10">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="col-span-2">
<div class="rounded-2xl overflow-hidden bg-black/20 border border-white/10 shadow-lg">
<img src="{{ $artwork->thumbnail_url ?? '/images/placeholder.jpg' }}" alt="{{ $artwork->title }}" class="w-full h-auto object-contain" />
</div>
</div>
<aside class="p-4 bg-white/3 rounded-2xl border border-white/6">
<h3 class="font-semibold text-white">{{ $artwork->title }}</h3>
<p class="text-sm text-neutral-400 mt-2">{!! $artwork->description ?? 'No description provided.' !!}</p>
<div class="mt-4">
<a href="{{ $artwork->file_path ?? '#' }}" class="inline-block px-4 py-2 bg-indigo-600 text-white rounded">Download</a>
</div>
</aside>
</div>
@if(isset($similarItems) && $similarItems->isNotEmpty())
<section class="mt-8" data-similar-analytics data-algo-version="{{ $similarAlgoVersion ?? '' }}" data-artwork-id="{{ $artwork->id }}">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg md:text-xl font-semibold text-white/95">Similar artworks</h2>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
@foreach($similarItems as $item)
<article class="rounded-2xl overflow-hidden border border-white/10 bg-black/20 shadow-lg">
<a
href="{{ $item['url'] }}"
class="group block"
data-similar-click
data-similar-id="{{ $item['id'] }}"
data-similar-title="{{ e($item['title']) }}"
>
<div class="aspect-[16/10] bg-neutral-900">
<img
src="{{ $item['thumb'] }}"
@if(!empty($item['thumb_srcset'])) srcset="{{ $item['thumb_srcset'] }}" @endif
loading="lazy"
decoding="async"
alt="{{ $item['title'] }}"
class="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
</div>
<div class="p-3">
<div class="truncate text-sm font-medium text-white/90">{{ $item['title'] }}</div>
@if(!empty($item['author']))
<div class="mt-1 truncate text-xs text-neutral-400">by {{ $item['author'] }}</div>
@endif
</div>
</a>
</article>
@endforeach
</div>
</section>
@endif
</section>
</main>
</div>
</div>
</div>
</div> <!-- end .legacy-page -->
@php
$jsonLdType = str_starts_with((string) ($artwork->mime_type ?? ''), 'image/') ? 'ImageObject' : 'CreativeWork';
$keywords = $artwork->tags()->pluck('name')->values()->all();
$jsonLd = [
'@context' => 'https://schema.org',
'@type' => $jsonLdType,
'name' => (string) $artwork->title,
'description' => trim(strip_tags((string) ($artwork->description ?? ''))),
'author' => [
'@type' => 'Person',
'name' => (string) optional($artwork->user)->name,
],
'datePublished' => optional($artwork->published_at)->toAtomString(),
'url' => request()->url(),
'image' => (string) ($artwork->thumbnail_url ?? ''),
'keywords' => $keywords,
];
@endphp
<script type="application/ld+json">{!! json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
@if(isset($similarItems) && $similarItems->isNotEmpty())
<script>
(function () {
var section = document.querySelector('[data-similar-analytics]');
if (!section) return;
var algoVersion = section.getAttribute('data-algo-version') || '';
var sourceArtworkId = section.getAttribute('data-artwork-id') || '';
var anchors = section.querySelectorAll('[data-similar-click]');
var impressionPayload = {
event: 'similar_artworks_impression',
source_artwork_id: sourceArtworkId,
algo_version: algoVersion,
item_ids: Array.prototype.map.call(anchors, function (anchor) {
return anchor.getAttribute('data-similar-id');
})
};
window.dataLayer = window.dataLayer || [];
function sendAnalytics(payload) {
var endpoint = '/api/analytics/similar-artworks';
var body = JSON.stringify(payload);
if (navigator.sendBeacon) {
var blob = new Blob([body], { type: 'application/json' });
navigator.sendBeacon(endpoint, blob);
return;
}
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body,
keepalive: true
}).catch(function () {
// ignore analytics transport errors
});
}
window.dataLayer.push(impressionPayload);
anchors.forEach(function (anchor, index) {
sendAnalytics({
event_type: 'impression',
source_artwork_id: Number(sourceArtworkId),
similar_artwork_id: Number(anchor.getAttribute('data-similar-id')),
algo_version: algoVersion,
position: index + 1,
items_count: anchors.length
});
});
anchors.forEach(function (anchor, index) {
anchor.addEventListener('click', function () {
window.dataLayer.push({
event: 'similar_artworks_click',
source_artwork_id: sourceArtworkId,
algo_version: algoVersion,
similar_artwork_id: anchor.getAttribute('data-similar-id'),
similar_artwork_title: anchor.getAttribute('data-similar-title') || '',
position: index + 1
});
sendAnalytics({
event_type: 'click',
source_artwork_id: Number(sourceArtworkId),
similar_artwork_id: Number(anchor.getAttribute('data-similar-id')),
algo_version: algoVersion,
position: index + 1
});
});
});
})();
</script>
@endif
@endsection
@push('styles')
<style>
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
}
</style>
@endpush

View File

@@ -0,0 +1,12 @@
@php
// Usage: <x-avatar :user="$user" size="128" />
$size = $size ?? 128;
$profile = $user->profile ?? null;
$hash = $profile->avatar_hash ?? null;
$src = $hash
? asset("storage/avatars/{$user->id}/{$size}.webp?v={$hash}")
: asset('img/default-avatar.webp');
$alt = $alt ?? ($user->username ?? 'avatar');
$class = $class ?? 'rounded-full';
@endphp
<img src="{{ $src }}" alt="{{ $alt }}" loading="lazy" decoding="async" class="{{ $class }}" width="{{ $size }}" height="{{ $size }}">

View File

@@ -29,7 +29,11 @@
<!-- Page Content -->
<main>
{{ $slot }}
@if(isset($slot))
{{ $slot }}
@else
@yield('content')
@endif
</main>
</div>
</body>

View File

@@ -25,5 +25,32 @@
</main>
@include('layouts.nova.footer')
{{-- Toast notifications (Alpine) --}}
@php
$toastMessage = session('status') ?? session('error') ?? null;
$toastType = session('error') ? 'error' : 'success';
$toastBorder = session('error') ? 'border-red-500' : 'border-green-500';
@endphp
@if($toastMessage)
<div x-data="{show:true}" x-show="show" x-init="setTimeout(()=>show=false,4000)" x-cloak
class="fixed right-4 bottom-6 z-50">
<div class="max-w-sm w-full rounded-lg shadow-lg overflow-hidden bg-nebula-600 border {{ $toastBorder }}">
<div class="px-4 py-3 flex items-start gap-3">
<div class="flex-shrink-0">
@if(session('error'))
<svg class="w-6 h-6 text-red-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 12H6"/></svg>
@else
<svg class="w-6 h-6 text-green-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
@endif
</div>
<div class="flex-1 text-sm text-white/95">{!! nl2br(e($toastMessage)) !!}</div>
<button @click="show=false" class="text-white/60 hover:text-white">
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 6l8 8M6 14L14 6"/></svg>
</button>
</div>
</div>
</div>
@endif
</body>
</html>

View File

@@ -11,31 +11,52 @@
@endphp
<article class="artwork" itemscope itemtype="http://schema.org/Photograph">
<a href="{{ $art->url ?? '#' }}" itemprop="url">
<div class="ribbon gid_{{ $art->gid_num }}" title="{{ $art->category_name ?? '' }}" itemprop="genre">
<span>{{ $art->category_name ?? '' }}</span>
</div>
<a href="{{ $art->url ?? '#' }}" itemprop="url" class="group relative rounded-2xl overflow-hidden bg-black/10 border border-white/6 shadow-sm hover:shadow-lg transition-shadow">
<img
src="{{ $img_src }}"
srcset="{{ $img_srcset }}"
loading="lazy"
decoding="async"
alt="{{ e($art->name) }}"
width="{{ $img_width }}"
height="{{ $img_height }}"
class="img-responsive"
itemprop="thumbnailUrl"
>
{{-- Category badge --}}
@if(!empty($art->category_name))
<div class="absolute top-3 left-3 z-30 bg-gradient-to-br from-black/65 to-black/40 text-xs text-white px-2 py-1 rounded-md backdrop-blur-sm">{{ $art->category_name }}</div>
@endif
<div class="details">
<div class="info" itemprop="author">
<span class="fa fa-user"></span> {{ $art->uname ?? '' }}
{{-- Image container with subtle overlay; details hidden until hover --}}
<div class="w-full h-48 md:h-56 lg:h-64 bg-neutral-900 relative overflow-hidden">
<img
src="{{ $img_src }}"
srcset="{{ $img_srcset }}"
loading="lazy"
decoding="async"
alt="{{ e($art->name) }}"
width="{{ $img_width }}"
height="{{ $img_height }}"
class="w-full h-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
itemprop="thumbnailUrl"
/>
{{-- Hover overlay: hidden by default, slides up on hover --}}
<div class="absolute inset-0 flex items-end pointer-events-none opacity-0 translate-y-3 group-hover:opacity-100 group-hover:translate-y-0 group-hover:pointer-events-auto transition-all duration-300">
<div class="w-full bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
@if(!empty($art->author_avatar))
<img src="{{ $art->author_avatar }}" alt="{{ $art->uname }}" class="w-8 h-8 rounded-full border border-white/8 object-cover">
@else
<span class="w-8 h-8 rounded-full bg-neutral-800 border border-white/6 flex items-center justify-center text-xs text-neutral-400">{{ strtoupper(substr($art->uname ?? '-',0,1)) }}</span>
@endif
<div class="text-sm">
<div class="text-white font-semibold leading-tight truncate">{{ $art->uname ?? '—' }}</div>
<div class="text-neutral-300 text-xs truncate">{{ optional(data_get($art, 'created_at'))->format('M j, Y') ?? '' }}</div>
</div>
</div>
<div class="flex items-center gap-3 text-xs text-neutral-300">
<div class="flex items-center gap-1"><i class="fa fa-eye"></i><span class="ml-1">{{ $art->views ?? 0 }}</span></div>
<div class="flex items-center gap-1"><i class="fa fa-heart"></i><span class="ml-1">{{ $art->likes ?? 0 }}</span></div>
</div>
</div>
</div>
</div>
<div class="title" itemprop="name">
{{ $art->name }}
</div>
</div>
{{-- Visually-hidden title for accessibility/SEO (details only shown on hover) --}}
<span class="sr-only">{{ $art->name ?? 'Artwork' }}</span>
</a>
</article>

View File

@@ -1,4 +1,4 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@section('content')
<div class="legacy-artwork">

View File

@@ -1,4 +1,4 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@php
use Illuminate\Support\Str;

View File

@@ -1,4 +1,4 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@php
use App\Banner;
@@ -8,72 +8,103 @@
<div class="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
<div class="category-wrapper">
<div class="row">
<main id="category-artworks" class="col-md-9">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<div class="category-display"><i class="fa fa-bars fa-fw"></i></div>
<div class="pt-0">
<div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]">
<div id="location_bar">
<a href="/{{ $contentType->slug }}" title="{{ $contentType->name }}">{{ $contentType->name }}</a>
@foreach ($category->breadcrumbs as $crumb)
&raquo; <a href="{{ $crumb->url }}" title="{{ $crumb->name }}">{{ $crumb->name }}</a>
@endforeach
:
</div>
<!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm">
<div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</span>
<span class="text-sm text-white/90">Menu</span>
</button>
<h1 class="page-header">{{ $category->name }}</h1>
<p style="clear:both">{!! $category->description ?? ($contentType->name . ' artworks on Skinbase.') !!}</p>
</header>
</div> <!-- end .effect2 -->
<div class="mt-6 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
@foreach($rootCategories as $root)
<li>
<a class="flex items-center gap-2 hover:text-white" href="{{ $root->url }}"><span class="opacity-70">📁</span> {{ $root->name }}</a>
</li>
@endforeach
</ul>
@if ($artworks->count())
<section id="gallery" class="gallery" aria-live="polite">
@foreach ($artworks as $art)
@include('legacy._artwork_card', ['art' => $art])
@endforeach
</section>
@else
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>No Artworks Yet</strong></div>
<div class="panel-body">
<p>Once uploads arrive they will appear here. Check back soon.</p>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
@foreach($subcategories as $sub)
<li><a class="hover:text-white {{ $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li>
@endforeach
</ul>
</div>
</div>
@endif
</aside>
<div class="paginationMenu text-center">
{{ $artworks->withQueryString()->links('pagination::bootstrap-4') }}
</div> <!-- end .paginationMenu -->
</main> <!-- end #category-artworks -->
<!-- MAIN -->
<main class="flex-1">
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 opacity-35"></div>
<aside id="category-list" class="col-md-3">
<div id="artwork_subcategories">
<div class="category-toggle"><i class="fa fa-bars fa-fw"></i></div>
<div class="relative px-6 py-8 md:px-10 md:py-10">
<div class="text-sm text-neutral-400">
<a class="hover:text-white" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
@foreach ($category->breadcrumbs as $crumb)
<span class="opacity-50"></span> <a class="hover:text-white" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@endforeach
</div>
<h5 class="browse_categories">Main Categories:</h5>
<ul>
@foreach ($rootCategories as $root)
<li>
<a href="{{ $root->url }}" title="{{ $root->name }}"><i class="fa fa-photo fa-fw"></i> {{ $root->name }}</a>
</li>
@endforeach
</ul>
@php
// Use the current (last) breadcrumb as the header so
// the page shows the leaf category name (e.g. "Winamp").
$breadcrumbs = is_array($category->breadcrumbs) ? $category->breadcrumbs : [$category];
$headerCategory = end($breadcrumbs) ?: $category;
@endphp
<h5 class="browse_categories">Browse Subcategories:</h5>
<ul class="scrollContent" data-mcs-theme="dark">
@foreach ($subcategories as $sub)
@php $selected = $sub->id === $category->id ? 'selected_group' : ''; @endphp
<li class="subgroup {{ $selected }}">
<a href="{{ $sub->url }}">{{ $sub->name }}</a>
</li>
@endforeach
</ul>
</div> <!-- end #artwork_subcategories -->
</aside> <!-- end #category-list -->
</div> <!-- end .row -->
</div> <!-- end .category-wrapper -->
<h1 class="mt-2 text-3xl md:text-4xl font-semibold tracking-tight text-white/95">{{ $headerCategory->name }}</h1>
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
<div class="p-5 md:p-6">
<div class="text-lg font-semibold text-white/90">{{ $headerCategory->name }}</div>
<p class="mt-2 text-sm leading-6 text-neutral-400">{!! $headerCategory->description ?? ($contentType->name . ' artworks on Skinbase.') !!}</p>
</div>
</section>
{{-- soft fade at bottom of hero to blend into main background --}}
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
</div>
<!-- Grid -->
<section class="px-6 pb-10 md:px-10">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@forelse ($artworks as $art)
<a href="{{ $art->url }}" class="group relative rounded-2xl overflow-hidden bg-black/20 border border-white/10 shadow-lg">
<div class="aspect-[16/10] bg-neutral-900">
<img src="{{ $art->thumbnail_url ?? '/images/placeholder.jpg' }}" alt="{{ $art->title ?? 'Artwork' }}" loading="lazy" class="w-full h-full object-cover" />
</div>
<div class="p-3 text-xs text-neutral-400 group-hover:text-white/80">{{ $art->title ?? 'Artwork' }}</div>
</a>
@empty
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>No Artworks Yet</strong></div>
<div class="panel-body">
<p>Once uploads arrive they will appear here. Check back soon.</p>
</div>
</div>
@endforelse
</div>
<div class="flex justify-center mt-10">
@if ($artworks instanceof \Illuminate\Contracts\Pagination\Paginator)
{{ $artworks->links() }}
@endif
</div>
</section>
</main>
</div>
</div>
</div>
</div> <!-- end .legacy-page -->
@endsection
@@ -106,5 +137,9 @@
columns: 1 100%;
}
}
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
/* tweak the colors above if the page background differs */
}
</style>
@endpush

View File

@@ -1,4 +1,4 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@section('content')
<div class="container-fluid legacy-page">

View File

@@ -1,55 +1,133 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@php
use App\Banner;
@endphp
@section('content')
<div class="container-fluid legacy-page category-wrapper">
@php
if (class_exists('\App\Banner')) { \App\Banner::ShowResponsiveAd(); }
@endphp
<div class="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
<div id="category-artworks">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<div class="category-display"><i class="fa fa-bars fa-fw"></i></div>
<div class="pt-0">
<div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]">
<h1 class="page-header">{{ $contentType->name }}</h1>
<p style="clear:both">{!! $page_meta_description ?? ($contentType->name . ' artworks on Skinbase.') !!}</p>
</header>
</div>
<!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm">
<div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</span>
<span class="text-sm text-white/90">Menu</span>
</button>
<div class="panel panel-default effect2">
<div class="panel-body">
<h4>Main Categories</h4>
<ul class="subcategory-list">
@foreach ($rootCategories as $cat)
<li>
<a href="{{ $cat->url }}">{{ $cat->name }}</a>
</li>
@endforeach
</ul>
<div class="mt-6 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
@foreach($rootCategories as $root)
<li>
<a class="flex items-center gap-2 hover:text-white" href="{{ $root->url }}"><span class="opacity-70">📁</span> {{ $root->name }}</a>
</li>
@endforeach
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
@if(isset($subcategories) && $subcategories && count($subcategories))
@foreach($subcategories as $sub)
@php
// support legacy objects (category_id/category_name/slug) and Category models
$slug = $sub->slug ?? ($sub->slug ?? null);
$name = $sub->category_name ?? $sub->name ?? null;
$url = null;
if (!empty($slug) && isset($contentType)) {
$url = '/' . $contentType->slug . '/' . $slug;
} elseif (!empty($sub->url)) {
$url = $sub->url;
}
@endphp
<li>
@if($url)
<a class="hover:text-white text-neutral-400" href="{{ $url }}">{{ $name }}</a>
@else
<span class="text-neutral-400">{{ $name }}</span>
@endif
</li>
@endforeach
@else
@foreach(\App\Models\ContentType::orderBy('id')->get() as $ct)
<li><a class="hover:text-white text-neutral-400" href="/{{ $ct->slug }}">{{ $ct->name }}</a></li>
@endforeach
@endif
</ul>
</div>
</div>
</aside>
<!-- MAIN -->
<main class="flex-1">
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 opacity-35"></div>
<div class="relative px-6 py-8 md:px-10 md:py-10">
<div class="text-sm text-neutral-400">
<a class="hover:text-white" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
</div>
<h1 class="mt-2 text-3xl md:text-4xl font-semibold tracking-tight text-white/95">{{ $contentType->name }}</h1>
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
<div class="p-5 md:p-6">
<div class="text-lg font-semibold text-white/90">{{ $contentType->name }}</div>
<p class="mt-2 text-sm leading-6 text-neutral-400">{!! $page_meta_description ?? ($contentType->name . ' artworks on Skinbase.') !!}</p>
</div>
</section>
{{-- soft fade at bottom of hero to blend into main background --}}
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
</div>
<!-- Artworks gallery (same layout as subcategory pages) -->
<section class="px-6 pb-10 md:px-10">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@forelse ($artworks as $art)
<a href="{{ $art->url }}" class="group relative rounded-2xl overflow-hidden bg-black/20 border border-white/10 shadow-lg">
<div class="aspect-[16/10] bg-neutral-900">
<img src="{{ $art->thumbnail_url ?? '/images/placeholder.jpg' }}" alt="{{ $art->title ?? 'Artwork' }}" loading="lazy" class="w-full h-full object-cover" />
</div>
<div class="p-3 text-xs text-neutral-400 group-hover:text-white/80">{{ $art->title ?? 'Artwork' }}</div>
</a>
@empty
<div class="panel panel-default effect2">
<div class="panel-heading"><strong>No Artworks Yet</strong></div>
<div class="panel-body">
<p>Once uploads arrive they will appear here. Check back soon.</p>
</div>
</div>
@endforelse
</div>
<div class="flex justify-center mt-10">
{{ $artworks->withQueryString()->links('pagination::tailwind') }}
</div>
</section>
</main>
</div>
</div>
</div>
<div id="category-list">
<div id="artwork_subcategories">
<div class="category-toggle"><i class="fa fa-bars fa-fw"></i></div>
<h5 class="browse_categories">Main Categories:</h5>
<ul>
@foreach (\App\Models\ContentType::orderBy('id')->get() as $ct)
<li><a href="/{{ $ct->slug }}">{{ $ct->name }}</a></li>
@endforeach
</ul>
<h5 class="browse_categories">Browse Subcategories:</h5>
<ul class="scrollContent" data-mcs-theme="dark">
{{-- Intentionally empty on content-type landing pages --}}
</ul>
</div>
</div>
</div>
</div> <!-- end .legacy-page -->
@endsection
@push('scripts')
<script src="/js/legacy-gallery-init.js"></script>
@push('styles')
<style>
.gallery { columns: 5 260px; column-gap: 16px; }
.artwork { break-inside: avoid; margin-bottom: 16px; }
@media (max-width: 992px) { .gallery { columns: 3 220px; } }
@media (max-width: 768px) { .gallery { columns: 2 180px; } }
@media (max-width: 576px) { .gallery { columns: 1 100%; } }
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
}
</style>
@endpush

View File

@@ -5,7 +5,7 @@
<div class="effect2 page-header-wrap">
<header class="page-heading">
<h1 class="page-header">Gallery: {{ $user->uname ?? $user->name ?? 'User' }}</h1>
<p>{{ $user->real_name ?? '' }}</p>
<p>{{ $user->name ?? '' }}</p>
</header>
</div>

View File

@@ -1,4 +1,4 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@php
use Illuminate\Support\Str;
@@ -15,4 +15,3 @@
@include('legacy.home.news')
</div>
@endsection

View File

@@ -4,14 +4,14 @@
<div class="featured-card effect2">
<div class="card-header">Featured Artwork</div>
<div class="card-body text-center">
<a href="/art/{{ $featured->id }}/{{ Str::slug($featured->name ?? 'artwork') }}" class="thumb-link">
<a href="/art/{{ data_get($featured, 'id') }}/{{ Str::slug(data_get($featured, 'name') ?? 'artwork') }}" class="thumb-link">
@php
$fthumb = $featured->thumb_url ?? $featured->thumb;
$fthumb = data_get($featured, 'thumb_url') ?? data_get($featured, 'thumb');
@endphp
<img src="{{ $fthumb }}" class="img-responsive featured-img" alt="{{ $featured->name }}">
<img src="{{ $fthumb }}" class="img-responsive featured-img" alt="{{ data_get($featured, 'name') }}">
</a>
<div class="featured-title">{{ $featured->name }}</div>
<div class="featured-author">by {{ $featured->uname }}</div>
<div class="featured-title">{{ data_get($featured, 'name') }}</div>
<div class="featured-author">by {{ data_get($featured, 'uname') }}</div>
</div>
</div>
</div>
@@ -20,14 +20,14 @@
<div class="featured-card effect2">
<div class="card-header">Featured by Members Vote</div>
<div class="card-body text-center">
<a href="/art/{{ $memberFeatured->id }}/{{ Str::slug($memberFeatured->name ?? 'artwork') }}" class="thumb-link">
<a href="/art/{{ data_get($memberFeatured, 'id') }}/{{ Str::slug(data_get($memberFeatured, 'name') ?? 'artwork') }}" class="thumb-link">
@php
$mthumb = $memberFeatured->thumb_url ?? $memberFeatured->thumb;
$mthumb = data_get($memberFeatured, 'thumb_url') ?? data_get($memberFeatured, 'thumb');
@endphp
<img src="{{ $mthumb }}" class="img-responsive featured-img" alt="{{ $memberFeatured->name }}">
<img src="{{ $mthumb }}" class="img-responsive featured-img" alt="{{ data_get($memberFeatured, 'name') }}">
</a>
<div class="featured-title">{{ $memberFeatured->name }}</div>
<div class="featured-author">by {{ $memberFeatured->uname }}</div>
<div class="featured-title">{{ data_get($memberFeatured, 'name') }}</div>
<div class="featured-author">by {{ data_get($memberFeatured, 'uname') }}</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@section('content')
<div class="container-fluid legacy-page">
@@ -11,45 +11,143 @@
</header>
</div>
<div class="container_photo gallery_box">
<div class="grid-sizer"></div>
@foreach($artworks as $art)
@include('legacy._artwork_card', ['art' => $art])
@endforeach
<div class="md:hidden px-4 py-3">
<button id="sidebarToggle" aria-controls="sidebar" aria-expanded="false" class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 hover:bg-white/7 border border-white/5">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
<span class="text-sm text-white/90">Categories</span>
</button>
</div>
<div class="paginationMenu text-center">
{{ method_exists($artworks, 'withQueryString') ? $artworks->withQueryString()->links() : '' }}
</div>
<div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]">
<!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm">
<div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</span>
<span class="text-sm text-white/90">Menu</span>
</button>
@if($subcategories && $subcategories->count())
<div id="category-list">
<div id="artwork_subcategories">
<div class="category-toggle"><i class="fa fa-bars fa-fw"></i></div>
<h5 class="browse_categories">Main Categories:</h5>
<ul>
<li><a href="/photography"><i class="fa fa-photo fa-fw"></i> Photography</a></li>
<li><a href="/wallpapers"><i class="fa fa-photo fa-fw"></i> Wallpapers</a></li>
<li><a href="/skins"><i class="fa fa-photo fa-fw"></i> Skins</a></li>
<li><a href="/other"><i class="fa fa-photo fa-fw"></i> Other</a></li>
</ul>
<div class="mt-6 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
<li><a class="flex items-center gap-2 hover:text-white" href="/photography"><span class="opacity-70">📷</span> Photography</a></li>
<li><a class="flex items-center gap-2 hover:text-white" href="/wallpapers"><span class="opacity-70">🖼️</span> Wallpapers</a></li>
<li><a class="flex items-center gap-2 hover:text-white" href="/skins"><span class="opacity-70">🧩</span> Skins</a></li>
<li><a class="flex items-center gap-2 hover:text-white" href="/other"><span class="opacity-70"></span> Other</a></li>
</ul>
<h5 class="browse_categories">Browse Subcategories:</h5>
<ul class="scrollContent" data-mcs-theme="dark">
@foreach($subcategories as $skupina)
@php
$slug = \Illuminate\Support\Str::slug($skupina->category_name);
$ctype = strtolower($group);
$addit = (isset($id) && $skupina->category_id == $id) ? 'selected_group' : '' ;
@endphp
<li class="subgroup {{ $addit }}">
<a href="/{{ $ctype }}/{{ $slug }}">{{ $skupina->category_name }}</a>
</li>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 sb-scrollbar max-h-56 overflow-auto pr-2">
@foreach($subcategories ?? [] as $skupina)
@php
// Prefer an explicit slug when provided by the model/mapping, otherwise build one from the name
$slug = $skupina->slug ?? \Illuminate\Support\Str::slug($skupina->category_name);
$ctype = strtolower($group ?? 'photography');
$addit = (isset($id) && ($skupina->category_id ?? null) == $id) ? 'selected_group' : '' ;
@endphp
<li class="subgroup {{ $addit }}"><a class="hover:text-white" href="/{{ $ctype }}/{{ $slug }}">{{ $skupina->category_name }}</a></li>
@endforeach
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Daily Uploads <span class="text-neutral-400 font-normal">(245)</span></div>
<div class="rounded-xl bg-white/5 border border-white/5 overflow-hidden">
<button class="w-full px-4 py-3 text-left hover:bg-white/5">All</button>
<button class="w-full px-4 py-3 text-left hover:bg-white/5">Hot</button>
</div>
<a class="mt-4 inline-flex items-center gap-2 text-neutral-400 hover:text-white" href="#">
<span>Link, more</span>
<span class="opacity-60"></span>
</a>
</div>
</div>
</aside>
<!-- Mobile overlay (shown when sidebar opens) -->
<div id="sidebarOverlay" class="hidden md:hidden fixed inset-0 bg-black/50 z-30"></div>
<!-- MAIN -->
<main class="flex-1">
<div class="nebula-gallery grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
@foreach($artworks as $art)
@include('legacy._artwork_card', ['art' => $art])
@endforeach
</ul>
</div>
</div>
<div class="paginationMenu text-center mt-6">
{{ method_exists($artworks, 'withQueryString') ? $artworks->withQueryString()->links() : '' }}
</div>
</main>
</div>
@endif
</div>
</div>
@endsection
@push('styles')
<style>
/* Nebula-like gallery tweaks: fixed-height thumbnails, tighter spacing, refined typography */
.nebula-gallery {
margin-top: 1.25rem;
}
.nebula-gallery .artwork a { display: block; }
/* Ensure consistent gap and card sizing across breakpoints */
@media (min-width: 1024px) {
.nebula-gallery { gap: 1rem; }
}
/* Typography refinements to match Nebula */
.nebula-gallery .artwork h3 { font-size: 0.95rem; line-height: 1.15; }
.nebula-gallery .artwork .text-xs { font-size: 0.72rem; }
/* Improve image loading artifact handling */
.nebula-gallery img { background: linear-gradient(180deg,#0b0b0b,#0f0f10); }
/* Remove any default margins on article cards that can create vertical gaps */
.nebula-gallery .artwork { margin: 0; }
/* Ensure grid items don't collapse when overlay hidden */
.nebula-gallery .artwork a { min-height: 0; }
</style>
@endpush
@push('scripts')
<script>
document.addEventListener('DOMContentLoaded', function () {
var toggle = document.getElementById('sidebarToggle');
var sidebar = document.getElementById('sidebar');
var overlay = document.getElementById('sidebarOverlay');
if (!toggle || !sidebar) return;
function openSidebar() {
sidebar.classList.remove('hidden');
sidebar.classList.add('fixed','left-0','top-0','bottom-0','z-40');
overlay.classList.remove('hidden');
toggle.setAttribute('aria-expanded','true');
}
function closeSidebar() {
sidebar.classList.add('hidden');
sidebar.classList.remove('fixed','left-0','top-0','bottom-0','z-40');
overlay.classList.add('hidden');
toggle.setAttribute('aria-expanded','false');
}
toggle.addEventListener('click', function (e) {
e.preventDefault();
if (sidebar.classList.contains('hidden')) openSidebar(); else closeSidebar();
});
overlay && overlay.addEventListener('click', function () { closeSidebar(); });
// Close on Escape
document.addEventListener('keyup', function (e) { if (e.key === 'Escape') closeSidebar(); });
});
</script>
@endpush

View File

@@ -5,7 +5,7 @@
<div class="effect2 page-header-wrap">
<header class="page-heading">
<h1 class="page-header">Profile: {{ $user->uname }}</h1>
<p>{{ $user->real_name ?? '' }}</p>
<p>{{ $user->name ?? '' }}</p>
</header>
</div>

View File

@@ -1,153 +1,247 @@
@extends('layouts.legacy')
@extends('layouts.nova')
@section('content')
<div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap">
<header class="page-heading">
<h1 class="page-header">Edit profile</h1>
<p>update your user account</p>
</header>
</div>
@if(session('status'))
<div class="alert alert-success">{{ session('status') }}</div>
@endif
@if(session('error'))
<div class="alert alert-danger">{{ session('error') }}</div>
@endif
<div class="min-h-screen bg-deep text-white py-12">
<form enctype="multipart/form-data" method="post" action="{{ route('legacy.user') }}" id="editUserProfileForm" role="form">
@csrf
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label><i class="fa fa-envelope"></i> Email:</label>
<input type="text" name="email" class="form-control" readonly value="{{ $user->email }}" />
</div>
<!-- Container -->
<div class="max-w-5xl mx-auto px-4">
<div class="form-group">
<label><i class="fa fa-user"></i> Username:</label>
<input type="text" name="uname" class="form-control" readonly value="{{ $user->uname ?? $user->name }}" />
</div>
<!-- Page Title -->
<h1 class="text-3xl font-semibold mb-8">
Edit Profile
</h1>
<div class="form-group">
<label>Real name:</label>
<input type="text" name="real_name" class="form-control" value="{{ $user->real_name ?? '' }}" />
</div>
@if ($errors->any())
<div class="mb-4 rounded-lg bg-red-700/10 border border-red-700/20 p-3 text-sm text-red-300">
<div class="font-semibold mb-2">Please fix the following errors:</div>
<ul class="list-disc pl-5 space-y-1">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div class="form-group">
<label><i class="fa fa-link"></i> Home page:</label>
<input type="text" name="web" class="form-control" value="{{ $user->web ?? '' }}" />
</div>
<div class="form-inline">
<label><i class="fa fa-birthday-cake"></i> Birthday</label><br>
<input maxlength="2" name="date1" class="form-control" size="2" placeholder="Day" value="{{ $birthDay ?? '' }}"> :
<select name="date2" class="form-control">
@for($i=1;$i<=12;$i++)
@php $mVal = str_pad($i, 2, '0', STR_PAD_LEFT); @endphp
<option value="{{ $mVal }}" {{ (isset($birthMonth) && $birthMonth == $mVal) ? 'selected' : '' }}>{{ DateTime::createFromFormat('!m',$i)->format('F') }}</option>
@endfor
</select> :
<input maxlength="4" class="form-control" name="date3" size="4" placeholder="year" value="{{ $birthYear ?? '' }}">
</div>
<!-- ================= Profile Card ================= -->
<div class="bg-panel rounded-xl shadow-lg p-8 mb-10">
<br>
<div class="form-group well">
<label>Gender:</label><br>
<input name="gender" type="radio" value="M" {{ ($user->gender ?? '') == 'M' ? 'checked' : '' }}> Male<br>
<input name="gender" type="radio" value="F" {{ ($user->gender ?? '') == 'F' ? 'checked' : '' }}> Female<br>
<input name="gender" type="radio" value="X" {{ ($user->gender ?? '') == 'X' ? 'checked' : '' }}> N/A
</div>
<form method="POST" action="{{ route('profile.update') }}" enctype="multipart/form-data">
@csrf
@method('PUT')
<div class="form-group">
<label>Country:</label>
@if(isset($countries) && $countries->count())
<select name="country_code" class="form-control">
@foreach($countries as $c)
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- LEFT COLUMN -->
<div class="space-y-5">
<!-- Email -->
<div>
<label class="form-label">Email</label>
<input type="email" name="email"
class="form-input"
value="{{ old('email', auth()->user()->email) }}">
</div>
<!-- Username -->
<div>
<label class="form-label">Username</label>
<input type="text" name="username"
class="form-input"
value="{{ old('username', auth()->user()->username) }}"
readonly>
</div>
<!-- Real Name -->
<div>
<label class="form-label">Real Name</label>
<input type="text" name="name"
class="form-input"
value="{{ old('name', auth()->user()->name) }}">
</div>
<!-- Homepage -->
<div>
<label class="form-label">Homepage</label>
<input type="url" name="homepage"
class="form-input"
placeholder="https://"
value="{{ old('homepage', auth()->user()->homepage ?? auth()->user()->website ?? '') }}">
</div>
<!-- Birthday -->
<div>
<label class="form-label">Birthday</label>
<div class="grid grid-cols-3 gap-3">
@php
$code = $c->country_code ?? ($c->code ?? null);
$label = $c->country_name ?? ($c->name ?? $code);
$currentYear = date('Y');
$startYear = $currentYear - 100;
$months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
@endphp
<option value="{{ $code }}" {{ ($user->country_code ?? '') == $code ? 'selected' : '' }}>{{ $label }}</option>
@endforeach
</select>
@else
<input type="text" name="country_code" class="form-control" value="{{ $user->country_code ?? '' }}">
@endif
<select name="day" class="form-input" aria-label="Day">
<option value="">Day</option>
@for($d = 1; $d <= 31; $d++)
<option value="{{ $d }}" @if(intval(old('day', $birthDay)) == $d) selected @endif>{{ $d }}</option>
@endfor
</select>
<select name="month" class="form-input" aria-label="Month">
<option value="">Month</option>
@foreach($months as $idx => $m)
@php $val = $idx + 1; @endphp
<option value="{{ $val }}" @if(intval(old('month', $birthMonth)) == $val) selected @endif>{{ $m }}</option>
@endforeach
</select>
<select name="year" class="form-input" aria-label="Year">
<option value="">Year</option>
@for($y = $currentYear; $y >= $startYear; $y--)
<option value="{{ $y }}" @if(intval(old('year', $birthYear)) == $y) selected @endif>{{ $y }}</option>
@endfor
</select>
</div>
</div>
<!-- Gender -->
<div>
<label class="form-label">Gender</label>
<div class="flex gap-6 mt-2 text-soft">
<label><input type="radio" name="gender" value="m" @if(old('gender', strtolower(auth()->user()->gender ?? '')) == 'm') checked @endif> Male</label>
<label><input type="radio" name="gender" value="f" @if(old('gender', strtolower(auth()->user()->gender ?? '')) == 'f') checked @endif> Female</label>
<label><input type="radio" name="gender" value="x" @if(old('gender', strtolower(auth()->user()->gender ?? '')) == 'x') checked @endif> N/A</label>
</div>
</div>
<!-- Country -->
<div>
<label class="form-label">Country</label>
<input type="text" name="country"
class="form-input"
value="{{ old('country', auth()->user()->country ?? auth()->user()->country_code ?? '') }}">
</div>
<!-- Preferences -->
<div class="flex gap-6 text-soft">
<label>
<input type="checkbox" name="mailing" @if(old('mailing', auth()->user()->mlist ?? false)) checked @endif>
Mailing List
</label>
<label>
<input type="checkbox" name="notify" @if(old('notify', auth()->user()->friend_upload_notice ?? false)) checked @endif>
Upload Notifications
</label>
</div>
</div>
<!-- RIGHT COLUMN -->
<div class="space-y-5">
<!-- Avatar -->
<div>
<label class="form-label">Avatar</label>
<input type="file" name="avatar" class="form-file">
</div>
<!-- Emoticon -->
<div>
<label class="form-label">Emoticon</label>
<input type="file" name="emoticon" class="form-file">
</div>
<!-- Personal Picture -->
<div>
<label class="form-label">Personal Picture</label>
<input type="file" name="photo" class="form-file">
</div>
<!-- About -->
<div>
<label class="form-label">About Me</label>
<textarea name="about" rows="4"
class="form-textarea">{{ old('about', auth()->user()->about ?? auth()->user()->about_me ?? '') }}</textarea>
</div>
<!-- Signature -->
<div>
<label class="form-label">Signature</label>
<textarea name="signature" rows="3"
class="form-textarea">{{ old('signature', auth()->user()->signature ?? '') }}</textarea>
</div>
<!-- Description -->
<div>
<label class="form-label">Description</label>
<textarea name="description" rows="4"
class="form-textarea">{{ old('description', auth()->user()->description ?? '') }}</textarea>
</div>
</div>
</div>
<div class="form-group well">
<input name="newsletter" type="checkbox" value="1" {{ ($user->mlist ?? 0) ? 'checked' : '' }}> Mailing list<br>
<input name="friend_upload_notice" type="checkbox" value="1" {{ ($user->friend_upload_notice ?? 0) ? 'checked' : '' }}> Friends upload notice
<!-- Save Button -->
<div class="mt-8 text-right">
<button type="submit"
class="btn-primary">
Update Profile
</button>
</div>
<div class="form-group">
<label>Signature</label>
<textarea name="signature" class="form-control" style="width:100%; height:100px;">{{ $user->signature ?? '' }}</textarea>
</div>
</form>
<div class="form-group">
<label>Description</label>
<textarea name="description" class="form-control" style="width:100%; height:100px;">{{ $user->description ?? '' }}</textarea>
</div>
</div>
<div class="col-md-6">
<div class="form-group well">
<label>Avatar:</label><br>
<input type="file" name="avatar" class="form-control">
@if(!empty($user->icon))
<div style="margin-top:10px"><img src="/avatar/{{ $user->id }}/{{ $user->icon }}" width="50"></div>
@endif
</div>
<div class="form-group well">
<label>Emotion Icon:</label><br>
<input type="file" name="emotion_icon" class="form-control">
</div>
<div class="form-group well">
<label>Personal picture:</label><br>
<input type="file" name="personal_picture" class="form-control">
@if(!empty($user->picture))
<div style="margin-top:10px"><img src="/user-picture/{{ $user->picture }}" style="max-width:300px"></div>
@endif
</div>
</div>
</div>
<div class="form-group">
<label>About Me</label>
<textarea name="about_me" class="summernote form-control" style="width:100%; height:100px;">{{ $user->about_me ?? '' }}</textarea>
<!-- ================= PASSWORD CARD ================= -->
<div class="bg-panel rounded-xl shadow-lg p-8">
<h2 class="text-xl font-semibold mb-6">
Change Password
</h2>
<form method="POST" action="{{ route('profile.password') }}">
@csrf
@method('PUT')
<div class="space-y-5 max-w-md">
<div>
<label class="form-label">Current Password</label>
<input type="password" name="current_password"
class="form-input">
</div>
<div>
<label class="form-label">New Password</label>
<input type="password" name="password"
class="form-input">
</div>
<div>
<label class="form-label">Repeat Password</label>
<input type="password" name="password_confirmation"
class="form-input">
</div>
<button class="btn-secondary">
Change Password
</button>
</div>
</form>
</div>
<input type="hidden" name="confirm" value="true">
<input type="submit" class="btn btn-success" value="Update my profile">
</form>
<hr>
<h3>Change password</h3>
<form action="{{ route('legacy.user') }}" method="post">
@csrf
<div class="form-group">
<label>Current password</label>
<input type="password" name="oldpass" class="form-control">
</div>
<div class="form-group">
<label>New password</label>
<input type="password" name="newpass" class="form-control">
</div>
<div class="form-group">
<label>Retype new password</label>
<input type="password" name="newpass2" class="form-control">
</div>
<input type="hidden" name="confirm" value="true_password">
<input type="submit" class="btn btn-success" value="Change Password">
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,37 @@
@extends('layouts.legacy')
@section('content')
<div class="container legacy-page">
<div class="effect2">
<div class="page-heading">
<h1 class="page-header">Tag: {{ $tag->name }}</h1>
<p class="text-muted">Browse artworks tagged with {{ $tag->name }}.</p>
</div>
</div>
<div class="panel panel-skinbase effect2">
<div class="panel-body">
@if($artworks->isEmpty())
<div class="alert alert-info">No artworks found for this tag.</div>
@else
<div class="row">
@foreach($artworks as $artwork)
<div class="col-xs-6 col-sm-4 col-md-3" style="margin-bottom:16px">
<a href="/{{ $artwork->slug }}" title="{{ $artwork->title }}" style="display:block">
<img src="{{ $artwork->thumb_url ?? $artwork->thumb }}" class="img-responsive img-thumbnail" alt="{{ $artwork->title }}" style="width:100%;height:160px;object-fit:cover">
</a>
<div style="margin-top:6px;font-weight:700;line-height:1.2">
<a href="/{{ $artwork->slug }}">{{ str($artwork->title)->limit(60) }}</a>
</div>
</div>
@endforeach
</div>
<div class="paginationMenu text-center">
{{ $artworks->links('pagination::bootstrap-3') }}
</div>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<title>{{ $page_title ?? 'Upload Artwork' }}</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link rel="shortcut icon" href="/favicon.ico">
<script>
window.SKINBASE_FLAGS = Object.assign({}, window.SKINBASE_FLAGS || {}, {
uploads_v2: @json((bool) config('features.uploads_v2', false)),
uploads: Object.assign({}, (window.SKINBASE_FLAGS && window.SKINBASE_FLAGS.uploads) || {}, {
v2: @json((bool) config('features.uploads_v2', false)),
}),
});
</script>
@vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/entry-topbar.jsx','resources/js/upload.jsx'])
</head>
<body class="bg-nebula-900 text-white min-h-screen">
<div id="topbar-root"></div>
@include('layouts.nova.toolbar')
<main class="pt-16">
@inertia
</main>
@include('layouts.nova.footer')
</body>
</html>