more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

View File

@@ -51,28 +51,83 @@ function SidebarContent({ isActive, onNavigate }) {
)
}
export default function SettingsLayout({ children, title }) {
function SectionSidebar({ sections = [], activeSection, onSectionChange }) {
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">
{sections.map((section) => {
const active = section.key === activeSection
return (
<button
key={section.key}
type="button"
onClick={() => onSectionChange?.(section.key)}
className={`w-full 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'
}`}
>
{section.icon ? <i className={`${section.icon} w-5 text-center text-base`} /> : null}
<span>{section.label}</span>
</button>
)
})}
</nav>
</>
)
}
export default function SettingsLayout({ children, title, sections = null, activeSection = null, onSectionChange = null }) {
const { url } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
const hasSectionMode = Array.isArray(sections) && sections.length > 0 && typeof onSectionChange === 'function'
const isActive = (href) => url.startsWith(href)
const currentSection = hasSectionMode
? sections.find((section) => section.key === activeSection)
: null
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 className="lg:hidden px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
{hasSectionMode ? (
<label className="block">
<span className="sr-only">Settings section</span>
<select
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
value={activeSection || ''}
onChange={(e) => onSectionChange(e.target.value)}
>
{sections.map((section) => (
<option key={section.key} value={section.key} className="bg-nova-900 text-white">
{section.label}
</option>
))}
</select>
</label>
) : (
<div className="flex items-center justify-between">
<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>
)}
</div>
{/* Mobile nav overlay */}
{mobileOpen && (
{/* Mobile nav overlay (legacy mode only) */}
{!hasSectionMode && 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"
@@ -86,13 +141,22 @@ export default function SettingsLayout({ children, title }) {
<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} />
{hasSectionMode ? (
<SectionSidebar sections={sections} activeSection={activeSection} onSectionChange={onSectionChange} />
) : (
<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">
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-5xl">
{title && (
<h1 className="text-2xl font-bold text-white mb-6">{title}</h1>
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">{title}</h1>
{currentSection?.description ? (
<p className="text-sm text-slate-400 mt-1">{currentSection.description}</p>
) : null}
</div>
)}
{children}
</main>

View File

@@ -4,13 +4,14 @@ import ThreadRow from '../../components/forum/ThreadRow'
import Pagination from '../../components/forum/Pagination'
import Button from '../../components/ui/Button'
export default function ForumCategory({ category, threads = [], pagination = {}, isAuthenticated = false }) {
export default function ForumCategory({ category, parentCategory = null, threads = [], pagination = {}, isAuthenticated = false }) {
const name = category?.name ?? 'Category'
const slug = category?.slug
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
...(parentCategory ? [{ label: parentCategory.name, href: `/forum/category/${parentCategory.slug}` }] : []),
{ label: name },
]
@@ -24,6 +25,7 @@ export default function ForumCategory({ category, threads = [], pagination = {},
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Forum</p>
<h1 className="text-3xl font-bold text-white leading-tight">{name}</h1>
{category?.description && <p className="mt-2 text-sm text-white/50">{category.description}</p>}
</div>
{isAuthenticated && slug && (
<a href={`/forum/${slug}/new`}>
@@ -35,7 +37,7 @@ export default function ForumCategory({ category, threads = [], pagination = {},
</svg>
}
>
New thread
New topic
</Button>
</a>
)}
@@ -45,8 +47,8 @@ export default function ForumCategory({ category, threads = [], pagination = {},
<section className="overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 backdrop-blur">
{/* Column header */}
<div className="flex items-center gap-4 border-b border-white/[0.06] px-5 py-3">
<span className="flex-1 text-xs font-semibold uppercase tracking-widest text-white/30">Threads</span>
<span className="w-12 text-center text-xs font-semibold uppercase tracking-widest text-white/30">Posts</span>
<span className="flex-1 text-xs font-semibold uppercase tracking-widest text-white/30">Topics</span>
<span className="w-16 text-center text-xs font-semibold uppercase tracking-widest text-white/30">Replies</span>
</div>
{threads.length === 0 ? (
@@ -54,7 +56,7 @@ export default function ForumCategory({ category, threads = [], pagination = {},
<svg className="mx-auto mb-4 text-zinc-600" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<p className="text-sm text-zinc-500">No threads in this section yet.</p>
<p className="text-sm text-zinc-500">No topics in this board yet.</p>
{isAuthenticated && slug && (
<a href={`/forum/${slug}/new`} className="mt-3 inline-block text-sm text-sky-300 hover:text-sky-200">
Be the first to start a discussion

View File

@@ -10,7 +10,7 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
{ label: thread?.title ?? 'Thread', href: thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum' },
{ label: thread?.title ?? 'Topic', href: thread?.slug ? `/forum/topic/${thread.slug}` : '/forum' },
{ label: 'Edit post' },
]
@@ -59,7 +59,7 @@ export default function ForumEditPost({ post, thread, csrfToken, errors = {} })
{/* Actions */}
<div className="flex items-center justify-between pt-2">
<a
href={thread?.id ? `/forum/thread/${thread.id}-${thread.slug ?? ''}` : '/forum'}
href={thread?.slug ? `/forum/topic/${thread.slug}` : '/forum'}
className="text-sm text-zinc-500 hover:text-zinc-300 transition-colors"
>
Cancel

View File

@@ -1,31 +1,149 @@
import React from 'react'
import CategoryCard from '../../components/forum/CategoryCard'
export default function ForumIndex({ categories = [] }) {
return (
<div className="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
{/* Header */}
<div className="mb-8">
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
<h1 className="text-3xl font-bold text-white leading-tight">Forum</h1>
<p className="mt-1.5 text-sm text-white/50">Browse forum sections and join the conversation.</p>
</div>
export default function ForumIndex({ categories = [], trendingTopics = [], latestTopics = [] }) {
const totalThreads = categories.reduce((sum, cat) => sum + (Number(cat?.thread_count) || 0), 0)
const totalPosts = categories.reduce((sum, cat) => sum + (Number(cat?.post_count) || 0), 0)
const sortedByActivity = [...categories].sort((a, b) => {
const aTime = a?.last_activity_at ? new Date(a.last_activity_at).getTime() : 0
const bTime = b?.last_activity_at ? new Date(b.last_activity_at).getTime() : 0
return bTime - aTime
})
const latestActive = sortedByActivity[0] ?? null
{/* Category grid */}
{categories.length === 0 ? (
<div className="rounded-2xl border border-white/[0.06] bg-nova-800/50 p-12 text-center">
<svg className="mx-auto mb-4 text-zinc-600" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<p className="text-sm text-zinc-500">No forum categories available yet.</p>
return (
<div className="pb-20">
<section className="relative overflow-hidden border-b border-white/10 bg-[radial-gradient(circle_at_15%_20%,rgba(34,211,238,0.24),transparent_40%),radial-gradient(circle_at_80%_0%,rgba(56,189,248,0.16),transparent_42%),linear-gradient(180deg,rgba(10,14,26,0.96),rgba(8,12,22,0.92))]">
<div className="pointer-events-none absolute inset-0 opacity-40 [background-image:linear-gradient(rgba(255,255,255,0.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.06)_1px,transparent_1px)] [background-size:40px_40px]" />
<div className="relative mx-auto w-full max-w-[1400px] px-4 py-10 sm:px-6 lg:px-10 lg:py-14">
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr] lg:items-end">
<div>
<p className="mb-2 inline-flex items-center gap-2 rounded-full border border-cyan-300/30 bg-cyan-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-100">
Community Hub
</p>
<h1 className="text-4xl font-black leading-[0.95] tracking-[-0.02em] text-white sm:text-5xl lg:text-6xl">
Skinbase Forum
</h1>
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-slate-200/80 sm:text-base">
Ask questions, share progress, and join focused conversations across every part of Skinbase.
This page is your launch point to active topics and community knowledge.
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<a href="/forum" className="inline-flex items-center gap-2 rounded-xl bg-cyan-400 px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-cyan-300">
Explore Categories
<span aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 lg:grid-cols-1">
<StatCard label="Sections" value={number(categories.length)} />
<StatCard label="Topics" value={number(totalThreads)} />
<StatCard label="Posts" value={number(totalPosts)} />
</div>
</div>
{latestActive && (
<div className="mt-7 rounded-2xl border border-white/15 bg-white/[0.04] p-4 backdrop-blur">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/50">Latest Activity</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1">
<a
href={`/forum/${latestActive.board_slug ?? latestActive.slug}`}
className="text-base font-semibold text-cyan-200 transition hover:text-cyan-100"
>
{latestActive.name}
</a>
<span className="text-xs text-white/45">{formatLastActivity(latestActive.last_activity_at)}</span>
</div>
</div>
)}
</div>
</section>
<section className="mx-auto w-full max-w-[1400px] px-4 pt-8 sm:px-6 lg:px-10">
<div className="mb-5 flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-white/40">Browse</p>
<h2 className="mt-1 text-2xl font-bold text-white sm:text-3xl">Forum Sections</h2>
</div>
<p className="text-xs text-white/50 sm:text-sm">Choose a section to view threads or start a discussion.</p>
</div>
{/* Category grid */}
{categories.length === 0 ? (
<div className="rounded-2xl border border-white/[0.08] bg-nova-800/50 p-12 text-center">
<svg className="mx-auto mb-4 text-zinc-600" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<p className="text-sm text-zinc-400">No forum categories available yet.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
{categories.map((cat) => (
<CategoryCard key={cat.id ?? cat.slug} category={cat} />
))}
</div>
)}
</section>
<section className="mx-auto grid w-full max-w-[1400px] gap-5 px-4 pt-8 sm:px-6 lg:grid-cols-2 lg:px-10">
<Panel title="Trending Topics" items={trendingTopics} emptyLabel="Trending topics will appear once boards become active." />
<Panel title="Latest Topics" items={latestTopics} emptyLabel="Latest topics will appear here." />
</section>
</div>
)
}
function Panel({ title, items, emptyLabel }) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-nova-800/50 p-5 backdrop-blur">
<h2 className="text-lg font-semibold text-white">{title}</h2>
{items.length === 0 ? (
<p className="mt-3 text-sm text-white/45">{emptyLabel}</p>
) : (
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{categories.map((cat) => (
<CategoryCard key={cat.id ?? cat.slug} category={cat} />
<div className="mt-4 space-y-3">
{items.map((item) => (
<a key={item.slug} href={`/forum/topic/${item.slug}`} className="block rounded-xl border border-white/6 px-4 py-3 transition hover:border-cyan-400/20 hover:bg-white/[0.03]">
<div className="text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 flex flex-wrap gap-3 text-xs text-white/45">
{item.board && <span>{item.board}</span>}
{item.author && <span>by {item.author}</span>}
{typeof item.replies_count === 'number' && <span>{item.replies_count} replies</span>}
{item.score !== undefined && <span>score {item.score}</span>}
{item.last_post_at && <span>{formatLastActivity(item.last_post_at)}</span>}
</div>
</a>
))}
</div>
)}
</div>
)
}
function StatCard({ label, value }) {
return (
<div className="rounded-xl border border-white/15 bg-white/[0.04] px-4 py-3 backdrop-blur">
<p className="text-[11px] uppercase tracking-[0.14em] text-white/50">{label}</p>
<p className="mt-1 text-2xl font-bold text-white">{value}</p>
</div>
)
}
function number(n) {
return (n ?? 0).toLocaleString()
}
function formatLastActivity(value) {
if (!value) {
return 'No recent activity'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return 'No recent activity'
}
return `Updated ${date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}`
}

View File

@@ -16,7 +16,7 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
{ label: categoryName, href: slug ? `/forum/${slug}` : '/forum' },
{ label: 'New thread' },
{ label: 'New topic' },
]
const handleSubmit = useCallback(async (e) => {
@@ -34,9 +34,9 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
{/* Header */}
<div className="mt-5 mb-6">
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">New thread</p>
<p className="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">New topic</p>
<h1 className="text-2xl font-bold text-white leading-tight">
Create thread in {categoryName}
Create topic in {categoryName}
</h1>
</div>
@@ -82,7 +82,7 @@ export default function ForumNewThread({ category, csrfToken, errors = {}, oldVa
Cancel
</a>
<Button type="submit" variant="primary" size="md" loading={submitting}>
Publish thread
Publish topic
</Button>
</div>
</form>

View File

@@ -0,0 +1,68 @@
import React from 'react'
import Breadcrumbs from '../../components/forum/Breadcrumbs'
export default function ForumSection({ category, boards = [] }) {
const name = category?.name ?? 'Forum Section'
const description = category?.description
const preview = category?.preview_image ?? '/images/forum/default.jpg'
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
{ label: name },
]
return (
<div className="mx-auto max-w-6xl px-4 pb-20 pt-10 sm:px-6 lg:px-8">
<Breadcrumbs items={breadcrumbs} />
<section className="mt-5 overflow-hidden rounded-3xl border border-white/10 bg-nova-800/55 shadow-xl backdrop-blur">
<div className="relative h-56 overflow-hidden sm:h-64">
<img src={preview} alt={`${name} preview`} className="h-full w-full object-cover object-center" />
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/35 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-6 sm:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200/85">Forum Section</p>
<h1 className="mt-2 text-3xl font-black text-white sm:text-4xl">{name}</h1>
{description && <p className="mt-2 max-w-3xl text-sm text-white/70 sm:text-base">{description}</p>}
</div>
</div>
</section>
<section className="mt-8 rounded-2xl border border-white/8 bg-nova-800/45 p-5 backdrop-blur sm:p-6">
<div className="flex items-end justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-white/40">Subcategories</p>
<h2 className="mt-1 text-2xl font-bold text-white">Browse boards</h2>
</div>
<p className="text-xs text-white/45 sm:text-sm">Select a board to open its thread list.</p>
</div>
{boards.length === 0 ? (
<div className="py-12 text-center text-sm text-white/45">No boards are available in this section yet.</div>
) : (
<div className="mt-5 grid gap-4 md:grid-cols-2">
{boards.map((board) => (
<a key={board.id ?? board.slug} href={`/forum/${board.slug}`} className="rounded-2xl border border-white/8 bg-white/[0.02] p-5 transition hover:border-cyan-400/25 hover:bg-white/[0.04]">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-white">{board.title}</h3>
{board.description && <p className="mt-2 text-sm text-white/55">{board.description}</p>}
</div>
<span className="rounded-full border border-cyan-300/20 bg-cyan-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-cyan-200">
Open
</span>
</div>
<div className="mt-4 flex flex-wrap gap-4 text-xs text-white/50">
<span>{board.topics_count ?? 0} topics</span>
<span>{board.posts_count ?? 0} posts</span>
{board.latest_topic?.title && <span>Latest: {board.latest_topic.title}</span>}
</div>
</a>
))}
</div>
)}
</section>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import Pagination from '../../components/forum/Pagination'
export default function ForumThread({
thread,
category,
forumCategory,
author,
opPost,
posts = [],
@@ -25,7 +26,8 @@ export default function ForumThread({
const breadcrumbs = [
{ label: 'Home', href: '/' },
{ label: 'Forum', href: '/forum' },
{ label: category?.name ?? 'Category', href: category?.slug ? `/forum/${category.slug}` : '/forum' },
...(forumCategory?.name ? [{ label: forumCategory.name }] : []),
{ label: category?.name ?? 'Board', href: category?.slug ? `/forum/${category.slug}` : '/forum' },
{ label: thread?.title ?? 'Thread' },
]
@@ -82,14 +84,14 @@ export default function ForumThread({
{canModerate && (
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-white/[0.06] pt-3">
{thread?.is_locked ? (
<ModForm action={`/forum/thread/${thread.id}/unlock`} csrf={csrfToken} label="Unlock" variant="danger" />
<ModForm action={`/forum/topic/${thread.slug}/unlock`} csrf={csrfToken} label="Unlock" variant="danger" />
) : (
<ModForm action={`/forum/thread/${thread.id}/lock`} csrf={csrfToken} label="Lock" variant="danger" />
<ModForm action={`/forum/topic/${thread.slug}/lock`} csrf={csrfToken} label="Lock" variant="danger" />
)}
{thread?.is_pinned ? (
<ModForm action={`/forum/thread/${thread.id}/unpin`} csrf={csrfToken} label="Unpin" variant="warning" />
<ModForm action={`/forum/topic/${thread.slug}/unpin`} csrf={csrfToken} label="Unpin" variant="warning" />
) : (
<ModForm action={`/forum/thread/${thread.id}/pin`} csrf={csrfToken} label="Pin" variant="warning" />
<ModForm action={`/forum/topic/${thread.slug}/pin`} csrf={csrfToken} label="Pin" variant="warning" />
)}
</div>
)}
@@ -155,7 +157,7 @@ export default function ForumThread({
</div>
) : (
<ReplyForm
threadId={thread?.id}
topicKey={thread?.slug ?? thread?.id}
prefill={replyPrefill}
quotedAuthor={quotedPost?.user?.name}
csrfToken={csrfToken}

View File

@@ -63,7 +63,7 @@ export default function HomeFresh({ items }) {
</a>
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-5">
{items.slice(0, Math.floor(items.length / 5) * 5 || items.length).map((item) => (
<FreshCard key={item.id} item={item} />
))}

View File

@@ -2,12 +2,10 @@ import React from 'react'
const FALLBACK = 'https://files.skinbase.org/default/missing_lg.webp'
export default function HomeHero({ artwork, isLoggedIn }) {
const uploadHref = isLoggedIn ? '/upload' : '/login?redirect=/upload'
export default function HomeHero({ artwork }) {
if (!artwork) {
return (
<section className="relative flex min-h-[27vw] max-h-[300px] w-full items-end overflow-hidden bg-nova-900">
<section className="relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent" />
<div className="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
<h1 className="text-2xl font-bold tracking-tight text-white sm:text-4xl">
@@ -18,7 +16,6 @@ export default function HomeHero({ artwork, isLoggedIn }) {
</p>
<div className="mt-4 flex flex-wrap gap-3">
<a href="/discover/trending" className="rounded-xl bg-accent px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:brightness-110">Explore Trending</a>
<a href={uploadHref} className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600">Upload</a>
</div>
</div>
</section>
@@ -28,7 +25,7 @@ export default function HomeHero({ artwork, isLoggedIn }) {
const src = artwork.thumb_lg || artwork.thumb || FALLBACK
return (
<section className="group relative flex min-h-[27vw] max-h-[300px] w-full items-end overflow-hidden bg-nova-900">
<section className="group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
{/* Background image */}
<img
src={src}
@@ -60,12 +57,6 @@ export default function HomeHero({ artwork, isLoggedIn }) {
>
Explore Trending
</a>
<a
href={uploadHref}
className="rounded-xl bg-nova-700 px-5 py-2 text-sm font-semibold text-white shadow-lg transition hover:bg-nova-600"
>
Upload
</a>
<a
href={artwork.url}
className="rounded-xl border border-nova-600 px-5 py-2 text-sm font-semibold text-nova-200 shadow transition hover:border-nova-400 hover:text-white"

View File

@@ -10,8 +10,9 @@ import TabFavourites from '../../Components/Profile/tabs/TabFavourites'
import TabCollections from '../../Components/Profile/tabs/TabCollections'
import TabActivity from '../../Components/Profile/tabs/TabActivity'
import TabPosts from '../../Components/Profile/tabs/TabPosts'
import TabStories from '../../Components/Profile/tabs/TabStories'
const VALID_TABS = ['artworks', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity']
const VALID_TABS = ['artworks', 'stories', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity']
function getInitialTab() {
try {
@@ -44,6 +45,7 @@ export default function ProfileShow() {
viewerIsFollowing,
heroBgUrl,
profileComments,
creatorStories,
countryName,
isOwner,
auth,
@@ -138,6 +140,12 @@ export default function ProfileShow() {
onTabChange={handleTabChange}
/>
)}
{activeTab === 'stories' && (
<TabStories
stories={creatorStories}
username={user.username || user.name}
/>
)}
{activeTab === 'collections' && (
<TabCollections collections={[]} />
)}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import SearchOverlay from './SearchOverlay'
const ARTWORKS_API = '/api/search/artworks'
const TAGS_API = '/api/tags/search'
const USERS_API = '/api/search/users'
const DEBOUNCE_MS = 280
const DEBOUNCE_MS = 300
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value)
@@ -25,15 +26,20 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const [mobileOverlayPhase, setMobileOverlayPhase] = useState('closed') // closed | opening | open | closing
const inputRef = useRef(null)
const mobileInputRef = useRef(null)
const wrapperRef = useRef(null)
const abortRef = useRef(null)
const openTimerRef = useRef(null)
const closeTimerRef = useRef(null)
const mobileOpenTimerRef = useRef(null)
const mobileCloseTimerRef = useRef(null)
const debouncedQuery = useDebounce(query, DEBOUNCE_MS)
const isExpanded = phase === 'opening' || phase === 'open'
const isMobileOverlayVisible = mobileOverlayPhase !== 'closed'
// flat list of navigable items: artworks → users → tags
const allItems = [
@@ -67,6 +73,31 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
}, 160)
}
function openMobileOverlay() {
clearTimeout(mobileCloseTimerRef.current)
setMobileOverlayPhase('opening')
mobileOpenTimerRef.current = setTimeout(() => {
setMobileOverlayPhase('open')
mobileInputRef.current?.focus()
}, 20)
}
function closeMobileOverlay() {
if (mobileOverlayPhase === 'closed' || mobileOverlayPhase === 'closing') return
clearTimeout(mobileOpenTimerRef.current)
setMobileOverlayPhase('closing')
clearTimeout(mobileCloseTimerRef.current)
mobileCloseTimerRef.current = setTimeout(() => {
setMobileOverlayPhase('closed')
setQuery('')
setActiveIdx(-1)
setOpen(false)
setArtworks([])
setTags([])
setUsers([])
}, 150)
}
// ── Ctrl/Cmd+K ───────────────────────────────────────────────────────────
useEffect(() => {
function onKey(e) {
@@ -86,6 +117,33 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
return () => document.removeEventListener('mousedown', onMouse)
}, [isExpanded, phase])
useEffect(() => {
if (!isMobileOverlayVisible) {
document.body.style.overflow = ''
return
}
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = ''
}
}, [isMobileOverlayVisible])
useEffect(() => {
if (!isMobileOverlayVisible) return
function onEscape(e) {
if (e.key === 'Escape') closeMobileOverlay()
}
document.addEventListener('keydown', onEscape)
return () => document.removeEventListener('keydown', onEscape)
}, [isMobileOverlayVisible, mobileOverlayPhase])
useEffect(() => {
return () => {
clearTimeout(mobileOpenTimerRef.current)
clearTimeout(mobileCloseTimerRef.current)
}
}, [])
// ── fetch (parallel artworks + tags) ────────────────────────────────────
const fetchSuggestions = useCallback(async (q) => {
const bare = q?.replace(/^@+/, '') ?? ''
@@ -160,16 +218,45 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
const formOpacity = phase === 'open' ? 1 : 0
return (
<div
ref={wrapperRef}
style={{
position: 'relative',
height: '40px',
width: isExpanded ? '100%' : '168px',
maxWidth: isExpanded ? '560px' : '168px',
transition: 'width 340ms cubic-bezier(0.16,1,0.3,1), max-width 340ms cubic-bezier(0.16,1,0.3,1)',
}}
>
<>
<button
type="button"
onClick={openMobileOverlay}
aria-label="Open search"
className="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg text-soft hover:text-white hover:bg-white/5 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
</button>
<SearchOverlay
phase={mobileOverlayPhase}
query={query}
inputRef={mobileInputRef}
loading={loading}
artworks={artworks}
users={users}
tags={tags}
activeIdx={activeIdx}
onQueryChange={(next) => { setQuery(next); setActiveIdx(-1) }}
onClose={closeMobileOverlay}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
onNavigate={navigate}
/>
<div
className="hidden md:block"
ref={wrapperRef}
style={{
position: 'relative',
height: '40px',
width: isExpanded ? '100%' : '168px',
maxWidth: isExpanded ? '560px' : '168px',
transition: 'width 340ms cubic-bezier(0.16,1,0.3,1), max-width 340ms cubic-bezier(0.16,1,0.3,1)',
}}
>
{/* ── COLLAPSED PILL ── */}
<button
type="button"
@@ -330,6 +417,7 @@ export default function SearchBar({ placeholder = 'Search artworks, artists, tag
</li>
</ul>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,190 @@
import React from 'react'
const OPENING_OR_OPEN = new Set(['opening', 'open'])
export default function SearchOverlay({
phase,
query,
inputRef,
loading,
artworks,
users,
tags,
activeIdx,
onQueryChange,
onClose,
onSubmit,
onKeyDown,
onNavigate,
}) {
if (phase === 'closed') return null
const hasResults = artworks.length > 0 || users.length > 0 || tags.length > 0
const isVisible = OPENING_OR_OPEN.has(phase)
return (
<div
className={`fixed inset-0 z-[1000] md:hidden transition-opacity ${isVisible ? 'opacity-100' : 'opacity-0'} ${phase === 'closing' ? 'duration-150' : 'duration-200'} ease-out`}
aria-modal="true"
role="dialog"
aria-label="Search overlay"
>
<div className="absolute inset-0 bg-nova/95 backdrop-blur-sm" onClick={onClose} aria-hidden="true" />
<div className={`relative h-full w-full bg-nova border-t border-white/[0.06] transform transition-transform ${isVisible ? 'translate-y-0' : '-translate-y-3'} ${phase === 'closing' ? 'duration-150' : 'duration-200'} ease-out`}>
<form onSubmit={onSubmit} role="search" className="h-full">
<div className="h-[72px] px-3 border-b border-white/[0.08] flex items-center gap-2">
<button
type="button"
onClick={onClose}
aria-label="Back"
className="w-11 h-11 rounded-lg inline-flex items-center justify-center text-white/80 hover:bg-white/10 hover:text-white transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.25">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 18l-6-6 6-6" />
</svg>
</button>
<div className="relative flex-1">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-soft pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
</svg>
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Search artworks, creators, tags..."
aria-label="Search"
aria-autocomplete="list"
aria-controls="sb-mobile-suggestions"
aria-activedescendant={activeIdx >= 0 ? `sb-mobile-item-${activeIdx}` : undefined}
autoComplete="off"
className="w-full h-11 bg-white/[0.06] border border-white/[0.12] rounded-lg py-0 pl-9 pr-9 text-sm text-white placeholder-soft outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
/>
{loading && (
<svg className="absolute right-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 animate-spin text-soft" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"/>
</svg>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close search"
className="w-11 h-11 rounded-lg inline-flex items-center justify-center text-white/80 hover:bg-white/10 hover:text-white transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.25">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-y-auto overscroll-contain" style={{ height: 'calc(100vh - 72px)' }}>
{hasResults ? (
<ul
id="sb-mobile-suggestions"
role="listbox"
aria-label="Search suggestions"
className="mx-2 my-2 px-2 py-2 rounded-xl border border-white/[0.10] bg-nova-900/95 backdrop-blur-sm shadow-2xl"
>
{artworks.length > 0 && (
<>
<li role="presentation" className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none">Artworks</li>
{artworks.map((item, i) => (
<li key={item.slug ?? i} role="option" id={`sb-mobile-item-${i}`} aria-selected={activeIdx === i}>
<button
type="button"
onClick={() => onNavigate({ type: 'artwork', ...item })}
className={`w-full min-h-12 flex items-center gap-3 px-3 py-2 text-left rounded-lg transition-colors ${activeIdx === i ? 'bg-white/[0.14]' : 'hover:bg-white/[0.08]'}`}
>
{item.thumbnail_url ? (
<img src={item.thumbnail_url} alt="" aria-hidden="true" className="w-10 h-10 rounded-lg object-cover shrink-0" loading="lazy" />
) : (
<span className="w-10 h-10 rounded-lg bg-white/[0.04] border border-white/[0.08] shrink-0" aria-hidden="true" />
)}
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">{item.title}</div>
{item.author?.name && <div className="text-xs text-neutral-400 truncate">by {item.author.name}</div>}
</div>
</button>
</li>
))}
</>
)}
{users.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Creators</li>
{users.map((user, j) => {
const flatIdx = artworks.length + j
return (
<li key={user.username} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
type="button"
onClick={() => onNavigate({ type: 'user', ...user })}
className={`w-full min-h-12 flex items-center gap-3 px-3 py-2 text-left rounded-lg transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.14]' : 'hover:bg-white/[0.08]'}`}
>
<img src={user.avatar_url} alt="" aria-hidden="true" className="w-10 h-10 rounded-full object-cover shrink-0 bg-white/[0.04] border border-white/[0.08]" loading="lazy" />
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">@{user.username}</div>
{user.uploads > 0 && <div className="text-xs text-neutral-400">{user.uploads.toLocaleString()} uploads</div>}
</div>
</button>
</li>
)
})}
</>
)}
{tags.length > 0 && (
<>
<li role="presentation" className={`px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-white/35 select-none ${artworks.length > 0 || users.length > 0 ? 'pt-2 border-t border-white/[0.06]' : 'pt-2'}`}>Tags</li>
{tags.map((tag, j) => {
const flatIdx = artworks.length + users.length + j
return (
<li key={tag.slug ?? tag.name ?? j} role="option" id={`sb-mobile-item-${flatIdx}`} aria-selected={activeIdx === flatIdx}>
<button
type="button"
onClick={() => onNavigate({ type: 'tag', ...tag })}
className={`w-full min-h-12 flex items-center gap-3 px-3 py-2 text-left rounded-lg transition-colors ${activeIdx === flatIdx ? 'bg-white/[0.14]' : 'hover:bg-white/[0.08]'}`}
>
<span className="w-10 h-10 rounded-lg bg-white/[0.04] border border-white/[0.07] inline-flex items-center justify-center shrink-0" aria-hidden="true">
<svg className="w-4 h-4 text-white/45" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-white truncate">#{tag.name ?? tag.slug}</div>
{tag.artworks_count != null && <div className="text-xs text-neutral-400">{tag.artworks_count.toLocaleString()} artworks</div>}
</div>
</button>
</li>
)
})}
</>
)}
<li role="presentation" className="px-3 pt-3 pb-2 border-t border-white/[0.06] mt-2">
<a href={`/search?q=${encodeURIComponent(query)}`} className="min-h-12 inline-flex items-center gap-1.5 text-sm text-accent hover:text-accent/80 transition-colors">
See all results for <span className="font-semibold">"{query}"</span>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</a>
</li>
</ul>
) : (
<div className="mx-2 my-2 px-6 py-10 rounded-xl border border-white/[0.10] bg-nova-900/95 backdrop-blur-sm shadow-2xl text-sm text-white/60">
{query.trim().length >= 2 ? 'No results found.' : 'Start typing to search artworks, creators, and tags.'}
</div>
)}
</div>
</form>
</div>
</div>
)
}

View File

@@ -23,3 +23,37 @@ document.querySelectorAll('[data-avatar-uploader="true"]').forEach((element) =>
})
);
});
const storyEditorRoot = document.getElementById('story-editor-react-root');
if (storyEditorRoot) {
const mode = storyEditorRoot.getAttribute('data-mode') || 'create';
const storyRaw = storyEditorRoot.getAttribute('data-story') || '{}';
const storyTypesRaw = storyEditorRoot.getAttribute('data-story-types') || '[]';
const endpointsRaw = storyEditorRoot.getAttribute('data-endpoints') || '{}';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
let initialStory = {};
let storyTypes = [];
let endpoints = {};
try {
initialStory = JSON.parse(storyRaw);
storyTypes = JSON.parse(storyTypesRaw);
endpoints = JSON.parse(endpointsRaw);
} catch (_) {
// If parsing fails, the editor falls back to empty defaults in the component.
}
void import('./components/editor/StoryEditor').then(({ default: StoryEditor }) => {
createRoot(storyEditorRoot).render(
React.createElement(StoryEditor, {
mode,
initialStory,
storyTypes,
endpoints,
csrfToken,
})
);
});
}

View File

@@ -102,9 +102,12 @@ function StatsCard({ stats, followerCount, user, onTabChange }) {
function AboutCard({ user, profile, socialLinks, countryName }) {
const bio = profile?.bio || profile?.about || profile?.description
const website = profile?.website || user?.website
const joined = user?.created_at
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const hasSocials = socialLinks && Object.keys(socialLinks).length > 0
const hasContent = bio || countryName || website || hasSocials
const hasContent = bio || countryName || website || joined || hasSocials
if (!hasContent) return null
@@ -119,12 +122,21 @@ function AboutCard({ user, profile, socialLinks, countryName }) {
{countryName && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-location-dot fa-fw text-slate-600 text-xs" />
<span>{countryName}</span>
<span className="text-slate-500">Location</span>
<span className="text-slate-300">{countryName}</span>
</div>
)}
{joined && (
<div className="flex items-center gap-2 text-[13px] text-slate-400">
<i className="fa-solid fa-calendar-days fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Joined</span>
<span className="text-slate-300">{joined}</span>
</div>
)}
{website && (
<div className="flex items-center gap-2 text-[13px]">
<i className="fa-solid fa-link fa-fw text-slate-600 text-xs" />
<span className="text-slate-500">Website</span>
<a
href={website.startsWith('http') ? website : `https://${website}`}
target="_blank"
@@ -365,13 +377,6 @@ export default function FeedSidebar({
}) {
return (
<div className="space-y-4">
<StatsCard
stats={stats}
followerCount={followerCount}
user={user}
onTabChange={onTabChange}
/>
<AboutCard
user={user}
profile={profile}
@@ -379,6 +384,13 @@ export default function FeedSidebar({
countryName={countryName}
/>
<StatsCard
stats={stats}
followerCount={followerCount}
user={user}
onTabChange={onTabChange}
/>
<RecentFollowersCard
recentFollowers={recentFollowers}
followerCount={followerCount}

View File

@@ -196,7 +196,7 @@ export default function PostComposer({ user, onPosted }) {
loading="lazy"
/>
<span className="text-sm text-slate-500 flex-1 bg-white/[0.04] rounded-xl px-4 py-2.5 hover:bg-white/[0.07] transition-colors">
What's on your mind, {user.name?.split(' ')[0] ?? user.username}?
Share an update with your followers.
</span>
</div>
) : (
@@ -229,7 +229,7 @@ export default function PostComposer({ user, onPosted }) {
onChange={handleBodyChange}
maxLength={2000}
rows={3}
placeholder="What's on your mind?"
placeholder="Share an update with your followers."
autoFocus
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2.5 text-sm text-white resize-none placeholder-slate-600 focus:outline-none focus:border-sky-500/50 transition-colors"
/>

View File

@@ -201,7 +201,6 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
setFavorited(Boolean(artwork?.viewer?.is_favorited))
}, [artwork?.id, artwork?.viewer?.is_favorited])
const fallbackUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#'
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
@@ -236,22 +235,14 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
if (downloading || !artwork?.id) return
setDownloading(true)
try {
const res = await fetch(`/api/art/${artwork.id}/download`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
const data = res.ok ? await res.json() : null
const url = data?.url || fallbackUrl
const a = document.createElement('a')
a.href = url
a.download = data?.filename || ''
a.href = `/download/artwork/${artwork.id}`
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch {
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
} finally {
setDownloading(false)
}

View File

@@ -5,8 +5,6 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [reporting, setReporting] = useState(false)
const [downloading, setDownloading] = useState(false)
// Fallback URL used only if the API call fails entirely
const fallbackUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#'
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
@@ -30,37 +28,20 @@ export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority =
}).catch(() => {})
}, [artwork?.id]) // eslint-disable-line react-hooks/exhaustive-deps
/**
* Async download handler:
* 1. POST /api/art/{id}/download → records the event, returns { url, filename }
* 2. Programmatically clicks a hidden <a download="filename"> to trigger the save dialog
* 3. Falls back to the pre-resolved fallbackUrl if the API is unreachable
*/
// Download through the secure Laravel route so original files are never exposed directly.
const handleDownload = async (e) => {
e.preventDefault()
if (downloading || !artwork?.id) return
setDownloading(true)
try {
const res = await fetch(`/api/art/${artwork.id}/download`, {
method: 'POST',
headers: { 'X-CSRF-TOKEN': csrfToken || '', 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
const data = res.ok ? await res.json() : null
const url = data?.url || fallbackUrl
const filename = data?.filename || ''
// Trigger browser save-dialog with the correct filename
const a = document.createElement('a')
a.href = url
a.download = filename
a.href = `/download/artwork/${artwork.id}`
a.rel = 'noopener noreferrer'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch {
// API unreachable — open the best available URL directly
window.open(fallbackUrl, '_blank', 'noopener,noreferrer')
window.open(`/download/artwork/${artwork.id}`, '_blank', 'noopener,noreferrer')
} finally {
setDownloading(false)
}

View File

@@ -0,0 +1,816 @@
// @ts-nocheck
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { EditorContent, Extension, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import Suggestion from '@tiptap/suggestion';
import { Node, mergeAttributes } from '@tiptap/core';
import tippy from 'tippy.js';
type StoryType = {
slug: string;
name: string;
};
type Artwork = {
id: number;
title: string;
url: string;
thumb: string | null;
thumbs?: {
xs?: string | null;
sm?: string | null;
md?: string | null;
lg?: string | null;
xl?: string | null;
};
};
type StoryPayload = {
id?: number;
title: string;
excerpt: string;
cover_image: string;
story_type: string;
tags_csv: string;
meta_title: string;
meta_description: string;
canonical_url: string;
og_image: string;
status: string;
scheduled_for: string;
content: Record<string, unknown>;
};
type Endpoints = {
create: string;
update: string;
autosave: string;
uploadImage: string;
artworks: string;
previewBase: string;
analyticsBase: string;
};
type Props = {
mode: 'create' | 'edit';
initialStory: StoryPayload;
storyTypes: StoryType[];
endpoints: Endpoints;
csrfToken: string;
};
const ArtworkBlock = Node.create({
name: 'artworkEmbed',
group: 'block',
atom: true,
addAttributes() {
return {
artworkId: { default: null },
title: { default: '' },
url: { default: '' },
thumb: { default: '' },
};
},
parseHTML() {
return [{ tag: 'figure[data-artwork-embed]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'figure',
mergeAttributes(HTMLAttributes, {
'data-artwork-embed': 'true',
class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70',
}),
[
'a',
{
href: HTMLAttributes.url || '#',
class: 'block',
rel: 'noopener noreferrer nofollow',
target: '_blank',
},
[
'img',
{
src: HTMLAttributes.thumb || '',
alt: HTMLAttributes.title || 'Artwork',
class: 'h-48 w-full object-cover',
loading: 'lazy',
},
],
[
'figcaption',
{ class: 'p-3 text-sm text-gray-200' },
`${HTMLAttributes.title || 'Artwork'} (#${HTMLAttributes.artworkId || 'n/a'})`,
],
],
];
},
});
const GalleryBlock = Node.create({
name: 'galleryBlock',
group: 'block',
atom: true,
addAttributes() {
return {
images: { default: [] },
};
},
parseHTML() {
return [{ tag: 'div[data-gallery-block]' }];
},
renderHTML({ HTMLAttributes }) {
const images = Array.isArray(HTMLAttributes.images) ? HTMLAttributes.images : [];
const children: Array<unknown> = images.slice(0, 6).map((src: string) => [
'img',
{ src, class: 'h-36 w-full rounded-lg object-cover', loading: 'lazy', alt: 'Gallery image' },
]);
if (children.length === 0) {
children.push(['div', { class: 'rounded-lg border border-dashed border-gray-600 p-4 text-xs text-gray-400' }, 'Empty gallery block']);
}
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-gallery-block': 'true',
class: 'my-4 grid grid-cols-2 gap-3 rounded-xl border border-gray-700 bg-gray-800/50 p-3',
}),
...children,
];
},
});
const VideoEmbedBlock = Node.create({
name: 'videoEmbed',
group: 'block',
atom: true,
addAttributes() {
return {
src: { default: '' },
title: { default: 'Embedded video' },
};
},
parseHTML() {
return [{ tag: 'figure[data-video-embed]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'figure',
mergeAttributes(HTMLAttributes, {
'data-video-embed': 'true',
class: 'my-4 overflow-hidden rounded-xl border border-gray-700 bg-gray-800/60',
}),
[
'iframe',
{
src: HTMLAttributes.src || '',
title: HTMLAttributes.title || 'Embedded video',
class: 'aspect-video w-full',
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
allowfullscreen: 'true',
frameborder: '0',
referrerpolicy: 'strict-origin-when-cross-origin',
},
],
];
},
});
const DownloadAssetBlock = Node.create({
name: 'downloadAsset',
group: 'block',
atom: true,
addAttributes() {
return {
url: { default: '' },
label: { default: 'Download asset' },
};
},
parseHTML() {
return [{ tag: 'div[data-download-asset]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, {
'data-download-asset': 'true',
class: 'my-4 rounded-xl border border-gray-700 bg-gray-800/60 p-4',
}),
[
'a',
{
href: HTMLAttributes.url || '#',
class: 'inline-flex items-center rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200',
target: '_blank',
rel: 'noopener noreferrer nofollow',
download: 'true',
},
HTMLAttributes.label || 'Download asset',
],
];
},
});
function createSlashCommandExtension(insert: {
image: () => void;
artwork: () => void;
code: () => void;
quote: () => void;
divider: () => void;
gallery: () => void;
}) {
return Extension.create({
name: 'slashCommands',
addOptions() {
return {
suggestion: {
char: '/',
startOfLine: true,
items: ({ query }: { query: string }) => {
const all = [
{ title: 'Image', key: 'image' },
{ title: 'Artwork', key: 'artwork' },
{ title: 'Code', key: 'code' },
{ title: 'Quote', key: 'quote' },
{ title: 'Divider', key: 'divider' },
{ title: 'Gallery', key: 'gallery' },
];
return all.filter((item) => item.key.startsWith(query.toLowerCase()));
},
command: ({ props }: { editor: any; props: { key: string } }) => {
if (props.key === 'image') insert.image();
if (props.key === 'artwork') insert.artwork();
if (props.key === 'code') insert.code();
if (props.key === 'quote') insert.quote();
if (props.key === 'divider') insert.divider();
if (props.key === 'gallery') insert.gallery();
},
render: () => {
let popup: any;
let root: HTMLDivElement | null = null;
let selected = 0;
let items: Array<{ title: string; key: string }> = [];
let command: ((item: { title: string; key: string }) => void) | null = null;
const draw = () => {
if (!root) return;
root.innerHTML = items
.map((item, index) => {
const active = index === selected ? 'bg-sky-500/20 text-sky-200' : 'text-gray-200';
return `<button data-index="${index}" class="block w-full rounded-md px-3 py-2 text-left text-sm ${active}">/${item.key} <span class="text-gray-400">${item.title}</span></button>`;
})
.join('');
root.querySelectorAll('button').forEach((button) => {
button.addEventListener('mousedown', (event) => {
event.preventDefault();
const idx = Number((event.currentTarget as HTMLButtonElement).dataset.index || 0);
const choice = items[idx];
if (choice && command) command(choice);
});
});
};
return {
onStart: (props: any) => {
items = props.items;
command = props.command;
selected = 0;
root = document.createElement('div');
root.className = 'w-52 rounded-lg border border-gray-700 bg-gray-900 p-1 shadow-xl';
draw();
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: root,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate: (props: any) => {
items = props.items;
command = props.command;
if (selected >= items.length) selected = 0;
draw();
popup?.[0]?.setProps({ getReferenceClientRect: props.clientRect });
},
onKeyDown: (props: any) => {
if (props.event.key === 'ArrowDown') {
selected = (selected + 1) % Math.max(items.length, 1);
draw();
return true;
}
if (props.event.key === 'ArrowUp') {
selected = (selected + Math.max(items.length, 1) - 1) % Math.max(items.length, 1);
draw();
return true;
}
if (props.event.key === 'Enter') {
const choice = items[selected];
if (choice && command) command(choice);
return true;
}
if (props.event.key === 'Escape') {
popup?.[0]?.hide();
return true;
}
return false;
},
onExit: () => {
popup?.[0]?.destroy();
popup = null;
root = null;
},
};
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});
}
async function requestJson<T>(url: string, method: string, body: unknown, csrfToken: string): Promise<T> {
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export default function StoryEditor({ mode, initialStory, storyTypes, endpoints, csrfToken }: Props) {
const [storyId, setStoryId] = useState<number | undefined>(initialStory.id);
const [title, setTitle] = useState(initialStory.title || '');
const [excerpt, setExcerpt] = useState(initialStory.excerpt || '');
const [coverImage, setCoverImage] = useState(initialStory.cover_image || '');
const [storyType, setStoryType] = useState(initialStory.story_type || 'creator_story');
const [tagsCsv, setTagsCsv] = useState(initialStory.tags_csv || '');
const [metaTitle, setMetaTitle] = useState(initialStory.meta_title || '');
const [metaDescription, setMetaDescription] = useState(initialStory.meta_description || '');
const [canonicalUrl, setCanonicalUrl] = useState(initialStory.canonical_url || '');
const [ogImage, setOgImage] = useState(initialStory.og_image || '');
const [status, setStatus] = useState(initialStory.status || 'draft');
const [scheduledFor, setScheduledFor] = useState(initialStory.scheduled_for || '');
const [saveStatus, setSaveStatus] = useState('Autosave idle');
const [artworkModalOpen, setArtworkModalOpen] = useState(false);
const [artworkResults, setArtworkResults] = useState<Artwork[]>([]);
const [artworkQuery, setArtworkQuery] = useState('');
const [showInsertMenu, setShowInsertMenu] = useState(false);
const [showLivePreview, setShowLivePreview] = useState(false);
const [livePreviewHtml, setLivePreviewHtml] = useState('');
const [inlineToolbar, setInlineToolbar] = useState({ visible: false, top: 0, left: 0 });
const lastSavedRef = useRef('');
const emitSaveEvent = useCallback((kind: 'autosave' | 'manual', id?: number) => {
window.dispatchEvent(new CustomEvent('story-editor:saved', {
detail: {
kind,
storyId: id,
savedAt: new Date().toISOString(),
},
}));
}, []);
const openLinkPrompt = useCallback((editor: any) => {
const prev = editor.getAttributes('link').href;
const url = window.prompt('Link URL', prev || 'https://');
if (url === null) return;
if (url.trim() === '') {
editor.chain().focus().unsetLink().run();
return;
}
editor.chain().focus().setLink({ href: url.trim() }).run();
}, []);
const fetchArtworks = useCallback(async (query: string) => {
const q = encodeURIComponent(query);
const response = await fetch(`${endpoints.artworks}?q=${q}`, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
if (!response.ok) return;
const data = await response.json();
setArtworkResults(Array.isArray(data.artworks) ? data.artworks : []);
}, [endpoints.artworks]);
const uploadImageFile = useCallback(async (file: File): Promise<string | null> => {
const formData = new FormData();
formData.append('image', file);
const response = await fetch(endpoints.uploadImage, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) {
return null;
}
const data = await response.json();
return data.medium_url || data.original_url || data.thumbnail_url || null;
}, [endpoints.uploadImage, csrfToken]);
const insertActions = useMemo(() => ({
image: () => {
const url = window.prompt('Image URL', 'https://');
if (!url || !editor) return;
editor.chain().focus().setImage({ src: url }).run();
},
artwork: () => setArtworkModalOpen(true),
code: () => {
if (!editor) return;
editor.chain().focus().toggleCodeBlock().run();
},
quote: () => {
if (!editor) return;
editor.chain().focus().toggleBlockquote().run();
},
divider: () => {
if (!editor) return;
editor.chain().focus().setHorizontalRule().run();
},
gallery: () => {
if (!editor) return;
const raw = window.prompt('Gallery image URLs (comma separated)', '');
const images = (raw || '').split(',').map((value) => value.trim()).filter(Boolean);
editor.chain().focus().insertContent({ type: 'galleryBlock', attrs: { images } }).run();
},
video: () => {
if (!editor) return;
const src = window.prompt('Video embed URL (YouTube/Vimeo)', 'https://www.youtube.com/embed/');
if (!src) return;
editor.chain().focus().insertContent({ type: 'videoEmbed', attrs: { src, title: 'Embedded video' } }).run();
},
download: () => {
if (!editor) return;
const url = window.prompt('Download URL', 'https://');
if (!url) return;
const label = window.prompt('Button label', 'Download asset') || 'Download asset';
editor.chain().focus().insertContent({ type: 'downloadAsset', attrs: { url, label } }).run();
},
}), []);
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
}),
Image,
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-sky-300 underline',
rel: 'noopener noreferrer nofollow',
target: '_blank',
},
}),
Placeholder.configure({
placeholder: 'Start writing your story...',
}),
ArtworkBlock,
GalleryBlock,
VideoEmbedBlock,
DownloadAssetBlock,
createSlashCommandExtension(insertActions),
],
content: initialStory.content || { type: 'doc', content: [{ type: 'paragraph' }] },
editorProps: {
attributes: {
class: 'tiptap prose prose-invert max-w-none min-h-[26rem] rounded-xl border border-gray-700 bg-gray-900/80 px-6 py-5 text-gray-200 focus:outline-none',
},
handleDrop: (_view, event) => {
const file = event.dataTransfer?.files?.[0];
if (!file || !file.type.startsWith('image/')) return false;
void (async () => {
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (uploaded && editor) {
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
} else {
setSaveStatus('Image upload failed');
}
})();
return true;
},
handlePaste: (_view, event) => {
const file = event.clipboardData?.files?.[0];
if (!file || !file.type.startsWith('image/')) return false;
void (async () => {
setSaveStatus('Uploading image...');
const uploaded = await uploadImageFile(file);
if (uploaded && editor) {
editor.chain().focus().setImage({ src: uploaded }).run();
setSaveStatus('Image uploaded');
} else {
setSaveStatus('Image upload failed');
}
})();
return true;
},
},
});
useEffect(() => {
if (!editor) return;
const updatePreview = () => {
setLivePreviewHtml(editor.getHTML());
};
updatePreview();
editor.on('update', updatePreview);
return () => {
editor.off('update', updatePreview);
};
}, [editor]);
useEffect(() => {
if (!artworkModalOpen) return;
void fetchArtworks(artworkQuery);
}, [artworkModalOpen, artworkQuery, fetchArtworks]);
useEffect(() => {
if (!editor) return;
const updateToolbar = () => {
const { from, to } = editor.state.selection;
if (from === to) {
setInlineToolbar({ visible: false, top: 0, left: 0 });
return;
}
const start = editor.view.coordsAtPos(from);
const end = editor.view.coordsAtPos(to);
setInlineToolbar({
visible: true,
top: Math.max(10, start.top + window.scrollY - 48),
left: Math.max(10, (start.left + end.right) / 2 + window.scrollX - 120),
});
};
editor.on('selectionUpdate', updateToolbar);
editor.on('blur', () => setInlineToolbar({ visible: false, top: 0, left: 0 }));
return () => {
editor.off('selectionUpdate', updateToolbar);
};
}, [editor]);
const payload = useCallback(() => ({
story_id: storyId,
title,
excerpt,
cover_image: coverImage,
story_type: storyType,
tags_csv: tagsCsv,
tags: tagsCsv.split(',').map((tag) => tag.trim()).filter(Boolean),
meta_title: metaTitle || title,
meta_description: metaDescription || excerpt,
canonical_url: canonicalUrl,
og_image: ogImage || coverImage,
status,
scheduled_for: scheduledFor || null,
content: editor?.getJSON() || { type: 'doc', content: [{ type: 'paragraph' }] },
}), [storyId, title, excerpt, coverImage, storyType, tagsCsv, metaTitle, metaDescription, canonicalUrl, ogImage, status, scheduledFor, editor]);
useEffect(() => {
if (!editor) return;
const timer = window.setInterval(async () => {
const body = payload();
const snapshot = JSON.stringify(body);
if (snapshot === lastSavedRef.current) {
return;
}
try {
setSaveStatus('Saving...');
const data = await requestJson<{ story_id?: number; message?: string }>(endpoints.autosave, 'POST', body, csrfToken);
if (data.story_id && !storyId) {
setStoryId(data.story_id);
}
lastSavedRef.current = snapshot;
setSaveStatus(data.message || 'Saved just now');
emitSaveEvent('autosave', data.story_id || storyId);
} catch {
setSaveStatus('Autosave failed');
}
}, 10000);
return () => window.clearInterval(timer);
}, [editor, payload, endpoints.autosave, csrfToken, storyId, emitSaveEvent]);
const persistStory = async (submitAction: 'save_draft' | 'submit_review' | 'publish_now' | 'schedule_publish') => {
const body = {
...payload(),
submit_action: submitAction,
status: submitAction === 'submit_review' ? 'pending_review' : submitAction === 'publish_now' ? 'published' : submitAction === 'schedule_publish' ? 'scheduled' : status,
scheduled_for: submitAction === 'schedule_publish' ? scheduledFor : null,
};
try {
setSaveStatus('Saving...');
const endpoint = storyId ? endpoints.update : endpoints.create;
const method = storyId ? 'PUT' : 'POST';
const data = await requestJson<{ story_id: number; message?: string }>(endpoint, method, body, csrfToken);
if (data.story_id) {
setStoryId(data.story_id);
}
setSaveStatus(data.message || 'Saved just now');
emitSaveEvent('manual', data.story_id || storyId);
} catch {
setSaveStatus('Save failed');
}
};
const insertArtwork = (item: Artwork) => {
if (!editor) return;
editor.chain().focus().insertContent({
type: 'artworkEmbed',
attrs: {
artworkId: item.id,
title: item.title,
url: item.url,
thumb: item.thumbs?.md || item.thumbs?.sm || item.thumb || '',
},
}).run();
setArtworkModalOpen(false);
};
return (
<div className="space-y-6">
<div className="rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="space-y-4">
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Title"
className="w-full rounded-xl border border-gray-700 bg-gray-900 px-4 py-3 text-2xl font-semibold text-gray-100"
/>
<div className="grid gap-3 md:grid-cols-2">
<input value={excerpt} onChange={(event) => setExcerpt(event.target.value)} placeholder="Excerpt" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={storyType} onChange={(event) => setStoryType(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
{storyTypes.map((type) => (
<option key={type.slug} value={type.slug}>{type.name}</option>
))}
</select>
<input value={tagsCsv} onChange={(event) => setTagsCsv(event.target.value)} placeholder="Tags (comma separated)" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<select value={status} onChange={(event) => setStatus(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200">
<option value="draft">Draft</option>
<option value="pending_review">Pending Review</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
<input value={coverImage} onChange={(event) => setCoverImage(event.target.value)} placeholder="Cover image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input type="datetime-local" value={scheduledFor} onChange={(event) => setScheduledFor(event.target.value)} className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaTitle} onChange={(event) => setMetaTitle(event.target.value)} placeholder="Meta title" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={metaDescription} onChange={(event) => setMetaDescription(event.target.value)} placeholder="Meta description" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={canonicalUrl} onChange={(event) => setCanonicalUrl(event.target.value)} placeholder="Canonical URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
<input value={ogImage} onChange={(event) => setOgImage(event.target.value)} placeholder="OG image URL" className="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200" />
</div>
</div>
</div>
<div className="relative rounded-xl border border-gray-700 bg-gray-800/60 p-4 shadow-lg">
<div className="mb-3 flex flex-wrap items-center gap-2">
<button type="button" onClick={() => setShowInsertMenu((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">+ Insert</button>
<button type="button" onClick={() => setShowLivePreview((current) => !current)} className="rounded-lg border border-gray-600 px-3 py-1 text-xs text-gray-200">{showLivePreview ? 'Hide Preview' : 'Live Preview'}</button>
<button type="button" onClick={() => persistStory('save_draft')} className="rounded-lg border border-gray-600 bg-gray-700/40 px-3 py-1 text-xs text-gray-200">Save Draft</button>
<button type="button" onClick={() => persistStory('submit_review')} className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">Submit for Review</button>
<button type="button" onClick={() => persistStory('publish_now')} className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-1 text-xs text-emerald-200">Publish Now</button>
<button type="button" onClick={() => persistStory('schedule_publish')} className="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">Schedule Publish</button>
<span className="ml-auto text-xs text-emerald-300">{saveStatus}</span>
</div>
{showInsertMenu && (
<div className="mb-3 grid grid-cols-2 gap-2 rounded-xl border border-gray-700 bg-gray-900/90 p-2 sm:grid-cols-3">
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.image}>Image</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.artwork}>Embed Artwork</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.code}>Code Block</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.quote}>Quote</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.divider}>Divider</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.gallery}>Gallery</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.video}>Video Embed</button>
<button type="button" className="rounded-md border border-gray-700 px-2 py-1 text-xs text-gray-200" onClick={insertActions.download}>Download Asset</button>
</div>
)}
{editor && inlineToolbar.visible && (
<div
className="fixed z-40 flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-900 px-2 py-1 shadow-lg"
style={{ top: `${inlineToolbar.top}px`, left: `${inlineToolbar.left}px` }}
>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('bold') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBold().run()}>B</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('italic') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleItalic().run()}>I</button>
<button type="button" className={`rounded px-2 py-1 text-xs ${editor.isActive('code') ? 'bg-sky-500/30 text-sky-100' : 'text-gray-200'}`} onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleCode().run()}>{'</>'}</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => openLinkPrompt(editor)}>Link</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</button>
<button type="button" className="rounded px-2 py-1 text-xs text-gray-200" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.chain().focus().toggleBlockquote().run()}>Quote</button>
</div>
)}
<EditorContent editor={editor} />
{showLivePreview && (
<div className="mt-4 rounded-xl border border-gray-700 bg-gray-900/60 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Live Preview</div>
<div className="prose prose-invert max-w-none prose-pre:bg-gray-900" dangerouslySetInnerHTML={{ __html: livePreviewHtml }} />
</div>
)}
</div>
<div className="flex flex-wrap gap-3">
{storyId && (
<a href={`${endpoints.previewBase}/${storyId}/preview`} className="rounded-xl border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200">Preview</a>
)}
{storyId && (
<a href={`${endpoints.analyticsBase}/${storyId}/analytics`} className="rounded-xl border border-violet-500/40 bg-violet-500/10 px-3 py-2 text-sm text-violet-200">Analytics</a>
)}
{mode === 'edit' && storyId && (
<form method="POST" action={`/creator/stories/${storyId}`} onSubmit={(event) => {
if (!window.confirm('Delete this story?')) {
event.preventDefault();
}
}}>
<input type="hidden" name="_token" value={csrfToken} />
<input type="hidden" name="_method" value="DELETE" />
<button type="submit" className="rounded-xl border border-rose-500/40 bg-rose-500/20 px-3 py-2 text-sm text-rose-200">Delete</button>
</form>
)}
</div>
{artworkModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-3xl rounded-xl border border-gray-700 bg-gray-900 p-4 shadow-lg">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">Embed Artwork</h3>
<button type="button" onClick={() => setArtworkModalOpen(false)} className="rounded border border-gray-600 px-2 py-1 text-xs text-gray-200">Close</button>
</div>
<input value={artworkQuery} onChange={(event) => setArtworkQuery(event.target.value)} className="mb-3 w-full rounded-xl border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200" placeholder="Search artworks" />
<div className="grid max-h-80 gap-3 overflow-y-auto sm:grid-cols-2">
{artworkResults.map((item) => (
<button key={item.id} type="button" onClick={() => insertArtwork(item)} className="rounded-xl border border-gray-700 bg-gray-800 p-3 text-left hover:border-sky-400">
{(item.thumbs?.sm || item.thumb) && <img src={item.thumbs?.sm || item.thumb || ''} alt={item.title} className="h-28 w-full rounded-lg object-cover" />}
<div className="mt-2 text-sm font-semibold text-white">{item.title}</div>
<div className="text-xs text-gray-400">#{item.id}</div>
</button>
))}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -18,6 +18,7 @@ export default function AuthorBadge({ user, size = 'md' }) {
const role = (user?.role ?? 'member').toLowerCase()
const cls = ROLE_STYLES[role] ?? ROLE_STYLES.member
const label = ROLE_LABELS[role] ?? 'Member'
const rank = user?.rank ?? null
const imgSize = size === 'sm' ? 'h-8 w-8' : 'h-10 w-10'
@@ -32,9 +33,16 @@ export default function AuthorBadge({ user, size = 'md' }) {
/>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-100">{name}</div>
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
<div className="mt-1 flex flex-wrap gap-1.5">
<span className={`inline-flex rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{label}
</span>
{rank && (
<span className="inline-flex rounded-full bg-emerald-500/12 px-2 py-0.5 text-[11px] font-medium text-emerald-300">
{rank}
</span>
)}
</div>
</div>
</div>
)

View File

@@ -3,64 +3,151 @@ import React from 'react'
export default function CategoryCard({ category }) {
const name = category?.name ?? 'Untitled'
const slug = category?.slug
const categoryHref = slug ? `/forum/category/${slug}` : null
const threads = category?.thread_count ?? 0
const posts = category?.post_count ?? 0
const lastActivity = category?.last_activity_at
const preview = category?.preview_image ?? '/images/forum-default.jpg'
const href = slug ? `/forum/${slug}` : '#'
const boards = category?.boards ?? []
const boardCount = boards.length
const activeBoards = boards.filter((board) => Number(board?.topics_count ?? 0) > 0).length
const latestBoard = boards
.filter((board) => board?.latest_topic?.last_post_at)
.sort((a, b) => new Date(b.latest_topic.last_post_at) - new Date(a.latest_topic.last_post_at))[0]
const timeAgo = lastActivity ? formatTimeAgo(lastActivity) : null
return (
<a
href={href}
className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400"
>
<div className="group relative block overflow-hidden rounded-2xl border border-white/[0.06] bg-nova-800/50 shadow-xl backdrop-blur transition-all duration-300 hover:border-cyan-400/20 hover:shadow-cyan-500/10 focus-within:ring-2 focus-within:ring-cyan-400">
{/* Image */}
<div className="relative aspect-[16/9]">
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{categoryHref ? (
<a href={categoryHref} className="block h-full">
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* Overlay content */}
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<h3 className="text-lg font-bold leading-snug text-white transition group-hover:text-cyan-200">
{name}
</h3>
{category?.description && (
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
)}
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
</div>
<div className="mt-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-cyan-200 transition group-hover:text-cyan-100">
View section
</span>
</div>
</div>
</a>
) : (
<>
<img
src={preview}
alt={`${name} preview`}
loading="lazy"
decoding="async"
className="h-full w-full object-cover object-center transition-transform duration-500 group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
<div className="absolute inset-x-0 bottom-0 p-5">
<div className="mb-2 inline-flex h-8 w-8 items-center justify-center rounded-lg bg-cyan-400/15 text-cyan-300">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
</div>
<h3 className="text-lg font-bold leading-snug text-white">{name}</h3>
{category?.description && (
<p className="mt-1 line-clamp-2 text-xs text-white/60">{category.description}</p>
)}
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
</div>
</div>
</>
)}
</div>
<div className="border-t border-white/8 p-4">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Boards</div>
<div className="mt-1 text-sm font-semibold text-white">{number(boardCount)}</div>
</div>
<h3 className="text-lg font-bold text-white leading-snug">{name}</h3>
{timeAgo && (
<p className="mt-1 text-xs text-white/50">
Last activity: <span className="text-white/70">{timeAgo}</span>
</p>
)}
<div className="mt-3 flex items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-cyan-300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
</svg>
{number(posts)} posts
</span>
<span className="flex items-center gap-1.5 text-cyan-300/70">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
{number(threads)} topics
</span>
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Topics</div>
<div className="mt-1 text-sm font-semibold text-white">{number(threads)}</div>
</div>
<div className="rounded-lg border border-white/8 bg-white/[0.02] px-3 py-2">
<div className="text-[10px] uppercase tracking-[0.12em] text-white/40">Posts</div>
<div className="mt-1 text-sm font-semibold text-white">{number(posts)}</div>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs text-white/50">
<span>{number(activeBoards)} active boards</span>
{latestBoard?.title ? <span>Latest: {latestBoard.title}</span> : <span>No recent board activity</span>}
</div>
</div>
</a>
</div>
)
}

View File

@@ -1,9 +1,17 @@
import React, { useState } from 'react'
import AuthorBadge from './AuthorBadge'
const REACTIONS = [
{ key: 'like', label: 'Like', emoji: '👍' },
{ key: 'love', label: 'Love', emoji: '❤️' },
{ key: 'fire', label: 'Amazing', emoji: '🔥' },
{ key: 'laugh', label: 'Funny', emoji: '😂' },
{ key: 'disagree', label: 'Disagree', emoji: '👎' },
]
export default function PostCard({ post, thread, isOp = false, isAuthenticated = false, canModerate = false }) {
const [reported, setReported] = useState(false)
const [reporting, setReporting] = useState(false)
const [reactionState, setReactionState] = useState(post?.reactions ?? { summary: {}, active: null })
const [reacting, setReacting] = useState(false)
const author = post?.user
const content = post?.rendered_content ?? post?.content ?? ''
@@ -11,14 +19,13 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
const editedAt = post?.edited_at
const isEdited = post?.is_edited
const postId = post?.id
const threadId = thread?.id
const threadSlug = thread?.slug
const handleReport = async () => {
if (reporting || reported) return
setReporting(true)
const handleReaction = async (reaction) => {
if (reacting || !isAuthenticated) return
setReacting(true)
try {
const res = await fetch(`/forum/post/${postId}/report`, {
const res = await fetch(`/forum/post/${postId}/react`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -26,10 +33,14 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
'Accept': 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({ reaction }),
})
if (res.ok) setReported(true)
if (res.ok) {
const json = await res.json()
setReactionState(json)
}
} catch { /* silent */ }
setReporting(false)
setReacting(false)
}
return (
@@ -82,32 +93,31 @@ export default function PostCard({ post, thread, isOp = false, isAuthenticated =
{/* Footer */}
<footer className="flex flex-wrap items-center gap-3 border-t border-white/[0.06] px-5 py-3 text-xs">
{/* Quote */}
{threadId && (
<a
href={`/forum/thread/${threadId}-${threadSlug ?? ''}?quote=${postId}#reply-content`}
className="rounded-lg border border-white/10 px-2.5 py-1 text-zinc-400 transition-colors hover:border-white/20 hover:text-zinc-200"
>
Quote
</a>
)}
<div className="flex flex-wrap items-center gap-2">
{REACTIONS.map((reaction) => {
const count = reactionState?.summary?.[reaction.key] ?? 0
const isActive = reactionState?.active === reaction.key
{/* Report */}
{isAuthenticated && (post?.user_id !== post?.current_user_id) && (
<button
type="button"
onClick={handleReport}
disabled={reported || reporting}
className={[
'rounded-lg border border-white/10 px-2.5 py-1 transition-colors',
reported
? 'text-emerald-400 border-emerald-500/20 cursor-default'
: 'text-zinc-400 hover:border-white/20 hover:text-zinc-200',
].join(' ')}
>
{reported ? 'Reported ✓' : reporting ? 'Reporting…' : 'Report'}
</button>
)}
return (
<button
key={reaction.key}
type="button"
disabled={!isAuthenticated || reacting}
onClick={() => handleReaction(reaction.key)}
className={[
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 transition-colors',
isActive
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-200'
: 'border-white/10 text-zinc-400 hover:border-white/20 hover:text-zinc-200',
].join(' ')}
title={reaction.label}
>
<span>{reaction.emoji}</span>
<span>{count}</span>
</button>
)
})}
</div>
{/* Edit */}
{(post?.can_edit) && (

View File

@@ -2,7 +2,7 @@ import React, { useState, useRef, useCallback } from 'react'
import Button from '../ui/Button'
import RichTextEditor from './RichTextEditor'
export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null, csrfToken }) {
export default function ReplyForm({ topicKey, prefill = '', quotedAuthor = null, csrfToken }) {
const [content, setContent] = useState(prefill)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
@@ -16,7 +16,7 @@ export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null,
setError(null)
try {
const res = await fetch(`/forum/thread/${threadId}/reply`, {
const res = await fetch(`/forum/topic/${topicKey}/reply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -42,7 +42,7 @@ export default function ReplyForm({ threadId, prefill = '', quotedAuthor = null,
}
setSubmitting(false)
}, [content, threadId, csrfToken, submitting])
}, [content, topicKey, csrfToken, submitting])
return (
<form

View File

@@ -10,7 +10,7 @@ export default function ThreadRow({ thread, isFirst = false }) {
const lastUpdate = thread?.last_update ?? thread?.post_date
const isPinned = thread?.is_pinned ?? false
const href = `/forum/thread/${id}-${slug}`
const href = `/forum/topic/${slug}`
return (
<a
@@ -36,7 +36,7 @@ export default function ThreadRow({ thread, isFirst = false }) {
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-white group-hover:text-sky-300 transition-colors">
<h3 className="m-0 truncate text-sm font-semibold leading-tight text-white transition-colors group-hover:text-sky-300">
{title}
</h3>
{isPinned && (

View File

@@ -18,7 +18,8 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
const category = (art.category_name || art.category || '').trim();
const likes = art.likes ?? art.favourites ?? 0;
const comments = art.comments_count ?? art.comment_count ?? 0;
const views = art.views ?? art.views_count ?? art.view_count ?? 0;
const downloads = art.downloads ?? art.downloads_count ?? art.download_count ?? 0;
const imgSrc = art.thumb || art.thumb_url || art.thumbnail_url || '/images/placeholder.jpg';
const imgSrcset = art.thumb_srcset || imgSrc;
@@ -74,7 +75,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
const imgClass = [
'nova-card-main-image',
'absolute inset-0 h-full w-full object-cover',
'transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]',
'transition-[transform,filter] duration-150 ease-out group-hover:scale-[1.03]',
loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '',
].join(' ');
@@ -97,7 +98,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
href={cardUrl}
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20
shadow-lg shadow-black/40
transition-all duration-300 ease-out
transition-all duration-150 ease-out
hover:scale-[1.02] hover:-translate-y-px hover:ring-white/15
hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
@@ -112,6 +113,12 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
>
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none" />
<div className="pointer-events-none absolute right-2 top-2 z-20 flex items-center gap-1.5 rounded-full border border-white/10 bg-black/45 px-2 py-1 text-[10px] text-white/85 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-heart text-[9px] text-rose-300" />{likes}</span>
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-eye text-[9px] text-sky-300" />{views}</span>
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-download text-[9px] text-emerald-300" />{downloads}</span>
</div>
<img
ref={imgRef}
src={imgSrc}
@@ -145,7 +152,7 @@ export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = nul
)}
</span>
</span>
<span className="shrink-0"> {likes} · 💬 {comments}</span>
<span className="shrink-0"> {likes} · 👁 {views} · {downloads}</span>
</div>
{metaParts.length > 0 && (
<div className="mt-1 text-[11px] text-white/70">

View File

@@ -56,10 +56,39 @@ async function fetchPageData(url) {
// JSON fast-path (if controller ever returns JSON)
if (ct.includes('application/json')) {
const json = await res.json();
// Support multiple API payload shapes across endpoints.
const artworks = Array.isArray(json.artworks)
? json.artworks
: Array.isArray(json.data)
? json.data
: Array.isArray(json.items)
? json.items
: Array.isArray(json.results)
? json.results
: [];
const nextCursor = json.next_cursor
?? json.nextCursor
?? json.meta?.next_cursor
?? null;
const nextPageUrl = json.next_page_url
?? json.nextPageUrl
?? json.meta?.next_page_url
?? null;
const hasMore = typeof json.has_more === 'boolean'
? json.has_more
: typeof json.hasMore === 'boolean'
? json.hasMore
: null;
return {
artworks: json.artworks ?? [],
nextCursor: json.next_cursor ?? null,
nextPageUrl: json.next_page_url ?? null,
artworks,
nextCursor,
nextPageUrl,
hasMore,
};
}
@@ -76,6 +105,7 @@ async function fetchPageData(url) {
artworks,
nextCursor: el.dataset.nextCursor || null,
nextPageUrl: el.dataset.nextPageUrl || null,
hasMore: null,
};
}
@@ -148,6 +178,7 @@ const SKELETON_COUNT = 10;
* rankApiEndpoint string|null /api/rank/* endpoint; used as fallback data
* source when no SSR artworks are available
* rankType string|null Ranking API ?type= param (trending|new_hot|best)
* gridClassName string|null Optional CSS class override for grid columns/gaps
*/
function MasonryGallery({
artworks: initialArtworks = [],
@@ -158,6 +189,7 @@ function MasonryGallery({
limit = 40,
rankApiEndpoint = null,
rankType = null,
gridClassName = null,
}) {
const [artworks, setArtworks] = useState(initialArtworks);
const [nextCursor, setNextCursor] = useState(initialNextCursor);
@@ -234,7 +266,7 @@ function MasonryGallery({
setLoading(true);
try {
const { artworks: newItems, nextCursor: nc, nextPageUrl: np } =
const { artworks: newItems, nextCursor: nc, nextPageUrl: np, hasMore } =
await fetchPageData(fetchUrl);
if (!newItems.length) {
@@ -243,7 +275,7 @@ function MasonryGallery({
setArtworks((prev) => [...prev, ...newItems]);
if (cursorEndpoint) {
setNextCursor(nc);
if (!nc) setDone(true);
if (hasMore === false || !nc) setDone(true);
} else {
setNextPageUrl(np);
if (!np) setDone(true);
@@ -272,7 +304,7 @@ function MasonryGallery({
// Gallery V2 spec §7: 5 col desktop / 3 tablet / 2 mobile for all gallery pages.
// Discover feeds (home/discover page) retain the same 5-col layout.
const gridClass = 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
const gridClass = gridClassName || 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6';
// ── Render ─────────────────────────────────────────────────────────────
return (

View File

@@ -0,0 +1,232 @@
import React, { useMemo, useRef, useState } from 'react'
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
export default function ProfileCoverEditor({
isOpen,
onClose,
coverUrl,
coverPosition,
onCoverUpdated,
onCoverRemoved,
}) {
const previewRef = useRef(null)
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const [removing, setRemoving] = useState(false)
const [position, setPosition] = useState(coverPosition ?? 50)
const csrfToken = useMemo(
() => document.querySelector('meta[name="csrf-token"]')?.content ?? '',
[]
)
if (!isOpen) {
return null
}
const updatePositionFromPointer = (clientY) => {
const el = previewRef.current
if (!el) return
const rect = el.getBoundingClientRect()
if (rect.height <= 0) return
const normalized = ((clientY - rect.top) / rect.height) * 100
setPosition(Math.round(clamp(normalized, 0, 100)))
}
const handlePointerDown = (event) => {
updatePositionFromPointer(event.clientY)
const onMove = (moveEvent) => updatePositionFromPointer(moveEvent.clientY)
const onUp = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const handleUpload = async (event) => {
const file = event.target.files?.[0]
if (!file) return
setUploading(true)
try {
const body = new FormData()
body.append('cover', file)
const response = await fetch('/api/profile/cover/upload', {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
body,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Cover upload failed.')
}
const nextPosition = Number.isFinite(payload.cover_position) ? payload.cover_position : 50
setPosition(nextPosition)
onCoverUpdated(payload.cover_url, nextPosition)
} catch (error) {
window.alert(error?.message || 'Cover upload failed.')
} finally {
setUploading(false)
event.target.value = ''
}
}
const handleSavePosition = async () => {
if (!coverUrl) return
setSaving(true)
try {
const response = await fetch('/api/profile/cover/position', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
body: JSON.stringify({ position }),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not save position.')
}
onCoverUpdated(coverUrl, payload.cover_position ?? position)
onClose()
} catch (error) {
window.alert(error?.message || 'Could not save position.')
} finally {
setSaving(false)
}
}
const handleRemove = async () => {
if (!coverUrl) return
setRemoving(true)
try {
const response = await fetch('/api/profile/cover', {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': csrfToken,
Accept: 'application/json',
},
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.error || 'Could not remove cover.')
}
setPosition(payload.cover_position ?? 50)
onCoverRemoved()
onClose()
} catch (error) {
window.alert(error?.message || 'Could not remove cover.')
} finally {
setRemoving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="w-full max-w-3xl rounded-2xl border border-white/10 bg-[#0d1524] shadow-2xl">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<h3 className="text-lg font-semibold text-white">Edit Cover</h3>
<button
type="button"
onClick={onClose}
className="rounded-lg p-2 text-slate-400 hover:bg-white/10 hover:text-white"
aria-label="Close cover editor"
>
<i className="fa-solid fa-xmark" />
</button>
</div>
<div className="space-y-4 p-5">
<div className="rounded-xl border border-dashed border-slate-600/70 bg-slate-900/50 p-3">
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500">
<i className="fa-solid fa-upload" />
{uploading ? 'Uploading...' : 'Upload Cover'}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
disabled={uploading}
/>
</label>
<p className="mt-2 text-xs text-slate-400">Allowed: JPG, PNG, WEBP. Max 5MB. Recommended: 1920x480.</p>
</div>
<div>
<p className="mb-2 text-sm text-slate-300">Drag vertically to reposition the cover.</p>
<div
ref={previewRef}
onPointerDown={handlePointerDown}
className="relative h-44 w-full cursor-ns-resize overflow-hidden rounded-xl border border-white/10 bg-[#101a2a]"
style={{
background: coverUrl
? `url('${coverUrl}') center ${position}% / cover no-repeat`
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
}}
>
<div className="absolute inset-0 bg-gradient-to-r from-[#0f1724]/70 to-[#0f1724]/30" />
<div
className="pointer-events-none absolute left-0 right-0 border-t border-dashed border-sky-400/80"
style={{ top: `${position}%` }}
/>
</div>
<div className="mt-2 flex items-center justify-between text-xs text-slate-400">
<span>Position</span>
<span>{position}%</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={handleRemove}
disabled={removing || !coverUrl}
className="inline-flex items-center gap-2 rounded-lg border border-red-400/30 px-4 py-2 text-sm font-medium text-red-300 hover:bg-red-500/10 disabled:opacity-50"
>
<i className={`fa-solid ${removing ? 'fa-circle-notch fa-spin' : 'fa-trash'}`} />
Remove Cover
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-white/15 px-4 py-2 text-sm text-slate-300 hover:bg-white/10"
>
Cancel
</button>
<button
type="button"
onClick={handleSavePosition}
disabled={saving || !coverUrl}
className="inline-flex items-center gap-2 rounded-lg bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-500 disabled:opacity-50"
>
<i className={`fa-solid ${saving ? 'fa-circle-notch fa-spin' : 'fa-floppy-disk'}`} />
Save Position
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react'
import { router } from '@inertiajs/react'
import ProfileCoverEditor from './ProfileCoverEditor'
/**
* ProfileHero
@@ -10,6 +10,9 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
const [count, setCount] = useState(followerCount)
const [loading, setLoading] = useState(false)
const [hovering, setHovering] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
const [coverUrl, setCoverUrl] = useState(user?.cover_url || heroBgUrl || null)
const [coverPosition, setCoverPosition] = useState(Number.isFinite(user?.cover_position) ? user.cover_position : 50)
const uname = user.username || user.name || 'Unknown'
const displayName = user.name || uname
@@ -18,6 +21,8 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
? new Date(user.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
: null
const bio = profile?.bio || profile?.about || ''
const toggleFollow = async () => {
if (loading) return
setLoading(true)
@@ -39,159 +44,190 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
}
return (
<div className="relative overflow-hidden border-b border-white/10">
{/* Cover / hero background */}
<div
className="w-full"
style={{
height: 'clamp(160px, 22vw, 260px)',
background: heroBgUrl
? `url('${heroBgUrl}') center/cover no-repeat`
: 'linear-gradient(135deg, #0f1724 0%, #151e2e 50%, #090f1a 100%)',
position: 'relative',
}}
>
{/* Overlay */}
<div
className="absolute inset-0"
style={{
background: heroBgUrl
? 'linear-gradient(to right, rgba(15,23,36,0.95) 0%, rgba(15,23,36,0.75) 50%, rgba(15,23,36,0.45) 100%)'
: 'radial-gradient(ellipse at 20% 50%, rgba(77,163,255,.12) 0%, transparent 60%), radial-gradient(ellipse at 80% 20%, rgba(224,122,33,.08) 0%, transparent 50%)',
}}
/>
{/* Nebula grain decoration */}
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
</div>
{/* Identity block overlaps cover at bottom */}
<div className="max-w-6xl mx-auto px-4">
<div className="relative -mt-16 pb-5 flex flex-col sm:flex-row sm:items-end gap-4">
{/* Avatar */}
<div className="shrink-0 z-10">
<img
src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`}
className="w-24 h-24 sm:w-28 sm:h-28 rounded-2xl object-cover ring-4 ring-[#0f1724] shadow-xl shadow-black/60"
/>
</div>
{/* Name + meta */}
<div className="flex-1 min-w-0 pb-1">
<h1 className="text-2xl sm:text-3xl font-bold text-white leading-tight">
{displayName}
</h1>
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-slate-500">
{countryName && (
<span className="flex items-center gap-1.5">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
)}
{countryName}
</span>
)}
{joinDate && (
<span className="flex items-center gap-1">
<i className="fa-solid fa-calendar-days fa-fw opacity-60" />
Joined {joinDate}
</span>
)}
{profile?.website && (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="flex items-center gap-1 text-sky-400 hover:text-sky-300 transition-colors"
<>
<div className="max-w-6xl mx-auto px-4 pt-4">
<div className="relative overflow-hidden rounded-2xl border border-white/10">
<div
className="w-full h-[180px] md:h-[220px] xl:h-[252px]"
style={{
background: coverUrl
? `url('${coverUrl}') center ${coverPosition}% / cover no-repeat`
: 'linear-gradient(140deg, #0f1724 0%, #101a2a 45%, #0a1220 100%)',
position: 'relative',
}}
>
{isOwner && (
<div className="absolute right-3 top-3 z-20">
<button
type="button"
onClick={() => setEditorOpen(true)}
className="inline-flex items-center gap-2 rounded-lg border border-white/20 bg-black/40 px-3 py-2 text-xs font-medium text-white hover:bg-black/60"
aria-label="Edit cover image"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
<i className="fa-solid fa-image" />
Edit Cover
</button>
</div>
)}
<div
className="absolute inset-0"
style={{
background: coverUrl
? 'linear-gradient(to bottom, rgba(0,0,0,0.2), rgba(0,0,0,0.62))'
: 'radial-gradient(ellipse at 16% 40%, rgba(77,163,255,.18) 0%, transparent 60%), radial-gradient(ellipse at 84% 22%, rgba(224,122,33,.12) 0%, transparent 54%)',
}}
/>
<div className="absolute inset-0 opacity-[0.06] pointer-events-none" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '32px' }} />
</div>
</div>
<div className="relative -mt-14 md:-mt-16 pb-4 px-1">
<div className="flex flex-col md:flex-row md:items-end gap-4 md:gap-5">
<div className="mx-auto md:mx-0 shrink-0 z-10">
<img
src={user.avatar_url || '/default/avatar_default.webp'}
alt={`${uname}'s avatar`}
className="w-[104px] h-[104px] md:w-[116px] md:h-[116px] rounded-full object-cover border-2 border-white/15 shadow-[0_0_0_6px_rgba(15,23,36,0.95),0_10px_32px_rgba(0,0,0,0.6)]"
/>
</div>
<div className="flex-1 min-w-0 text-center md:text-left">
<h1 className="text-[28px] md:text-[34px] font-bold text-white leading-tight tracking-tight">
{displayName}
</h1>
<p className="text-slate-400 text-sm mt-0.5 font-mono">@{uname}</p>
<div className="flex flex-wrap items-center justify-center md:justify-start gap-2.5 mt-2 text-xs text-slate-400">
{countryName && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
{profile?.country_code && (
<img
src={`/gfx/flags/shiny/24/${encodeURIComponent(profile.country_code)}.png`}
alt={countryName}
className="w-4 h-auto rounded-sm"
onError={(e) => { e.target.style.display = 'none' }}
/>
)}
{countryName}
</span>
)}
{joinDate && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1">
<i className="fa-solid fa-calendar-days fa-fw opacity-70" />
Joined {joinDate}
</span>
)}
{profile?.website && (
<a
href={profile.website.startsWith('http') ? profile.website : `https://${profile.website}`}
target="_blank"
rel="nofollow noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full bg-white/5 border border-white/10 px-2.5 py-1 text-sky-300 hover:text-sky-200 hover:bg-white/10 transition-colors"
>
<i className="fa-solid fa-link fa-fw" />
{(() => {
try {
const url = profile.website.startsWith('http') ? profile.website : `https://${profile.website}`
return new URL(url).hostname
} catch {
return profile.website
}
})()}
</a>
)}
</div>
{bio && (
<p className="text-sm text-slate-300/90 mt-3 max-w-2xl leading-relaxed line-clamp-2 md:line-clamp-3 mx-auto md:mx-0">
{bio}
</p>
)}
</div>
<div className="shrink-0 flex items-center justify-center md:justify-end gap-2 pb-0.5">
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
Edit Profile
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
Studio
</a>
</>
) : (
<>
<button
onClick={toggleFollow}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
disabled={loading}
aria-label={following ? 'Unfollow' : 'Follow'}
className={`inline-flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium border transition-all ${
following
? hovering
? 'bg-red-500/10 border-red-400/40 text-red-400'
: 'bg-green-500/10 border-green-400/40 text-green-400'
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
}`}
>
<i className={`fa-solid fa-fw ${
loading
? 'fa-circle-notch fa-spin'
: following
? hovering ? 'fa-user-minus' : 'fa-user-check'
: 'fa-user-plus'
}`} />
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
<span className="text-xs opacity-70">{count.toLocaleString()}</span>
</button>
<button
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
</>
)}
</div>
</div>
{/* Action buttons */}
<div className="shrink-0 flex items-center gap-2 pb-1">
{isOwner ? (
<>
<a
href="/dashboard/profile"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border border-white/15 text-slate-300 hover:text-white hover:bg-white/5 transition-all"
aria-label="Edit profile"
>
<i className="fa-solid fa-pen fa-fw" />
<span className="hidden sm:inline">Edit Profile</span>
</a>
<a
href="/studio"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium bg-sky-600 hover:bg-sky-500 text-white transition-all shadow-lg shadow-sky-900/30"
aria-label="Open Studio"
>
<i className="fa-solid fa-wand-magic-sparkles fa-fw" />
<span className="hidden sm:inline">Studio</span>
</a>
</>
) : (
<>
{/* Follow button */}
<button
onClick={toggleFollow}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
disabled={loading}
aria-label={following ? 'Unfollow' : 'Follow'}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium border transition-all ${
following
? hovering
? 'bg-red-500/10 border-red-400/40 text-red-400'
: 'bg-green-500/10 border-green-400/40 text-green-400'
: 'bg-sky-500/10 border-sky-400/40 text-sky-400 hover:bg-sky-500/20'
}`}
>
<i className={`fa-solid fa-fw ${
loading
? 'fa-circle-notch fa-spin'
: following
? hovering ? 'fa-user-minus' : 'fa-user-check'
: 'fa-user-plus'
}`} />
<span>{following ? (hovering ? 'Unfollow' : 'Following') : 'Follow'}</span>
<span className="text-xs opacity-60">({count.toLocaleString()})</span>
</button>
{/* Share */}
<button
onClick={() => {
if (navigator.share) {
navigator.share({ title: `${displayName} on Skinbase`, url: window.location.href })
} else {
navigator.clipboard.writeText(window.location.href)
}
}}
aria-label="Share profile"
className="p-2.5 rounded-xl border border-white/10 text-slate-400 hover:text-white hover:bg-white/5 transition-all"
>
<i className="fa-solid fa-share-nodes fa-fw" />
</button>
</>
)}
</div>
</div>
</div>
</div>
<ProfileCoverEditor
isOpen={editorOpen}
onClose={() => setEditorOpen(false)}
coverUrl={coverUrl}
coverPosition={coverPosition}
onCoverUpdated={(nextUrl, nextPosition) => {
setCoverUrl(nextUrl)
setCoverPosition(nextPosition)
}}
onCoverRemoved={() => {
setCoverUrl(null)
setCoverPosition(50)
}}
/>
</>
)
}

View File

@@ -25,9 +25,9 @@ export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
}
return (
<div className="bg-white/3 border-b border-white/10 overflow-x-auto" style={{ background: 'rgba(255,255,255,0.025)' }}>
<div className="border-b border-white/10" style={{ background: 'rgba(255,255,255,0.02)' }}>
<div className="max-w-6xl mx-auto px-4">
<div className="flex gap-1 py-2 min-w-max sm:min-w-0 sm:flex-wrap">
<div className="grid grid-cols-3 md:grid-cols-6 gap-2 py-3">
{PILLS.map((pill) => (
<button
key={pill.key}
@@ -35,19 +35,19 @@ export default function ProfileStatsRow({ stats, followerCount, onTabChange }) {
title={pill.label}
disabled={!pill.tab}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm transition-all
flex flex-col items-center justify-center gap-1 px-2 py-3 rounded-xl text-sm transition-all text-center
border border-white/10 bg-white/[0.02]
${pill.tab
? 'cursor-pointer hover:bg-white/8 hover:text-white text-slate-300 group'
: 'cursor-default text-slate-400'
? 'cursor-pointer hover:bg-white/[0.06] hover:border-white/20 hover:text-white text-slate-300 group'
: 'cursor-default text-slate-400 opacity-90'
}
`}
style={{ background: 'transparent' }}
>
<i className={`fa-solid ${pill.icon} fa-fw text-xs opacity-60 group-hover:opacity-80`} />
<span className="font-bold text-white tabular-nums">
<i className={`fa-solid ${pill.icon} fa-fw text-xs ${pill.tab ? 'opacity-70 group-hover:opacity-100' : 'opacity-60'}`} />
<span className="font-bold text-white tabular-nums text-base leading-none">
{Number(values[pill.key]).toLocaleString()}
</span>
<span className="text-slate-500 text-xs hidden sm:inline">{pill.label}</span>
<span className="text-slate-500 text-[11px] uppercase tracking-wide leading-none">{pill.label}</span>
</button>
))}
</div>

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react'
export const TABS = [
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
{ id: 'stories', label: 'Stories', icon: 'fa-feather-pointed' },
{ id: 'posts', label: 'Posts', icon: 'fa-newspaper' },
{ id: 'collections', label: 'Collections', icon: 'fa-layer-group' },
{ id: 'about', label: 'About', icon: 'fa-id-card' },
@@ -35,7 +36,7 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
aria-label="Profile sections"
role="tablist"
>
<div className="max-w-6xl mx-auto px-3 flex gap-0 min-w-max sm:min-w-0">
<div className="max-w-6xl mx-auto px-3 flex gap-1 py-1 min-w-max sm:min-w-0">
{TABS.map((tab) => {
const isActive = activeTab === tab.id
return (
@@ -47,16 +48,16 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
aria-selected={isActive}
aria-controls={`tabpanel-${tab.id}`}
className={`
relative flex items-center gap-2 px-4 py-3.5 text-sm font-medium whitespace-nowrap
relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap rounded-lg
transition-colors duration-150 outline-none
focus-visible:ring-2 focus-visible:ring-sky-400/70 rounded-t
${isActive
? 'text-white'
: 'text-slate-400 hover:text-slate-200'
? 'text-white bg-white/[0.05]'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.03]'
}
`}
>
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : ''}`} />
<i className={`fa-solid ${tab.icon} fa-fw text-xs ${isActive ? 'text-sky-400' : 'opacity-75'}`} />
{tab.label}
{/* Active indicator bar */}
{isActive && (

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react'
import ArtworkCard from '../../gallery/ArtworkCard'
import React, { useState } from 'react'
import MasonryGallery from '../../gallery/MasonryGallery'
const SORT_OPTIONS = [
{ value: 'latest', label: 'Latest' },
@@ -9,30 +9,6 @@ const SORT_OPTIONS = [
{ value: 'favs', label: 'Most Favourited' },
]
function ArtworkSkeleton() {
return (
<div className="rounded-2xl overflow-hidden bg-white/5 animate-pulse">
<div className="aspect-[4/3] bg-white/8" />
<div className="p-2 space-y-1.5">
<div className="h-3 bg-white/8 rounded w-3/4" />
<div className="h-2 bg-white/5 rounded w-1/2" />
</div>
</div>
)
}
function EmptyState({ username }) {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20 text-center">
<div className="w-20 h-20 rounded-2xl bg-white/5 flex items-center justify-center mb-5 text-slate-500">
<i className="fa-solid fa-image text-3xl" />
</div>
<p className="text-slate-400 font-medium">No artworks yet</p>
<p className="text-slate-600 text-sm mt-1">@{username} hasn't uploaded anything yet.</p>
</div>
)
}
/**
* Featured artworks horizontal scroll strip.
*/
@@ -40,31 +16,31 @@ function FeaturedStrip({ featuredArtworks }) {
if (!featuredArtworks?.length) return null
return (
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-500 mb-3 flex items-center gap-2">
<i className="fa-solid fa-star text-yellow-400 fa-fw" />
<div className="mb-7 rounded-2xl border border-white/10 bg-white/[0.02] p-4 md:p-5">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-400 mb-3 flex items-center gap-2">
<i className="fa-solid fa-star text-amber-400 fa-fw" />
Featured
</h2>
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
{featuredArtworks.map((art) => (
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-hide snap-x snap-mandatory">
{featuredArtworks.slice(0, 5).map((art) => (
<a
key={art.id}
href={`/art/${art.id}/${slugify(art.name)}`}
className="group shrink-0 snap-start w-40 sm:w-48"
className="group shrink-0 snap-start w-56 md:w-64"
>
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[4/3] hover:ring-sky-400/40 transition-all">
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 aspect-[5/3] hover:ring-sky-400/40 transition-all">
<img
src={art.thumb}
alt={art.name}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
/>
</div>
<p className="text-xs text-slate-400 mt-1.5 truncate group-hover:text-white transition-colors">
<p className="text-sm text-slate-300 mt-2 truncate group-hover:text-white transition-colors">
{art.name}
</p>
{art.label && (
<p className="text-[10px] text-slate-600 truncate">{art.label}</p>
<p className="text-[11px] text-slate-600 truncate">{art.label}</p>
)}
</a>
))}
@@ -86,8 +62,6 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
const [sort, setSort] = useState('latest')
const [items, setItems] = useState(artworks?.data ?? artworks ?? [])
const [nextCursor, setNextCursor] = useState(artworks?.next_cursor ?? null)
const [loadingMore, setLoadingMore] = useState(false)
const [isInitialLoad] = useState(false) // data SSR-loaded
const handleSort = async (newSort) => {
setSort(newSort)
@@ -104,23 +78,6 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
} catch (_) {}
}
const loadMore = async () => {
if (!nextCursor || loadingMore) return
setLoadingMore(true)
try {
const res = await fetch(
`/api/profile/${encodeURIComponent(username)}/artworks?sort=${sort}&cursor=${encodeURIComponent(nextCursor)}`,
{ headers: { Accept: 'application/json' } }
)
if (res.ok) {
const data = await res.json()
setItems((prev) => [...prev, ...(data.data ?? data)])
setNextCursor(data.next_cursor ?? null)
}
} catch (_) {}
setLoadingMore(false)
}
return (
<div
id="tabpanel-artworks"
@@ -151,45 +108,16 @@ export default function TabArtworks({ artworks, featuredArtworks, username, isAc
</div>
</div>
{/* Grid */}
{isInitialLoad ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => <ArtworkSkeleton key={i} />)}
</div>
) : items.length === 0 ? (
<div className="grid grid-cols-1">
<EmptyState username={username} />
</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{items.map((art, i) => (
<ArtworkCard
key={art.id ?? i}
art={art}
loading={i < 8 ? 'eager' : 'lazy'}
/>
))}
{loadingMore && Array.from({ length: 4 }).map((_, i) => <ArtworkSkeleton key={`sk-${i}`} />)}
</div>
{/* Load more */}
{nextCursor && (
<div className="mt-8 text-center">
<button
onClick={loadMore}
disabled={loadingMore}
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-sm font-medium border border-white/10 transition-all"
>
{loadingMore
? <><i className="fa-solid fa-circle-notch fa-spin fa-fw" /> Loading</>
: <><i className="fa-solid fa-chevron-down fa-fw" /> Load more</>
}
</button>
</div>
)}
</>
)}
{/* Shared masonry gallery component reused from discover/explore */}
<MasonryGallery
key={`profile-${username}-${sort}`}
artworks={items}
galleryType="profile"
cursorEndpoint={`/api/profile/${encodeURIComponent(username)}/artworks?sort=${encodeURIComponent(sort)}`}
initialNextCursor={nextCursor}
limit={24}
gridClassName="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"
/>
</div>
)
}

View File

@@ -57,7 +57,7 @@ export default function TabCollections({ collections }) {
</div>
<h3 className="text-lg font-bold text-white mb-2">Collections Coming Soon</h3>
<p className="text-slate-500 text-sm max-w-sm mx-auto">
Group artworks into curated collections. This feature is currently in development.
Group your artworks into curated collections.
</p>
</div>
</div>

View File

@@ -14,10 +14,10 @@ function EmptyPostsState({ isOwner, username }) {
<p className="text-slate-400 font-medium mb-1">No posts yet</p>
{isOwner ? (
<p className="text-slate-600 text-sm max-w-xs">
Share your thoughts or showcase your artworks. Your first post is a tap away.
Share updates or showcase your artworks.
</p>
) : (
<p className="text-slate-600 text-sm">@{username} hasn't posted anything yet.</p>
<p className="text-slate-600 text-sm">@{username} has not posted anything yet.</p>
)}
</div>
)

View File

@@ -0,0 +1,49 @@
import React from 'react'
export default function TabStories({ stories, username }) {
const list = Array.isArray(stories) ? stories : []
if (!list.length) {
return (
<div className="rounded-xl border border-white/10 bg-white/[0.02] px-6 py-12 text-center text-slate-300">
No stories published yet.
</div>
)
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{list.map((story) => (
<a
key={story.id}
href={`/stories/${story.slug}`}
className="group overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition duration-200 hover:scale-[1.01] hover:border-sky-500/40"
>
{story.cover_url ? (
<img src={story.cover_url} alt={story.title} className="h-44 w-full object-cover transition-transform duration-300 group-hover:scale-105" />
) : (
<div className="h-44 w-full bg-gradient-to-br from-gray-900 via-slate-900 to-sky-950" />
)}
<div className="space-y-2 p-4">
<h3 className="line-clamp-2 text-base font-semibold text-white">{story.title}</h3>
<p className="line-clamp-2 text-xs text-gray-300">{story.excerpt || ''}</p>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>{story.reading_time || 1} min read</span>
<span>{story.views || 0} views</span>
<span>{story.likes_count || 0} likes</span>
</div>
</div>
</a>
))}
</div>
<a
href={`/stories/creator/${username}`}
className="inline-flex rounded-lg border border-sky-400/30 bg-sky-500/10 px-3 py-2 text-sm text-sky-300 transition hover:scale-[1.01] hover:text-sky-200"
>
View all stories
</a>
</div>
)
}

View File

@@ -0,0 +1,39 @@
import React from 'react'
import QuickActions from './components/QuickActions'
import ActivityFeed from './components/ActivityFeed'
import CreatorAnalytics from './components/CreatorAnalytics'
import TrendingArtworks from './components/TrendingArtworks'
import RecommendedCreators from './components/RecommendedCreators'
export default function DashboardPage({ username, isCreator }) {
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<div className="mx-auto w-full max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<header className="mb-6 rounded-xl border border-gray-700 bg-gray-800/90 p-6 shadow-lg">
<p className="text-sm uppercase tracking-[0.2em] text-gray-400">Skinbase Nova</p>
<h1 className="mt-2 text-2xl font-semibold sm:text-3xl">Welcome back {username}</h1>
<p className="mt-2 text-sm text-gray-300">
Your dashboard combines activity, creator tools, analytics, and discovery in one place.
</p>
</header>
<QuickActions isCreator={isCreator} />
<div className="mt-6 grid grid-cols-1 gap-6 xl:grid-cols-12">
<section className="xl:col-span-7">
<ActivityFeed />
</section>
<section className="xl:col-span-5">
<CreatorAnalytics isCreator={isCreator} />
</section>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<TrendingArtworks />
<RecommendedCreators />
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react'
function actorLabel(item) {
if (!item.actor) {
return 'System'
}
return item.actor.username ? `@${item.actor.username}` : item.actor.name || 'User'
}
function timeLabel(dateString) {
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) {
return 'just now'
}
return date.toLocaleString()
}
export default function ActivityFeed() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
try {
setLoading(true)
const response = await window.axios.get('/api/dashboard/activity')
if (!cancelled) {
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
}
} catch (err) {
if (!cancelled) {
setError('Could not load activity right now.')
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Activity Feed</h2>
<span className="text-xs text-gray-400">Recent actions</span>
</div>
{loading ? <p className="text-sm text-gray-400">Loading activity...</p> : null}
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
{!loading && !error && items.length === 0 ? (
<p className="text-sm text-gray-400">No recent activity yet.</p>
) : null}
{!loading && !error && items.length > 0 ? (
<div className="max-h-[520px] space-y-3 overflow-y-auto pr-1">
{items.map((item) => (
<article
key={item.id}
className={`rounded-xl border p-3 transition ${
item.is_unread
? 'border-cyan-500/40 bg-cyan-500/10'
: 'border-gray-700 bg-gray-900/60'
}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-gray-100">
<span className="font-semibold text-white">{actorLabel(item)}</span> {item.message}
</p>
{item.is_unread ? (
<span className="rounded-full bg-cyan-500/20 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-cyan-200">
unread
</span>
) : null}
</div>
<p className="mt-2 text-xs text-gray-400">{timeLabel(item.created_at)}</p>
</article>
))}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react'
function Widget({ label, value }) {
return (
<div className="rounded-xl border border-gray-700 bg-gray-900/70 p-4 shadow-lg transition hover:scale-[1.02]">
<p className="text-xs uppercase tracking-wide text-gray-400">{label}</p>
<p className="mt-2 text-2xl font-semibold text-white">{value}</p>
</div>
)
}
export default function CreatorAnalytics({ isCreator }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/dashboard/analytics')
if (!cancelled) {
setData(response.data?.data || null)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Creator Analytics</h2>
<a href="/creator/analytics" className="text-xs text-cyan-300 hover:text-cyan-200">
Open analytics
</a>
</div>
{loading ? <p className="text-sm text-gray-400">Loading analytics...</p> : null}
{!loading && !isCreator && !data?.is_creator ? (
<div className="rounded-xl border border-gray-700 bg-gray-900/60 p-4 text-sm text-gray-300">
Upload your first artwork to unlock creator-only insights.
</div>
) : null}
{!loading && (isCreator || data?.is_creator) ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Widget label="Total Artworks" value={data?.total_artworks ?? 0} />
<Widget label="Total Story Views" value={data?.total_story_views ?? 0} />
<Widget label="Total Followers" value={data?.total_followers ?? 0} />
<Widget label="Total Likes" value={data?.total_likes ?? 0} />
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,64 @@
import React from 'react'
const baseCard =
'group rounded-xl border border-gray-700 bg-gray-800 p-4 shadow-lg transition hover:scale-[1.02] hover:border-cyan-500/40'
const actions = [
{
key: 'upload-artwork',
label: 'Upload Artwork',
href: '/upload',
icon: 'fa-solid fa-cloud-arrow-up',
description: 'Publish a new piece to your portfolio.',
},
{
key: 'write-story',
label: 'Write Story',
href: '/creator/stories/create',
icon: 'fa-solid fa-pen-nib',
description: 'Create a story, tutorial, or showcase.',
},
{
key: 'edit-profile',
label: 'Edit Profile',
href: '/settings/profile',
icon: 'fa-solid fa-user-gear',
description: 'Update your profile details and links.',
},
{
key: 'notifications',
label: 'View Notifications',
href: '/messages',
icon: 'fa-solid fa-bell',
description: 'Catch up with mentions and updates.',
},
]
export default function QuickActions({ isCreator }) {
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Quick Actions</h2>
<span className="rounded-full border border-gray-600 px-2 py-1 text-xs text-gray-300">
{isCreator ? 'Creator mode' : 'User mode'}
</span>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
{actions.map((action) => (
<a key={action.key} href={action.href} className={baseCard}>
<div className="flex items-start gap-3">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-lg bg-gray-700 text-cyan-300">
<i className={action.icon} aria-hidden="true" />
</span>
<div>
<p className="text-sm font-semibold text-white">{action.label}</p>
<p className="mt-1 text-xs text-gray-300">{action.description}</p>
</div>
</div>
</a>
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,78 @@
import React, { useEffect, useState } from 'react'
export default function RecommendedCreators() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/dashboard/recommended-creators')
if (!cancelled) {
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Recommended Creators</h2>
<a className="text-xs text-cyan-300 hover:text-cyan-200" href="/creators/top">
See all
</a>
</div>
{loading ? <p className="text-sm text-gray-400">Loading creators...</p> : null}
{!loading && items.length === 0 ? (
<p className="text-sm text-gray-400">No creator recommendations right now.</p>
) : null}
{!loading && items.length > 0 ? (
<div className="space-y-3">
{items.map((creator) => (
<article
key={creator.id}
className="flex items-center justify-between rounded-xl border border-gray-700 bg-gray-900/70 p-3 transition hover:scale-[1.02]"
>
<a href={creator.url || '#'} className="flex min-w-0 items-center gap-3">
<img
src={creator.avatar || '/images/default-avatar.png'}
alt={creator.username || creator.name || 'Creator'}
className="h-10 w-10 rounded-full border border-gray-600 object-cover"
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-white">
{creator.username ? `@${creator.username}` : creator.name}
</p>
<p className="text-xs text-gray-400">{creator.followers_count} followers</p>
</div>
</a>
<a
href={creator.url || '#'}
className="rounded-lg border border-cyan-400/60 px-3 py-1 text-xs font-semibold text-cyan-200 transition hover:bg-cyan-500/20"
>
Follow
</a>
</article>
))}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,71 @@
import React, { useEffect, useState } from 'react'
export default function TrendingArtworks() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const response = await window.axios.get('/api/dashboard/trending-artworks')
if (!cancelled) {
setItems(Array.isArray(response.data?.data) ? response.data.data : [])
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
load()
return () => {
cancelled = true
}
}, [])
return (
<section className="rounded-xl border border-gray-700 bg-gray-800 p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Trending Artworks</h2>
<a className="text-xs text-cyan-300 hover:text-cyan-200" href="/discover/trending">
Explore more
</a>
</div>
{loading ? <p className="text-sm text-gray-400">Loading trending artworks...</p> : null}
{!loading && items.length === 0 ? (
<p className="text-sm text-gray-400">No trending artworks available.</p>
) : null}
{!loading && items.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{items.map((item) => (
<a
key={item.id}
href={item.url}
className="group overflow-hidden rounded-xl border border-gray-700 bg-gray-900/70 transition hover:scale-[1.02] hover:border-cyan-500/40"
>
<img
src={item.thumbnail || '/images/placeholder.jpg'}
alt={item.title}
loading="lazy"
className="h-28 w-full object-cover sm:h-32"
/>
<div className="p-2">
<p className="line-clamp-1 text-sm font-semibold text-white">{item.title}</p>
<p className="mt-1 text-xs text-gray-400">
{item.likes} likes {item.views} views
</p>
</div>
</a>
))}
</div>
) : null}
</section>
)
}

View File

@@ -0,0 +1,16 @@
import '../bootstrap'
import React from 'react'
import { createRoot } from 'react-dom/client'
import DashboardPage from './DashboardPage'
const rootElement = document.getElementById('dashboard-root')
if (rootElement) {
const root = createRoot(rootElement)
root.render(
<DashboardPage
username={rootElement.dataset.username || 'Creator'}
isCreator={rootElement.dataset.isCreator === '1'}
/>
)
}

View File

@@ -10,6 +10,7 @@ import { createRoot } from 'react-dom/client'
const MOUNTS = [
{ rootId: 'forum-index-root', propsId: 'forum-index-props', loader: () => import('./Pages/Forum/ForumIndex') },
{ rootId: 'forum-section-root', propsId: 'forum-section-props', loader: () => import('./Pages/Forum/ForumSection') },
{ rootId: 'forum-category-root', propsId: 'forum-category-props', loader: () => import('./Pages/Forum/ForumCategory') },
{ rootId: 'forum-thread-root', propsId: 'forum-thread-props', loader: () => import('./Pages/Forum/ForumThread') },
{ rootId: 'forum-new-thread-root', propsId: 'forum-new-thread-props', loader: () => import('./Pages/Forum/ForumNewThread') },

View File

@@ -5,6 +5,8 @@
// 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';
import React from 'react';
import { createRoot } from 'react-dom/client';
if (!window.Alpine) {
window.Alpine = Alpine;
Alpine.start();
@@ -13,6 +15,52 @@ if (!window.Alpine) {
// Gallery navigation context: stores artwork list for prev/next on artwork page
import './lib/nav-context.js';
function mountStoryEditor() {
var storyEditorRoot = document.getElementById('story-editor-react-root');
if (!storyEditorRoot) return;
if (storyEditorRoot.dataset.reactMounted === 'true') return;
var mode = storyEditorRoot.getAttribute('data-mode') || 'create';
var storyRaw = storyEditorRoot.getAttribute('data-story') || '{}';
var storyTypesRaw = storyEditorRoot.getAttribute('data-story-types') || '[]';
var endpointsRaw = storyEditorRoot.getAttribute('data-endpoints') || '{}';
var csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
var initialStory = {};
var storyTypes = [];
var endpoints = {};
try {
initialStory = JSON.parse(storyRaw);
storyTypes = JSON.parse(storyTypesRaw);
endpoints = JSON.parse(endpointsRaw);
} catch (_error) {
// If parsing fails, the editor falls back to component defaults.
}
storyEditorRoot.dataset.reactMounted = 'true';
void import('./components/editor/StoryEditor')
.then(function (module) {
var StoryEditor = module.default;
createRoot(storyEditorRoot).render(
React.createElement(StoryEditor, {
mode: mode,
initialStory: initialStory,
storyTypes: storyTypes,
endpoints: endpoints,
csrfToken: csrfToken,
})
);
})
.catch(function () {
storyEditorRoot.dataset.reactMounted = 'false';
storyEditorRoot.innerHTML = '<div class="rounded-xl border border-rose-700 bg-rose-900/20 p-4 text-rose-200">Failed to load editor. Please refresh the page.</div>';
});
}
mountStoryEditor();
(function () {
function initBlurPreviewImages() {
var selector = 'img[data-blur-preview]';
@@ -115,12 +163,23 @@ import './lib/nav-context.js';
return document.getElementById('mobileMenu');
}
function setMobileToggleVisual(isOpen) {
var toggle = document.querySelector('[data-mobile-toggle]') || document.getElementById('btnSidebar');
if (!toggle) return;
setExpanded(toggle, !!isOpen);
var hamburgerIcon = toggle.querySelector('[data-mobile-icon-hamburger]');
var closeIcon = toggle.querySelector('[data-mobile-icon-close]');
if (hamburgerIcon) hamburgerIcon.classList.toggle('hidden', !!isOpen);
if (closeIcon) closeIcon.classList.toggle('hidden', !isOpen);
}
function closeMobileMenu() {
var menu = getMobileMenu();
if (!menu) return;
menu.classList.add('hidden');
var toggle = document.querySelector('[data-mobile-toggle]');
setExpanded(toggle, false);
setMobileToggleVisual(false);
}
function toggleMobileMenu() {
@@ -132,8 +191,7 @@ import './lib/nav-context.js';
closeMobileMenu();
} else {
menu.classList.remove('hidden');
var toggle = document.querySelector('[data-mobile-toggle]');
setExpanded(toggle, true);
setMobileToggleVisual(true);
closeAllDropdowns();
}
}
@@ -196,6 +254,40 @@ import './lib/nav-context.js';
return;
}
var mobileSectionToggle = closest(e.target, '[data-mobile-section-toggle]');
if (mobileSectionToggle) {
e.preventDefault();
var panelId = mobileSectionToggle.getAttribute('aria-controls');
var panel = panelId ? document.getElementById(panelId) : null;
if (!panel) return;
var wasOpen = !panel.classList.contains('hidden');
var menuRoot = getMobileMenu();
// Keep mobile navigation tidy: close all sections first.
if (menuRoot) {
menuRoot.querySelectorAll('[data-mobile-section-panel]').forEach(function (el) {
el.classList.add('hidden');
});
menuRoot.querySelectorAll('[data-mobile-section-toggle]').forEach(function (btn) {
setExpanded(btn, false);
var icon = btn.querySelector('[data-mobile-section-icon]');
if (icon) icon.classList.remove('rotate-180');
});
}
// If it was closed, open it. If it was open, it stays closed (toggle behavior).
if (!wasOpen) {
panel.classList.remove('hidden');
setExpanded(mobileSectionToggle, true);
var currentIcon = mobileSectionToggle.querySelector('[data-mobile-section-icon]');
if (currentIcon) currentIcon.classList.add('rotate-180');
}
return;
}
// Submenu toggle (touch/click fallback)
var submenuToggle = closest(e.target, '[data-submenu-toggle]');
if (submenuToggle) {
@@ -237,6 +329,13 @@ import './lib/nav-context.js';
if (!closest(e.target, '[data-dropdown]')) {
closeAllDropdowns();
}
// Close mobile menu when tapping outside of it and outside the hamburger toggle.
var mobileMenu = getMobileMenu();
var mobileToggle = closest(e.target, '[data-mobile-toggle]') || closest(e.target, '#btnSidebar');
if (mobileMenu && !mobileMenu.classList.contains('hidden') && !mobileToggle && !closest(e.target, '#mobileMenu')) {
closeMobileMenu();
}
});
// Hover-to-open for desktop pointers