more fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">→</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' })}`
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
68
resources/js/Pages/Forum/ForumSection.jsx
Normal file
68
resources/js/Pages/Forum/ForumSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
190
resources/js/Search/SearchOverlay.jsx
Normal file
190
resources/js/Search/SearchOverlay.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
816
resources/js/components/editor/StoryEditor.tsx
Normal file
816
resources/js/components/editor/StoryEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
232
resources/js/components/profile/ProfileCoverEditor.jsx
Normal file
232
resources/js/components/profile/ProfileCoverEditor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
49
resources/js/components/profile/tabs/TabStories.jsx
Normal file
49
resources/js/components/profile/tabs/TabStories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
resources/js/dashboard/DashboardPage.jsx
Normal file
39
resources/js/dashboard/DashboardPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
resources/js/dashboard/components/ActivityFeed.jsx
Normal file
95
resources/js/dashboard/components/ActivityFeed.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
resources/js/dashboard/components/CreatorAnalytics.jsx
Normal file
66
resources/js/dashboard/components/CreatorAnalytics.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
resources/js/dashboard/components/QuickActions.jsx
Normal file
64
resources/js/dashboard/components/QuickActions.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
78
resources/js/dashboard/components/RecommendedCreators.jsx
Normal file
78
resources/js/dashboard/components/RecommendedCreators.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
resources/js/dashboard/components/TrendingArtworks.jsx
Normal file
71
resources/js/dashboard/components/TrendingArtworks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
resources/js/dashboard/index.jsx
Normal file
16
resources/js/dashboard/index.jsx
Normal 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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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') },
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user