feat: Inertia profile settings page, Studio edit redesign, EGS, Nova UI components\n\n- Redesign /dashboard/profile as Inertia React page (Settings/ProfileEdit)\n with SettingsLayout sidebar, Nova UI components (TextInput, Textarea,\n Toggle, Select, RadioGroup, Modal, Button), avatar drag-and-drop,\n password change, and account deletion sections\n- Redesign Studio artwork edit page with two-column layout, Nova components,\n integrated TagPicker, and version history modal\n- Add shared MarkdownEditor component\n- Add Early-Stage Growth System (EGS): SpotlightEngine, FeedBlender,\n GridFiller, AdaptiveTimeWindow, ActivityLayer, admin panel\n- Fix upload category/tag persistence (V1+V2 paths)\n- Fix tag source enum, category tree display, binding resolution\n- Add settings.jsx Vite entry, settings.blade.php wrapper\n- Update ProfileController with JSON response support for API calls\n- Various route fixes (profile.edit, toolbar settings link)"

This commit is contained in:
2026-03-03 20:57:43 +01:00
parent dc51d65440
commit b9c2d8597d
114 changed files with 8760 additions and 693 deletions

View File

@@ -0,0 +1,102 @@
import React, { useState } from 'react'
import { Link, usePage } from '@inertiajs/react'
const navItems = [
{ label: 'Profile', href: '/dashboard/profile', icon: 'fa-solid fa-user' },
// Future: { label: 'Notifications', href: '/dashboard/notifications', icon: 'fa-solid fa-bell' },
// Future: { label: 'Privacy', href: '/dashboard/privacy', icon: 'fa-solid fa-shield-halved' },
]
function NavLink({ item, active, onClick }) {
return (
<Link
href={item.href}
onClick={onClick}
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
active
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
<i className={`${item.icon} w-5 text-center text-base`} />
<span>{item.label}</span>
</Link>
)
}
function SidebarContent({ isActive, onNavigate }) {
return (
<>
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
</div>
<nav className="space-y-1 flex-1">
{navItems.map((item) => (
<NavLink key={item.href} item={item} active={isActive(item.href)} onClick={onNavigate} />
))}
</nav>
<div className="mt-auto pt-6 space-y-2">
<Link
href="/studio"
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-colors"
onClick={onNavigate}
>
<i className="fa-solid fa-palette w-5 text-center" />
Creator Studio
</Link>
</div>
</>
)
}
export default function SettingsLayout({ children, title }) {
const { url } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
const isActive = (href) => url.startsWith(href)
return (
<div className="min-h-screen bg-nova-900">
{/* Mobile top bar */}
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
<h1 className="text-lg font-bold text-white">Settings</h1>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="text-slate-400 hover:text-white p-2"
aria-label="Toggle navigation"
>
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
</button>
</div>
{/* Mobile nav overlay */}
{mobileOpen && (
<div className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
<nav
className="absolute left-0 top-0 bottom-0 w-72 bg-nova-900 border-r border-white/10 p-4 pt-20 space-y-1"
onClick={(e) => e.stopPropagation()}
>
<SidebarContent isActive={isActive} onNavigate={() => setMobileOpen(false)} />
</nav>
</div>
)}
<div className="flex">
{/* Desktop sidebar */}
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
<SidebarContent isActive={isActive} />
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-4xl">
{title && (
<h1 className="text-2xl font-bold text-white mb-6">{title}</h1>
)}
{children}
</main>
</div>
</div>
)
}

View File

@@ -34,7 +34,7 @@ export default function HomeHero({ artwork, isLoggedIn }) {
src={src}
alt={artwork.title}
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.02]"
fetchpriority="high"
fetchPriority="high"
decoding="async"
onError={(e) => { e.currentTarget.src = FALLBACK }}
/>

View File

@@ -0,0 +1,727 @@
import React, { useState, useRef, useCallback } from 'react'
import { usePage } from '@inertiajs/react'
import SettingsLayout from '../../Layouts/SettingsLayout'
import TextInput from '../../components/ui/TextInput'
import Textarea from '../../components/ui/Textarea'
import Button from '../../components/ui/Button'
import Toggle from '../../components/ui/Toggle'
import Select from '../../components/ui/Select'
import Modal from '../../components/ui/Modal'
import { RadioGroup } from '../../components/ui/Radio'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
const MONTHS = [
{ value: '1', label: 'January' }, { value: '2', label: 'February' },
{ value: '3', label: 'March' }, { value: '4', label: 'April' },
{ value: '5', label: 'May' }, { value: '6', label: 'June' },
{ value: '7', label: 'July' }, { value: '8', label: 'August' },
{ value: '9', label: 'September' }, { value: '10', label: 'October' },
{ value: '11', label: 'November' }, { value: '12', label: 'December' },
]
const GENDER_OPTIONS = [
{ value: 'm', label: 'Male' },
{ value: 'f', label: 'Female' },
{ value: 'x', label: 'Prefer not to say' },
]
function buildDayOptions() {
return Array.from({ length: 31 }, (_, i) => ({ value: String(i + 1), label: String(i + 1) }))
}
function buildYearOptions() {
const currentYear = new Date().getFullYear()
const years = []
for (let y = currentYear; y >= currentYear - 100; y--) {
years.push({ value: String(y), label: String(y) })
}
return years
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function Section({ children, className = '' }) {
return (
<section className={`bg-nova-900/60 border border-white/10 rounded-2xl p-6 ${className}`}>
{children}
</section>
)
}
function SectionTitle({ icon, children, description }) {
return (
<div className="mb-5">
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
{icon && <i className={`${icon} text-accent/70 text-[11px]`} />}
{children}
</h3>
{description && <p className="text-xs text-slate-500 mt-1">{description}</p>}
</div>
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function ProfileEdit() {
const { props } = usePage()
const {
user,
avatarUrl: initialAvatarUrl,
birthDay: initDay,
birthMonth: initMonth,
birthYear: initYear,
countries = [],
flash = {},
} = props
// ── Profile State ──────────────────────────────────────────────────────────
const [name, setName] = useState(user?.name || '')
const [email, setEmail] = useState(user?.email || '')
const [username, setUsername] = useState(user?.username || '')
const [homepage, setHomepage] = useState(user?.homepage || user?.website || '')
const [about, setAbout] = useState(user?.about_me || user?.about || '')
const [signature, setSignature] = useState(user?.signature || '')
const [description, setDescription] = useState(user?.description || '')
const [day, setDay] = useState(initDay ? String(parseInt(initDay, 10)) : '')
const [month, setMonth] = useState(initMonth ? String(parseInt(initMonth, 10)) : '')
const [year, setYear] = useState(initYear ? String(initYear) : '')
const [gender, setGender] = useState(() => {
const g = (user?.gender || '').toLowerCase()
if (g === 'm') return 'm'
if (g === 'f') return 'f'
if (g === 'x' || g === 'n') return 'x'
return ''
})
const [country, setCountry] = useState(user?.country_code || user?.country || '')
const [mailing, setMailing] = useState(!!user?.mlist)
const [notify, setNotify] = useState(!!user?.friend_upload_notice)
const [autoPost, setAutoPost] = useState(!!user?.auto_post_upload)
// Avatar
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
const [avatarFile, setAvatarFile] = useState(null)
const [avatarUploading, setAvatarUploading] = useState(false)
const avatarInputRef = useRef(null)
const [dragActive, setDragActive] = useState(false)
// Save state
const [saving, setSaving] = useState(false)
const [profileErrors, setProfileErrors] = useState({})
const [profileSaved, setProfileSaved] = useState(!!flash?.status)
// Password state
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [passwordSaving, setPasswordSaving] = useState(false)
const [passwordErrors, setPasswordErrors] = useState({})
const [passwordSaved, setPasswordSaved] = useState(false)
// Delete account
const [showDelete, setShowDelete] = useState(false)
const [deletePassword, setDeletePassword] = useState('')
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
// ── Country Options ─────────────────────────────────────────────────────────
const countryOptions = (countries || []).map((c) => ({
value: c.country_code || c.code || c.id || '',
label: c.country_name || c.name || '',
}))
// ── Avatar Handlers ────────────────────────────────────────────────────────
const handleAvatarSelect = (file) => {
if (!file || !file.type.startsWith('image/')) return
setAvatarFile(file)
setAvatarUrl(URL.createObjectURL(file))
}
const handleAvatarUpload = useCallback(async () => {
if (!avatarFile) return
setAvatarUploading(true)
try {
const fd = new FormData()
fd.append('avatar', avatarFile)
const res = await fetch('/avatar/upload', {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: fd,
})
const data = await res.json()
if (res.ok && data.url) {
setAvatarUrl(data.url)
setAvatarFile(null)
}
} catch (err) {
console.error('Avatar upload failed:', err)
} finally {
setAvatarUploading(false)
}
}, [avatarFile])
const handleDrag = (e) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragIn = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(true)
}
const handleDragOut = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
}
const handleDrop = (e) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
const file = e.dataTransfer?.files?.[0]
if (file) handleAvatarSelect(file)
}
// ── Profile Save ───────────────────────────────────────────────────────────
const handleProfileSave = useCallback(async () => {
setSaving(true)
setProfileSaved(false)
setProfileErrors({})
try {
const fd = new FormData()
fd.append('_method', 'PUT')
fd.append('email', email)
fd.append('username', username)
fd.append('name', name)
if (homepage) fd.append('web', homepage)
if (about) fd.append('about', about)
if (signature) fd.append('signature', signature)
if (description) fd.append('description', description)
if (day) fd.append('day', day)
if (month) fd.append('month', month)
if (year) fd.append('year', year)
if (gender) fd.append('gender', gender)
if (country) fd.append('country', country)
fd.append('mailing', mailing ? '1' : '0')
fd.append('notify', notify ? '1' : '0')
fd.append('auto_post_upload', autoPost ? '1' : '0')
if (avatarFile) fd.append('avatar', avatarFile)
const res = await fetch('/profile', {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: fd,
})
if (res.ok || res.status === 302) {
setProfileSaved(true)
setAvatarFile(null)
setTimeout(() => setProfileSaved(false), 4000)
} else {
const data = await res.json().catch(() => ({}))
if (data.errors) setProfileErrors(data.errors)
else if (data.message) setProfileErrors({ _general: [data.message] })
}
} catch (err) {
console.error('Profile save failed:', err)
} finally {
setSaving(false)
}
}, [email, username, name, homepage, about, signature, description, day, month, year, gender, country, mailing, notify, autoPost, avatarFile])
// ── Password Change ────────────────────────────────────────────────────────
const handlePasswordChange = useCallback(async () => {
setPasswordSaving(true)
setPasswordSaved(false)
setPasswordErrors({})
try {
const res = await fetch('/profile/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({
current_password: currentPassword,
password: newPassword,
password_confirmation: confirmPassword,
}),
})
if (res.ok || res.status === 302) {
setPasswordSaved(true)
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setTimeout(() => setPasswordSaved(false), 4000)
} else {
const data = await res.json().catch(() => ({}))
if (data.errors) setPasswordErrors(data.errors)
else if (data.message) setPasswordErrors({ _general: [data.message] })
}
} catch (err) {
console.error('Password change failed:', err)
} finally {
setPasswordSaving(false)
}
}, [currentPassword, newPassword, confirmPassword])
// ── Delete Account ─────────────────────────────────────────────────────────
const handleDeleteAccount = async () => {
setDeleting(true)
setDeleteError('')
try {
const res = await fetch('/profile', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify({ password: deletePassword }),
})
if (res.ok || res.status === 302) {
window.location.href = '/'
} else {
const data = await res.json().catch(() => ({}))
setDeleteError(data.errors?.password?.[0] || data.message || 'Deletion failed.')
}
} catch (err) {
setDeleteError('Request failed.')
} finally {
setDeleting(false)
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<SettingsLayout title="Edit Profile">
<div className="space-y-8">
{/* ── General Errors ── */}
{profileErrors._general && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{profileErrors._general[0]}
</div>
)}
{/* ════════════════════════════════════════════════════════════════════
AVATAR SECTION
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-camera" description="JPG, PNG or WebP. Max 2 MB.">
Avatar
</SectionTitle>
<div className="flex items-center gap-6">
{/* Preview */}
<div className="relative shrink-0">
<img
src={avatarUrl}
alt={username || 'Avatar'}
className="w-24 h-24 rounded-full object-cover ring-2 ring-white/10 shadow-lg"
/>
{avatarUploading && (
<div className="absolute inset-0 rounded-full bg-black/60 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
</div>
{/* Dropzone */}
<div className="flex-1 min-w-0">
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
onDragEnter={handleDragIn}
onDragLeave={handleDragOut}
onDragOver={handleDrag}
onDrop={handleDrop}
className={[
'w-full rounded-xl border-2 border-dashed px-5 py-5 text-left transition-all',
dragActive
? 'border-accent/50 bg-accent/10'
: 'border-white/15 hover:border-white/25 hover:bg-white/[0.03]',
].join(' ')}
>
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-10 h-10 rounded-lg bg-white/5 text-slate-400">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M5.5 13a3.5 3.5 0 01-.369-6.98 4 4 0 117.753-1.977A4.5 4.5 0 0113.5 13H11V9.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 101.414 1.414L9 9.414V13H5.5z" />
<path d="M9 13h2v5a1 1 0 11-2 0v-5z" />
</svg>
</span>
<div className="min-w-0">
<p className="text-sm text-white/90 font-medium">
{avatarFile ? avatarFile.name : 'Drop an image or click to browse'}
</p>
<p className="text-[11px] text-slate-500 mt-0.5">
{avatarFile ? 'Ready to upload with save' : 'Recommended: 256×256 px or larger'}
</p>
</div>
</div>
</button>
<input
ref={avatarInputRef}
type="file"
className="hidden"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => handleAvatarSelect(e.target.files?.[0])}
/>
{avatarFile && (
<div className="flex items-center gap-2 mt-2">
<Button variant="accent" size="xs" loading={avatarUploading} onClick={handleAvatarUpload}>
Upload now
</Button>
<button
type="button"
onClick={() => { setAvatarFile(null); setAvatarUrl(initialAvatarUrl || '') }}
className="text-[11px] text-slate-500 hover:text-white transition-colors"
>
Cancel
</button>
</div>
)}
</div>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
ACCOUNT INFO
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-user" description="Your public identity on Skinbase.">
Account
</SectionTitle>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<TextInput
label="Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
error={profileErrors.username?.[0]}
hint={user?.username_changed_at ? `Last changed: ${new Date(user.username_changed_at).toLocaleDateString()}` : undefined}
/>
<TextInput
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
error={profileErrors.email?.[0]}
required
/>
<TextInput
label="Display Name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your real name (optional)"
error={profileErrors.name?.[0]}
/>
<TextInput
label="Website"
type="url"
value={homepage}
onChange={(e) => setHomepage(e.target.value)}
placeholder="https://"
error={profileErrors.web?.[0] || profileErrors.homepage?.[0]}
/>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
ABOUT & BIO
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-pen-fancy" description="Tell the community about yourself.">
About &amp; Bio
</SectionTitle>
<div className="space-y-5">
<Textarea
label="About Me"
value={about}
onChange={(e) => setAbout(e.target.value)}
placeholder="Share something about yourself…"
rows={4}
error={profileErrors.about?.[0]}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<Textarea
label="Signature"
value={signature}
onChange={(e) => setSignature(e.target.value)}
placeholder="Forum signature"
rows={3}
error={profileErrors.signature?.[0]}
/>
<Textarea
label="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Short bio / tagline"
rows={3}
error={profileErrors.description?.[0]}
/>
</div>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
PERSONAL DETAILS
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-id-card" description="Optional details — only shown if you choose.">
Personal Details
</SectionTitle>
<div className="space-y-5">
{/* Birthday */}
<div>
<label className="text-sm font-medium text-white/85 block mb-1.5">Birthday</label>
<div className="grid grid-cols-3 gap-3 max-w-md">
<Select
placeholder="Day"
value={day}
onChange={(e) => setDay(e.target.value)}
options={buildDayOptions()}
size="sm"
/>
<Select
placeholder="Month"
value={month}
onChange={(e) => setMonth(e.target.value)}
options={MONTHS}
size="sm"
/>
<Select
placeholder="Year"
value={year}
onChange={(e) => setYear(e.target.value)}
options={buildYearOptions()}
size="sm"
/>
</div>
</div>
{/* Gender */}
<RadioGroup
label="Gender"
name="gender"
options={GENDER_OPTIONS}
value={gender}
onChange={setGender}
direction="horizontal"
error={profileErrors.gender?.[0]}
/>
{/* Country */}
{countryOptions.length > 0 ? (
<Select
label="Country"
value={country}
onChange={(e) => setCountry(e.target.value)}
options={countryOptions}
placeholder="Select country"
error={profileErrors.country?.[0]}
/>
) : (
<TextInput
label="Country"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="Country code (e.g. US, DE, TR)"
error={profileErrors.country?.[0]}
/>
)}
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
PREFERENCES
════════════════════════════════════════════════════════════════════ */}
<Section>
<SectionTitle icon="fa-solid fa-sliders" description="Control emails and sharing behavior.">
Preferences
</SectionTitle>
<div className="space-y-4">
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm text-white/90 font-medium">Mailing List</p>
<p className="text-xs text-slate-500">Receive occasional emails about Skinbase news</p>
</div>
<Toggle
checked={mailing}
onChange={(e) => setMailing(e.target.checked)}
variant="accent"
size="md"
/>
</div>
<div className="border-t border-white/5" />
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm text-white/90 font-medium">Upload Notifications</p>
<p className="text-xs text-slate-500">Get notified when people you follow upload new work</p>
</div>
<Toggle
checked={notify}
onChange={(e) => setNotify(e.target.checked)}
variant="emerald"
size="md"
/>
</div>
<div className="border-t border-white/5" />
<div className="flex items-center justify-between py-1">
<div>
<p className="text-sm text-white/90 font-medium">Auto-post Uploads</p>
<p className="text-xs text-slate-500">Automatically post to your feed when you publish artwork</p>
</div>
<Toggle
checked={autoPost}
onChange={(e) => setAutoPost(e.target.checked)}
variant="sky"
size="md"
/>
</div>
</div>
</Section>
{/* ── Save Profile Button ── */}
<div className="flex items-center gap-3">
<Button variant="accent" size="md" loading={saving} onClick={handleProfileSave}>
Save Profile
</Button>
{profileSaved && (
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Profile updated
</span>
)}
</div>
{/* ════════════════════════════════════════════════════════════════════
CHANGE PASSWORD
════════════════════════════════════════════════════════════════════ */}
<Section className="mt-4">
<SectionTitle icon="fa-solid fa-lock" description="Use a strong, unique password.">
Change Password
</SectionTitle>
{passwordErrors._general && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-300 mb-4">
{passwordErrors._general[0]}
</div>
)}
<div className="space-y-4 max-w-md">
<TextInput
label="Current Password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
error={passwordErrors.current_password?.[0]}
autoComplete="current-password"
/>
<TextInput
label="New Password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
error={passwordErrors.password?.[0]}
hint="Minimum 8 characters"
autoComplete="new-password"
/>
<TextInput
label="Confirm New Password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
error={passwordErrors.password_confirmation?.[0]}
autoComplete="new-password"
/>
<div className="flex items-center gap-3 pt-1">
<Button variant="secondary" size="md" loading={passwordSaving} onClick={handlePasswordChange}>
Update Password
</Button>
{passwordSaved && (
<span className="text-sm text-emerald-400 flex items-center gap-1.5 animate-pulse">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Password updated
</span>
)}
</div>
</div>
</Section>
{/* ════════════════════════════════════════════════════════════════════
DANGER ZONE
════════════════════════════════════════════════════════════════════ */}
<Section className="border-red-500/20">
<SectionTitle icon="fa-solid fa-triangle-exclamation" description="Permanent actions that cannot be undone.">
Danger Zone
</SectionTitle>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-white/90 font-medium">Delete Account</p>
<p className="text-xs text-slate-500">Remove your account and all associated data permanently.</p>
</div>
<Button variant="danger" size="sm" onClick={() => setShowDelete(true)}>
Delete Account
</Button>
</div>
</Section>
{/* spacer for bottom padding */}
<div className="h-4" />
</div>
{/* ── Delete Confirmation Modal ── */}
<Modal
open={showDelete}
onClose={() => setShowDelete(false)}
title="Delete Account"
size="sm"
footer={
<div className="flex items-center gap-3 ml-auto">
<Button variant="ghost" size="sm" onClick={() => setShowDelete(false)}>
Cancel
</Button>
<Button variant="danger" size="sm" loading={deleting} onClick={handleDeleteAccount}>
Permanently Delete
</Button>
</div>
}
>
<div className="space-y-4">
<p className="text-sm text-slate-300">
This action is <span className="text-red-400 font-semibold">irreversible</span>. All your artworks,
comments, and profile data will be permanently deleted.
</p>
<TextInput
label="Confirm your password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
error={deleteError}
autoComplete="current-password"
/>
</div>
</Modal>
</SettingsLayout>
)
}

View File

@@ -1,6 +1,15 @@
import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
import React, { useState, useMemo, useRef, useCallback } from 'react'
import { usePage, Link } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import MarkdownEditor from '../../components/ui/MarkdownEditor'
import TextInput from '../../components/ui/TextInput'
import Button from '../../components/ui/Button'
import Toggle from '../../components/ui/Toggle'
import Modal from '../../components/ui/Modal'
import FormField from '../../components/ui/FormField'
import TagPicker from '../../components/tags/TagPicker'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
@@ -21,39 +30,55 @@ function getContentTypeVisualKey(slug) {
function buildCategoryTree(contentTypes) {
return (contentTypes || []).map((ct) => ({
...ct,
rootCategories: (ct.root_categories || []).map((rc) => ({
rootCategories: (ct.categories || ct.root_categories || []).map((rc) => ({
...rc,
children: rc.children || [],
})),
}))
}
// ─── Sub-components ──────────────────────────────────────────────────────────
/** Glass-morphism section card (Nova theme) */
function Section({ children, className = '' }) {
return (
<section className={`bg-nova-900/60 border border-white/10 rounded-2xl p-6 ${className}`}>
{children}
</section>
)
}
/** Section heading */
function SectionTitle({ icon, children }) {
return (
<h3 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">
{icon && <i className={`${icon} text-accent/70 text-[11px]`} />}
{children}
</h3>
)
}
// ─── Main Component ──────────────────────────────────────────────────────────
export default function StudioArtworkEdit() {
const { props } = usePage()
const { artwork, contentTypes: rawContentTypes } = props
const contentTypes = useMemo(() => buildCategoryTree(rawContentTypes || []), [rawContentTypes])
// --- State ---
// ── State ──────────────────────────────────────────────────────────────────
const [contentTypeId, setContentTypeId] = useState(artwork?.content_type_id || null)
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
const [title, setTitle] = useState(artwork?.title || '')
const [description, setDescription] = useState(artwork?.description || '')
const [tags, setTags] = useState(() => (artwork?.tags || []).map((t) => ({ id: t.id, name: t.name, slug: t.slug || t.name })))
const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name))
const [isPublic, setIsPublic] = useState(artwork?.is_public ?? true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [errors, setErrors] = useState({})
// Tag picker state
const [tagQuery, setTagQuery] = useState('')
const [tagResults, setTagResults] = useState([])
const [tagLoading, setTagLoading] = useState(false)
const tagInputRef = useRef(null)
const tagSearchTimer = useRef(null)
// File replace state
// File replace
const fileInputRef = useRef(null)
const [replacing, setReplacing] = useState(false)
const [thumbUrl, setThumbUrl] = useState(artwork?.thumb_url_lg || artwork?.thumb_url || null)
@@ -63,60 +88,24 @@ export default function StudioArtworkEdit() {
width: artwork?.width || 0,
height: artwork?.height || 0,
})
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false)
const [changeNote, setChangeNote] = useState('')
const [changeNote, setChangeNote] = useState('')
const [showChangeNote, setShowChangeNote] = useState(false)
// Version history modal state
const [showHistory, setShowHistory] = useState(false)
const [historyData, setHistoryData] = useState(null)
// Version history
const [showHistory, setShowHistory] = useState(false)
const [historyData, setHistoryData] = useState(null)
const [historyLoading, setHistoryLoading] = useState(false)
const [restoring, setRestoring] = useState(null) // version id being restored
const [restoring, setRestoring] = useState(null)
// --- Tag search ---
const searchTags = useCallback(async (q) => {
setTagLoading(true)
try {
const params = new URLSearchParams()
if (q) params.set('q', q)
const res = await fetch(`/api/studio/tags/search?${params.toString()}`, {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setTagResults(data || [])
} catch {
setTagResults([])
} finally {
setTagLoading(false)
}
}, [])
useEffect(() => {
clearTimeout(tagSearchTimer.current)
tagSearchTimer.current = setTimeout(() => searchTags(tagQuery), 250)
return () => clearTimeout(tagSearchTimer.current)
}, [tagQuery, searchTags])
const toggleTag = (tag) => {
setTags((prev) => {
const exists = prev.find((t) => t.id === tag.id)
return exists ? prev.filter((t) => t.id !== tag.id) : [...prev, { id: tag.id, name: tag.name, slug: tag.slug }]
})
}
const removeTag = (id) => {
setTags((prev) => prev.filter((t) => t.id !== id))
}
// --- Derived data ---
// ── Derived ────────────────────────────────────────────────────────────────
const selectedCT = contentTypes.find((ct) => ct.id === contentTypeId) || null
const rootCategories = selectedCT?.rootCategories || []
const selectedRoot = rootCategories.find((c) => c.id === categoryId) || null
const subCategories = selectedRoot?.children || []
// --- Handlers ---
// ── Handlers ───────────────────────────────────────────────────────────────
const handleContentTypeChange = (id) => {
setContentTypeId(id)
setCategoryId(null)
@@ -128,7 +117,7 @@ export default function StudioArtworkEdit() {
setSubCategoryId(null)
}
const handleSave = async () => {
const handleSave = useCallback(async () => {
setSaving(true)
setSaved(false)
setErrors({})
@@ -138,7 +127,7 @@ export default function StudioArtworkEdit() {
description,
is_public: isPublic,
category_id: subCategoryId || categoryId || null,
tags: tags.map((t) => t.slug || t.name),
tags: tagSlugs,
}
const res = await fetch(`/api/studio/artworks/${artwork.id}`, {
method: 'PUT',
@@ -152,14 +141,13 @@ export default function StudioArtworkEdit() {
} else {
const data = await res.json()
if (data.errors) setErrors(data.errors)
console.error('Save failed:', data)
}
} catch (err) {
console.error('Save failed:', err)
} finally {
setSaving(false)
}
}
}, [title, description, isPublic, subCategoryId, categoryId, tagSlugs, artwork?.id])
const handleFileReplace = async (e) => {
const file = e.target.files?.[0]
@@ -202,8 +190,7 @@ export default function StudioArtworkEdit() {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setHistoryData(data)
setHistoryData(await res.json())
} catch (err) {
console.error('Failed to load version history:', err)
} finally {
@@ -212,7 +199,7 @@ export default function StudioArtworkEdit() {
}
const handleRestoreVersion = async (versionId) => {
if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return
if (!window.confirm('Restore this version? A copy will become the new current version.')) return
setRestoring(versionId)
try {
const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, {
@@ -222,7 +209,6 @@ export default function StudioArtworkEdit() {
})
const data = await res.json()
if (res.ok && data.success) {
alert(data.message)
setVersionCount((n) => n + 1)
setShowHistory(false)
} else {
@@ -235,411 +221,422 @@ export default function StudioArtworkEdit() {
}
}
// --- Render ---
// ── Render ─────────────────────────────────────────────────────────────────
return (
<StudioLayout title="Edit Artwork">
<Link
href="/studio/artworks"
className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white mb-6 transition-colors"
>
<i className="fa-solid fa-arrow-left" />
Back to Artworks
</Link>
<div className="max-w-3xl space-y-8">
{/* ── Uploaded Asset ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Uploaded Asset</h3>
<div className="flex items-center gap-2">
{requiresReapproval && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
<i className="fa-solid fa-triangle-exclamation" /> Under Review
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-accent/20 text-accent border border-accent/30">
v{versionCount}
{/* ── Page Header ── */}
<div className="flex items-center justify-between gap-4 mb-8">
<div className="flex items-center gap-4 min-w-0">
<Link
href="/studio/artworks"
className="flex items-center justify-center w-9 h-9 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all shrink-0"
aria-label="Back to artworks"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M10 3L5 8l5 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Link>
<div className="min-w-0">
<h1 className="text-lg font-bold text-white truncate">
{title || 'Untitled artwork'}
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Editing &middot;{' '}
<span className={isPublic ? 'text-emerald-400' : 'text-amber-400'}>
{isPublic ? 'Published' : 'Draft'}
</span>
{versionCount > 1 && (
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{saved && (
<span className="text-xs text-emerald-400 flex items-center gap-1 animate-pulse">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Saved
</span>
)}
<Button variant="accent" size="sm" loading={saving} onClick={handleSave}>
Save changes
</Button>
</div>
</div>
{/* ── Two-column Layout ── */}
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start">
{/* ─────────── LEFT SIDEBAR ─────────── */}
<div className="space-y-6 lg:sticky lg:top-6">
{/* Preview Card */}
<Section>
<SectionTitle icon="fa-solid fa-image">Preview</SectionTitle>
{/* Thumbnail */}
<div className="relative aspect-square rounded-xl overflow-hidden bg-white/5 border border-white/10 mb-4">
{thumbUrl ? (
<img
src={thumbUrl}
alt={title || 'Artwork preview'}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-slate-600">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" fill="currentColor" />
<path d="M21 15l-5-5L5 21" />
</svg>
</div>
)}
{replacing && (
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
<div className="w-7 h-7 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
</div>
{/* File Metadata */}
<div className="space-y-3">
<p className="text-sm font-medium text-white truncate" title={fileMeta.name}>{fileMeta.name}</p>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
{fileMeta.width > 0 && (
<span className="flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
<path d="M2 3a1 1 0 011-1h10a1 1 0 011 1v10a1 1 0 01-1 1H3a1 1 0 01-1-1V3zm2 1v8h8V4H4z" />
</svg>
{fileMeta.width} &times; {fileMeta.height}
</span>
)}
<span className="flex items-center gap-1">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" className="text-slate-600" aria-hidden="true">
<path d="M4 1.5a.5.5 0 00-1 0V3H1.5a.5.5 0 000 1h11a.5.5 0 000-1H11V1.5a.5.5 0 00-1 0V3H6V1.5a.5.5 0 00-1 0V3H4V1.5z" />
<path d="M1.5 5v8.5A1.5 1.5 0 003 15h10a1.5 1.5 0 001.5-1.5V5h-13z" />
</svg>
{formatBytes(fileMeta.size)}
</span>
</div>
{/* Version + History */}
<div className="flex items-center gap-2 pt-1">
<span className="inline-flex items-center gap-1 text-[10px] font-bold text-accent bg-accent/15 px-2 py-0.5 rounded-full border border-accent/20">
v{versionCount}
</span>
<button
type="button"
onClick={loadVersionHistory}
className="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
className="inline-flex items-center gap-1.5 text-[11px] text-slate-400 hover:text-accent transition-colors"
>
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 3.5a4.5 4.5 0 00-4.04 2.51.75.75 0 01-1.34-.67A6 6 0 1114 8a.75.75 0 01-1.5 0A4.5 4.5 0 008 3.5z" clipRule="evenodd" />
<path fillRule="evenodd" d="M4.75.75a.75.75 0 00-.75.75v3.5c0 .414.336.75.75.75h3.5a.75.75 0 000-1.5H5.5V1.5a.75.75 0 00-.75-.75z" clipRule="evenodd" />
</svg>
History
</button>
</div>
{requiresReapproval && (
<p className="text-[11px] text-amber-400/90 flex items-center gap-1.5 mt-1">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8.982 1.566a1.13 1.13 0 00-1.964 0L.165 13.233c-.457.778.091 1.767.982 1.767h13.706c.891 0 1.439-.989.982-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 01-1.1 0L7.1 5.995A.905.905 0 018 5zm.002 6a1 1 0 100 2 1 1 0 000-2z" />
</svg>
Requires re-approval after replace
</p>
)}
</div>
</div>
<div className="flex items-start gap-5">
{thumbUrl ? (
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
) : (
<div className="w-32 h-32 rounded-xl bg-nova-800 flex items-center justify-center text-slate-600 flex-shrink-0">
<i className="fa-solid fa-image text-2xl" />
</div>
)}
<div className="flex-1 min-w-0 space-y-1">
<p className="text-sm text-white font-medium truncate">{fileMeta.name}</p>
<p className="text-xs text-slate-400">{formatBytes(fileMeta.size)}</p>
{fileMeta.width > 0 && (
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
)}
{/* Replace File */}
<div className="mt-4 pt-4 border-t border-white/8 space-y-2.5">
{showChangeNote && (
<textarea
<TextInput
value={changeNote}
onChange={(e) => setChangeNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="What changed? (optional)"
className="mt-2 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50 resize-none"
placeholder="Change note (optional)…"
size="sm"
/>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={() => {
setShowChangeNote((s) => !s)
if (!showChangeNote) fileInputRef.current?.click()
}}
disabled={replacing}
className="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
<div className="flex items-center gap-2">
<Button
variant="secondary"
size="xs"
loading={replacing}
onClick={() => fileInputRef.current?.click()}
>
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
{replacing ? 'Replacing…' : 'Replace file'}
</button>
{showChangeNote && (
</Button>
{!showChangeNote && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={replacing}
className="inline-flex items-center gap-1.5 text-xs bg-accent/20 hover:bg-accent/30 text-accent px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50"
onClick={() => setShowChangeNote(true)}
className="text-[11px] text-slate-500 hover:text-white transition-colors"
>
<i className="fa-solid fa-upload" /> Choose file
+ note
</button>
)}
</div>
<input ref={fileInputRef} type="file" className="hidden" accept="image/*" onChange={handleFileReplace} />
</div>
</div>
</section>
</Section>
{/* ── Content Type ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Content Type</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{contentTypes.map((ct) => {
const active = ct.id === contentTypeId
const vk = getContentTypeVisualKey(ct.slug)
return (
<button
key={ct.id}
type="button"
onClick={() => handleContentTypeChange(ct.id)}
className={`relative flex flex-col items-center gap-2 rounded-xl border-2 p-4 transition-all cursor-pointer
${active ? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-400/10' : 'border-white/10 bg-white/5 hover:border-white/20'}`}
>
<img src={`/gfx/mascot_${vk}.webp`} alt={ct.name} className="w-14 h-14 object-contain" />
<span className={`text-xs font-semibold ${active ? 'text-emerald-300' : 'text-slate-300'}`}>{ct.name}</span>
{active && (
<span className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center">
<i className="fa-solid fa-check text-[10px] text-white" />
{/* Quick Links */}
<Section className="py-3 px-4">
<Link
href={`/studio/artworks/${artwork?.id}/analytics`}
className="flex items-center gap-3 py-2 text-sm text-slate-400 hover:text-white transition-colors group"
>
<span className="flex items-center justify-center w-8 h-8 rounded-lg bg-white/5 group-hover:bg-accent/15 transition-colors">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="text-slate-500 group-hover:text-accent transition-colors" aria-hidden="true">
<path d="M1 11a1 1 0 011-1h2a1 1 0 011 1v3a1 1 0 01-1 1H2a1 1 0 01-1-1v-3zm5-4a1 1 0 011-1h2a1 1 0 011 1v7a1 1 0 01-1 1H7a1 1 0 01-1-1V7zm5-5a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V2z" />
</svg>
</span>
View Analytics
</Link>
</Section>
</div>
{/* ─────────── RIGHT MAIN FORM ─────────── */}
<div className="space-y-6">
{/* ── Content Type ── */}
<Section>
<SectionTitle icon="fa-solid fa-palette">Content Type</SectionTitle>
<div className="grid grid-cols-3 sm:grid-cols-5 gap-2">
{contentTypes.map((ct) => {
const isActive = contentTypeId === ct.id
const visualKey = getContentTypeVisualKey(ct.slug)
return (
<button
key={ct.id}
type="button"
onClick={() => handleContentTypeChange(ct.id)}
className={[
'group flex flex-col items-center gap-2 rounded-xl border p-3 text-center transition-all',
isActive
? 'border-emerald-400/50 bg-emerald-400/10 ring-1 ring-emerald-400/30'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<img
src={`/gfx/mascot_${visualKey}.webp`}
alt=""
className="h-10 w-10 object-contain"
onError={(e) => { e.target.style.display = 'none' }}
/>
<span className={`text-xs font-medium ${isActive ? 'text-emerald-300' : 'text-slate-400 group-hover:text-white'}`}>
{ct.name}
</span>
)}
</button>
)
})}
</div>
</section>
</button>
)
})}
</div>
</Section>
{/* ── Category ── */}
{rootCategories.length > 0 && (
<Section className="space-y-4">
<SectionTitle icon="fa-solid fa-layer-group">Category</SectionTitle>
{/* ── Category ── */}
{rootCategories.length > 0 && (
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Category</h3>
<div className="flex flex-wrap gap-2">
{rootCategories.map((cat) => {
const active = cat.id === categoryId
const isActive = categoryId === cat.id
return (
<button
key={cat.id}
type="button"
onClick={() => handleCategoryChange(cat.id)}
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
${active ? 'border-purple-600/90 bg-purple-700/35 text-purple-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
className={[
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
isActive
? 'border-purple-400/50 bg-purple-400/15 text-purple-300'
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/[0.06]',
].join(' ')}
>
{cat.name}
</button>
)
})}
</div>
</div>
{/* Subcategory */}
{subCategories.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-3">Subcategory</h3>
<div className="flex flex-wrap gap-2">
{subCategories.map((sub) => {
const active = sub.id === subCategoryId
return (
<button
key={sub.id}
type="button"
onClick={() => setSubCategoryId(active ? null : sub.id)}
className={`px-4 py-2 rounded-full text-sm font-medium border transition-all cursor-pointer
${active ? 'border-cyan-600/90 bg-cyan-700/35 text-cyan-200' : 'border-white/10 bg-white/5 text-slate-300 hover:border-white/20'}`}
>
{sub.name}
</button>
)
})}
{subCategories.length > 0 && (
<div className="space-y-2 pl-1 border-l-2 border-white/5 ml-2">
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 pl-3">Subcategory</h4>
<div className="flex flex-wrap gap-2 pl-3">
{subCategories.map((sub) => {
const isActive = subCategoryId === sub.id
return (
<button
key={sub.id}
type="button"
onClick={() => setSubCategoryId(sub.id)}
className={[
'px-3 py-1.5 rounded-lg text-xs font-medium border transition-all',
isActive
? 'border-cyan-400/50 bg-cyan-400/15 text-cyan-300'
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:text-white hover:bg-white/[0.06]',
].join(' ')}
>
{sub.name}
</button>
)
})}
</div>
</div>
</div>
)}
</section>
)}
)}
{/* ── Basics ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-1">Basics</h3>
{errors.category_id && <p className="text-xs text-red-400">{errors.category_id[0]}</p>}
</Section>
)}
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Title</label>
<input
type="text"
{/* ── Details (Title + Description) ── */}
<Section className="space-y-5">
<SectionTitle icon="fa-solid fa-pen-fancy">Details</SectionTitle>
<TextInput
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
placeholder="Give your artwork a title"
error={errors.title?.[0]}
required
/>
{errors.title && <p className="text-xs text-red-400 mt-1">{errors.title[0]}</p>}
</div>
<div>
<label className="text-xs font-medium text-slate-400 mb-1.5 block">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={5}
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50 resize-y"
<FormField label="Description" htmlFor="artwork-description">
<MarkdownEditor
id="artwork-description"
value={description}
onChange={setDescription}
placeholder="Describe your artwork, tools, inspiration…"
rows={6}
error={errors.description?.[0]}
/>
</FormField>
</Section>
{/* ── Tags ── */}
<Section className="space-y-4">
<SectionTitle icon="fa-solid fa-tags">Tags</SectionTitle>
<TagPicker
value={tagSlugs}
onChange={setTagSlugs}
searchEndpoint="/api/studio/tags/search"
popularEndpoint="/api/studio/tags/search"
error={errors.tags?.[0]}
/>
{errors.description && <p className="text-xs text-red-400 mt-1">{errors.description[0]}</p>}
</div>
</section>
</Section>
{/* ── Tags ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Tags</h3>
{/* Search input */}
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-sm pointer-events-none" />
<input
ref={tagInputRef}
type="text"
value={tagQuery}
onChange={(e) => setTagQuery(e.target.value)}
className="w-full py-2.5 rounded-xl bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
style={{ paddingLeft: '2.5rem' }}
placeholder="Search tags…"
/>
</div>
{/* Selected tag chips */}
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-xs font-medium bg-accent/20 text-accent"
>
{tag.name}
<button
onClick={() => removeTag(tag.id)}
className="ml-0.5 w-4 h-4 rounded-full hover:bg-white/10 flex items-center justify-center"
>
<i className="fa-solid fa-xmark text-[10px]" />
</button>
</span>
))}
</div>
)}
{/* Results list */}
<div className="max-h-48 overflow-y-auto sb-scrollbar space-y-0.5 rounded-xl bg-white/[0.02] border border-white/5 p-1">
{tagLoading && (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
{/* ── Visibility ── */}
<Section>
<div className="flex items-center justify-between">
<div className="space-y-1">
<SectionTitle icon="fa-solid fa-eye">Visibility</SectionTitle>
<p className="text-xs text-slate-500 -mt-2">
{isPublic
? 'Your artwork is visible to everyone'
: 'Your artwork is only visible to you'}
</p>
</div>
<Toggle
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
label={isPublic ? 'Published' : 'Draft'}
variant={isPublic ? 'emerald' : 'accent'}
size="md"
/>
</div>
</Section>
{/* ── Bottom Save Bar (mobile) ── */}
<div className="flex items-center justify-between gap-3 py-2 lg:hidden">
<Button variant="accent" loading={saving} onClick={handleSave}>
Save changes
</Button>
{saved && (
<span className="text-xs text-emerald-400 flex items-center gap-1">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
Saved
</span>
)}
{!tagLoading && tagResults.length === 0 && (
<p className="text-center text-sm text-slate-500 py-4">
{tagQuery ? 'No tags found' : 'Type to search tags'}
</p>
)}
{!tagLoading &&
tagResults.map((tag) => {
const isSelected = tags.some((t) => t.id === tag.id)
return (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
isSelected
? 'bg-accent/10 text-accent'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
<span className="flex items-center gap-2">
<i
className={`fa-${isSelected ? 'solid fa-circle-check' : 'regular fa-circle'} text-xs ${
isSelected ? 'text-accent' : 'text-slate-500'
}`}
/>
{tag.name}
</span>
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
</button>
)
})}
</div>
<p className="text-xs text-slate-500">{tags.length}/15 tags selected</p>
{errors.tags && <p className="text-xs text-red-400">{errors.tags[0]}</p>}
</section>
{/* ── Visibility ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Visibility</h3>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={isPublic} onChange={() => setIsPublic(true)} className="text-accent focus:ring-accent/50" />
<span className="text-sm text-white">Published</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="radio" checked={!isPublic} onChange={() => setIsPublic(false)} className="text-accent focus:ring-accent/50" />
<span className="text-sm text-white">Draft</span>
</label>
</div>
</section>
{/* ── Actions ── */}
<div className="flex items-center gap-3">
<button
onClick={handleSave}
disabled={saving}
className="px-6 py-2.5 rounded-xl bg-accent hover:bg-accent/90 text-white font-semibold text-sm transition-all shadow-lg shadow-accent/25 disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save changes'}
</button>
{saved && (
<span className="text-sm text-emerald-400 flex items-center gap-1">
<i className="fa-solid fa-check" /> Saved
</span>
)}
<Link
href={`/studio/artworks/${artwork?.id}/analytics`}
className="ml-auto px-4 py-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 text-sm transition-all"
>
<i className="fa-solid fa-chart-line mr-2" />
Analytics
</Link>
</div>
</div>
{/* ── Version History Modal ── */}
{showHistory && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) setShowHistory(false) }}
>
<div className="bg-nova-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
<i className="fa-solid fa-clock-rotate-left text-accent" />
Version History
</h2>
<button
onClick={() => setShowHistory(false)}
className="w-7 h-7 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
<Modal
open={showHistory}
onClose={() => setShowHistory(false)}
title="Version History"
size="lg"
footer={
<p className="text-xs text-slate-500 mr-auto">
Restoring creates a new version nothing is deleted.
</p>
}
>
{historyLoading && (
<div className="flex items-center justify-center py-12">
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!historyLoading && historyData && (
<div className="space-y-3">
{historyData.versions.map((v) => (
<div
key={v.id}
className={[
'rounded-xl border p-4 transition-all',
v.is_current
? 'border-accent/40 bg-accent/10'
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]',
].join(' ')}
>
<i className="fa-solid fa-xmark text-xs" />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 sb-scrollbar p-4 space-y-3">
{historyLoading && (
<div className="flex items-center justify-center py-10">
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!historyLoading && historyData && historyData.versions.map((v) => (
<div
key={v.id}
className={`rounded-xl border p-4 transition-all ${
v.is_current
? 'border-accent/40 bg-accent/10'
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-white">v{v.version_number}</span>
{v.is_current && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">Current</span>
)}
</div>
<p className="text-[11px] text-slate-400">
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
</p>
{v.width && (
<p className="text-[11px] text-slate-400">{v.width} × {v.height} px &middot; {formatBytes(v.file_size)}</p>
)}
{v.change_note && (
<p className="text-xs text-slate-300 mt-1 italic">&ldquo;{v.change_note}&rdquo;</p>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-white">v{v.version_number}</span>
{v.is_current && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">
Current
</span>
)}
</div>
{!v.is_current && (
<button
type="button"
disabled={restoring === v.id}
onClick={() => handleRestoreVersion(v.id)}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white/5 hover:bg-accent/20 text-slate-300 hover:text-accent border border-white/10 hover:border-accent/30 transition-all disabled:opacity-50"
>
{restoring === v.id
? <><i className="fa-solid fa-spinner fa-spin" /> Restoring</>
: <><i className="fa-solid fa-rotate-left" /> Restore</>
}
</button>
<p className="text-[11px] text-slate-400">
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
</p>
{v.width && (
<p className="text-[11px] text-slate-400">
{v.width} &times; {v.height} px &middot; {formatBytes(v.file_size)}
</p>
)}
{v.change_note && (
<p className="text-xs text-slate-300 mt-1 italic">&ldquo;{v.change_note}&rdquo;</p>
)}
</div>
{!v.is_current && (
<Button
variant="ghost"
size="xs"
loading={restoring === v.id}
onClick={() => handleRestoreVersion(v.id)}
>
Restore
</Button>
)}
</div>
))}
</div>
))}
{!historyLoading && historyData && historyData.versions.length === 0 && (
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/10">
<p className="text-xs text-slate-500">
Older versions are preserved. Restoring creates a new versionnothing is deleted.
</p>
</div>
{historyData.versions.length === 0 && (
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
)}
</div>
</div>
)}
)}
</Modal>
</StudioLayout>
)
}

View File

@@ -10,11 +10,28 @@ export default function EmbeddedArtworkCard({ artwork }) {
const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}`
const authorUrl = `/@${artwork.author.username}`
const handleCardClick = (e) => {
// Don't navigate when clicking the author link
if (e.defaultPrevented) return
window.location.href = artUrl
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
window.location.href = artUrl
}
}
return (
<a
href={artUrl}
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors"
title={artwork.title}
// Outer element is a div to avoid <a> inside <a> — navigation handled via onClick
<div
role="link"
tabIndex={0}
aria-label={artwork.title}
onClick={handleCardClick}
onKeyDown={handleKeyDown}
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors cursor-pointer"
>
{/* Thumbnail */}
<div className="w-20 h-16 rounded-lg overflow-hidden shrink-0 bg-white/5">
@@ -45,7 +62,7 @@ export default function EmbeddedArtworkCard({ artwork }) {
</a>
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
</div>
</a>
</div>
)
}

View File

@@ -8,7 +8,7 @@ function slugify(str) {
* React version of resources/views/components/artwork-card.blade.php
* Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies.
*/
export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = null }) {
export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = null }) {
const imgRef = useRef(null);
const mediaRef = useRef(null);
@@ -119,7 +119,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchpriority = nul
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
loading={loading}
decoding={loading === 'eager' ? 'sync' : 'async'}
fetchPriority={fetchpriority || undefined}
fetchPriority={fetchPriority || undefined}
alt={title}
width={hasDimensions ? art.width : undefined}
height={hasDimensions ? art.height : undefined}

View File

@@ -297,7 +297,7 @@ function MasonryGallery({
key={`${art.id}-${idx}`}
art={art}
loading={idx < 8 ? 'eager' : 'lazy'}
fetchpriority={idx === 0 ? 'high' : null}
fetchPriority={idx === 0 ? 'high' : undefined}
/>
))}
</div>

View File

@@ -0,0 +1,181 @@
import React, { useCallback, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
function ToolbarButton({ title, onClick, children }) {
return (
<button
type="button"
title={title}
onMouseDown={(event) => {
event.preventDefault()
onClick()
}}
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
>
{children}
</button>
)
}
export default function MarkdownEditor({ id, value, onChange, placeholder, error, rows = 5 }) {
const [tab, setTab] = useState('write')
const textareaRef = useRef(null)
const wrapSelection = useCallback((before, after) => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const replacement = before + (selected || 'text') + after
const next = current.slice(0, start) + replacement + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.selectionStart = selected ? start + replacement.length : start + before.length
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
})
}, [onChange, value])
const prefixLines = useCallback((prefix) => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const lines = (selected || '').split('\n')
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
const next = current.slice(0, start) + normalized + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.selectionStart = start
textarea.selectionEnd = start + normalized.length
})
}, [onChange, value])
const insertLink = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const replacement = selected && /^https?:\/\//i.test(selected)
? `[link](${selected})`
: `[${selected || 'link'}](https://)`
const next = current.slice(0, start) + replacement + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
})
}, [onChange, value])
const handleKeyDown = useCallback((event) => {
const withModifier = event.ctrlKey || event.metaKey
if (!withModifier) return
switch (event.key.toLowerCase()) {
case 'b':
event.preventDefault()
wrapSelection('**', '**')
break
case 'i':
event.preventDefault()
wrapSelection('*', '*')
break
case 'k':
event.preventDefault()
insertLink()
break
case 'e':
event.preventDefault()
wrapSelection('`', '`')
break
default:
break
}
}, [insertLink, wrapSelection])
return (
<div className={`rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('write')}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
>
Write
</button>
<button
type="button"
onClick={() => setTab('preview')}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
>
Preview
</button>
</div>
</div>
{tab === 'write' && (
<>
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}> List</ToolbarButton>
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}></ToolbarButton>
</div>
<textarea
id={id}
ref={textareaRef}
value={value}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={handleKeyDown}
rows={rows}
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
placeholder={placeholder}
/>
<p className="px-3 pb-2 text-[11px] text-white/45">
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
</p>
</>
)}
{tab === 'preview' && (
<div className="min-h-[132px] px-3 py-2">
{String(value || '').trim() ? (
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
unwrapDisallowed
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
),
}}
>
{String(value || '')}
</ReactMarkdown>
</div>
) : (
<p className="text-sm italic text-white/35">Nothing to preview</p>
)}
</div>
)}
</div>
)
}

View File

@@ -1,186 +1,7 @@
import React, { useCallback, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import React from 'react'
import TagPicker from '../tags/TagPicker'
import Checkbox from '../../Components/ui/Checkbox'
function ToolbarButton({ title, onClick, children }) {
return (
<button
type="button"
title={title}
onMouseDown={(event) => {
event.preventDefault()
onClick()
}}
className="inline-flex h-7 min-w-7 items-center justify-center rounded-md px-1.5 text-xs font-semibold text-white/55 transition hover:bg-white/10 hover:text-white"
>
{children}
</button>
)
}
function MarkdownEditor({ id, value, onChange, placeholder, error }) {
const [tab, setTab] = useState('write')
const textareaRef = useRef(null)
const wrapSelection = useCallback((before, after) => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const replacement = before + (selected || 'text') + after
const next = current.slice(0, start) + replacement + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.selectionStart = selected ? start + replacement.length : start + before.length
textarea.selectionEnd = selected ? start + replacement.length : start + before.length + 4
})
}, [onChange, value])
const prefixLines = useCallback((prefix) => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const lines = (selected || '').split('\n')
const normalized = (lines.length ? lines : ['']).map((line) => `${prefix}${line}`).join('\n')
const next = current.slice(0, start) + normalized + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
textarea.selectionStart = start
textarea.selectionEnd = start + normalized.length
})
}, [onChange, value])
const insertLink = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
const current = String(value || '')
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selected = current.slice(start, end)
const replacement = selected && /^https?:\/\//i.test(selected)
? `[link](${selected})`
: `[${selected || 'link'}](https://)`
const next = current.slice(0, start) + replacement + current.slice(end)
onChange?.(next)
requestAnimationFrame(() => {
textarea.focus()
})
}, [onChange, value])
const handleKeyDown = useCallback((event) => {
const withModifier = event.ctrlKey || event.metaKey
if (!withModifier) return
switch (event.key.toLowerCase()) {
case 'b':
event.preventDefault()
wrapSelection('**', '**')
break
case 'i':
event.preventDefault()
wrapSelection('*', '*')
break
case 'k':
event.preventDefault()
insertLink()
break
case 'e':
event.preventDefault()
wrapSelection('`', '`')
break
default:
break
}
}, [insertLink, wrapSelection])
return (
<div className={`mt-2 rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setTab('write')}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
>
Write
</button>
<button
type="button"
onClick={() => setTab('preview')}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
>
Preview
</button>
</div>
</div>
{tab === 'write' && (
<>
<div className="flex items-center gap-1 border-b border-white/10 px-2 py-1">
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}> List</ToolbarButton>
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}></ToolbarButton>
</div>
<textarea
id={id}
ref={textareaRef}
value={value}
onChange={(event) => onChange?.(event.target.value)}
onKeyDown={handleKeyDown}
rows={5}
className="w-full resize-y bg-transparent px-3 py-2 text-sm text-white placeholder-white/45 focus:outline-none"
placeholder={placeholder}
/>
<p className="px-3 pb-2 text-[11px] text-white/45">
Markdown supported · Ctrl+B bold · Ctrl+I italic · Ctrl+K link
</p>
</>
)}
{tab === 'preview' && (
<div className="min-h-[132px] px-3 py-2">
{String(value || '').trim() ? (
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
unwrapDisallowed
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
),
}}
>
{String(value || '')}
</ReactMarkdown>
</div>
) : (
<p className="text-sm italic text-white/35">Nothing to preview</p>
)}
</div>
)}
</div>
)
}
import MarkdownEditor from '../ui/MarkdownEditor'
export default function UploadSidebar({
title = 'Artwork details',

View File

@@ -388,9 +388,13 @@ export default function useUploadMachine({
const { mode = 'now', publishAt = null, timezone = null, visibility = 'public' } = opts
const resolvedCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null
const buildPayload = () => ({
title: String(metadata.title || '').trim() || undefined,
description: String(metadata.description || '').trim() || null,
category: resolvedCategoryId ? String(resolvedCategoryId) : null,
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
mode,
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
...(timezone ? { timezone } : {}),

View File

@@ -2,6 +2,14 @@
// - dropdown menus via [data-dropdown]
// - mobile menu toggle via [data-mobile-toggle] + #mobileMenu
// Alpine.js — powers x-data/x-show/@click in Blade layouts (e.g. cookie banner, toasts).
// Guard: don't start a second instance if app.js already loaded Alpine on this page.
import Alpine from 'alpinejs';
if (!window.Alpine) {
window.Alpine = Alpine;
Alpine.start();
}
// Gallery navigation context: stores artwork list for prev/next on artwork page
import './lib/nav-context.js';

18
resources/js/settings.jsx Normal file
View File

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

View File

@@ -0,0 +1,156 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-4xl space-y-8">
{{-- ── Header ── --}}
<div class="flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-white">Early-Stage Growth System</h2>
<p class="mt-1 text-sm text-neutral-400">
A non-deceptive layer that keeps Nova feeling alive when uploads are sparse.
Toggle via <code class="text-sky-400">.env</code> no deployment required for mode changes.
</p>
</div>
{{-- Cache flush button --}}
<form method="POST" action="{{ route('admin.early-growth.cache.flush') }}" onsubmit="return confirm('Flush all EGS caches?')">
@csrf
@method('DELETE')
<button type="submit"
class="inline-flex items-center gap-2 rounded-lg bg-neutral-800 px-4 py-2 text-sm font-medium text-white
hover:bg-neutral-700 border border-neutral-700 transition">
🔄 Flush EGS Cache
</button>
</form>
</div>
@if(session('success'))
<div class="rounded-lg bg-green-900/40 border border-green-700 px-4 py-3 text-green-300 text-sm">
{{ session('success') }}
</div>
@endif
{{-- ── Live Status ── --}}
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Live Status</h3>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
@php
$pill = fn(bool $on) => $on
? '<span class="inline-block rounded-full bg-emerald-800 px-3 py-0.5 text-xs font-semibold text-emerald-200">ON</span>'
: '<span class="inline-block rounded-full bg-neutral-700 px-3 py-0.5 text-xs font-semibold text-neutral-400">OFF</span>';
@endphp
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">System</p>
{!! $pill($status['enabled']) !!}
</div>
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">Mode</p>
<span class="text-sm font-mono font-semibold {{ $mode === 'aggressive' ? 'text-amber-400' : ($mode === 'light' ? 'text-sky-400' : 'text-neutral-400') }}">
{{ strtoupper($mode) }}
</span>
</div>
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">Adaptive Window</p>
{!! $pill($status['adaptive_window']) !!}
</div>
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">Grid Filler</p>
{!! $pill($status['grid_filler']) !!}
</div>
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">Spotlight</p>
{!! $pill($status['spotlight']) !!}
</div>
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">Activity Layer</p>
{!! $pill($status['activity_layer']) !!}
</div>
</div>
</div>
{{-- ── Upload Stats ── --}}
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Upload Metrics</h3>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">Uploads / day (7-day avg)</p>
<p class="text-2xl font-bold text-white">{{ number_format($uploads_per_day, 1) }}</p>
</div>
<div class="rounded-lg bg-neutral-800/50 p-4">
<p class="text-xs text-neutral-500 mb-1">Active trending window</p>
<p class="text-2xl font-bold text-white">{{ $window_days }}d</p>
</div>
</div>
</div>
{{-- ── Activity Signals ── --}}
@if($status['activity_layer'] && !empty($activity))
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Activity Signals</h3>
<ul class="space-y-2">
@foreach($activity as $signal)
<li class="text-sm text-neutral-200">{{ $signal['icon'] }} {{ $signal['text'] }}</li>
@endforeach
</ul>
</div>
@endif
{{-- ── ENV Toggles ── --}}
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-4">
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">ENV Configuration</h3>
<p class="text-xs text-neutral-500">Edit <code class="text-sky-400">.env</code> to change these values. Run <code class="text-sky-400">php artisan config:clear</code> after changes.</p>
<div class="overflow-hidden rounded-lg border border-neutral-800">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-neutral-800 bg-neutral-800/40">
<th class="px-4 py-2 text-left text-xs text-neutral-400">Variable</th>
<th class="px-4 py-2 text-left text-xs text-neutral-400">Current Value</th>
<th class="px-4 py-2 text-left text-xs text-neutral-400">Effect</th>
</tr>
</thead>
<tbody>
@foreach($env_toggles as $t)
<tr class="border-b border-neutral-800/50">
<td class="px-4 py-2 font-mono text-sky-400">{{ $t['key'] }}</td>
<td class="px-4 py-2 font-mono text-white">{{ $t['current'] }}</td>
<td class="px-4 py-2 text-neutral-400">
@switch($t['key'])
@case('NOVA_EARLY_GROWTH_ENABLED') Master switch. Set to <code>false</code> to disable entire system. @break
@case('NOVA_EARLY_GROWTH_MODE') <code>off</code> / <code>light</code> / <code>aggressive</code> @break
@case('NOVA_EGS_ADAPTIVE_WINDOW') Widen trending window when uploads low. @break
@case('NOVA_EGS_GRID_FILLER') Backfill page-1 grids to 12 items. @break
@case('NOVA_EGS_SPOTLIGHT') Daily-rotating curated picks. @break
@case('NOVA_EGS_ACTIVITY_LAYER') Real activity summary badges. @break
@endswitch
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
<div class="rounded-lg bg-neutral-800/30 border border-neutral-700 p-4 text-xs text-neutral-400 space-y-1">
<p><strong class="text-white">To enable (light mode):</strong></p>
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=true
NOVA_EARLY_GROWTH_MODE=light</pre>
<p class="mt-2"><strong class="text-white">To disable instantly:</strong></p>
<pre class="text-sky-400 font-mono">NOVA_EARLY_GROWTH_ENABLED=false</pre>
</div>
</div>
{{-- ── Cache Keys Reference ── --}}
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-6 space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-widest text-neutral-400">Cache Keys</h3>
<ul class="space-y-1">
@foreach($cache_keys as $key)
<li class="font-mono text-xs text-neutral-400">{{ $key }}</li>
@endforeach
</ul>
<p class="text-xs text-neutral-600">Use the "Flush EGS Cache" button above to clear these in one action.</p>
</div>
</div>
@endsection

View File

@@ -0,0 +1,36 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-5xl">
<h2 class="text-xl font-semibold text-white mb-4">Staff / Contact Submissions</h2>
<div class="overflow-hidden rounded-lg border border-neutral-800 bg-nova-900 p-4">
<table class="w-full table-auto text-left text-sm">
<thead>
<tr class="text-neutral-400">
<th class="py-2">When</th>
<th class="py-2">Topic</th>
<th class="py-2">Name</th>
<th class="py-2">Email</th>
<th class="py-2">Actions</th>
</tr>
</thead>
<tbody>
@forelse($items as $i)
<tr class="border-t border-neutral-800">
<td class="py-3 text-neutral-400">{{ $i->created_at->toDayDateTimeString() }}</td>
<td class="py-3">{{ ucfirst($i->topic) }}</td>
<td class="py-3">{{ $i->name }}</td>
<td class="py-3">{{ $i->email }}</td>
<td class="py-3"><a class="text-sky-400 hover:underline" href="{{ route('admin.applications.show', $i->id) }}">View</a></td>
</tr>
@empty
<tr><td colspan="5" class="py-6 text-neutral-400">No submissions yet.</td></tr>
@endforelse
</tbody>
</table>
<div class="mt-4">{{ $items->links() }}</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,36 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-3xl">
<h2 class="text-xl font-semibold text-white mb-2">Submission</h2>
<div class="rounded-lg border border-neutral-800 bg-nova-900 p-6">
<dl class="grid grid-cols-1 gap-4 text-sm text-neutral-300">
<div>
<dt class="text-neutral-400">Topic</dt>
<dd>{{ ucfirst($item->topic) }}</dd>
</div>
<div>
<dt class="text-neutral-400">Name</dt>
<dd>{{ $item->name }}</dd>
</div>
<div>
<dt class="text-neutral-400">Email</dt>
<dd>{{ $item->email }}</dd>
</div>
<div>
<dt class="text-neutral-400">Portfolio</dt>
<dd>{{ $item->portfolio }}</dd>
</div>
<div>
<dt class="text-neutral-400">Message</dt>
<dd class="whitespace-pre-line">{{ $item->message }}</dd>
</div>
<div>
<dt class="text-neutral-400">Received</dt>
<dd>{{ $item->created_at->toDayDateTimeString() }}</dd>
</div>
</dl>
</div>
</div>
@endsection

View File

@@ -0,0 +1,28 @@
{{--
<x-ad-unit slot="1234567890" />
<x-ad-unit slot="1234567890" format="rectangle" class="my-6" />
Props:
slot AdSense ad slot ID (required)
format AdSense data-ad-format (default: auto)
class additional wrapper classes
Renders nothing when:
- GOOGLE_ADSENSE_PUBLISHER_ID is not set in .env
- User has not given consent (handled client-side via CSS class .ads-disabled)
--}}
@php
$publisherId = config('services.google_adsense.publisher_id');
@endphp
@if($publisherId)
<div class="ad-unit-wrapper {{ $attributes->get('class', '') }}">
<ins class="adsbygoogle"
style="display:block"
data-ad-client="{{ $publisherId }}"
data-ad-slot="{{ $slot }}"
data-ad-format="{{ $format ?? 'auto' }}"
data-full-width-responsive="true"></ins>
<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>
</div>
@endif

View File

@@ -0,0 +1,19 @@
{{--
Breadcrumb component with schema.org structured data.
@param \Illuminate\Support\Collection $breadcrumbs
Collection of objects with ->name and ->url properties.
--}}
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
<a class="hover:text-white transition-colors" href="/">Home</a>
@foreach($breadcrumbs as $crumb)
<span class="opacity-40" aria-hidden="true"></span>
@if(!$loop->last)
<a class="hover:text-white transition-colors" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@else
<span class="text-white/70">{{ $crumb->name }}</span>
@endif
@endforeach
</nav>
@endif

View File

@@ -0,0 +1,5 @@
@props(['max' => '3xl'])
<div {{ $attributes->merge(['class' => "mx-auto px-6 md:px-10 max-w-{$max}"]) }}>
{{ $slot }}
</div>

View File

@@ -0,0 +1,13 @@
<div class="max-w-4xl mx-auto py-12 text-center">
@if(isset($title))
<h1 class="text-3xl font-extrabold text-white">{{ $title }}</h1>
@endif
@if(isset($subtitle))
<p class="mt-3 text-sm text-neutral-300">{{ $subtitle }}</p>
@endif
@if($slot->isNotEmpty())
<div class="mt-6">{{ $slot }}</div>
@endif
</div>

View File

@@ -0,0 +1,82 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; color:#0f172a; }
.container { max-width:700px; margin:24px auto; background:#ffffff; border-radius:8px; padding:20px; box-shadow:0 6px 18px rgba(2,6,23,0.08); }
.brand { display:flex; align-items:center; gap:12px; }
.brand img { height:40px; }
.title { margin-top:16px; font-size:20px; font-weight:700; color:#0b1220; }
.meta { margin-top:8px; color:#475569; font-size:13px; }
.section { margin-top:18px; }
.label { font-weight:600; color:#0b1220; font-size:13px; }
.value { margin-top:6px; color:#0f172a; }
.footer { margin-top:22px; color:#64748b; font-size:12px; }
a { color:#0ea5e9; text-decoration:none; }
.pill { display:inline-block; padding:4px 8px; border-radius:999px; background:#f1f5f9; color:#0b1220; font-size:12px; }
pre { white-space:pre-wrap; font-family:inherit; font-size:13px; color:#0f172a; }
</style>
</head>
<body>
<div class="container">
<div class="brand">
<img src="{{ asset('gfx/skinbase_logo.png') }}" alt="Skinbase">
<div>
<div style="font-weight:700;">Skinbase</div>
<div style="font-size:12px;color:#64748b;">New staff application / contact form submission</div>
</div>
</div>
<div class="title">New {{ $topicLabel }}: {{ $application->name }}</div>
<div class="meta">Received {{ $application->created_at->toDayDateTimeString() }}</div>
<div class="section">
<div class="label">Details</div>
<div class="value">
<div><span class="pill">Topic</span> {{ $topicLabel }}</div>
<div><strong>Name:</strong> {{ $application->name }}</div>
<div><strong>Email:</strong> <a href="mailto:{{ $application->email }}">{{ $application->email }}</a></div>
@if($application->role)<div><strong>Role:</strong> {{ $application->role }}</div>@endif
@if($application->portfolio)<div><strong>Portfolio:</strong> <a href="{{ $application->portfolio }}">{{ $application->portfolio }}</a></div>@endif
</div>
</div>
@if($application->topic === 'bug')
<div class="section">
<div class="label">Bug report</div>
<div class="value">
@if($application->payload['data']['affected_url'] ?? false)
<div><strong>Affected URL:</strong> <a href="{{ $application->payload['data']['affected_url'] }}">{{ $application->payload['data']['affected_url'] }}</a></div>
@endif
@if($application->payload['data']['steps'] ?? false)
<div style="margin-top:8px"><strong>Steps to reproduce:</strong>
<pre>{{ $application->payload['data']['steps'] }}</pre>
</div>
@endif
</div>
</div>
@endif
<div class="section">
<div class="label">Message</div>
<div class="value"><pre>{{ $application->message }}</pre></div>
</div>
<div class="section">
<div class="label">Technical</div>
<div class="value">
<div><strong>IP:</strong> {{ $application->ip }}</div>
<div style="margin-top:6px"><strong>User agent:</strong> {{ $application->user_agent }}</div>
</div>
</div>
<div class="footer">
<div>If you prefer to manage submissions in the admin UI, open: <a href="{{ url('/admin/applications') }}">/admin/applications</a></div>
<div style="margin-top:8px"> Skinbase</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,22 @@
New {{ $topicLabel }}: {{ $application->name }}
Received: {{ $application->created_at->toDayDateTimeString() }}
Topic: {{ $topicLabel }}
Name: {{ $application->name }}
Email: {{ $application->email }}
@if($application->role)
Role: {{ $application->role }}
@endif
@if($application->portfolio)
Portfolio: {{ $application->portfolio }}
@endif
Message:
{{ $application->message }}
Technical:
IP: {{ $application->ip }}
User agent: {{ $application->user_agent }}
Manage submissions: {{ url('/admin/applications') }}

View File

@@ -0,0 +1,28 @@
{{--
401 Unauthorized
Use for: routes that require authentication when user is not logged in.
--}}
@extends('errors._layout', [
'error_code' => 401,
'error_title' => 'Sign In Required',
'error_message' => 'Please sign in to access this page.',
])
@section('badge', 'Unauthorized')
@section('primary-cta')
<a href="/login"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
Sign In
</a>
@endsection
@section('secondary-ctas')
<a href="/register" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Create Account
</a>
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Home
</a>
@endsection

View File

@@ -0,0 +1,43 @@
{{--
403 Forbidden
Use for: private artwork, banned content, region restrictions.
Shows login button if user is a guest; profile/discover if logged in.
--}}
@extends('errors._layout', [
'error_code' => 403,
'error_title' => 'Access Denied',
'error_message' => $message ?? 'You do not have permission to view this content.',
])
@section('badge', 'Forbidden')
@section('primary-cta')
@guest
<a href="/login"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
Sign In
</a>
@else
<a href="/discover/trending"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-compass" aria-hidden="true"></i>
Back to Discover
</a>
@endguest
@endsection
@section('secondary-ctas')
@guest
<a href="/register" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Create Account
</a>
@else
<a href="/@{{ Auth::user()->username }}" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
My Profile
</a>
@endguest
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Home
</a>
@endsection

View File

@@ -0,0 +1,90 @@
{{--
404 Generic Not Found
Returned when a route has no match.
Includes: trending artworks (6), top tags (10), discover CTA.
--}}
@extends('errors._layout', [
'error_code' => 404,
'error_title' => '404 — Lost in the Nova',
'error_message' => 'This page drifted into deep space. Let\'s get you back on track.',
])
@section('badge', 'Page Not Found')
@section('primary-cta')
<a href="/discover/trending"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-compass" aria-hidden="true"></i>
Explore Discover
</a>
@endsection
@section('secondary-ctas')
<a href="/explore/wallpapers" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Browse Wallpapers
</a>
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
</a>
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Home
</a>
@endsection
@section('recovery')
{{-- Trending artworks --}}
@if(isset($trendingArtworks) && $trendingArtworks->count())
<div class="mb-12">
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Trending Right Now</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
@foreach($trendingArtworks->take(6) as $artwork)
@include('errors._artwork-card', ['artwork' => $artwork])
@endforeach
</div>
</div>
@endif
{{-- Top tags --}}
@if(isset($trendingTags) && $trendingTags->count())
<div class="mb-12">
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Popular Tags</h2>
<div class="flex flex-wrap gap-2">
@foreach($trendingTags->take(10) as $tag)
<a href="/tag/{{ $tag->slug }}"
class="rounded-full bg-white/5 hover:bg-sky-500/20 border border-white/8 hover:border-sky-500/30 text-white/70 hover:text-sky-300 px-3 py-1 text-xs font-medium transition-colors">
#{{ $tag->name }}
</a>
@endforeach
</div>
</div>
@endif
{{-- Explore categories --}}
<div>
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Explore Categories</h2>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<a href="/explore/wallpapers"
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
<i class="fas fa-image text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium text-white/80 group-hover:text-white">Wallpapers</span>
</a>
<a href="/explore/skins"
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
<i class="fas fa-paint-brush text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium text-white/80 group-hover:text-white">Skins</span>
</a>
<a href="/explore/photography"
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
<i class="fas fa-camera text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium text-white/80 group-hover:text-white">Photography</span>
</a>
<a href="/explore/other"
class="rounded-xl bg-white/3 hover:bg-sky-500/10 border border-white/5 hover:border-sky-500/20 px-4 py-3 flex items-center gap-3 transition-all group">
<i class="fas fa-layer-group text-sky-400/60 group-hover:text-sky-400 text-lg" aria-hidden="true"></i>
<span class="text-sm font-medium text-white/80 group-hover:text-white">Other</span>
</a>
</div>
</div>
@endsection

View File

@@ -0,0 +1,29 @@
{{--
410 Gone
Use for permanently deleted artworks, DMCA removed content, deleted blog posts.
Minimal content no heavy suggestions needed by spec.
--}}
@extends('errors._layout', [
'error_code' => 410,
'error_title' => 'Content Permanently Removed',
'error_message' => 'This content has been permanently removed and is no longer available.',
])
@section('badge', 'Gone')
@section('primary-cta')
<a href="/discover/trending"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-compass" aria-hidden="true"></i>
Explore Discover
</a>
@endsection
@section('secondary-ctas')
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Return Home
</a>
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
</a>
@endsection

View File

@@ -0,0 +1,24 @@
{{--
419 Page Expired (CSRF token mismatch)
--}}
@extends('errors._layout', [
'error_code' => 419,
'error_title' => 'Page Expired',
'error_message' => 'Your session has expired. Please refresh the page and try again.',
])
@section('badge', 'Session Expired')
@section('primary-cta')
<button onclick="window.location.reload()"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors cursor-pointer">
<i class="fas fa-redo" aria-hidden="true"></i>
Refresh Page
</button>
@endsection
@section('secondary-ctas')
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Return Home
</a>
@endsection

View File

@@ -0,0 +1,40 @@
{{--
500 Server Error
Shows a user-friendly message and a reference/correlation ID.
Never shows a stack trace in production.
--}}
@extends('errors._layout', [
'error_code' => 500,
'error_title' => 'Something Went Wrong in the Nova',
'error_message' => 'An unexpected error occurred. Our team has been notified and is on it.',
])
@section('badge', 'Server Error')
@section('primary-cta')
<button onclick="window.location.reload()"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors cursor-pointer">
<i class="fas fa-redo" aria-hidden="true"></i>
Try Again
</button>
@endsection
@section('secondary-ctas')
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Return Home
</a>
<a href="/contact" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Report Issue
</a>
@endsection
@section('recovery')
@if(isset($correlationId))
<div class="flex justify-center">
<div class="inline-flex items-center gap-2 rounded-xl bg-white/4 border border-white/8 px-5 py-3 text-xs text-white/40">
<i class="fas fa-fingerprint text-white/25" aria-hidden="true"></i>
Reference ID: <span class="font-mono font-semibold text-white/60 select-all">{{ $correlationId }}</span>
</div>
</div>
@endif
@endsection

View File

@@ -0,0 +1,18 @@
{{--
503 Service Unavailable / Maintenance Mode
--}}
@extends('errors._layout', [
'error_code' => 503,
'error_title' => 'We\'ll Be Right Back',
'error_message' => 'Skinbase is under scheduled maintenance. Check back soon.',
])
@section('badge', 'Maintenance')
@section('primary-cta')
<button onclick="window.location.reload()"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors cursor-pointer">
<i class="fas fa-redo" aria-hidden="true"></i>
Check Again
</button>
@endsection

View File

@@ -0,0 +1,22 @@
{{--
Shared: artwork suggestion card for error pages.
Expects $artwork array: [id, title, author, url, thumb]
--}}
<a href="{{ $artwork['url'] }}" class="group relative rounded-xl overflow-hidden bg-nova-800 border border-white/5 hover:border-sky-500/30 transition-all duration-200 block">
@if($artwork['thumb'])
<div class="aspect-video w-full overflow-hidden bg-nova-700">
<img src="{{ $artwork['thumb'] }}"
alt="{{ $artwork['title'] }}"
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300 opacity-80 group-hover:opacity-100" />
</div>
@else
<div class="aspect-video w-full bg-gradient-to-br from-nova-700 to-nova-800 flex items-center justify-center">
<i class="fas fa-image text-white/20 text-3xl" aria-hidden="true"></i>
</div>
@endif
<div class="p-3">
<p class="text-sm font-semibold text-white truncate">{{ $artwork['title'] }}</p>
<p class="text-xs text-white/50 truncate mt-0.5">by {{ $artwork['author'] }}</p>
</div>
</a>

View File

@@ -0,0 +1,71 @@
{{--
Error Layout extends nova.blade.php
Shared structure for all error pages (404, 410, 403, 401, 500, contextual variants).
Enforces:
noindex
No canonical link
Dark Nova design
Full navigation visible
Recovery CTAs
Variables:
$error_code int HTTP status code
$error_title string Short headline
$error_message string Friendly sentence
--}}
@extends('layouts.nova')
@php
$code = $error_code ?? 404;
$title = $error_title ?? 'Page Not Found';
$message = $error_message ?? 'This page drifted into deep space.';
@endphp
@push('head')
{{-- SEO: never index error pages --}}
<meta name="robots" content="noindex, nofollow" />
@endpush
@section('content')
<div class="min-h-[70vh] flex flex-col items-center justify-center px-4 py-16">
{{-- Hero block --}}
<div class="text-center max-w-xl mx-auto">
{{-- Code glow --}}
<div class="text-8xl font-extrabold text-sky-500/20 select-none leading-none mb-2">{{ $code }}</div>
{{-- Gradient badge --}}
<div class="inline-flex items-center gap-2 rounded-full bg-sky-500/10 border border-sky-500/20 px-4 py-1 text-xs font-semibold text-sky-400 uppercase tracking-widest mb-6">
@yield('badge', 'Error')
</div>
<h1 class="text-3xl sm:text-4xl font-extrabold text-white leading-tight mb-4">
{{ $title }}
</h1>
<p class="text-white/60 text-base sm:text-lg leading-relaxed mb-8">
{{ $message }}
</p>
{{-- Primary CTA --}}
@yield('primary-cta')
{{-- Secondary CTAs --}}
@hasSection('secondary-ctas')
<div class="flex flex-wrap justify-center gap-3 mt-4">
@yield('secondary-ctas')
</div>
@endif
</div>
{{-- Contextual recovery section --}}
@hasSection('recovery')
<div class="w-full max-w-5xl mx-auto mt-16">
@yield('recovery')
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,99 @@
{{--
Artwork Not Found (contextual) HTTP 404 or 403
Shown when:
- Artwork ID not found at all HTTP 404
- Artwork exists but is private/unapproved HTTP 403 ($isForbidden=true)
Separate view for permanently deleted errors/410.blade.php
Variables:
$isForbidden bool true when private/403
$trendingArtworks Collection (max 6)
$creatorArtworks Collection (max 6, optional)
$creatorUsername string|null
--}}
@php
$isForbidden = $isForbidden ?? false;
$errorCode = $isForbidden ? 403 : 404;
$errorTitle = $isForbidden ? 'Access Denied' : 'Artwork Not Found';
$errorMessage = $isForbidden
? 'This artwork is private and not publicly available.'
: 'This artwork is no longer available, or the link may be broken.';
$badgeLabel = $isForbidden ? 'Private Artwork' : 'Artwork Not Found';
@endphp
@extends('errors._layout', [
'error_code' => $errorCode,
'error_title' => $errorTitle,
'error_message' => $errorMessage,
])
@section('badge', $badgeLabel)
@section('primary-cta')
@if($isForbidden)
@guest
<a href="/login"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-sign-in-alt" aria-hidden="true"></i>
Sign In to View
</a>
@else
<a href="/discover/trending"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-compass" aria-hidden="true"></i>
Explore Discover
</a>
@endguest
@else
<a href="/discover/trending"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-compass" aria-hidden="true"></i>
Explore Discover
</a>
@endif
@endsection
@section('secondary-ctas')
<a href="/explore/wallpapers" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Browser Wallpapers
</a>
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
</a>
@endsection
@section('recovery')
{{-- Creator's other artworks (if we have a hint about the creator) --}}
@if(isset($creatorArtworks) && $creatorArtworks->count())
<div class="mb-12">
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">
More from this Creator
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
@foreach($creatorArtworks->take(6) as $artwork)
@include('errors._artwork-card', ['artwork' => $artwork])
@endforeach
</div>
@if(isset($creatorUsername))
<div class="mt-3">
<a href="/@{{ $creatorUsername }}" class="text-xs text-sky-400 hover:text-sky-300 transition-colors">
View full gallery
</a>
</div>
@endif
</div>
@endif
{{-- Trending artworks --}}
@if(isset($trendingArtworks) && $trendingArtworks->count())
<div>
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Trending Wallpapers</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
@foreach($trendingArtworks->take(6) as $artwork)
@include('errors._artwork-card', ['artwork' => $artwork])
@endforeach
</div>
</div>
@endif
@endsection

View File

@@ -0,0 +1,52 @@
{{--
Blog Post Not Found Contextual 404
Shown at /blog/:slug when post doesn't exist or is unpublished.
Variables:
$latestPosts Collection (max 6)
--}}
@extends('errors._layout', [
'error_code' => 404,
'error_title' => 'Article Not Found',
'error_message' => 'This article is no longer available or the link has changed.',
])
@section('badge', 'Article Not Found')
@section('primary-cta')
<a href="/blog"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-newspaper" aria-hidden="true"></i>
Visit Blog
</a>
@endsection
@section('secondary-ctas')
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Home
</a>
@endsection
@section('recovery')
@if(isset($latestPosts) && $latestPosts->count())
<div>
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Latest Articles</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
@foreach($latestPosts->take(6) as $post)
<a href="{{ $post['url'] }}"
class="flex flex-col gap-2 rounded-xl p-4 bg-white/3 hover:bg-white/7 border border-white/5 hover:border-sky-500/20 transition-all">
<p class="text-sm font-semibold text-white leading-snug">{{ $post['title'] }}</p>
@if(!empty($post['excerpt']))
<p class="text-xs text-white/50 leading-relaxed flex-1">{{ $post['excerpt'] }}</p>
@endif
@if(!empty($post['published_at']))
<p class="text-xs text-white/30 mt-auto">{{ $post['published_at'] }}</p>
@endif
</a>
@endforeach
</div>
</div>
@endif
@endsection

View File

@@ -0,0 +1,94 @@
{{--
Creator Not Found Contextual 404
Shown at /@:username when user doesn't exist.
Variables:
$requestedUsername string|null
$trendingCreators Collection (max 6)
$recentCreators Collection (max 6)
--}}
@extends('errors._layout', [
'error_code' => 404,
'error_title' => 'Creator Not Found',
'error_message' => isset($requestedUsername)
? 'The creator "@' . $requestedUsername . '" does not exist on Skinbase.'
: 'This creator profile does not exist.',
])
@section('badge', 'Creator Not Found')
@section('primary-cta')
{{-- Inline creator search --}}
<form action="/search" method="GET" class="flex items-center gap-2 w-full max-w-sm mx-auto mb-2">
<input
type="text"
name="q"
placeholder="Search for a creator…"
value="{{ isset($requestedUsername) ? '@'.$requestedUsername : '' }}"
class="flex-1 rounded-xl bg-white/8 border border-white/12 focus:border-sky-500/50 focus:ring-0 text-sm text-white placeholder-white/30 px-4 py-2.5 outline-none transition-colors"
/>
<button type="submit"
class="rounded-xl bg-sky-500 hover:bg-sky-400 text-white px-4 py-2.5 text-sm font-semibold transition-colors shrink-0">
<i class="fas fa-search" aria-hidden="true"></i>
</button>
</form>
@endsection
@section('secondary-ctas')
<a href="/creators/top" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
<i class="fas fa-trophy mr-1.5" aria-hidden="true"></i> Top Creators
</a>
<a href="/register" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
<i class="fas fa-star mr-1.5" aria-hidden="true"></i> Join Skinbase
</a>
@endsection
@section('recovery')
{{-- Trending creators --}}
@if(isset($trendingCreators) && $trendingCreators->count())
<div class="mb-12">
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Top Creators</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
@foreach($trendingCreators->take(6) as $creator)
<a href="{{ $creator['url'] }}"
class="flex flex-col items-center gap-2 rounded-xl p-4 bg-white/3 hover:bg-white/7 border border-white/5 hover:border-sky-500/20 transition-all text-center group">
<img src="{{ $creator['avatar_url'] }}"
alt="{{ $creator['name'] }}"
loading="lazy"
class="w-14 h-14 rounded-full object-cover ring-2 ring-white/10 group-hover:ring-sky-500/30 transition-all"
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
<div>
<p class="text-sm font-semibold text-white truncate max-w-[100px]">{{ $creator['name'] }}</p>
<p class="text-xs text-white/40">{{ $creator['artworks_count'] }} uploads</p>
</div>
</a>
@endforeach
</div>
</div>
@endif
{{-- Recently joined creators --}}
@if(isset($recentCreators) && $recentCreators->count())
<div>
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Recently Joined</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
@foreach($recentCreators->take(6) as $creator)
<a href="{{ $creator['url'] }}"
class="flex flex-col items-center gap-2 rounded-xl p-4 bg-white/3 hover:bg-white/7 border border-white/5 hover:border-sky-500/20 transition-all text-center group">
<img src="{{ $creator['avatar_url'] }}"
alt="{{ $creator['name'] }}"
loading="lazy"
class="w-14 h-14 rounded-full object-cover ring-2 ring-white/10 group-hover:ring-sky-500/30 transition-all"
onerror="this.src='https://files.skinbase.org/default/avatar_default.webp'" />
<div>
<p class="text-sm font-semibold text-white truncate max-w-[100px]">{{ $creator['name'] }}</p>
<p class="text-xs text-white/40">{{ $creator['artworks_count'] }} uploads</p>
</div>
</a>
@endforeach
</div>
</div>
@endif
@endsection

View File

@@ -0,0 +1,31 @@
{{--
Static Page Not Found Contextual 404
Shown at /pages/:slug or /about|/help|/contact when page not in DB.
--}}
@extends('errors._layout', [
'error_code' => 404,
'error_title' => 'Page Not Found',
'error_message' => 'This page was removed or renamed. Try one of the links below.',
])
@section('badge', 'Page Not Found')
@section('primary-cta')
<a href="/help"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-question-circle" aria-hidden="true"></i>
Help Center
</a>
@endsection
@section('secondary-ctas')
<a href="/about" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
About
</a>
<a href="/contact" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Contact
</a>
<a href="/" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Home
</a>
@endsection

View File

@@ -0,0 +1,70 @@
{{--
Tag Not Found Contextual 404
Shown at /tag/:slug when slug not in DB.
Variables:
$requestedSlug string
$similarTags Collection (max 10)
$trendingTags Collection (max 10)
--}}
@extends('errors._layout', [
'error_code' => 404,
'error_title' => 'Tag Not Found',
'error_message' => 'The tag "' . ($requestedSlug ?? '') . '" doesn\'t exist yet.',
])
@section('badge', 'Tag Not Found')
@section('primary-cta')
<a href="/tags"
class="inline-flex items-center gap-2 rounded-xl bg-sky-500 hover:bg-sky-400 text-white font-semibold px-6 py-3 text-sm shadow-lg shadow-sky-900/30 transition-colors">
<i class="fas fa-tags" aria-hidden="true"></i>
Browse All Tags
</a>
@endsection
@section('secondary-ctas')
<a href="/discover/trending" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
Trending
</a>
<a href="/search" class="rounded-xl border border-white/10 hover:border-white/25 text-white/70 hover:text-white px-4 py-2 text-sm transition-colors">
<i class="fas fa-search mr-1.5" aria-hidden="true"></i> Search
</a>
@endsection
@section('recovery')
{{-- Similar tags --}}
@if(isset($similarTags) && $similarTags->count())
<div class="mb-10">
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Similar Tags</h2>
<div class="flex flex-wrap gap-2">
@foreach($similarTags->take(10) as $tag)
<a href="/tag/{{ $tag->slug }}"
class="rounded-full bg-sky-500/10 hover:bg-sky-500/20 border border-sky-500/20 hover:border-sky-500/40 text-sky-300 hover:text-sky-200 px-3 py-1 text-xs font-medium transition-colors">
#{{ $tag->name }}
@if($tag->artworks_count ?? null)
<span class="text-sky-400/60 ml-1">{{ number_format($tag->artworks_count) }}</span>
@endif
</a>
@endforeach
</div>
</div>
@endif
{{-- Trending tags --}}
@if(isset($trendingTags) && $trendingTags->count())
<div>
<h2 class="text-sm font-semibold text-white/40 uppercase tracking-widest mb-4">Popular Tags</h2>
<div class="flex flex-wrap gap-2">
@foreach($trendingTags->take(10) as $tag)
<a href="/tag/{{ $tag->slug }}"
class="rounded-full bg-white/5 hover:bg-sky-500/20 border border-white/8 hover:border-sky-500/30 text-white/70 hover:text-sky-300 px-3 py-1 text-xs font-medium transition-colors">
#{{ $tag->name }}
</a>
@endforeach
</div>
</div>
@endif
@endsection

View File

@@ -31,6 +31,22 @@
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
<meta name="twitter:description" content="{{ $page_meta_description ?? '' }}" />
{{-- Breadcrumb structured data --}}
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $breadcrumbs->values()->map(fn ($crumb, $i) => [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $crumb->name,
'item' => url($crumb->url),
])->all(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endif
@endpush
@php
@@ -76,19 +92,11 @@
<div class="relative px-6 py-10 md:px-10 md:py-14">
{{-- Breadcrumb --}}
<nav class="flex items-center gap-1.5 flex-wrap text-sm text-neutral-400" aria-label="Breadcrumb">
<a class="hover:text-white transition-colors" href="/browse">Gallery</a>
@if(isset($contentType) && $contentType)
<span class="opacity-40" aria-hidden="true"></span>
<a class="hover:text-white transition-colors" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
@endif
@if(($gallery_type ?? null) === 'category')
@foreach($breadcrumbs as $crumb)
<span class="opacity-40" aria-hidden="true"></span>
<a class="hover:text-white transition-colors" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@endforeach
@endif
</nav>
@include('components.breadcrumbs', ['breadcrumbs' => collect(array_filter([
isset($contentType) && $contentType ? (object) ['name' => 'Explore', 'url' => '/explore'] : null,
isset($contentType) && $contentType ? (object) ['name' => $contentType->name, 'url' => '/explore/' . strtolower($contentType->slug)] : (object) ['name' => 'Explore', 'url' => '/explore'],
...(($gallery_type ?? null) === 'category' && isset($breadcrumbs) ? $breadcrumbs->all() : []),
]))])
{{-- Glass title panel --}}
<div class="mt-4 py-5">

View File

@@ -44,8 +44,62 @@
.auth-card { max-width: 720px; margin-left: auto; margin-right: auto; }
.auth-card h1 { font-size: 1.25rem; line-height: 1.2; }
.auth-card p { color: rgba(203,213,225,0.9); }
/* Global heading styles for better hierarchy */
h1, h2, h3, h4, h5, h6 { color: #ffffff; margin-top: 1rem; margin-bottom: 0.5rem; }
h1 { font-size: 2.25rem; line-height: 1.05; font-weight: 800; letter-spacing: -0.02em; }
h2 { font-size: 1.5rem; line-height: 1.15; font-weight: 700; letter-spacing: -0.01em; }
h3 { font-size: 1.125rem; line-height: 1.2; font-weight: 600; }
h4 { font-size: 1rem; line-height: 1.25; font-weight: 600; }
h5 { font-size: 0.95rem; line-height: 1.25; font-weight: 600; }
h6 { font-size: 0.85rem; line-height: 1.3; font-weight: 600; text-transform: uppercase; opacity: 0.85; }
/* Prose (typography plugin) overrides */
.prose h1 { font-size: 2.25rem; }
.prose h2 { font-size: 1.5rem; }
.prose h3 { font-size: 1.125rem; }
.prose h4, .prose h5, .prose h6 { font-weight: 600; }
/* Alpine: hide x-cloak elements until Alpine picks them up */
[x-cloak] { display: none !important; }
</style>
@stack('head')
@if(config('services.google_adsense.publisher_id'))
{{-- Google AdSense consent-gated loader --}}
{{-- Script is only injected after the user accepts all cookies. --}}
{{-- If consent was given on a previous visit it fires on page load. --}}
<script>
(function () {
var PUB = '{{ config('services.google_adsense.publisher_id') }}';
var SCRIPT_ID = 'adsense-js';
function injectAdsense() {
if (document.getElementById(SCRIPT_ID)) return;
var s = document.createElement('script');
s.id = SCRIPT_ID;
s.async = true;
s.crossOrigin = 'anonymous';
s.src = 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=' + PUB;
document.head.appendChild(s);
}
// Expose so Alpine consent banner can trigger immediately on accept
window.sbLoadAds = injectAdsense;
// If the user already consented on a previous visit, load straight away
if (localStorage.getItem('sb_cookie_consent') === 'all') {
injectAdsense();
}
// Handle consent granted in another tab
window.addEventListener('storage', function (e) {
if (e.key === 'sb_cookie_consent' && e.newValue === 'all') {
injectAdsense();
}
});
})();
</script>
@endif
</head>
@php
$authBgRoutes = [
@@ -107,6 +161,60 @@
</div>
</div>
@endif
{{-- Cookie Consent Banner --}}
<div
x-data="{
show: false,
init() {
if (!localStorage.getItem('sb_cookie_consent')) {
this.show = true;
}
},
accept() {
localStorage.setItem('sb_cookie_consent', 'all');
this.show = false;
if (typeof window.sbLoadAds === 'function') window.sbLoadAds();
},
essential() {
localStorage.setItem('sb_cookie_consent', 'essential');
this.show = false;
}
}"
x-show="show"
x-cloak
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-4"
class="fixed bottom-0 left-0 right-0 z-50 border-t border-orange-400/30 bg-orange-950/50 backdrop-blur-2xl px-4 md:px-8 py-5"
role="dialog"
aria-label="Cookie consent"
aria-live="polite"
>
<div class="max-w-6xl mx-auto flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-6">
<div class="flex items-start gap-3 flex-1">
<span class="text-orange-400 mt-0.5 shrink-0 text-lg">🍪</span>
<p class="text-sm text-orange-100/90 leading-relaxed">
We use <strong class="text-white">essential cookies</strong> to keep you logged in and protect your session.
With your permission we also load <strong class="text-white">advertising cookies</strong> from third-party networks.
<a href="/privacy-policy#cookies" class="text-orange-300 hover:text-orange-200 hover:underline ml-1">Learn more </a>
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
@click="essential()"
class="rounded-lg border border-orange-400/40 px-4 py-2 text-sm text-orange-200 hover:text-white hover:border-orange-400/70 hover:bg-white/5 transition-colors"
>Essential only</button>
<button
@click="accept()"
class="rounded-lg bg-orange-500 hover:bg-orange-400 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-orange-900/40 transition-colors"
>Accept all</button>
</div>
</div>
</div>
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,83 @@
{{--
ContentLayout minimal hero for tags directory, blog, static pages, legal.
Used by /tags, /blog/*, /pages/*, /about, /help, /legal/*
Expected variables:
$page_title, $page_meta_description, $page_canonical, $page_robots
$breadcrumbs (collection, optional)
Content via @yield('page-content')
--}}
@extends('layouts.nova')
@push('head')
@isset($page_canonical)
<link rel="canonical" href="{{ $page_canonical }}" />
@endisset
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
<meta property="og:title" content="{{ $page_title ?? 'Skinbase' }}" />
<meta property="og:description" content="{{ $page_meta_description ?? '' }}" />
<meta property="og:site_name" content="Skinbase" />
{{-- Breadcrumb structured data --}}
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $breadcrumbs->values()->map(fn ($crumb, $i) => [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $crumb->name,
'item' => url($crumb->url),
])->all(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endif
@endpush
@section('content')
{{-- Minimal hero --}}
@if(!empty($center_content))
<x-centered-content :max="$center_max ?? '3xl'" class="pt-10 pb-6" style="padding-top:2.5rem;padding-bottom:1.5rem;">
{{-- Breadcrumbs --}}
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
<div class="mt-4">
<h1 class="text-3xl font-bold text-white leading-tight">
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
</h1>
@isset($hero_description)
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
@endisset
</div>
</x-centered-content>
{{-- Page body (centered) --}}
<x-centered-content :max="$center_max ?? '3xl'" class="pb-16">
@yield('page-content')
</x-centered-content>
@else
<div class="px-6 pt-10 pb-6 md:px-10">
{{-- Breadcrumbs --}}
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
<div class="mt-4">
<h1 class="text-3xl font-bold text-white leading-tight">
{{ $hero_title ?? $page_title ?? 'Skinbase' }}
</h1>
@isset($hero_description)
<p class="mt-1 text-sm text-white/50 max-w-xl">{{ $hero_description }}</p>
@endisset
</div>
</div>
{{-- Page body --}}
<div class="px-6 pb-16 md:px-10">
@yield('page-content')
</div>
@endif
@endsection

View File

@@ -0,0 +1,47 @@
{{--
DiscoverLayout compact header + mode pills.
Used by /discover/* pages.
Expected variables:
$page_title, $description, $icon, $section
Content via @yield('discover-content')
--}}
@extends('layouts.nova')
@push('head')
@isset($page_canonical)
<link rel="canonical" href="{{ $page_canonical }}" />
@endisset
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
<meta property="og:title" content="{{ $page_title ?? 'Discover — Skinbase' }}" />
<meta property="og:description" content="{{ $description ?? '' }}" />
<meta property="og:site_name" content="Skinbase" />
@endpush
@section('content')
{{-- Compact header --}}
<div class="px-6 pt-10 pb-6 md:px-10">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Discover</p>
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
<i class="fa-solid {{ $icon ?? 'fa-compass' }} text-sky-400 text-2xl"></i>
{{ $page_title ?? 'Discover' }}
</h1>
@isset($description)
<p class="mt-1 text-sm text-white/50">{{ $description }}</p>
@endisset
</div>
{{-- Mode pills --}}
@include('web.discover._nav', ['section' => $section ?? ''])
</div>
</div>
{{-- Page body --}}
@yield('discover-content')
@endsection

View File

@@ -0,0 +1,166 @@
{{--
ExploreLayout hero header + mode tabs + filters + paginated grid.
Used by /explore/*, /tag/:slug, and gallery pages.
Expected variables:
$hero_title, $hero_description, $breadcrumbs (collection),
$current_sort, $sort_options, $artworks,
$contentTypes (collection, optional), $activeType (string, optional)
$page_title, $page_meta_description, $page_canonical, $page_robots
--}}
@extends('layouts.nova')
@php
use App\Banner;
$seoPage = max(1, (int) request()->query('page', 1));
$seoBase = url()->current();
$seoQ = request()->query(); unset($seoQ['page']);
$seoUrl = fn(int $p) => $seoBase . ($p > 1
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
? $artworks->nextPageUrl() : null;
@endphp
@push('head')
<link rel="canonical" href="{{ $page_canonical ?? $seoUrl($seoPage) }}">
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
<meta property="og:type" content="website" />
<meta property="og:url" content="{{ $page_canonical ?? $seoUrl(1) }}" />
<meta property="og:title" content="{{ $page_title ?? ($hero_title ?? 'Skinbase') }}" />
<meta property="og:description" content="{{ $page_meta_description ?? '' }}" />
<meta property="og:site_name" content="Skinbase" />
{{-- Breadcrumb structured data --}}
@if(isset($breadcrumbs) && $breadcrumbs->isNotEmpty())
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $breadcrumbs->values()->map(fn ($crumb, $i) => [
'@type' => 'ListItem',
'position' => $i + 1,
'name' => $crumb->name,
'item' => url($crumb->url),
])->all(),
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endif
@endpush
@section('content')
<div class="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
<div class="pt-0">
<div class="mx-auto w-full">
<div class="relative min-h-[calc(100vh-64px)]">
<main class="w-full">
{{-- ══ HERO HEADER ══ --}}
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>
<div class="absolute inset-0 opacity-20 bg-[radial-gradient(ellipse_80%_60%_at_50%_-10%,#E07A2130,transparent)]" aria-hidden="true"></div>
<div class="relative px-6 py-10 md:px-10 md:py-14">
{{-- Breadcrumbs --}}
@include('components.breadcrumbs', ['breadcrumbs' => $breadcrumbs ?? collect()])
{{-- Title panel --}}
<div class="mt-4 py-5">
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-white/95 leading-tight">
{{ $hero_title ?? 'Explore' }}
</h1>
@if(!empty($hero_description))
<p class="mt-2 text-sm leading-6 text-neutral-400 max-w-xl">{!! $hero_description !!}</p>
@endif
@if(is_object($artworks) && method_exists($artworks, 'total') && $artworks->total() > 0)
<div class="mt-3 flex items-center gap-1.5 text-xs text-neutral-500">
<svg class="h-3.5 w-3.5 text-accent/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span>{{ number_format($artworks->total()) }} artworks</span>
</div>
@endif
</div>
{{-- Content type chips (Explore only) --}}
@if(isset($contentTypes) && $contentTypes->isNotEmpty())
<div class="flex flex-wrap gap-2 mt-2">
@foreach($contentTypes as $ct)
<a href="{{ $ct->url }}"
class="inline-flex items-center px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
{{ ($activeType ?? '') === $ct->slug
? 'bg-sky-600 text-white'
: 'bg-white/[0.05] text-white/60 hover:bg-white/[0.1] hover:text-white' }}">
{{ $ct->name }}
</a>
@endforeach
</div>
@endif
</div>
<div class="absolute left-0 right-0 bottom-0 h-16 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
{{-- ══ RANKING TABS ══ --}}
@php
$rankingTabs = $sort_options ?? [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'new-hot', 'label' => '🚀 New & Hot'],
['value' => 'best', 'label' => '⭐ Best'],
['value' => 'latest', 'label' => '🕐 Latest'],
];
$activeTab = $current_sort ?? 'trending';
@endphp
<div class="sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
<div class="px-6 md:px-10">
<div class="flex items-center justify-between gap-4">
<nav class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist">
@foreach($rankingTabs as $tab)
@php $isActive = $activeTab === $tab['value']; @endphp
<button
role="tab"
aria-selected="{{ $isActive ? 'true' : 'false' }}"
data-rank-tab="{{ $tab['value'] }}"
class="gallery-rank-tab relative flex items-center gap-1.5 whitespace-nowrap px-5 py-4 text-sm font-medium transition-colors focus-visible:outline-none {{ $isActive ? 'text-white' : 'text-neutral-400 hover:text-white' }}"
>
{{ $tab['label'] }}
<span class="nb-tab-indicator absolute bottom-0 left-0 right-0 h-0.5 {{ $isActive ? 'bg-accent scale-x-100' : 'bg-transparent scale-x-0' }} transition-transform duration-300 origin-left rounded-full"></span>
</button>
@endforeach
</nav>
<button id="gallery-filter-panel-toggle" type="button"
class="hidden md:flex items-center gap-2 shrink-0 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white/80 hover:bg-white/10 hover:text-white transition-colors"
aria-haspopup="dialog" aria-expanded="false">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2a1 1 0 01-.293.707L13 13.414V19a1 1 0 01-.553.894l-4 2A1 1 0 017 21v-7.586L3.293 6.707A1 1 0 013 6V4z" /></svg>
Filters
</button>
</div>
</div>
</div>
{{-- ══ ARTWORK GRID ══ --}}
@yield('explore-grid')
{{-- ══ PAGINATION ══ --}}
@if(is_object($artworks) && method_exists($artworks, 'links'))
<div class="px-6 md:px-10 py-8 flex justify-center">
{{ $artworks->withQueryString()->links() }}
</div>
@endif
</main>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -7,12 +7,18 @@
</div>
<div class="flex flex-wrap gap-x-6 gap-y-2 text-sm text-neutral-400">
<a class="hover:text-white" href="/bug-report">Bug Report</a>
<a class="hover:text-white" href="/contact">Contact / Apply</a>
<a class="hover:text-white" href="/rss-feeds">RSS Feeds</a>
<a class="hover:text-white" href="/faq">FAQ</a>
<a class="hover:text-white" href="/rules-and-guidelines">Rules and Guidelines</a>
<a class="hover:text-white" href="/staff">Staff</a>
<a class="hover:text-white" href="/privacy-policy">Privacy Policy</a>
<a class="hover:text-white" href="/terms-of-service">Terms of Service</a>
<button
x-data
@click="localStorage.removeItem('sb_cookie_consent'); window.location.reload()"
class="hover:text-white cursor-pointer bg-transparent border-0 p-0 text-sm text-neutral-400"
>Cookie Preferences</button>
</div>
<div class="text-xs text-neutral-400">© 2026 Skinbase.org</div>

View File

@@ -220,7 +220,9 @@
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
$routeEditProfile = Route::has('settings') ? route('settings') : '/settings';
$routeEditProfile = Route::has('dashboard.profile')
? route('dashboard.profile')
: (Route::has('settings') ? route('settings') : '/settings');
$routePublicProfile = Route::has('profile.show') ? route('profile.show', ['username' => $toolbarUsername]) : '/@'.$toolbarUsername;
@endphp
@@ -343,7 +345,7 @@
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ $mobileProfile }}">
<i class="fa-solid fa-circle-user w-4 text-center text-sb-muted"></i>View Profile
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('settings') ? route('settings') : '/settings' }}">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('dashboard.profile') ? route('dashboard.profile') : (Route::has('settings') ? route('settings') : '/settings') }}">
<i class="fa-solid fa-cog w-4 text-center text-sb-muted"></i>Settings
</a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))

View File

@@ -0,0 +1,28 @@
<?php echo '<?xml version="1.0" encoding="UTF-8"?>'; ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>{{ htmlspecialchars($channelTitle) }}</title>
<link>{{ $channelLink }}</link>
<description>{{ htmlspecialchars($channelDescription) }}</description>
<language>en-us</language>
<lastBuildDate>{{ $buildDate }}</lastBuildDate>
<atom:link href="{{ $feedUrl }}" rel="self" type="application/rss+xml" />
@foreach ($artworks as $artwork)
<item>
<title><![CDATA[{{ $artwork->title }}]]></title>
<link>{{ url('/art/' . $artwork->id . '/' . ($artwork->slug ?? '')) }}</link>
<guid isPermaLink="true">{{ url('/art/' . $artwork->id . '/' . ($artwork->slug ?? '')) }}</guid>
<pubDate>{{ $artwork->published_at?->toRfc2822String() }}</pubDate>
<author><![CDATA[{{ $artwork->user?->username ?? 'Unknown' }}]]></author>
@if ($artwork->description)
<description><![CDATA[{{ strip_tags($artwork->description) }}]]></description>
@endif
@php $thumb = $artwork->thumbUrl('sm'); @endphp
@if ($thumb)
<media:thumbnail url="{{ $thumb }}" />
<media:content url="{{ $thumb }}" medium="image" />
@endif
</item>
@endforeach
</channel>
</rss>

View File

@@ -0,0 +1,18 @@
@extends('layouts.nova')
@push('head')
<meta name="csrf-token" content="{{ csrf_token() }}" />
@vite(['resources/js/settings.jsx'])
<style>
body.page-settings main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-settings')
})
</script>
@endpush
@section('content')
@inertia
@endsection

View File

@@ -0,0 +1,86 @@
@extends('layouts.nova.content-layout')
@php
$page_title = 'Apply to Join the Team';
$hero_description = "We're always grateful for volunteers who want to help.";
$center_content = true;
$center_max = '3xl';
@endphp
@section('page-content')
<div class="max-w-3xl">
@if(session('success'))
<div class="mb-4 rounded-lg bg-emerald-800/20 border border-emerald-700 p-4 text-emerald-200">
{{ session('success') }}
</div>
@endif
<form x-data='{ topic: @json(old('topic','apply')) }' method="POST" action="{{ route('contact.submit') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm font-medium text-neutral-200">Reason for contact</label>
<select name="topic" x-model="topic" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white">
<option value="apply" {{ old('topic') === 'apply' ? 'selected' : '' }}>Apply to join the team</option>
<option value="bug" {{ old('topic') === 'bug' ? 'selected' : '' }}>Report a bug / site issue</option>
<option value="contact" {{ old('topic') === 'contact' ? 'selected' : '' }}>General contact / question</option>
<option value="other" {{ old('topic') === 'other' ? 'selected' : '' }}>Other</option>
</select>
@error('topic') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-neutral-200">Full name</label>
<input name="name" value="{{ old('name') }}" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" required />
@error('name') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-neutral-200">Email</label>
<input name="email" value="{{ old('email') }}" type="email" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" required />
@error('email') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div x-show="topic === 'apply'" x-cloak>
<label class="block text-sm font-medium text-neutral-200">Role you're applying for</label>
<input name="role" value="{{ old('role') }}" placeholder="e.g. Moderator, Community Manager" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" />
@error('role') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div x-show="topic === 'apply'" x-cloak>
<label class="block text-sm font-medium text-neutral-200">Portfolio / profile (optional)</label>
<input name="portfolio" value="{{ old('portfolio') }}" placeholder="https://" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" />
@error('portfolio') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-neutral-200">Tell us about yourself</label>
<textarea name="message" rows="6" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white">{{ old('message') }}</textarea>
@error('message') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
</div>
{{-- Bug-specific fields --}}
<div x-show="topic === 'bug'" x-cloak>
<label class="block text-sm font-medium text-neutral-200">Affected URL (optional)</label>
<input name="affected_url" value="{{ old('affected_url') }}" placeholder="https://" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white" />
@error('affected_url') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
<label class="block text-sm font-medium text-neutral-200 mt-3">Steps to reproduce (optional)</label>
<textarea name="steps" rows="4" class="mt-1 block w-full rounded bg-white/2 border border-white/10 p-2 text-white">{{ old('steps') }}</textarea>
@error('steps') <p class="text-rose-400 text-sm mt-1">{{ $message }}</p> @enderror
</div>
{{-- Honeypot field (hidden from real users) --}}
<div style="display:none;" aria-hidden="true">
<label>Website</label>
<input type="text" name="website" value="" autocomplete="off" />
</div>
<div class="flex items-center justify-end">
<button type="submit" class="inline-flex items-center gap-2 rounded bg-sky-500 px-4 py-2 text-sm font-medium text-white hover:bg-sky-600">Submit</button>
</div>
</form>
<p class="mt-6 text-sm text-neutral-400">By submitting this form you consent to Skinbase storing your application details for review.</p>
</div>
@endsection

View File

@@ -0,0 +1,54 @@
{{--
Blog index uses ContentLayout.
--}}
@extends('layouts.nova.content-layout')
@php
$hero_title = 'Blog';
$hero_description = 'News, tutorials and community stories from the Skinbase team.';
@endphp
@section('page-content')
@if($posts->isNotEmpty())
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($posts as $post)
<a href="{{ $post->url }}"
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
@if($post->featured_image)
<div class="aspect-video bg-nova-800 overflow-hidden">
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" loading="lazy" />
</div>
@else
<div class="aspect-video bg-gradient-to-br from-sky-900/30 to-purple-900/30 flex items-center justify-center">
<i class="fa-solid fa-newspaper text-3xl text-white/20"></i>
</div>
@endif
<div class="p-5">
<h2 class="text-lg font-semibold text-white group-hover:text-sky-300 transition-colors line-clamp-2">
{{ $post->title }}
</h2>
@if($post->excerpt)
<p class="mt-2 text-sm text-white/50 line-clamp-3">{{ $post->excerpt }}</p>
@endif
@if($post->published_at)
<time class="mt-3 block text-xs text-white/30" datetime="{{ $post->published_at->toIso8601String() }}">
{{ $post->published_at->format('M j, Y') }}
</time>
@endif
</div>
</a>
@endforeach
</div>
<div class="mt-10 flex justify-center">
{{ $posts->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
<i class="fa-solid fa-newspaper text-4xl text-white/20 mb-4"></i>
<p class="text-white/40 text-sm">No blog posts published yet. Check back soon!</p>
</div>
@endif
@endsection

View File

@@ -0,0 +1,60 @@
{{--
Blog post uses ContentLayout.
--}}
@extends('layouts.nova.content-layout')
@php
$hero_title = $post->title;
@endphp
@push('head')
{{-- Article structured data --}}
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $post->title,
'datePublished' => $post->published_at?->toIso8601String(),
'dateModified' => $post->updated_at?->toIso8601String(),
'author' => [
'@type' => 'Organization',
'name' => 'Skinbase',
],
'publisher' => [
'@type' => 'Organization',
'name' => 'Skinbase',
],
'description' => $post->meta_description ?: $post->excerpt ?: '',
'mainEntityOfPage' => $post->url,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endpush
@section('page-content')
<article class="max-w-3xl">
@if($post->featured_image)
<div class="rounded-xl overflow-hidden mb-8">
<img src="{{ $post->featured_image }}" alt="{{ $post->title }}" class="w-full" />
</div>
@endif
@if($post->published_at)
<time class="block text-sm text-white/40 mb-4" datetime="{{ $post->published_at->toIso8601String() }}">
{{ $post->published_at->format('F j, Y') }}
</time>
@endif
<div class="prose prose-invert prose-headings:text-white prose-a:text-sky-400 prose-p:text-white/70 max-w-none">
{!! $post->body !!}
</div>
<div class="mt-12 pt-8 border-t border-white/10">
<a href="/blog" class="inline-flex items-center gap-2 text-sm text-sky-400 hover:text-sky-300 transition-colors">
<i class="fa-solid fa-arrow-left text-xs"></i>
Back to Blog
</a>
</div>
</article>
@endsection

View File

@@ -0,0 +1,75 @@
@extends('layouts.nova.content-layout')
@section('page-content')
@if ($success)
<div class="mb-6 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 px-5 py-4 text-sm">
Your report was submitted successfully. Thank you we'll look into it as soon as possible.
</div>
@endif
<div class="max-w-2xl">
@guest
<div class="rounded-lg bg-nova-800 border border-neutral-700 px-6 py-8 text-center">
<svg class="mx-auto mb-3 h-10 w-10 text-neutral-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
</svg>
<p class="text-neutral-400 text-sm mb-4">You need to be signed in to submit a bug report.</p>
<a href="{{ route('login') }}" class="inline-block rounded-md bg-accent px-5 py-2 text-sm font-medium text-white hover:opacity-90">
Sign In
</a>
</div>
@else
<p class="text-neutral-400 text-sm mb-6">
Found a bug or have a suggestion? Fill out the form below and our team will review it.
For security issues, please contact us directly via email.
</p>
<form action="{{ route('bug-report.submit') }}" method="POST" class="space-y-5">
@csrf
<div>
<label for="subject" class="block text-sm font-medium text-neutral-300 mb-1">Subject</label>
<input
type="text"
id="subject"
name="subject"
required
maxlength="255"
value="{{ old('subject') }}"
placeholder="Brief summary of the issue"
class="w-full rounded-md bg-nova-800 border border-neutral-700 px-4 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
>
@error('subject')
<p class="mt-1 text-xs text-red-400">{{ $message }}</p>
@enderror
</div>
<div>
<label for="description" class="block text-sm font-medium text-neutral-300 mb-1">Description</label>
<textarea
id="description"
name="description"
required
rows="7"
maxlength="5000"
placeholder="Describe the bug in detail — steps to reproduce, what you expected, and what actually happened."
class="w-full rounded-md bg-nova-800 border border-neutral-700 px-4 py-2.5 text-sm text-white placeholder-neutral-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-y"
>{{ old('description') }}</textarea>
@error('description')
<p class="mt-1 text-xs text-red-400">{{ $message }}</p>
@enderror
</div>
<div>
<button type="submit"
class="rounded-md bg-accent px-6 py-2.5 text-sm font-medium text-white hover:opacity-90 transition-opacity">
Submit Report
</button>
</div>
</form>
@endguest
</div>
@endsection

View File

@@ -38,7 +38,7 @@
$profileUrl = ($creator->username ?? null)
? '/@' . $creator->username
: '/profile/' . (int) $creator->user_id;
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, null, 40);
$avatarUrl = \App\Support\AvatarUrl::forUser((int) $creator->user_id, $creator->avatar_hash ?? null, 40);
@endphp
<div class="grid grid-cols-[3rem_1fr_auto] items-center gap-4 px-5 py-4
{{ $rank <= 3 ? 'bg-white/[0.015]' : '' }} hover:bg-white/[0.03] transition-colors">
@@ -62,7 +62,7 @@
<div class="min-w-0">
<p class="text-sm font-medium text-white truncate">{{ $creator->uname }}</p>
@if($creator->username ?? null)
<p class="text-xs text-white/40 truncate">@{{ $creator->username }}</p>
<p class="text-xs text-white/40 truncate">{{ '@' . $creator->username }}</p>
@endif
</div>
</a>

View File

@@ -0,0 +1,73 @@
{{--
Explore index uses ExploreLayout.
Displays an artwork grid with hero header, mode tabs, pagination.
--}}
@extends('layouts.nova.explore-layout')
@section('explore-grid')
{{-- ══ EGS §11: FEATURED SPOTLIGHT ROW ══ --}}
@if(!empty($spotlight) && $spotlight->isNotEmpty())
<div class="px-6 md:px-10 pt-6 pb-2">
<div class="flex items-center gap-2 mb-4">
<span class="text-xs font-semibold uppercase tracking-widest text-amber-400"> Featured Today</span>
<span class="flex-1 border-t border-white/10"></span>
</div>
<div class="flex gap-4 overflow-x-auto nb-scrollbar-none pb-2">
@foreach($spotlight as $item)
<a href="{{ $item->slug ? route('artwork.show', $item->slug) : '#' }}"
class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
bg-neutral-800 border border-white/10 hover:border-amber-400/40
hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200"
title="{{ $item->name ?? '' }}">
{{-- Thumbnail --}}
<div class="aspect-[4/3] overflow-hidden bg-neutral-900">
<img
src="{{ $item->thumb_url ?? '' }}"
@if(!empty($item->thumb_srcset)) srcset="{{ $item->thumb_srcset }}" @endif
alt="{{ $item->name ?? 'Featured artwork' }}"
loading="lazy"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</div>
{{-- Label overlay --}}
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-3 py-2">
<p class="text-xs font-medium text-white truncate leading-snug">{{ $item->name ?? '' }}</p>
@if(!empty($item->uname))
<p class="text-[10px] text-neutral-400 truncate">@{{ $item->uname }}</p>
@endif
</div>
</a>
@endforeach
</div>
</div>
@endif
@php
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
'id' => $art->id,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
])->values();
@endphp
<div
data-react-masonry-gallery
data-artworks="{{ json_encode($galleryArtworks) }}"
data-gallery-type="explore"
data-limit="24"
class="min-h-32 px-6 md:px-10 py-6"
></div>
@endsection

View File

@@ -0,0 +1,332 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-3xl space-y-10">
{{-- Intro --}}
<div>
<p class="text-neutral-400 text-sm mb-2">Last updated: March 1, 2026</p>
<p class="text-neutral-300 leading-relaxed">
Answers to the questions we hear most often. If something isn't covered here, feel free to reach out to
any <a href="/staff" class="text-sky-400 hover:underline">staff member</a> we're happy to help.
</p>
</div>
{{-- Table of Contents --}}
<nav class="rounded-xl border border-white/10 bg-white/[0.03] p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">Contents</p>
<ol class="space-y-1.5 text-sm text-sky-400">
<li><a href="#about" class="hover:underline">01 About Skinbase</a></li>
<li><a href="#uploading" class="hover:underline">02 Uploading &amp; Submissions</a></li>
<li><a href="#copyright" class="hover:underline">03 Copyright &amp; Photoskins</a></li>
<li><a href="#skinning" class="hover:underline">04 Skinning Help</a></li>
<li><a href="#account" class="hover:underline">05 Account &amp; Profile</a></li>
<li><a href="#community" class="hover:underline">06 Community &amp; Forums</a></li>
<li><a href="#policies" class="hover:underline">07 Policies &amp; Conduct</a></li>
</ol>
</nav>
{{-- 01 About Skinbase --}}
<section id="about">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">01</span>
About Skinbase
</h2>
<dl class="space-y-6">
<div>
<dt class="font-medium text-white">What is Skinbase?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Skinbase is a community gallery for desktop customisation skins, themes, wallpapers, icons, and
more. Members upload their own creations, collect favourites, leave feedback, and discuss all things
design in the forums. We've been online since 2001 and still going strong.
</dd>
</div>
<div>
<dt class="font-medium text-white">Is Skinbase free to use?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Yes, completely. Browsing and downloading are free without an account. Registering (also free)
unlocks uploading, commenting, favourites, collections, and messaging.
</dd>
</div>
<div>
<dt class="font-medium text-white">Who runs Skinbase?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Skinbase is maintained by a small volunteer <a href="/staff" class="text-sky-400 hover:underline">staff team</a>.
Staff moderate uploads, help members, and keep the lights on. There is no corporate ownership
this is a community project.
</dd>
</div>
<div>
<dt class="font-medium text-white">How can I support Skinbase?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
The best support is participation upload your work, leave constructive comments, report problems,
and invite other creators. You can also help by flagging rule-breaking content so staff can review it quickly.
</dd>
</div>
</dl>
</section>
{{-- 02 Uploading & Submissions --}}
<section id="uploading">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">02</span>
Uploading &amp; Submissions
</h2>
<dl class="space-y-6">
<div>
<dt class="font-medium text-white">What file types are accepted?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Skins and resources are generally uploaded as <strong class="text-white">.zip</strong> archives.
Preview images are accepted as JPG, PNG, or WebP. Wallpapers may be uploaded directly as image files.
Check the upload form for the exact size and type limits per category.
</dd>
</div>
<div>
<dt class="font-medium text-white">Is there a file size limit?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Yes. The current limit is displayed on the upload page. If your file exceeds the limit, try removing
any unnecessary assets from the archive before re-uploading.
</dd>
</div>
<div>
<dt class="font-medium text-white">Why was my upload removed?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Uploads are removed when they break the <a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>
most commonly for containing photographs you don't own (photoskins), missing preview images, or
violating copyright. You will usually receive a message explaining the reason.
If you believe a removal was in error, contact a staff member.
</dd>
</div>
<div>
<dt class="font-medium text-white">Can I upload work-in-progress skins?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
You may share works-in-progress in the forums for feedback. The main gallery is intended for
finished, download-ready submissions only.
</dd>
</div>
</dl>
</section>
{{-- 03 Copyright & Photoskins --}}
<section id="copyright">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">03</span>
Copyright &amp; Photoskins
</h2>
<dl class="space-y-6">
<div>
<dt class="font-medium text-white">What is a photoskin?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
A photoskin is a skin that uses photographic images typically sourced from the internet as its
primary visual element. Because those photos belong to the photographer (or their publisher), using
them without permission is copyright infringement.
</dd>
</div>
<div>
<dt class="font-medium text-white">Can I upload photoskins?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
<strong class="text-white">No.</strong> Photoskins are not allowed on Skinbase. All artwork in a
skin must be created by you, or you must include written proof of permission from the original
artist inside the zip file. Stock images with a licence that explicitly permits use in
redistributed works are allowed include a copy of that licence in the zip.
</dd>
</div>
<div>
<dt class="font-medium text-white">Can I base my skin on someone else's artwork?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Only with documented permission. Include the permission statement (email, forum post, etc.) inside
your zip file and note it in your upload description. Staff may still remove the work if we cannot
verify the permission.
</dd>
</div>
<div>
<dt class="font-medium text-white">Someone uploaded my artwork without permission. What do I do?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Use the Report button on the artwork's page, or contact a
<a href="/staff" class="text-sky-400 hover:underline">staff member</a> directly.
Provide a link to the infringing upload and evidence that you are the copyright holder.
We take copyright complaints seriously and act promptly.
</dd>
</div>
</dl>
</section>
{{-- 04 Skinning Help --}}
<section id="skinning">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">04</span>
Skinning Help
</h2>
<dl class="space-y-6">
<div>
<dt class="font-medium text-white">How do I make a skin?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Every application is different, but skins generally consist of a folder of images and small
text/config files. A good starting point is to unpack an existing skin (many Winamp skins are
simply renamed <code class="text-sky-300">.zip</code> files), study the structure, then replace the
images with your own artwork. Check the application's official documentation for its exact format.
</dd>
</div>
<div>
<dt class="font-medium text-white">How do I apply a Windows theme?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
To change the visual style of Windows you typically need a third-party tool such as
<strong class="text-white">WindowBlinds</strong>, <strong class="text-white">SecureUxTheme</strong>,
or a patched <code class="text-sky-300">uxtheme.dll</code>. Install your chosen tool, download a
compatible theme from Skinbase, then follow the tool's instructions to apply it.
</dd>
</div>
<div>
<dt class="font-medium text-white">Where can I get help with a specific application?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
The forums are the best place there are dedicated sections for popular skinnable applications.
You can also check the comments on popular skins for tips from other members.
</dd>
</div>
<div>
<dt class="font-medium text-white">What image editing software do skinners use?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
The community uses a wide range of tools. Popular choices include
<strong class="text-white">Adobe Photoshop</strong>, <strong class="text-white">GIMP</strong> (free),
<strong class="text-white">Affinity Designer</strong>, <strong class="text-white">Figma</strong>,
and <strong class="text-white">Krita</strong> (free). The best tool is the one you're comfortable with.
</dd>
</div>
</dl>
</section>
{{-- 05 Account & Profile --}}
<section id="account">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">05</span>
Account &amp; Profile
</h2>
<dl class="space-y-6">
<div>
<dt class="font-medium text-white">How do I set a profile picture?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Go to <strong class="text-white">Settings Avatar</strong>, choose an image from your device, and
save. Your avatar appears on your profile page and next to all your comments.
</dd>
</div>
<div>
<dt class="font-medium text-white">Can I change my username?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Username changes are handled by staff. Send a message to a
<a href="/staff" class="text-sky-400 hover:underline">staff member</a> with your requested new name.
We reserve the right to decline requests that are inappropriate or conflict with an existing account.
</dd>
</div>
<div>
<dt class="font-medium text-white">How do I delete my account?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Account deletion requests must be sent to staff. Please be aware that your publicly submitted
artwork may remain in the gallery under your username unless you also request removal of specific
uploads. See our <a href="/privacy-policy" class="text-sky-400 hover:underline">Privacy Policy</a>
for details on data retention.
</dd>
</div>
<div>
<dt class="font-medium text-white">I forgot my password. How do I reset it?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Use the <strong class="text-white">Forgot password?</strong> link on the login page. An email with
a reset link will be sent to the address on your account. If you no longer have access to that
email, contact a staff member for assistance.
</dd>
</div>
</dl>
</section>
{{-- 06 Community & Forums --}}
<section id="community">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">06</span>
Community &amp; Forums
</h2>
<dl class="space-y-6">
<div>
<dt class="font-medium text-white">Do I need an account to use the forums?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Guests can read most forum threads without an account. Posting, replying, and creating new topics
require a registered account.
</dd>
</div>
<div>
<dt class="font-medium text-white">Can I promote my own work in the forums?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Yes there are dedicated showcase and feedback sections. Limit self-promotion to those areas and
avoid spamming multiple threads with the same content.
</dd>
</div>
<div>
<dt class="font-medium text-white">How do I report a bad comment or forum post?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Every comment and post has a Report link. Use it to flag content that breaks the rules; staff will
review it promptly. For urgent issues, message a
<a href="/staff" class="text-sky-400 hover:underline">staff member</a> directly.
</dd>
</div>
<div>
<dt class="font-medium text-white">What are the messaging rules?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Private messaging is for genuine one-to-one communication. Do not use it to harass, solicit, or
send unsolicited promotional material. Violations can result in messaging being disabled on your account.
</dd>
</div>
</dl>
</section>
{{-- 07 Policies & Conduct --}}
<section id="policies">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">07</span>
Policies &amp; Conduct
</h2>
<dl class="space-y-6">
<div>
<dt class="font-medium text-white">Are there many rules to follow?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
We keep the rules straightforward: respect everyone, only upload work you own or have permission to
share, and keep it drama-free. The full list is in our
<a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>.
</dd>
</div>
<div>
<dt class="font-medium text-white">What happens if I break the rules?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Depending on severity, staff may issue a warning, remove the offending content, temporarily
restrict your account, or permanently ban you. Serious offences (harassment, illegal content)
result in an immediate permanent ban with no prior warning.
</dd>
</div>
<div>
<dt class="font-medium text-white">How do I appeal a ban or removed upload?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Contact a senior staff member and explain the situation calmly. Provide any supporting evidence.
Staff decisions can be reversed when new information comes to light, but appeals submitted
aggressively or repeatedly will not be reconsidered.
</dd>
</div>
<div>
<dt class="font-medium text-white">I still can't find what I need. What now?</dt>
<dd class="mt-1.5 text-sm text-neutral-400 leading-relaxed">
Send a private message to any <a href="/staff" class="text-sky-400 hover:underline">staff member</a>
or post in the Help section of the forums. Someone from the community will usually respond
quickly.
</dd>
</div>
</dl>
</section>
{{-- Footer note --}}
<div class="rounded-xl border border-white/10 bg-white/[0.03] p-5 text-sm text-neutral-400 leading-relaxed">
This FAQ is reviewed periodically. For legal matters such as copyright, data, or account deletion,
please refer to our <a href="/privacy-policy" class="text-sky-400 hover:underline">Privacy Policy</a>
and <a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>,
or contact the <a href="/staff" class="text-sky-400 hover:underline">staff team</a> directly.
</div>
</div>
@endsection

View File

@@ -0,0 +1,18 @@
{{--
Static page uses ContentLayout.
--}}
@extends('layouts.nova.content-layout')
@php
$hero_title = $page->title;
@endphp
@section('page-content')
<article class="max-w-3xl">
<div class="prose prose-invert prose-headings:text-white prose-a:text-sky-400 prose-p:text-white/70 max-w-none">
{!! $page->body !!}
</div>
</article>
@endsection

View File

@@ -0,0 +1,392 @@
@extends('layouts.nova.content-layout')
@section('page-content')
{{-- Table of contents --}}
<div class="max-w-3xl">
<p class="text-sm text-white/40 mb-1">Last updated: <time datetime="2026-03-01">March 1, 2026</time></p>
<p class="text-white/60 text-sm leading-relaxed mb-8">
This Privacy Policy explains how Skinbase ("we", "us", "our") collects, uses, stores, and protects
information about you when you use our website at <strong class="text-white">skinbase.org</strong>.
By using Skinbase you agree to the practices described in this policy.
</p>
{{-- TOC --}}
<nav class="mb-10 rounded-xl border border-white/[0.08] bg-white/[0.03] px-6 py-5">
<h2 class="text-xs font-semibold uppercase tracking-widest text-white/40 mb-3">Contents</h2>
<ol class="space-y-1.5 text-sm text-sky-400">
<li><a href="#information-we-collect" class="hover:text-sky-300 hover:underline transition-colors">1. Information We Collect</a></li>
<li><a href="#how-we-use-information" class="hover:text-sky-300 hover:underline transition-colors">2. How We Use Your Information</a></li>
<li><a href="#cookies" class="hover:text-sky-300 hover:underline transition-colors">3. Cookies &amp; Tracking</a></li>
<li><a href="#sharing" class="hover:text-sky-300 hover:underline transition-colors">4. Sharing of Information</a></li>
<li><a href="#user-content" class="hover:text-sky-300 hover:underline transition-colors">5. User-Generated Content</a></li>
<li><a href="#data-retention" class="hover:text-sky-300 hover:underline transition-colors">6. Data Retention</a></li>
<li><a href="#security" class="hover:text-sky-300 hover:underline transition-colors">7. Security</a></li>
<li><a href="#your-rights" class="hover:text-sky-300 hover:underline transition-colors">8. Your Rights</a></li>
<li><a href="#advertising" class="hover:text-sky-300 hover:underline transition-colors">9. Advertising</a></li>
<li><a href="#third-party-links" class="hover:text-sky-300 hover:underline transition-colors">10. Third-Party Links</a></li>
<li><a href="#children" class="hover:text-sky-300 hover:underline transition-colors">11. Children's Privacy</a></li>
<li><a href="#changes" class="hover:text-sky-300 hover:underline transition-colors">12. Changes to This Policy</a></li>
<li><a href="#contact" class="hover:text-sky-300 hover:underline transition-colors">13. Contact Us</a></li>
</ol>
</nav>
{{-- Sections --}}
<div class="space-y-10">
{{-- 1 --}}
<section id="information-we-collect">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">01</span>
Information We Collect
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
We collect information in two ways: information you give us directly, and information
collected automatically as you use the site.
</p>
<h3 class="text-base font-semibold text-white mt-5 mb-2">Information you provide</h3>
<ul class="list-disc list-inside space-y-1.5 text-sm text-white/70 pl-2">
<li><strong class="text-white/90">Account registration</strong> username, email address, and password (stored as a secure hash).</li>
<li><strong class="text-white/90">Profile information</strong> display name, avatar, bio, website URL, and location if you choose to provide them.</li>
<li><strong class="text-white/90">Uploaded content</strong> artworks, wallpapers, skins, and photographs, along with their titles, descriptions, and tags.</li>
<li><strong class="text-white/90">Communications</strong> messages sent through features such as private messaging, forum posts, comments, and bug reports.</li>
</ul>
<h3 class="text-base font-semibold text-white mt-5 mb-2">Information collected automatically</h3>
<ul class="list-disc list-inside space-y-1.5 text-sm text-white/70 pl-2">
<li><strong class="text-white/90">Log data</strong> IP address, browser type and version, operating system, referring URL, pages visited, and timestamps.</li>
<li><strong class="text-white/90">Usage data</strong> download counts, favourite actions, search queries, and interaction events used to improve recommendations.</li>
<li><strong class="text-white/90">Cookies &amp; local storage</strong> see Section 3 for full details.</li>
</ul>
</section>
{{-- 2 --}}
<section id="how-we-use-information">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">02</span>
How We Use Your Information
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">We use the information we collect to:</p>
<ul class="list-disc list-inside space-y-1.5 text-sm text-white/70 pl-2">
<li>Provide, operate, and maintain the Skinbase service.</li>
<li>Authenticate your identity and keep your account secure.</li>
<li>Personalise your experience, including content recommendations.</li>
<li>Send transactional emails (password resets, email verification, notifications you subscribe to).</li>
<li>Moderate content and enforce our <a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>.</li>
<li>Analyse usage patterns to improve site performance and features.</li>
<li>Detect, prevent, and investigate fraud, abuse, or security incidents.</li>
<li>Comply with legal obligations.</li>
</ul>
<p class="mt-4 text-sm text-white/50">
We will never sell your personal data or use it for purposes materially different from those
stated above without first obtaining your explicit consent.
</p>
{{-- Lawful basis table (GDPR Art. 13(1)(c)) --}}
<h3 class="text-base font-semibold text-white mt-6 mb-3">Lawful basis for processing (GDPR Art. 6)</h3>
<div class="overflow-hidden rounded-lg border border-white/[0.08]">
<table class="w-full text-sm">
<thead class="bg-white/[0.05]">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/40">Processing activity</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/40">Lawful basis</th>
</tr>
</thead>
<tbody class="divide-y divide-white/[0.05]">
<tr class="bg-white/[0.02]">
<td class="px-4 py-3 text-white/80">Account registration &amp; authentication</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(b) Performance of contract</td>
</tr>
<tr>
<td class="px-4 py-3 text-white/80">Delivering and operating the Service</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(b) Performance of contract</td>
</tr>
<tr class="bg-white/[0.02]">
<td class="px-4 py-3 text-white/80">Transactional emails (password reset, verification)</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(b) Performance of contract</td>
</tr>
<tr>
<td class="px-4 py-3 text-white/80">Security, fraud prevention, abuse detection</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(f) Legitimate interests</td>
</tr>
<tr class="bg-white/[0.02]">
<td class="px-4 py-3 text-white/80">Analytics &amp; site-performance monitoring</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(f) Legitimate interests</td>
</tr>
<tr>
<td class="px-4 py-3 text-white/80">Essential cookies (session, CSRF, remember-me)</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(f) Legitimate interests</td>
</tr>
<tr class="bg-white/[0.02]">
<td class="px-4 py-3 text-white/80">Third-party advertising cookies</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(a) <strong class="text-white/90">Consent</strong> (via cookie banner)</td>
</tr>
<tr>
<td class="px-4 py-3 text-white/80">Compliance with legal obligations</td>
<td class="px-4 py-3 text-white/60">Art. 6(1)(c) Legal obligation</td>
</tr>
</tbody>
</table>
</div>
</section>
{{-- 3 --}}
<section id="cookies">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">03</span>
Cookies &amp; Tracking
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-4">
Skinbase uses cookies small text files stored in your browser to deliver a reliable,
personalised experience. No cookies are linked to sensitive personal data.
</p>
<div class="overflow-hidden rounded-lg border border-white/[0.08]">
<table class="w-full text-sm">
<thead class="bg-white/[0.05]">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/40">Cookie</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/40">Purpose</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-white/40">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-white/[0.05]">
<tr class="bg-white/[0.02]">
<td class="px-4 py-3 text-white/80 font-mono text-xs">skinbase_session</td>
<td class="px-4 py-3 text-white/60">Authentication session identifier</td>
<td class="px-4 py-3 text-white/50">Browser session</td>
</tr>
<tr>
<td class="px-4 py-3 text-white/80 font-mono text-xs">XSRF-TOKEN</td>
<td class="px-4 py-3 text-white/60">Cross-site request forgery protection</td>
<td class="px-4 py-3 text-white/50">Browser session</td>
</tr>
<tr class="bg-white/[0.02]">
<td class="px-4 py-3 text-white/80 font-mono text-xs">remember_web_*</td>
<td class="px-4 py-3 text-white/60">"Remember me" persistent login</td>
<td class="px-4 py-3 text-white/50">30 days</td>
</tr>
<tr>
<td class="px-4 py-3 text-white/80 font-mono text-xs">__gads, ar_debug,<br>DSID, IDE, NID</td>
<td class="px-4 py-3 text-white/60">Google AdSense interest-based ad targeting &amp; frequency capping. Only loaded after you accept cookies. (See Section 9)</td>
<td class="px-4 py-3 text-white/50">Up to 13 months</td>
</tr>
</tbody>
</table>
</div>
<p class="mt-3 text-sm text-white/50">
You can disable cookies in your browser settings. Doing so may prevent some features
(such as staying logged in) from working correctly.
</p>
</section>
{{-- 4 --}}
<section id="sharing">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">04</span>
Sharing of Information
</h2>
<p class="text-white/70 text-sm leading-relaxed">
We do not sell or rent your personal data. We may share information only in the following
limited circumstances:
</p>
<ul class="mt-3 list-disc list-inside space-y-1.5 text-sm text-white/70 pl-2">
<li><strong class="text-white/90">Legal requirements</strong> if required by law, court order, or governmental authority.</li>
<li><strong class="text-white/90">Protection of rights</strong> to enforce our policies, prevent fraud, or protect the safety of our users or the public.</li>
<li><strong class="text-white/90">Service providers</strong> trusted third-party vendors (e.g. hosting, email delivery, analytics) who are contractually bound to handle data only as instructed by us.</li>
<li><strong class="text-white/90">Business transfers</strong> in the event of a merger, acquisition, or sale of assets, you will be notified via email and/or a prominent notice on the site.</li>
</ul>
</section>
{{-- 5 --}}
<section id="user-content">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">05</span>
User-Generated Content
</h2>
<p class="text-white/70 text-sm leading-relaxed">
Artworks, comments, forum posts, and other content you upload or publish on Skinbase are
publicly visible. Do not include personal information (phone numbers, home addresses, etc.)
in public content. You retain ownership of your original work; by uploading you grant
Skinbase a non-exclusive licence to display and distribute it as part of the service.
You may delete your own content at any time from your dashboard.
</p>
<p class="mt-3 text-sm text-white/50">
Content found to infringe copyright or violate our rules will be removed.
To report a submission, please <a href="/bug-report" class="text-sky-400 hover:underline">contact a staff member</a>.
</p>
</section>
{{-- 6 --}}
<section id="data-retention">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">06</span>
Data Retention
</h2>
<p class="text-white/70 text-sm leading-relaxed">
We retain your account data for as long as your account is active. If you delete your
account, we will remove or anonymise your personal data within <strong class="text-white/90">30 days</strong>,
except where we are required to retain it for legal or fraud-prevention purposes.
Anonymised aggregate statistics (e.g. download counts) may be retained indefinitely.
Server log files containing IP addresses are rotated and deleted after <strong class="text-white/90">90 days</strong>.
</p>
</section>
{{-- 7 --}}
<section id="security">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">07</span>
Security
</h2>
<p class="text-white/70 text-sm leading-relaxed">
We implement industry-standard measures to protect your information, including:
</p>
<ul class="mt-3 list-disc list-inside space-y-1.5 text-sm text-white/70 pl-2">
<li>HTTPS (TLS) encryption for all data in transit.</li>
<li>Bcrypt hashing for all stored passwords we never store passwords in plain text.</li>
<li>CSRF protection on all state-changing requests.</li>
<li>Rate limiting and account lockouts to resist brute-force attacks.</li>
</ul>
<p class="mt-3 text-sm text-white/50">
No method of transmission over the Internet is 100% secure. If you believe your account
has been compromised, please <a href="/bug-report" class="text-sky-400 hover:underline">contact us immediately</a>.
</p>
</section>
{{-- 8 --}}
<section id="your-rights">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">08</span>
Your Rights
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
Depending on where you live, you may have certain rights over your personal data:
</p>
<div class="grid sm:grid-cols-2 gap-3">
@foreach ([
['Access', 'Request a copy of the personal data we hold about you.'],
['Rectification', 'Correct inaccurate or incomplete data via your account settings.'],
['Erasure', 'Request deletion of your account and associated personal data.'],
['Portability', 'Receive your data in a structured, machine-readable format.'],
['Restriction', 'Ask us to limit how we process your data in certain circumstances.'],
['Objection', 'Object to processing based on legitimate interests or for direct marketing.'],
] as [$right, $desc])
<div class="rounded-lg border border-white/[0.07] bg-white/[0.03] px-4 py-3">
<p class="text-sm font-semibold text-white mb-0.5">{{ $right }}</p>
<p class="text-xs text-white/50">{{ $desc }}</p>
</div>
@endforeach
</div>
<p class="mt-4 text-sm text-white/50">
To exercise any of these rights, please <a href="/bug-report" class="text-sky-400 hover:underline">contact us</a>.
We will respond within 30 days. You also have the right to lodge a complaint with your
local data protection authority.
</p>
</section>
{{-- 9 --}}
<section id="advertising">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">09</span>
Advertising
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
Skinbase uses <strong class="text-white/90">Google AdSense</strong> (operated by Google LLC,
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA) to display advertisements. Google AdSense
may use cookies and web beacons to collect information about your browsing activity in order to
serve interest-based (personalised) ads.
</p>
<p class="text-white/70 text-sm leading-relaxed mb-3">
<strong class="text-white/90">Consent required.</strong> Google AdSense cookies are only loaded
after you click <em>Accept all</em> in the cookie consent banner. If you choose
<em>Essential only</em>, no advertising cookies will be placed.
You can withdraw consent at any time by clicking <strong class="text-white/90">Cookie Preferences</strong>
in the footer.
</p>
<p class="text-white/70 text-sm leading-relaxed mb-3">
Data collected by Google AdSense (such as browser type, pages visited, and ad interactions) is
processed by Google under
<a href="https://policies.google.com/privacy" class="text-sky-400 hover:underline" target="_blank" rel="noopener noreferrer">Google's Privacy Policy</a>.
Skinbase does not share any personally identifiable information with Google AdSense beyond what is
automatically collected through the ad script.
</p>
<p class="text-white/70 text-sm leading-relaxed mb-3">
Google's use of advertising cookies can be managed at
<a href="https://www.google.com/settings/ads" class="text-sky-400 hover:underline" target="_blank" rel="noopener noreferrer">google.com/settings/ads</a>,
or you may opt out of personalised advertising through the
<a href="https://optout.aboutads.info/" class="text-sky-400 hover:underline" target="_blank" rel="noopener noreferrer">Digital Advertising Alliance opt-out</a>.
</p>
<p class="mt-1 text-sm text-white/50">
Registered members may see reduced advertising frequency depending on their account status.
</p>
</section>
{{-- 10 --}}
<section id="third-party-links">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">10</span>
Third-Party Links
</h2>
<p class="text-white/70 text-sm leading-relaxed">
Skinbase may contain links to external websites. We are not responsible for the privacy
practices or content of those sites and encourage you to review their privacy policies
before disclosing any personal information.
</p>
</section>
{{-- 11 --}}
<section id="children">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">11</span>
Children's Privacy
</h2>
<p class="text-white/70 text-sm leading-relaxed">
Skinbase is a general-audience website. In compliance with the Children's Online Privacy
Protection Act (COPPA) we do not knowingly collect personal information from children
under the age of <strong class="text-white/90">13</strong>. If we become aware that a
child under 13 has registered, we will promptly delete their account and data.
If you believe a child has provided us with personal information, please
<a href="/bug-report" class="text-sky-400 hover:underline">contact us</a>.
</p>
</section>
{{-- 12 --}}
<section id="changes">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">12</span>
Changes to This Policy
</h2>
<p class="text-white/70 text-sm leading-relaxed">
We may update this Privacy Policy from time to time. When we do, we will revise the
"Last updated" date at the top of this page. For material changes we will notify
registered members by email and/or by a prominent notice on the site. We encourage you
to review this policy periodically. Continued use of Skinbase after changes are posted
constitutes your acceptance of the revised policy.
</p>
</section>
{{-- 13 --}}
<section id="contact">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">13</span>
Contact Us
</h2>
<p class="text-white/70 text-sm leading-relaxed">
If you have any questions, concerns, or requests regarding this Privacy Policy or our
data practices, please reach out via our
<a href="/bug-report" class="text-sky-400 hover:underline">contact form</a> or by
sending a private message to any <a href="/staff" class="text-sky-400 hover:underline">staff member</a>.
We aim to respond to all privacy-related enquiries within <strong class="text-white/90">10 business days</strong>.
</p>
<div class="mt-6 rounded-lg border border-sky-500/20 bg-sky-500/5 px-5 py-4 text-sm text-sky-300">
<p class="font-semibold mb-1">Data Controller</p>
<p class="text-sky-300/70">
Skinbase.org &mdash; operated by the Skinbase team.<br>
Contact: <a href="/bug-report" class="underline hover:text-sky-200">via contact form</a>
</p>
</div>
</section>
</div>
</div>
@endsection

View File

@@ -0,0 +1,63 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-2xl space-y-10">
{{-- Feed list --}}
<div>
<h2 class="text-lg font-semibold text-white mb-4">Available Feeds</h2>
<ul class="divide-y divide-neutral-800 rounded-lg border border-neutral-800 overflow-hidden">
@foreach ($feeds as $key => $feed)
<li class="flex items-center gap-4 px-5 py-4 bg-nova-900/50 hover:bg-nova-800/60 transition-colors">
<svg class="h-6 w-6 flex-shrink-0 text-orange-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"/>
</svg>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white">{{ $feed['title'] }}</p>
<p class="text-xs text-neutral-500 truncate">{{ url($feed['url']) }}</p>
</div>
<a href="{{ $feed['url'] }}"
class="flex-shrink-0 rounded-md border border-neutral-700 px-3 py-1.5 text-xs text-neutral-400 hover:border-orange-500 hover:text-orange-400 transition-colors">
Subscribe
</a>
</li>
@endforeach
</ul>
</div>
{{-- About RSS --}}
<div class="prose prose-invert prose-sm max-w-none">
<h2>About RSS</h2>
<p>
RSS is a family of web feed formats used to publish frequently updated digital content,
such as blogs, news feeds, or upload streams. By subscribing to an RSS feed you can
follow Skinbase updates in your favourite feed reader without needing to visit the site.
</p>
<h3>How to subscribe</h3>
<p>
Copy one of the feed URLs above and paste it into your feed reader (e.g. Feedly, Inoreader,
or any app that supports RSS 2.0). The reader will automatically check for new content and
notify you of updates.
</p>
<h3>Feed formats</h3>
<ul>
<li>Really Simple Syndication (RSS 2.0)</li>
<li>Rich Site Summary (RSS 0.91, RSS 1.0)</li>
<li>RDF Site Summary (RSS 0.9 and 1.0)</li>
</ul>
<p>
RSS delivers its information as an XML file. Our feeds include title, description,
author, publication date, and a media thumbnail for each item.
</p>
</div>
</div>
@push('head')
@foreach ($feeds as $key => $feed)
<link rel="alternate" type="application/rss+xml" title="{{ $feed['title'] }} — Skinbase" href="{{ url($feed['url']) }}">
@endforeach
@endpush
@endsection

View File

@@ -0,0 +1,251 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-3xl">
<p class="text-sm text-white/40 mb-1">Last updated: <time datetime="2026-03-01">March 1, 2026</time></p>
<p class="text-white/60 text-sm leading-relaxed mb-8">
Skinbase is a creative community built on respect and trust. These rules apply to all members
and all content. By registering or uploading you agree to follow them. They are intentionally
kept minimal so that the most important ones are easy to remember <strong class="text-white">be respectful,
upload only what you own, and have fun.</strong>
</p>
{{-- TOC --}}
<nav class="mb-10 rounded-xl border border-white/[0.08] bg-white/[0.03] px-6 py-5">
<h2 class="text-xs font-semibold uppercase tracking-widest text-white/40 mb-3">Contents</h2>
<ol class="space-y-1.5 text-sm text-sky-400">
<li><a href="#community-conduct" class="hover:text-sky-300 hover:underline transition-colors">1. Community Conduct</a></li>
<li><a href="#ownership" class="hover:text-sky-300 hover:underline transition-colors">2. Ownership &amp; Copyright</a></li>
<li><a href="#licence" class="hover:text-sky-300 hover:underline transition-colors">3. Licence to Skinbase</a></li>
<li><a href="#submission-quality" class="hover:text-sky-300 hover:underline transition-colors">4. Submission Quality</a></li>
<li><a href="#prohibited-content" class="hover:text-sky-300 hover:underline transition-colors">5. Prohibited Content</a></li>
<li><a href="#ripping" class="hover:text-sky-300 hover:underline transition-colors">6. Ripping (Copyright Theft)</a></li>
<li><a href="#accounts" class="hover:text-sky-300 hover:underline transition-colors">7. Accounts &amp; Identity</a></li>
<li><a href="#moderation" class="hover:text-sky-300 hover:underline transition-colors">8. Moderation &amp; Enforcement</a></li>
<li><a href="#appeals" class="hover:text-sky-300 hover:underline transition-colors">9. Appeals</a></li>
<li><a href="#liability" class="hover:text-sky-300 hover:underline transition-colors">10. Liability</a></li>
</ol>
</nav>
<div class="space-y-10">
{{-- 1 --}}
<section id="community-conduct">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">01</span>
Community Conduct
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
Skinbase is a friendly, general-audience community. Treat every member and guest as you
would wish to be treated yourself.
</p>
<ul class="list-disc list-inside space-y-2 text-sm text-white/70 pl-2">
<li>Be respectful in comments, messages, forum posts, and all other interactions.</li>
<li>Constructive criticism is welcome; personal attacks, harassment, or bullying are not.</li>
<li>No hate speech, discrimination, or content targeting individuals based on race, ethnicity, religion, gender, sexual orientation, disability, or nationality.</li>
<li>No spam this includes repetitive comments, self-promotion outside designated areas, and unsolicited advertising in private messages.</li>
<li>Keep drama off the site. Disputes should be resolved respectfully or escalated to a <a href="/staff" class="text-sky-400 hover:underline">staff member</a>.</li>
</ul>
</section>
{{-- 2 --}}
<section id="ownership">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">02</span>
Ownership &amp; Copyright
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
You retain ownership of everything you create and upload. By submitting, you confirm that:
</p>
<ul class="list-disc list-inside space-y-2 text-sm text-white/70 pl-2">
<li>The work is entirely your own creation, <strong class="text-white/90">or</strong> you have explicit written permission from the original author for any third-party assets used.</li>
<li>If third-party assets are included, proof of permission must be included in the zip file.</li>
<li>The submission does not violate any trademark, copyright, or other intellectual property right.</li>
</ul>
<p class="mt-3 text-sm text-white/50">
Uploads found to infringe copyright will be removed. Repeat infringers will have their
accounts terminated. To report a suspected infringement, use our
<a href="/bug-report" class="text-sky-400 hover:underline">contact form</a>.
</p>
</section>
{{-- 3 --}}
<section id="licence">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">03</span>
Licence to Skinbase
</h2>
<p class="text-white/70 text-sm leading-relaxed">
By uploading your work you grant Skinbase a <strong class="text-white/90">non-exclusive, royalty-free licence</strong>
to display, distribute, and promote your content as part of the service for example,
displaying it in galleries, featuring it on the homepage, or including it in promotional
material for Skinbase. This licence exists only to allow the site to function and does
<strong class="text-white/90">not</strong> transfer ownership.
</p>
<p class="mt-3 text-sm text-white/50">
The site is free you don't pay to store your work, and we don't charge others to
download it. You may delete your uploads at any time from your dashboard.
</p>
</section>
{{-- 4 --}}
<section id="submission-quality">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">04</span>
Submission Quality
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
Every submission represents the Skinbase community. Please put care into what you publish:
</p>
<ul class="list-disc list-inside space-y-2 text-sm text-white/70 pl-2">
<li><strong class="text-white/90">Test before uploading.</strong> Incomplete or broken zip files will be removed.</li>
<li><strong class="text-white/90">Full-size screenshots only.</strong> Our server auto-generates thumbnails do not pre-scale your preview image.</li>
<li><strong class="text-white/90">Accurate categorisation.</strong> Choose the correct content type and category to help others find your work.</li>
<li><strong class="text-white/90">Meaningful title &amp; description.</strong> Titles like "skin1" or "untitled" are discouraged; a short description helps your work get discovered.</li>
<li><strong class="text-white/90">Appropriate tags.</strong> Add relevant tags, but do not keyword-stuff.</li>
</ul>
</section>
{{-- 5 --}}
<section id="prohibited-content">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">05</span>
Prohibited Content
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
The following will be removed immediately and may result in account suspension or permanent termination:
</p>
<div class="grid sm:grid-cols-2 gap-3">
@foreach ([
['Pornography &amp; explicit nudity', 'Frontal nudity is not accepted. Exceptional artistic work with incidental nudity may be considered on a case-by-case basis.'],
['Hate &amp; discriminatory content', 'Content that demeans or attacks people based on protected characteristics.'],
['Violence &amp; gore', 'Graphic depictions of real-world violence or gratuitous gore.'],
['Malware &amp; harmful files', 'Any executable or zip that contains malware, spyware, or harmful scripts.'],
['Personal information', 'Posting another person\'s private data (doxxing) without consent.'],
['Illegal content', 'Anything that violates applicable law, including DMCA violations.'],
] as [$title, $desc])
<div class="rounded-lg border border-white/[0.07] bg-white/[0.02] px-4 py-3">
<p class="text-sm font-semibold text-white mb-0.5">{!! $title !!}</p>
<p class="text-xs text-white/50">{{ $desc }}</p>
</div>
@endforeach
</div>
</section>
{{-- 6 --}}
<section id="ripping">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">06</span>
Ripping (Copyright Theft)
</h2>
<p class="text-white/70 text-sm leading-relaxed">
"Ripping" means uploading another artist's work in whole or in part without their
explicit permission. This includes extracting assets from commercial software, games, or
other skins and re-releasing them as your own. Ripped submissions will be removed and the
uploader's account will be reviewed. Repeat offenders will be permanently banned.
</p>
<div class="mt-4 rounded-lg border border-amber-500/20 bg-amber-500/5 px-5 py-4 text-sm text-amber-300">
<p class="font-semibold mb-1">Photo-based skins</p>
<p class="text-amber-300/70">
Using photographs found on the internet without the photographer's consent constitutes
copyright infringement, even if the skin itself is original artwork.
Only use photos you took yourself, or images with a licence that explicitly permits
use in derivative works (e.g. CC0 or a compatible Creative Commons licence).
</p>
</div>
</section>
{{-- 7 --}}
<section id="accounts">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">07</span>
Accounts &amp; Identity
</h2>
<ul class="list-disc list-inside space-y-2 text-sm text-white/70 pl-2">
<li>One account per person. Duplicate accounts created to evade a suspension or ban will be terminated.</li>
<li>Do not impersonate staff, other members, or real individuals.</li>
<li>Keep your contact email address up to date it is used for important account notifications.</li>
<li>You are responsible for all activity that occurs under your account. Keep your password secure.</li>
<li>Accounts that have been inactive for more than 3 years and contain no uploads may be reclaimed for the username pool.</li>
</ul>
</section>
{{-- 8 --}}
<section id="moderation">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">08</span>
Moderation &amp; Enforcement
</h2>
<p class="text-white/70 text-sm leading-relaxed mb-3">
Skinbase <a href="/staff" class="text-sky-400 hover:underline">staff</a> may take any of
the following actions in response to rule violations:
</p>
<div class="grid sm:grid-cols-3 gap-3">
@foreach ([
['Warning', 'A private message from staff explaining the violation.'],
['Content removal', 'Removal of the offending upload, comment, or post.'],
['Temporary suspension', 'Account access restricted for a defined period.'],
['Permanent ban', 'Account terminated for severe or repeated violations.'],
['IP block', 'Used in cases of persistent abuse or ban evasion.'],
['Legal referral', 'For serious illegal activity, authorities may be notified.'],
] as [$action, $desc])
<div class="rounded-lg border border-white/[0.07] bg-white/[0.03] px-4 py-3">
<p class="text-sm font-semibold text-white mb-0.5">{{ $action }}</p>
<p class="text-xs text-white/50">{{ $desc }}</p>
</div>
@endforeach
</div>
<p class="mt-4 text-sm text-white/50">
Skinbase reserves the right to remove any content or terminate any account at any time,
with or without prior notice, at staff discretion.
</p>
</section>
{{-- 9 --}}
<section id="appeals">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">09</span>
Appeals
</h2>
<p class="text-white/70 text-sm leading-relaxed">
If you believe a moderation action was made in error, you may appeal by contacting a
senior staff member via the <a href="/bug-report" class="text-sky-400 hover:underline">contact form</a>
or by sending a private message to an <a href="/staff" class="text-sky-400 hover:underline">admin</a>.
Please include your username, the content or account involved, and a clear explanation
of why you believe the decision was incorrect. We aim to review all appeals within
<strong class="text-white/90">5 business days</strong>.
</p>
</section>
{{-- 10 --}}
<section id="liability">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-4">
<span class="text-sky-400 font-mono text-base">10</span>
Liability
</h2>
<p class="text-white/70 text-sm leading-relaxed">
Skinbase is provided "as is". We make no warranties regarding uptime, data integrity,
or fitness for a particular purpose. We are not responsible for user-generated content
when you upload something, the legal and moral responsibility lies with you. We will
act promptly on valid takedown requests and reports of illegal content, but we cannot
pre-screen every submission.
</p>
</section>
</div>
{{-- Footer note --}}
<div class="mt-12 rounded-xl border border-white/[0.07] bg-white/[0.02] px-6 py-5 text-sm text-white/50">
<p>
Questions about these rules? Send a message to any
<a href="/staff" class="text-sky-400 hover:underline">staff member</a>
or use our <a href="/bug-report" class="text-sky-400 hover:underline">contact form</a>.
We're here to help not to catch you out.
</p>
</div>
</div>
@endsection

View File

@@ -0,0 +1,83 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-3xl">
<p class="text-sm text-white/40 mb-1">Last updated: <time datetime="2026-03-01">March 1, 2026</time></p>
<p class="text-neutral-300 text-sm leading-relaxed mb-6">
Our volunteer staff help keep Skinbase running from moderation and technical maintenance to community support.
If you need assistance, reach out to any team member listed below or use the <a href="/contact" class="text-sky-400 hover:underline">contact form</a>.
</p>
</div>
@if ($staffByRole->isEmpty())
<div class="max-w-md rounded-lg border border-neutral-800 bg-nova-900/50 px-8 py-10 text-center">
<svg class="mx-auto mb-3 h-10 w-10 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0"/>
</svg>
<p class="text-neutral-400 text-sm">We're building our team. Check back soon!</p>
</div>
@else
<div class="space-y-12">
@foreach ($roleLabels as $roleSlug => $roleLabel)
@if ($staffByRole->has($roleSlug))
<section>
<h2 class="text-base font-semibold uppercase tracking-widest text-accent border-b border-neutral-800 pb-2 mb-6">
{{ $roleLabel }}
</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($staffByRole[$roleSlug] as $member)
@php
$avatarUrl = $member->profile?->avatar_url;
$profileUrl = '/@' . $member->username;
@endphp
<div class="flex gap-4 rounded-lg border border-neutral-800 bg-nova-900/50 p-5 hover:border-neutral-700 transition-colors">
{{-- Avatar --}}
<a href="{{ $profileUrl }}" class="flex-shrink-0">
@if ($avatarUrl)
<img src="{{ $avatarUrl }}"
alt="{{ $member->username }}"
class="h-16 w-16 rounded-full object-cover ring-2 ring-neutral-700">
@else
<div class="h-16 w-16 rounded-full bg-neutral-800 flex items-center justify-center ring-2 ring-neutral-700">
<span class="text-xl font-semibold text-neutral-400 uppercase">
{{ substr($member->username, 0, 1) }}
</span>
</div>
@endif
</a>
{{-- Info --}}
<div class="min-w-0 flex-1">
<a href="{{ $profileUrl }}"
class="font-semibold text-white hover:text-accent transition-colors truncate block">
{{ $member->username }}
</a>
@if ($member->name && $member->name !== $member->username)
<p class="text-xs text-neutral-500 mt-0.5 truncate">{{ $member->name }}</p>
@endif
<span class="mt-2 inline-block rounded-full px-2 py-0.5 text-xs font-medium
{{ $roleSlug === 'admin' ? 'bg-accent/10 text-accent' : 'bg-neutral-800 text-neutral-400' }}">
{{ ucfirst($roleSlug) }}
</span>
@if ($member->profile?->bio)
<p class="mt-2 text-xs text-neutral-400 line-clamp-2">{{ $member->profile->bio }}</p>
@endif
</div>
</div>
@endforeach
</div>
</section>
@endif
@endforeach
</div>
@endif
{{-- Footer note: contact staff --}}
<div class="mt-10 rounded-xl border border-white/10 bg-white/[0.03] p-4 text-sm text-neutral-400">
Need help? Start with the <a href="/contact" class="text-sky-400 hover:underline">Contact / Apply</a> form or send a private message to any staff member.
</div>
@endsection

View File

@@ -1,19 +1,15 @@
@extends('layouts.nova')
@extends('layouts.nova.content-layout')
@section('content')
@php
$hero_title = 'Tags';
$hero_description = 'Browse all artwork tags on Skinbase.';
$breadcrumbs = $breadcrumbs ?? collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => 'Tags', 'url' => '/tags'],
]);
@endphp
<div class="px-6 pt-10 pb-6 md:px-10">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Browse</p>
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
<i class="fa-solid fa-tags text-sky-400 text-2xl"></i>
Tags
</h1>
<p class="mt-1 text-sm text-white/50">Browse all artwork tags on Skinbase.</p>
</div>
</div>
<div class="px-6 pb-16 md:px-10">
@section('page-content')
@if($tags->isNotEmpty())
<div class="flex flex-wrap gap-2">
@foreach($tags as $tag)
@@ -35,6 +31,4 @@
<p class="text-white/40 text-sm">No tags found.</p>
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,380 @@
@extends('layouts.nova.content-layout')
@section('page-content')
<div class="max-w-3xl space-y-10">
{{-- Intro --}}
<div>
<p class="text-neutral-400 text-sm mb-2">Last updated: March 1, 2026</p>
<p class="text-neutral-300 leading-relaxed">
These Terms of Service ("Terms") govern your access to and use of Skinbase ("we", "us", "our",
"the Service") at <strong class="text-white">skinbase.org</strong>. By creating an account or
using the Service in any way, you agree to be bound by these Terms. If you do not agree, do not
use Skinbase.
</p>
</div>
{{-- Table of Contents --}}
<nav class="rounded-xl border border-white/10 bg-white/[0.03] p-5">
<p class="text-xs font-semibold uppercase tracking-widest text-neutral-400 mb-3">Contents</p>
<ol class="space-y-1.5 text-sm text-sky-400">
<li><a href="#acceptance" class="hover:underline">01 Acceptance of Terms</a></li>
<li><a href="#the-service" class="hover:underline">02 The Service</a></li>
<li><a href="#accounts" class="hover:underline">03 Accounts &amp; Eligibility</a></li>
<li><a href="#content-licence" class="hover:underline">04 Your Content &amp; Licence Grant</a></li>
<li><a href="#prohibited" class="hover:underline">05 Prohibited Conduct</a></li>
<li><a href="#copyright" class="hover:underline">06 Copyright &amp; DMCA</a></li>
<li><a href="#our-ip" class="hover:underline">07 Skinbase Intellectual Property</a></li>
<li><a href="#disclaimers" class="hover:underline">08 Disclaimers</a></li>
<li><a href="#liability" class="hover:underline">09 Limitation of Liability</a></li>
<li><a href="#indemnification" class="hover:underline">10 Indemnification</a></li>
<li><a href="#termination" class="hover:underline">11 Termination</a></li>
<li><a href="#governing-law" class="hover:underline">12 Governing Law</a></li>
<li><a href="#changes" class="hover:underline">13 Changes to These Terms</a></li>
<li><a href="#contact" class="hover:underline">14 Contact</a></li>
</ol>
</nav>
{{-- 01 --}}
<section id="acceptance">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">01</span>
Acceptance of Terms
</h2>
<p class="text-sm text-neutral-400 leading-relaxed">
By accessing or using Skinbase whether by browsing the site, registering an account, uploading
content, or any other interaction you confirm that you have read, understood, and agree to
these Terms and our <a href="/privacy-policy" class="text-sky-400 hover:underline">Privacy Policy</a>,
which is incorporated into these Terms by reference. If you are using Skinbase on behalf of an
organisation, you represent that you have authority to bind that organisation to these Terms.
</p>
</section>
{{-- 02 --}}
<section id="the-service">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">02</span>
The Service
</h2>
<p class="text-sm text-neutral-400 leading-relaxed mb-4">
Skinbase is a community platform for sharing and discovering desktop customisation artwork
including skins, themes, wallpapers, icons, and related resources. The Service includes the
website, galleries, forums, messaging, comments, and any other features we provide.
</p>
<p class="text-sm text-neutral-400 leading-relaxed">
We reserve the right to modify, suspend, or discontinue any part of the Service at any time
with or without notice. We will not be liable to you or any third party for any modification,
suspension, or discontinuation of the Service.
</p>
</section>
{{-- 03 --}}
<section id="accounts">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">03</span>
Accounts &amp; Eligibility
</h2>
<div class="space-y-4 text-sm text-neutral-400 leading-relaxed">
<p>
<strong class="text-white">Age.</strong> You must be at least <strong class="text-white">13 years old</strong>
to create a Skinbase account. If you are under 18, you represent that you have your parent's or
guardian's permission to use the Service.
</p>
<p>
<strong class="text-white">Accurate information.</strong> You agree to provide accurate, current, and
complete information when registering and to keep your account information up to date.
</p>
<p>
<strong class="text-white">Account security.</strong> You are responsible for maintaining the confidentiality
of your password and for all activity that occurs under your account. Notify us immediately at
<a href="/bug-report" class="text-sky-400 hover:underline">skinbase.org/bug-report</a> if you believe
your account has been compromised.
</p>
<p>
<strong class="text-white">One account per person.</strong> You may not create multiple accounts to
circumvent bans or restrictions, or to misrepresent your identity to other users.
</p>
<p>
<strong class="text-white">Account transfer.</strong> Accounts are personal and non-transferable.
You may not sell, trade, or give away your account.
</p>
</div>
</section>
{{-- 04 --}}
<section id="content-licence">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">04</span>
Your Content &amp; Licence Grant
</h2>
<div class="space-y-4 text-sm text-neutral-400 leading-relaxed">
<p>
<strong class="text-white">Ownership.</strong> You retain ownership of any original artwork,
skins, themes, or other creative works ("Your Content") that you upload to Skinbase. These Terms
do not transfer any intellectual property rights to us.
</p>
<p>
<strong class="text-white">Licence to Skinbase.</strong> By uploading or publishing Your Content
on Skinbase, you grant us a worldwide, non-exclusive, royalty-free, sublicensable licence to
host, store, reproduce, display, distribute, and make Your Content available as part of the
Service, including in thumbnails, feeds, promotional materials, and search results. This licence
exists only for as long as Your Content remains on the Service.
</p>
<p>
<strong class="text-white">Licence to other users.</strong> Unless you specify otherwise in your
upload description, Your Content may be downloaded and used by other users for personal,
non-commercial use. You are responsible for clearly communicating any additional licence terms
or restrictions within your upload.
</p>
<p>
<strong class="text-white">Representations.</strong> By submitting Your Content you represent and
warrant that: (a) you own or have all necessary rights to the content; (b) the content does not
infringe any third-party intellectual property, privacy, or publicity rights; and (c) the content
complies with these Terms and our
<a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>.
</p>
<p>
<strong class="text-white">Removal.</strong> You may delete Your Content from your account at any
time via your dashboard. Upon deletion, the content will be removed from public view within a
reasonable time, though cached copies may persist briefly.
</p>
</div>
</section>
{{-- 05 --}}
<section id="prohibited">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">05</span>
Prohibited Conduct
</h2>
<p class="text-sm text-neutral-400 leading-relaxed mb-4">
You agree not to use the Service to:
</p>
<ul class="space-y-2 text-sm text-neutral-400 leading-relaxed list-disc list-inside pl-2">
<li>Upload content that infringes any copyright, trademark, patent, trade secret, or other proprietary right.</li>
<li>Upload photographs or photoskins using images you do not own or have permission to use (see our <a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>).</li>
<li>Harass, threaten, bully, stalk, or intimidate any person.</li>
<li>Post content that is defamatory, obscene, pornographic, hateful, or promotes violence or illegal activity.</li>
<li>Impersonate any person or entity, or falsely claim affiliation with any person, entity, or Skinbase staff.</li>
<li>Distribute spam, chain letters, unsolicited commercial messages, or phishing content.</li>
<li>Attempt to gain unauthorised access to any part of the Service, other accounts, or our systems.</li>
<li>Use automated tools (bots, scrapers, crawlers) to access the Service without prior written permission.</li>
<li>Interfere with or disrupt the integrity or performance of the Service or the data contained therein.</li>
<li>Collect or harvest personal information about other users without their consent.</li>
<li>Use the Service for any unlawful purpose or in violation of applicable laws or regulations.</li>
</ul>
<p class="mt-4 text-sm text-neutral-500">
Violations may result in content removal, account suspension, or a permanent ban. Serious violations
may be reported to relevant authorities.
</p>
</section>
{{-- 06 --}}
<section id="copyright">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">06</span>
Copyright &amp; DMCA
</h2>
<div class="space-y-4 text-sm text-neutral-400 leading-relaxed">
<p>
Skinbase respects intellectual property rights. We respond to valid notices of copyright
infringement in accordance with applicable law, including the Digital Millennium Copyright Act (DMCA).
</p>
<p>
<strong class="text-white">To report infringement:</strong> If you believe your copyrighted work has
been copied and is accessible on the Service in a way that constitutes infringement, please contact
a <a href="/staff" class="text-sky-400 hover:underline">staff member</a> or use our
<a href="/bug-report" class="text-sky-400 hover:underline">contact form</a>. Your notice must include:
</p>
<ul class="list-disc list-inside pl-2 space-y-1.5">
<li>A description of the copyrighted work you claim has been infringed.</li>
<li>A description of where the infringing material is located on Skinbase (with URL).</li>
<li>Your contact information (name, email address).</li>
<li>A statement that you have a good-faith belief that the use is not authorised.</li>
<li>A statement, under penalty of perjury, that the information in your notice is accurate and that you are the copyright owner or authorised to act on their behalf.</li>
</ul>
<p>
<strong class="text-white">Counter-notices:</strong> If your content was removed in error, you may
submit a counter-notice to a staff member including your identification details, description of the
removed content, and a statement under penalty of perjury that you have a good-faith belief the
content was removed in error.
</p>
<p>
<strong class="text-white">Repeat infringers:</strong> We will terminate the accounts of users who
are determined to be repeat infringers.
</p>
</div>
</section>
{{-- 07 --}}
<section id="our-ip">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">07</span>
Skinbase Intellectual Property
</h2>
<p class="text-sm text-neutral-400 leading-relaxed">
The Skinbase name, logo, website design, software, and all Skinbase-produced content are owned by
Skinbase and are protected by copyright, trademark, and other intellectual property laws. Nothing in
these Terms grants you any right to use the Skinbase name, logo, or branding without our prior written
consent. You may not copy, modify, distribute, sell, or lease any part of our Service or included
software, nor may you reverse-engineer or attempt to extract the source code of the Service.
</p>
</section>
{{-- 08 --}}
<section id="disclaimers">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">08</span>
Disclaimers
</h2>
<div class="rounded-xl border border-amber-500/20 bg-amber-500/5 px-5 py-4 mb-4">
<p class="text-sm text-amber-300/90 leading-relaxed">
THE SERVICE IS PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS WITHOUT WARRANTIES OF ANY KIND,
EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND UNINTERRUPTED OR ERROR-FREE OPERATION.
</p>
</div>
<div class="space-y-3 text-sm text-neutral-400 leading-relaxed">
<p>
We do not warrant that the Service will be uninterrupted, secure, or free of errors, viruses,
or other harmful components. We do not endorse any user-submitted content and are not responsible
for its accuracy, legality, or appropriateness.
</p>
<p>
Downloaded files are provided by third-party users. You download and install any content at your own
risk. Always scan downloaded files with up-to-date antivirus software.
</p>
</div>
</section>
{{-- 09 --}}
<section id="liability">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">09</span>
Limitation of Liability
</h2>
<div class="rounded-xl border border-amber-500/20 bg-amber-500/5 px-5 py-4 mb-4">
<p class="text-sm text-amber-300/90 leading-relaxed">
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, SKINBASE AND ITS OPERATORS, STAFF, AND
CONTRIBUTORS SHALL NOT BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR
PUNITIVE DAMAGES INCLUDING BUT NOT LIMITED TO LOSS OF DATA, LOSS OF PROFITS, OR LOSS OF
GOODWILL ARISING OUT OF OR IN CONNECTION WITH THESE TERMS OR YOUR USE OF THE SERVICE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
</p>
</div>
<p class="text-sm text-neutral-400 leading-relaxed">
Our total liability to you for any claim arising out of or relating to these Terms or the Service
shall not exceed the greater of (a) the amount you paid us in the twelve months prior to the claim,
or (b) USD $50. Some jurisdictions do not allow limitations on implied warranties or exclusion of
incidental/consequential damages, so the above limitations may not apply to you.
</p>
</section>
{{-- 10 --}}
<section id="indemnification">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">10</span>
Indemnification
</h2>
<p class="text-sm text-neutral-400 leading-relaxed">
You agree to indemnify, defend, and hold harmless Skinbase, its operators, staff, and contributors
from and against any claims, liabilities, damages, losses, and expenses (including reasonable legal
fees) arising out of or in any way connected with: (a) your access to or use of the Service;
(b) Your Content; (c) your violation of these Terms; or (d) your violation of any rights of another
person or entity.
</p>
</section>
{{-- 11 --}}
<section id="termination">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">11</span>
Termination
</h2>
<div class="space-y-3 text-sm text-neutral-400 leading-relaxed">
<p>
<strong class="text-white">By you.</strong> You may close your account at any time by contacting
a <a href="/staff" class="text-sky-400 hover:underline">staff member</a>. Account deletion requests
are processed within 30 days.
</p>
<p>
<strong class="text-white">By us.</strong> We may suspend or terminate your account immediately
and without notice if we determine, in our sole discretion, that you have violated these Terms,
the <a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>,
or applicable law. We may also terminate accounts that have been inactive for an extended period,
with prior notice where practicable.
</p>
<p>
<strong class="text-white">Effect of termination.</strong> Upon termination, your right to access
the Service ceases immediately. Publicly uploaded content may remain on the Service unless you
separately request its removal. Sections of these Terms that by their nature should survive
termination (including Sections 4, 6, 7, 8, 9, 10, and 12) shall survive.
</p>
</div>
</section>
{{-- 12 --}}
<section id="governing-law">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">12</span>
Governing Law
</h2>
<p class="text-sm text-neutral-400 leading-relaxed">
These Terms are governed by and construed in accordance with applicable law. Any dispute arising
under or in connection with these Terms that cannot be resolved informally shall be submitted to
the exclusive jurisdiction of the competent courts in the applicable jurisdiction. Nothing in this
section limits your rights under mandatory consumer-protection or data-protection laws of your
country of residence.
</p>
</section>
{{-- 13 --}}
<section id="changes">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">13</span>
Changes to These Terms
</h2>
<p class="text-sm text-neutral-400 leading-relaxed">
We may update these Terms from time to time. When we make material changes, we will revise the
"Last updated" date at the top of this page and, where the changes are significant, notify
registered members by email and/or a prominent notice on the site. Your continued use of the
Service after any changes take effect constitutes your acceptance of the revised Terms. If you do
not agree to the revised Terms, you must stop using the Service.
</p>
</section>
{{-- 14 --}}
<section id="contact">
<h2 class="flex items-center gap-2 text-xl font-bold text-white border-b border-white/10 pb-3 mb-6">
<span class="text-sky-400 font-mono text-base">14</span>
Contact
</h2>
<p class="text-sm text-neutral-400 leading-relaxed">
If you have questions about these Terms, please contact us via our
<a href="/bug-report" class="text-sky-400 hover:underline">contact form</a> or by sending a
private message to any <a href="/staff" class="text-sky-400 hover:underline">staff member</a>.
We aim to respond to all legal enquiries within <strong class="text-white">10 business days</strong>.
</p>
<div class="mt-6 rounded-lg border border-sky-500/20 bg-sky-500/5 px-5 py-4 text-sm text-sky-300">
<p class="font-semibold mb-1">Skinbase.org</p>
<p class="text-sky-300/70">
Operated by the Skinbase team.<br>
Contact: <a href="/bug-report" class="underline hover:text-sky-200">via contact form</a> &nbsp;|&nbsp;
<a href="/staff" class="underline hover:text-sky-200">Staff page</a>
</p>
</div>
</section>
{{-- Footer note --}}
<div class="rounded-xl border border-white/10 bg-white/[0.03] p-5 text-sm text-neutral-400 leading-relaxed">
These Terms of Service should be read alongside our
<a href="/privacy-policy" class="text-sky-400 hover:underline">Privacy Policy</a> and
<a href="/rules-and-guidelines" class="text-sky-400 hover:underline">Rules &amp; Guidelines</a>,
which together form the complete agreement between you and Skinbase.
</div>
</div>
@endsection