Upload beautify
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
6
resources/js/Pages/Admin/UploadQueue.jsx
Normal file
6
resources/js/Pages/Admin/UploadQueue.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import AdminUploadQueue from '../../components/admin/AdminUploadQueue'
|
||||
|
||||
export default function UploadQueuePage() {
|
||||
return <AdminUploadQueue />
|
||||
}
|
||||
1035
resources/js/Pages/Upload/Index.jsx
Normal file
1035
resources/js/Pages/Upload/Index.jsx
Normal file
File diff suppressed because it is too large
Load Diff
5
resources/js/bootstrap.js
vendored
5
resources/js/bootstrap.js
vendored
@@ -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;
|
||||
}
|
||||
|
||||
94
resources/js/components/admin/AdminUploadQueue.jsx
Normal file
94
resources/js/components/admin/AdminUploadQueue.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
resources/js/components/admin/AdminUploadQueue.test.jsx
Normal file
112
resources/js/components/admin/AdminUploadQueue.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
498
resources/js/components/tags/TagInput.jsx
Normal file
498
resources/js/components/tags/TagInput.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
resources/js/components/tags/TagInput.test.jsx
Normal file
113
resources/js/components/tags/TagInput.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
156
resources/js/components/upload/ScreenshotUploader.jsx
Normal file
156
resources/js/components/upload/ScreenshotUploader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
resources/js/components/upload/UploadActions.jsx
Normal file
152
resources/js/components/upload/UploadActions.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
199
resources/js/components/upload/UploadDropzone.jsx
Normal file
199
resources/js/components/upload/UploadDropzone.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
resources/js/components/upload/UploadPreview.jsx
Normal file
76
resources/js/components/upload/UploadPreview.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
resources/js/components/upload/UploadProgress.jsx
Normal file
152
resources/js/components/upload/UploadProgress.jsx
Normal 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 20–30 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>
|
||||
)
|
||||
}
|
||||
96
resources/js/components/upload/UploadSidebar.jsx
Normal file
96
resources/js/components/upload/UploadSidebar.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
resources/js/components/upload/UploadStepper.jsx
Normal file
53
resources/js/components/upload/UploadStepper.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1199
resources/js/components/upload/UploadWizard.jsx
Normal file
1199
resources/js/components/upload/UploadWizard.jsx
Normal file
File diff suppressed because it is too large
Load Diff
310
resources/js/components/upload/__tests__/UploadWizard.test.jsx
Normal file
310
resources/js/components/upload/__tests__/UploadWizard.test.jsx
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
130
resources/js/components/uploads/ScreenshotUploader.jsx
Normal file
130
resources/js/components/uploads/ScreenshotUploader.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
640
resources/js/components/uploads/UploadWizard.jsx
Normal file
640
resources/js/components/uploads/UploadWizard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
resources/js/components/uploads/UploadWizard.test.jsx
Normal file
113
resources/js/components/uploads/UploadWizard.test.jsx
Normal 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)
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
18
resources/js/lib/feedAnalytics.js
Normal file
18
resources/js/lib/feedAnalytics.js
Normal 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(() => {
|
||||
})
|
||||
}
|
||||
24
resources/js/lib/uploadAnalytics.js
Normal file
24
resources/js/lib/uploadAnalytics.js
Normal 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
|
||||
}
|
||||
}
|
||||
23
resources/js/lib/uploadEndpoints.js
Normal file
23
resources/js/lib/uploadEndpoints.js
Normal 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`
|
||||
}
|
||||
1
resources/js/test/setupTests.js
Normal file
1
resources/js/test/setupTests.js
Normal file
@@ -0,0 +1 @@
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
17
resources/js/upload.jsx
Normal file
17
resources/js/upload.jsx
Normal 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} />)
|
||||
},
|
||||
})
|
||||
266
resources/views/artworks/show.blade.php
Normal file
266
resources/views/artworks/show.blade.php
Normal 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
|
||||
12
resources/views/components/avatar.blade.php
Normal file
12
resources/views/components/avatar.blade.php
Normal 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 }}">
|
||||
@@ -29,7 +29,11 @@
|
||||
|
||||
<!-- Page Content -->
|
||||
<main>
|
||||
{{ $slot }}
|
||||
@if(isset($slot))
|
||||
{{ $slot }}
|
||||
@else
|
||||
@yield('content')
|
||||
@endif
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,4 +1,4 @@
|
||||
@extends('layouts.legacy')
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="legacy-artwork">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@extends('layouts.legacy')
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@@ -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)
|
||||
» <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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@extends('layouts.legacy')
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
37
resources/views/tags/show.blade.php
Normal file
37
resources/views/tags/show.blade.php
Normal 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
|
||||
34
resources/views/upload.blade.php
Normal file
34
resources/views/upload.blade.php
Normal 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>
|
||||
Reference in New Issue
Block a user