more fixes

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

View File

@@ -51,28 +51,83 @@ function SidebarContent({ isActive, onNavigate }) {
)
}
export default function SettingsLayout({ children, title }) {
function SectionSidebar({ sections = [], activeSection, onSectionChange }) {
return (
<>
<div className="mb-6">
<h2 className="text-xs font-semibold uppercase tracking-wider text-slate-500 px-4 mb-2">Settings</h2>
</div>
<nav className="space-y-1 flex-1">
{sections.map((section) => {
const active = section.key === activeSection
return (
<button
key={section.key}
type="button"
onClick={() => onSectionChange?.(section.key)}
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${
active
? 'bg-accent/20 text-accent shadow-sm shadow-accent/10'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`}
>
{section.icon ? <i className={`${section.icon} w-5 text-center text-base`} /> : null}
<span>{section.label}</span>
</button>
)
})}
</nav>
</>
)
}
export default function SettingsLayout({ children, title, sections = null, activeSection = null, onSectionChange = null }) {
const { url } = usePage()
const [mobileOpen, setMobileOpen] = useState(false)
const hasSectionMode = Array.isArray(sections) && sections.length > 0 && typeof onSectionChange === 'function'
const isActive = (href) => url.startsWith(href)
const currentSection = hasSectionMode
? sections.find((section) => section.key === activeSection)
: null
return (
<div className="min-h-screen bg-nova-900">
{/* Mobile top bar */}
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
<h1 className="text-lg font-bold text-white">Settings</h1>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="text-slate-400 hover:text-white p-2"
aria-label="Toggle navigation"
>
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
</button>
<div className="lg:hidden px-4 py-3 border-b border-white/10 bg-nova-900/80 backdrop-blur-xl sticky top-16 z-30">
{hasSectionMode ? (
<label className="block">
<span className="sr-only">Settings section</span>
<select
className="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
value={activeSection || ''}
onChange={(e) => onSectionChange(e.target.value)}
>
{sections.map((section) => (
<option key={section.key} value={section.key} className="bg-nova-900 text-white">
{section.label}
</option>
))}
</select>
</label>
) : (
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold text-white">Settings</h1>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="text-slate-400 hover:text-white p-2"
aria-label="Toggle navigation"
>
<i className={`fa-solid ${mobileOpen ? 'fa-xmark' : 'fa-bars'} text-xl`} />
</button>
</div>
)}
</div>
{/* Mobile nav overlay */}
{mobileOpen && (
{/* Mobile nav overlay (legacy mode only) */}
{!hasSectionMode && mobileOpen && (
<div className="lg:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm" onClick={() => setMobileOpen(false)}>
<nav
className="absolute left-0 top-0 bottom-0 w-72 bg-nova-900 border-r border-white/10 p-4 pt-20 space-y-1"
@@ -86,13 +141,22 @@ export default function SettingsLayout({ children, title }) {
<div className="flex">
{/* Desktop sidebar */}
<aside className="hidden lg:flex flex-col w-64 min-h-[calc(100vh-4rem)] border-r border-white/10 bg-nova-900/60 backdrop-blur-xl p-4 pt-6 sticky top-16 self-start">
<SidebarContent isActive={isActive} />
{hasSectionMode ? (
<SectionSidebar sections={sections} activeSection={activeSection} onSectionChange={onSectionChange} />
) : (
<SidebarContent isActive={isActive} />
)}
</aside>
{/* Main content */}
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-4xl">
<main className="flex-1 min-w-0 px-4 lg:px-8 pt-4 pb-8 max-w-5xl">
{title && (
<h1 className="text-2xl font-bold text-white mb-6">{title}</h1>
<div className="mb-6">
<h1 className="text-2xl font-bold text-white">{title}</h1>
{currentSection?.description ? (
<p className="text-sm text-slate-400 mt-1">{currentSection.description}</p>
) : null}
</div>
)}
{children}
</main>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

257
resources/lang/en/admin.php Normal file
View File

@@ -0,0 +1,257 @@
<?php
return [
'2FA' => '2FA',
'2FA_ENABLE_DESCRIPTION' => '&amp;lt;p&amp;gt;Two factor authentication (2FA) strengthens access security by requiring two methods (also referred to as factors) to verify your identity. Two factor authentication protects against phishing, social engineering and password brute force attacks and secures your logins from attackers exploiting weak or stolen credentials.&amp;lt;/p&amp;gt; &amp;lt;p&amp;gt;To Enable Two Factor Authentication on your Account, you need to do following steps&amp;lt;/p&amp;gt; &amp;lt;ol&amp;gt; &amp;lt;li&amp;gt;Click on Generate Secret Button , To Generate a Unique secret QR code for your profile&amp;lt;/li&amp;gt; &amp;lt;li&amp;gt;Verify the OTP from Google Authenticator Mobile App&amp;lt;/li&amp;gt; &amp;lt;/ol&amp;gt;',
'2FA_ENABLE_STEP_1' => '1. Scan this barcode with your Google Authenticator App:',
'2FA_ENABLE_STEP_2' => '2.Enter the pin the code to Enable 2FA',
'2FA_MAIL' => 'mail for 2FA',
'ACCESS' => 'Access',
'ACCESS_RESTRICTED' => 'Access to this page is restricted',
'ACCESS_RESTRICTED_TEXT' => 'Please check with the site admin if you believe this is a mistake.',
'ACCESS_TO_CONTROL_PANEL' => 'Access to control panel',
'ACTION' => 'Action',
'ACTIVATE_2FA_SECURITY' => 'Activate 2FA security',
'ACTIVATE_2FA_SECURITY_DESCRIPTION' => 'When 2FA is active you will receive security code in your mailbox',
'ACTIVATE_SHARETHIS_INTEGRATION' => 'Activate ShareThis integration',
'ACTIVATE_TRUENDO_CONSENT' => 'Activate Truendo Consent',
'ACTIVE' => 'Active',
'ADD' => 'Add',
'ADD_SELECTED_PERMISSIONS' => 'Add selected permissions',
'ADD_TRANSLATION' => 'Add translation',
'ADMIN_USERS' => 'Admin users',
'ADMINISTRATOR_ACCESS' => 'Administrator access',
'ALERT' => 'Alert',
'ALL' => 'All',
'ALL_LANGUAGES' => 'All languages',
'ALL_USERS' => 'All users',
'ANGLE' => 'Angle',
'APP_DOMAIN' => 'App domain',
'APP_NAME' => 'Application name',
'APP_URL' => 'Application URL',
'ARE_YOU_SURE' => 'Are you sure?',
'ARE_YOU_SURE_YOU_WANT_TO_DELETE_THIS_TRANSLATION' => 'Are you sure you want to delete this translation?',
'ATTACHMENTS' => 'Attachments',
'AUTHENTICATOR_CODE' => 'Enter the code from authenticaror',
'AUTHOR' => 'Author',
'BACK' => 'Back',
'BACKEND' => 'Backend',
'BROWSER' => 'Browser',
'BROWSER_SESSIONS' => 'Browser Sessions',
'BUILD' => 'Build',
'CALENDAR' => 'Calendar',
'CHOOSE_COLOR_SCHEME' => 'Choose color scheme',
'CHOOSE_FILE' => 'Choose file',
'CHOOSE_YOUR_LANGUAGE' => 'Choose your language',
'CLOSE' => 'Close',
'CODE' => 'Code',
'COLOR' => 'Color',
'CONFIG_WAS_UPDATED' => 'Configuration was updated',
'CONFIGURATION' => 'Configuration',
'CONTROL_PANEL' => 'Control Panel',
'CONTROL_PANEL_MANAGER' => 'Contol Panel Manager',
'COPY' => 'Copy',
'COUNTRY' => 'Country',
'CPAD' => 'cPad',
'CREATED' => 'Created',
'CSV' => 'CSV',
'CSV_IMPORTED_SUCCESSFULLY' => 'CSV imported successfully',
'CURRENT_PASSWORD' => 'Current Password',
'DASHBOARD' => 'Dashboard',
'DATA_HAS_BEEN_UPDATED' => 'Data has been updated',
'DATE' => 'Date',
'DAYS' => 'Days',
'DEBUGBAR_SHOW_FOR_SELECTED_IP' => 'Display Debugbar for selected IP',
'DELETE' => 'Delete',
'DELETE_POST' => 'Delete post',
'DESCRIPTION' => 'Description',
'DISABLE_2FA' => 'Disable 2FA',
'DISCOVER' => 'Discover',
'DISPLAY_DEBUG_BLOCKS_ON_FRONTEND' => 'Display debug for block on frontend',
'DOCUMENTS' => 'Documents',
'DOESNT_EXISTS' => 'Doesn&amp;apos;t exists',
'DRAG_AND_DROP' => 'Drag and Drop',
'EDIT' => 'Edit',
'EDITOR' => 'Editor',
'EMAIL' => 'Email',
'EMAIL_ADDRESS' => 'E-mail address',
'ENABLE_2FA' => 'Enable 2FA',
'ENABLE_DEBUGBAR' => 'Enable Debugbar',
'ENABLE_FRONTEND' => 'Enable frontend',
'ENABLE_QUEUE' => 'Enable queue',
'ENTER_THE_2FA_AUTHORIZATION_CODE' => 'Eneter the 2FA authorization code',
'ENTER_URL_TO_FETCH_IMAGE' => 'Enter URL to fetch image',
'ERROR' => 'Error',
'ERROR_IMPORTING_CSV' => 'Error importing CSV',
'ERROR_LOADING_FORM' => 'Error while loading form',
'ERROR_REORDERING_ITEMS' => 'Error reordering items',
'ERROR_SAVING_FORM' => 'Error while saving form data',
'ERROR_UPDATING_RECORD' => 'Error updating record',
'EVENT' => 'Event',
'EXPORT_AS_CSV' => 'Export as CSV',
'FACEBOOK' => 'Facebook',
'FACEBOOK_APP_ID' => 'Facebook App ID',
'FACEBOOK_PAGE' => 'Facebook page',
'FAILED_TO_CREATE_FOLDER' => 'Failed to create folder',
'FILE_MANAGER' => 'Filemanager',
'FILEMANAGER' => 'Filemanager',
'FILENAME' => 'Filename',
'FLAG' => 'Flag',
'FOLDER' => 'Folder',
'FORBIDDEN' => 'Forbidden',
'FORGOT_PASSWORD' => 'Forgot password',
'FRONTEND' => 'Frontend',
'GENERATE_SECRET_KEY_TO_ENABLE_2FA' => 'Generate secret key to enable 2FA',
'GET_YOUR_CODE' => 'Get your code',
'GOOGLE_ADSENSE_PUBLISHER_ID' => 'Google AdSense publisher ID',
'GOOGLE_SITE_VERIFICATION' => 'Google Site verification',
'GRADIENT_BACKGROUND' => 'Gradient background',
'GROUP' => 'Group',
'HELLO' => 'Hello',
'ID' => 'ID',
'IMPORT' => 'Import',
'IMPORT_AS_CSV' => 'Import from CSV',
'IMPORT_MISSING_TRANSLATIONS' => 'Import missing translations',
'IMPORT_TRANSLATIONS' => 'Import translations',
'INSTAGRAM' => 'Instagram',
'INVALID_CSV_STRUCTURE' => 'Invalid CSV structure',
'IP' => 'IP',
'ITEMS_SUCCESSFULLY_REORDERED' => 'Items successfully reordered',
'KEYCODE' => 'Keycode',
'KEYCODE_ALREADY_EXISTS' => 'Current keycode already exists in system. If you will click save button you will delete old keycode data and replaced with new one',
'KEYCODE_OK' => 'This keycode doesnt exists yet',
'LANGUAGE' => 'Language',
'LANGUAGE_STATE_SUCCESSFULLY_CHANGED' => 'Language state was successffully changed',
'LANGUAGES' => 'Languages',
'LANGUAGES_REORDER_SUCCESS' => 'Languages reordered',
'LAST_30_DAYS' => 'Last 30 days',
'LAST_7_DAYS' => 'Last 7 days',
'LAST_ACTIVE' => 'Last active',
'LIST' => 'List',
'LOGIN' => 'Login',
'LOGO' => 'Logo',
'LOGO_ALT' => 'Logo Alt',
'LOGO_MICROTAGS' => 'Logo microtags',
'LOGO_TITLE' => 'Logo Title',
'LOGOUT' => 'Logout',
'MAINTENANCE' => 'Maintenance',
'MAINTENANCE_MODE' => 'Maintenance mode',
'META' => 'Meta',
'META_DESCRIPTION' => 'Meta Description',
'META_KEYWORDS' => 'Meta Keywords',
'METHOD' => 'Methoda',
'MIGRATIONS' => 'Migrations',
'MISSING' => 'Missing',
'MISSING_DATA' => 'Missing data',
'MISSING_FOLDER_NAME' => 'Missing folder name',
'MISSING_ID' => 'Missing ID',
'MISSING_WIDGET' => 'Missing Widget',
'MS_VALIDATE' => 'MS Validate',
'NAME' => 'Name',
'NAVIGATION' => 'Navigation',
'NEED_HELP' => 'Need Help',
'NETWORK_RESPONSE_WAS_NOT_OK' => 'Network response was not ok',
'NEW_PASSWORD' => 'New Password',
'NEWS' => 'News',
'NO' => 'No',
'NO_ACCESS' => 'No access',
'NO_ACTIVE_LANGUAGES' => 'No active languages',
'NO_COMPANY' => 'No company',
'NO_CONTENT' => 'No Content',
'NO_DATA_AVAILABLE' => 'No data available',
'NO_PERMISSIONS' => 'No Permissions',
'NO_PRIVILEGES' => 'No privileges',
'NO_PRIVILEGIES' => 'No privilegies',
'NON_ADMIN_USERS' => 'Non admin users',
'OF' => 'of',
'OG_DESCRIPTION' => 'OpenGraph Description',
'OG_PICTURE' => 'OpenGraph picture',
'OG_TITLE' => 'OpenGraph title',
'OG_TYPE' => 'OpenGraph Type',
'OPTIONS' => 'Options',
'OR' => 'or',
'OS' => 'OS',
'PAGE_TITLE' => 'Page title',
'PASSWORD' => 'Password',
'PERMISSION_MODULE' => 'Permission module',
'PERMISSION_PROBLEM' => 'Permission problem',
'PERMISSIONS' => 'Permissions',
'PERMISSIONS_REVIEW' => 'Permission review',
'PICTURE' => 'Picture',
'PLUGINS' => 'Plugins',
'PROFILE' => 'Profile',
'PROFILE_PICTURE' => 'Profile picture',
'PROJECT_NAME' => 'Project name',
'PUBLISHER' => 'Publisher',
'QUEUE' => 'Queue',
'QUICK_LINKS' => 'Quick links',
'RECORD_CREATED_SUCCESSFULLY' => 'Record updated successfully',
'RECORD_UPDATED_SUCCESSFULLY' => 'Updated successfully',
'REFRESH' => 'Refresh',
'REMOVE_PICTURE' => 'Remove Picture',
'REQUIRED_PERMISSIONS' => 'Required permissions',
'REQUIRED_ROLES' => 'Required roles',
'RESEND_CODE' => 'Resend code',
'REVIEW_PERMISSIONS' => 'Review permissions',
'ROLES' => 'Roles',
'ROW_MUST_BE_A_POSITIVE_INTEGER' => 'Row must be positive integer number',
'SAVE' => 'Save',
'SEARCH' => 'Search',
'SELECT_ALL' => 'Select all',
'SELECT_GROUP' => 'Select group',
'SELECT_LANGUAGES' => 'Select languages',
'SELECT_USER_TYPE' => 'Select user type',
'SETTINGS' => 'Settings',
'SETUP' => 'Setup',
'SHARETHIS_PROPERTY_ID' => 'Sharethis Property ID',
'SHOW' => 'Show',
'SHOWING' => 'Showing',
'SIZE' => 'Size',
'SLUG' => 'Slug',
'SORT' => 'Sort',
'STAMP_ADD' => 'Created',
'STAMP_END' => 'Finished',
'STAMP_START' => 'Start',
'STATUS' => 'Status',
'STATUS_CHANGED' => 'Status changed',
'SUBJECT' => 'Subject',
'SUBMIT' => 'Submit',
'SUPPORTS' => 'Supports',
'SURNAME' => 'Surname',
'TAGS' => 'TAGS',
'TEMPLATE' => 'Template',
'TEMPLATES' => 'Templates',
'THERE_WAS_A_PROBLEM_WITH_YOUR_FETCH_OPERATION' => 'There was a problem with your fetch operation',
'THIS_DEVICE' => 'This device',
'TIME' => 'Time',
'TITLE' => 'Title',
'TODAY' => 'Today',
'TRANSLATE' => 'Translate',
'TRANSLATION' => 'Translation',
'TRANSLATION_UPDATED' => 'Tanslations were updated',
'TRANSLATIONS' => 'Translations',
'TRUENDO_SITE_ID' => 'Truendo Site ID',
'TWITTER' => 'Twitter',
'TWITTER_HANDLE' => 'Twitter handle',
'TWITTER_PAGE' => 'Twitter page',
'TYPE' => 'Type',
'UID' => 'UID',
'UNKNOWN' => 'Unknown',
'UPDATE' => 'Update',
'UPDATED' => 'Updated',
'URL' => 'URL',
'USER' => 'User',
'USER_DETAILS' => 'User details',
'USER_GROUP' => 'User group',
'USER_GROUPS' => 'User groups',
'USERS' => 'Users',
'VALUE' => 'Value',
'VERIFY_PASSWORD' => 'Verify password',
'VERSION' => 'Version',
'VIEW' => 'View',
'WARNING' => 'Important',
'WE_SENT_CODE_TO_EMAIL' => 'Code was sent to your email address',
'WEATHER' => 'Weather',
'WRONG_EMAIL_OR_PASSWORD' => 'Wrong email address or password',
'YES' => 'Yes',
'YOU_DONT_HAVE_ACCESS' => 'You don&amp;apos;t have access',
];

5
resources/lang/en/fp.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
'TEST' => 'test',
'TESTX' => 'test x',
];

View File

@@ -0,0 +1,8 @@
@extends('layouts.nova')
@section('content')
<div class="mx-auto max-w-4xl px-4 py-8">
<h1 class="mb-2 text-xl font-semibold text-gray-100">Story Comments Moderation</h1>
<p class="text-sm text-gray-400">Story comments currently use the existing profile comment pipeline. Use this page as moderation entrypoint and link to the global comments moderation tools.</p>
</div>
@endsection

View File

@@ -0,0 +1,8 @@
@extends('layouts.nova')
@section('content')
<div class="mx-auto max-w-4xl px-4 py-8">
<h1 class="mb-4 text-xl font-semibold text-gray-100">Create Story</h1>
@include('admin.stories.partials.form', ['action' => route('admin.stories.store'), 'method' => 'POST'])
</div>
@endsection

View File

@@ -0,0 +1,21 @@
@extends('layouts.nova')
@section('content')
<div class="mx-auto max-w-4xl px-4 py-8">
<h1 class="mb-4 text-xl font-semibold text-gray-100">Edit Story</h1>
@include('admin.stories.partials.form', ['action' => route('admin.stories.update', $story->id), 'method' => 'PUT'])
<div class="mt-4 flex items-center gap-3">
<form method="POST" action="{{ route('admin.stories.publish', $story->id) }}">
@csrf
<button class="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-4 py-2 text-emerald-200">Publish</button>
</form>
<form method="POST" action="{{ route('admin.stories.destroy', $story->id) }}" onsubmit="return confirm('Delete this story?');">
@csrf
@method('DELETE')
<button class="rounded-lg border border-rose-500/40 bg-rose-500/10 px-4 py-2 text-rose-200">Delete</button>
</form>
</div>
</div>
@endsection

View File

@@ -0,0 +1,71 @@
<form method="POST" action="{{ $action }}" class="space-y-5 rounded-xl border border-gray-700 bg-gray-800/60 p-6">
@csrf
@if($method !== 'POST')
@method($method)
@endif
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm text-gray-200">Creator</label>
<select name="creator_id" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
@foreach($creators as $creator)
<option value="{{ $creator->id }}" @selected(old('creator_id', $story->creator_id ?? '') == $creator->id)>{{ $creator->username }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Status</label>
<select name="status" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
@foreach(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'] as $status)
<option value="{{ $status }}" @selected(old('status', $story->status ?? 'draft') === $status)>{{ ucfirst($status) }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Title</label>
<input name="title" value="{{ old('title', $story->title ?? '') }}" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Cover image URL</label>
<input name="cover_image" value="{{ old('cover_image', $story->cover_image ?? '') }}" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Excerpt</label>
<textarea name="excerpt" rows="3" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">{{ old('excerpt', $story->excerpt ?? '') }}</textarea>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm text-gray-200">Story type</label>
<select name="story_type" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" required>
@foreach(['creator_story', 'tutorial', 'interview', 'project_breakdown', 'announcement', 'resource'] as $type)
<option value="{{ $type }}" @selected(old('story_type', $story->story_type ?? 'creator_story') === $type)>{{ str_replace('_', ' ', ucfirst($type)) }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Tags</label>
<select name="tags[]" multiple class="min-h-24 w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">
@php
$selectedTags = collect(old('tags', isset($story) ? $story->tags->pluck('id')->all() : []))->map(fn($id) => (int) $id)->all();
@endphp
@foreach($tags as $tag)
<option value="{{ $tag->id }}" @selected(in_array($tag->id, $selectedTags, true))>{{ $tag->name }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Content</label>
<textarea name="content" rows="14" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">{{ old('content', $story->content ?? '') }}</textarea>
</div>
<div class="flex items-center gap-3">
<button class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-4 py-2 text-sky-200">Save</button>
</div>
</form>

View File

@@ -4,7 +4,7 @@
<!-- Nova main preview ported into Blade (server-rendered) -->
<div class="pt-0">
<div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]">
<div class="flex min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
<!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4">

View File

@@ -0,0 +1,38 @@
@props(['story'])
<a href="{{ route('stories.show', $story->slug) }}"
class="group block overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition-transform duration-200 hover:scale-[1.02] hover:border-sky-500/40">
@if($story->cover_url)
<div class="aspect-video overflow-hidden bg-gray-900">
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" loading="lazy" />
</div>
@else
<div class="aspect-video bg-gradient-to-br from-gray-900 via-slate-900 to-sky-950"></div>
@endif
<div class="space-y-3 p-4">
<h3 class="line-clamp-2 text-lg font-bold text-white">{{ $story->title }}</h3>
<div class="flex items-center gap-2 text-sm text-gray-300">
<img
src="{{ $story->creator?->profile?->avatar_hash ? \App\Support\AvatarUrl::forUser((int) $story->creator->id, $story->creator->profile->avatar_hash, 48) : \App\Support\AvatarUrl::default() }}"
alt="{{ $story->creator?->username ?? 'Creator' }}"
class="h-6 w-6 rounded-full border border-gray-600 object-cover"
/>
<span>{{ $story->creator?->username ?? 'Unknown creator' }}</span>
@if($story->published_at)
<span class="text-gray-500"></span>
<time datetime="{{ $story->published_at->toIso8601String() }}" class="text-gray-400">
{{ $story->published_at->format('M j, Y') }}
</time>
@endif
</div>
<div class="flex items-center gap-3 text-xs text-gray-400">
<span>{{ $story->reading_time }} min read</span>
<span>{{ number_format((int) $story->likes_count) }} likes</span>
<span>{{ number_format((int) $story->comments_count) }} comments</span>
<span>{{ number_format((int) $story->views) }} views</span>
</div>
</div>
</a>

View File

@@ -1,45 +1,21 @@
@extends('layouts.nova')
@push('head')
@vite(['resources/js/dashboard/index.jsx'])
@endpush
@section('content')
<div class="container">
@php($page_title = 'Dashboard')
<div
id="dashboard-root"
data-username="{{ $dashboard_user_name }}"
data-is-creator="{{ $dashboard_is_creator ? '1' : '0' }}"
></div>
<h2>Dashboard</h2>
@if(session('status'))
<div class="alert alert-success" role="status">{{ session('status') }}</div>
@if (session('status'))
<div class="mx-auto max-w-7xl px-4 mt-4">
<div class="rounded-lg border border-emerald-500/30 bg-emerald-900/20 px-4 py-3 text-sm text-emerald-100" role="status">
{{ session('status') }}
</div>
</div>
@endif
<div class="alert alert-info" role="status">
You're logged in.
</div>
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Your content</div>
<div class="panel-body">
<p>
<a class="btn btn-primary" href="{{ route('dashboard.artworks.index') }}">
<i class="fa fa-picture-o fa-fw"></i> Manage My Artworks
</a>
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">Account</div>
<div class="panel-body">
<p>
<a class="btn btn-default" href="{{ route('profile.edit') }}">
<i class="fa fa-user fa-fw"></i> Edit Profile
</a>
</p>
</div>
</div>
</div>
</div>
</div>
@endsection

View File

@@ -45,7 +45,7 @@
<div class="container-fluid legacy-page">
<div class="pt-0">
<div class="mx-auto w-full">
<div class="relative min-h-[calc(100vh-64px)]">
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
<main class="w-full">
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 nb-hero-gradient" aria-hidden="true"></div>

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Skinbase Email Verification</title>
</head>
<body style="margin:0;padding:0;background:#0f172a;color:#e2e8f0;font-family:Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0f172a;padding:24px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#111827;border:1px solid #1f2937;border-radius:12px;overflow:hidden;">
<tr>
<td style="padding:24px;">
<h1 style="margin:0 0 12px 0;font-size:20px;line-height:1.2;color:#f8fafc;">Verify your new email address</h1>
<p style="margin:0 0 16px 0;font-size:14px;line-height:1.6;color:#cbd5e1;">Use this code to confirm your email change on Skinbase:</p>
<p style="margin:0 0 16px 0;font-size:32px;line-height:1.2;font-weight:700;letter-spacing:4px;color:#f8fafc;">{{ $code }}</p>
<p style="margin:0 0 12px 0;font-size:14px;line-height:1.6;color:#cbd5e1;">This code expires in {{ $expiresInMinutes }} minutes.</p>
<p style="margin:0;font-size:13px;line-height:1.5;color:#94a3b8;">If you did not request this change, you can ignore this email.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Skinbase Security Alert</title>
</head>
<body style="margin:0;padding:0;background:#0f172a;color:#e2e8f0;font-family:Arial,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0f172a;padding:24px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;background:#111827;border:1px solid #1f2937;border-radius:12px;overflow:hidden;">
<tr>
<td style="padding:24px;">
<h1 style="margin:0 0 12px 0;font-size:20px;line-height:1.2;color:#f8fafc;">Your Skinbase email address was changed</h1>
<p style="margin:0 0 12px 0;font-size:14px;line-height:1.6;color:#cbd5e1;">Your account email is now set to <strong>{{ $newEmail }}</strong>.</p>
<p style="margin:0 0 12px 0;font-size:14px;line-height:1.6;color:#cbd5e1;">If you did not perform this action, contact support immediately.</p>
<p style="margin:0;font-size:13px;line-height:1.5;color:#94a3b8;">Support: {{ $supportEmail }}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -77,7 +77,7 @@
<div class="pt-0">
<div class="mx-auto w-full">
<div class="relative min-h-[calc(100vh-64px)]">
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
<main class="w-full">

View File

@@ -58,7 +58,7 @@
<div class="pt-0">
<div class="mx-auto w-full">
<div class="relative min-h-[calc(100vh-64px)]">
<div class="relative min-h-[calc(120vh-64px)] md:min-h-[calc(100vh-64px)]">
<main class="w-full">
{{-- ══ HERO HEADER ══ --}}

View File

@@ -1,13 +1,20 @@
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel">
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-black/40 backdrop-blur border-b border-white/10">
<div class="mx-auto w-full h-full px-4 flex items-center gap-3">
<!-- Mobile hamburger -->
<button id="btnSidebar"
type="button"
data-mobile-toggle
class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5"
aria-label="Open menu">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
aria-label="Open menu"
aria-controls="mobileMenu"
aria-expanded="false">
<svg data-mobile-icon-hamburger class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg data-mobile-icon-close class="hidden w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
<!-- Logo -->
@@ -112,6 +119,12 @@
<i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories
</a>
@auth
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('creator.stories.index') }}">
<i class="fa-solid fa-rectangle-list w-4 text-center text-sb-muted"></i>My Stories
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('creator.stories.create') }}">
<i class="fa-solid fa-pen-to-square w-4 text-center text-sb-muted"></i>Write Story
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('dashboard.following') }}">
<i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following
</a>
@@ -219,6 +232,9 @@
@php
$toolbarUsername = strtolower((string) (Auth::user()->username ?? ''));
$routeUpload = Route::has('upload') ? route('upload') : '/upload';
$routeDashboard = Route::has('dashboard') ? route('dashboard') : '/dashboard';
$routeMyArtworks = Route::has('creator.artworks') ? route('creator.artworks') : '/creator/artworks';
$routeMyStories = Route::has('creator.stories.index') ? route('creator.stories.index') : '/creator/stories';
$routeDashboardFavorites = Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites';
$routeEditProfile = Route::has('dashboard.profile')
? route('dashboard.profile')
@@ -233,6 +249,18 @@
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-upload text-xs text-sb-muted"></i></span>
Upload
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeDashboard }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-table-columns text-xs text-sb-muted"></i></span>
Dashboard
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeMyArtworks }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-image text-xs text-sb-muted"></i></span>
My Artworks
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ $routeMyStories }}">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-book-open text-xs text-sb-muted"></i></span>
My Stories
</a>
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="/studio/artworks">
<span class="w-6 h-6 rounded-md bg-white/5 inline-flex items-center justify-center shrink-0"><i class="fa-solid fa-palette text-xs text-sb-muted"></i></span>
Studio
@@ -271,94 +299,115 @@
</div>
</div>
@else
<!-- Guest: Upload CTA + Join / Sign in -->
<div class="hidden md:flex items-center gap-2">
<!-- Guest auth toolbar: desktop CTA + secondary sign-in. -->
<div class="hidden md:flex items-center gap-4">
<a href="/register"
class="px-3 py-2 rounded-lg text-sm text-sb-muted hover:text-white hover:bg-white/5 transition-colors">Join</a>
aria-label="Join Skinbase"
class="inline-flex items-center px-4 py-2 rounded-lg bg-gradient-to-r from-indigo-500 to-cyan-500 text-white text-sm font-semibold shadow-sm transition duration-200 ease-out hover:-translate-y-[1px] hover:shadow-[0_0_15px_rgba(99,102,241,0.7)] focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-black/40">
Join Skinbase
</a>
<a href="/login"
class="px-4 py-2 rounded-lg bg-sky-600 hover:bg-sky-500 text-white text-sm font-medium transition-colors">Sign in</a>
aria-label="Sign in"
class="text-sm font-medium text-gray-300 transition-colors hover:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-black/40 rounded-md px-1.5 py-1">
Sign in
</a>
</div>
<!-- Guest auth on mobile: icon trigger with lightweight dropdown menu. -->
<details class="relative md:hidden">
<summary
aria-label="Open authentication menu"
class="list-none inline-flex items-center justify-center w-10 h-10 rounded-lg text-gray-300 hover:text-white hover:bg-white/5 cursor-pointer focus:outline-none focus:ring-2 focus:ring-indigo-500">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M20 21a8 8 0 1 0-16 0" />
<circle cx="12" cy="7" r="4" />
</svg>
</summary>
<div class="absolute right-0 mt-2 w-48 rounded-xl border border-white/10 bg-black/80 backdrop-blur p-2 shadow-sb">
<a href="/register"
aria-label="Join Skinbase"
class="block px-3 py-2 rounded-lg bg-gradient-to-r from-indigo-500 to-cyan-500 text-white text-sm font-semibold transition duration-200 ease-out hover:shadow-[0_0_12px_rgba(59,130,246,0.6)] focus:outline-none focus:ring-2 focus:ring-indigo-500">
Join Skinbase
</a>
<a href="/login"
aria-label="Sign in"
class="mt-1 block px-3 py-2 rounded-lg text-sm font-medium text-gray-300 transition-colors hover:text-white hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-indigo-500">
Sign in
</a>
</div>
</details>
@endauth
</div>
</header>
<!-- MOBILE MENU -->
<div class="hidden fixed top-16 left-0 right-0 z-40 bg-nova border-b border-panel p-4 shadow-sb" id="mobileMenu">
<div class="hidden fixed inset-x-0 top-16 bottom-0 z-40 overflow-y-auto overscroll-contain bg-nova border-b border-panel p-4 shadow-sb" id="mobileMenu">
<div class="space-y-0.5 text-sm text-soft">
@guest
<a class="block py-2.5 px-3 rounded-lg font-medium text-sky-400" href="/register">Join Skinbase</a>
<a class="block py-2.5 px-3 rounded-lg" href="/login">Sign in</a>
<div class="my-2 border-t border-panel"></div>
@endguest
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Discover</div>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/trending"><i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/rising"><i class="fa-solid fa-rocket w-4 text-center text-sb-muted"></i>Rising</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/fresh"><i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
@auth
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-yellow-400/70"></i>For You</a>
@endauth
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Browse</div>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera w-4 text-center text-sb-muted"></i>Photography</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop w-4 text-center text-sb-muted"></i>Wallpapers</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group w-4 text-center text-sb-muted"></i>Skins</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/tags"><i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags</a>
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Creators</div>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/top"><i class="fa-solid fa-star w-4 text-center text-sb-muted"></i>Top Creators</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a>
@auth
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.following') }}"><i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following</a>
@endauth
<div class="pt-3 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">Community</div>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>Announcements</a>
@auth
<div class="pt-4 pb-2">
<a href="{{ route('upload') }}"
class="flex items-center justify-center gap-2 w-full py-2.5 px-4 rounded-lg bg-sky-600 hover:bg-sky-500 text-white font-medium transition-colors">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14" />
</svg>
Upload Artwork
</a>
<div class="pt-1">
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionDiscover" aria-expanded="true" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
<span>Discover</span>
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform rotate-180"></i>
</button>
<div id="mobileSectionDiscover" data-mobile-section-panel class="mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/trending"><i class="fa-solid fa-fire w-4 text-center text-sb-muted"></i>Trending</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/rising"><i class="fa-solid fa-rocket w-4 text-center text-sb-muted"></i>Rising</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/fresh"><i class="fa-solid fa-bolt w-4 text-center text-sb-muted"></i>Fresh</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/top-rated"><i class="fa-solid fa-medal w-4 text-center text-sb-muted"></i>Top Rated</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/most-downloaded"><i class="fa-solid fa-download w-4 text-center text-sb-muted"></i>Most Downloaded</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/discover/on-this-day"><i class="fa-solid fa-calendar-day w-4 text-center text-sb-muted"></i>On This Day</a>
@auth
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('discover.for-you') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-yellow-400/70"></i>For You</a>
@endauth
</div>
</div>
<div class="pt-1">
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionBrowse" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
<span>Browse</span>
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i>
</button>
<div id="mobileSectionBrowse" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/browse"><i class="fa-solid fa-border-all w-4 text-center text-sb-muted"></i>All Artworks</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/photography"><i class="fa-solid fa-camera w-4 text-center text-sb-muted"></i>Photography</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/wallpapers"><i class="fa-solid fa-desktop w-4 text-center text-sb-muted"></i>Wallpapers</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/skins"><i class="fa-solid fa-layer-group w-4 text-center text-sb-muted"></i>Skins</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/other"><i class="fa-solid fa-folder-open w-4 text-center text-sb-muted"></i>Other</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/tags"><i class="fa-solid fa-tags w-4 text-center text-sb-muted"></i>Tags</a>
</div>
</div>
<div class="pt-1">
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionCreators" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
<span>Creators</span>
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i>
</button>
<div id="mobileSectionCreators" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/top"><i class="fa-solid fa-star w-4 text-center text-sb-muted"></i>Top Creators</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/creators/rising"><i class="fa-solid fa-arrow-trend-up w-4 text-center text-sb-muted"></i>Rising Creators</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/stories"><i class="fa-solid fa-microphone w-4 text-center text-sb-muted"></i>Creator Stories</a>
@auth
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('creator.stories.index') }}"><i class="fa-solid fa-rectangle-list w-4 text-center text-sb-muted"></i>My Stories</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('creator.stories.create') }}"><i class="fa-solid fa-pen-to-square w-4 text-center text-sb-muted"></i>Write Story</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('dashboard.following') }}"><i class="fa-solid fa-user-plus w-4 text-center text-sb-muted"></i>Following</a>
@endauth
</div>
</div>
<div class="pt-1">
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionCommunity" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
<span>Community</span>
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform"></i>
</button>
<div id="mobileSectionCommunity" data-mobile-section-panel class="hidden mt-0.5 space-y-0.5">
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/forum"><i class="fa-solid fa-comments w-4 text-center text-sb-muted"></i>Forum</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/news"><i class="fa-solid fa-newspaper w-4 text-center text-sb-muted"></i>Announcements</a>
</div>
</div>
@php
$mobileUsername = strtolower((string) (Auth::user()->username ?? ''));
// Guard: username may be null for OAuth users still in onboarding.
$mobileProfile = $mobileUsername !== ''
? (Route::has('profile.show') ? route('profile.show', ['username' => $mobileUsername]) : '/@'.$mobileUsername)
: route('setup.username.create');
@endphp
<div class="pt-1 pb-1 px-3 text-[11px] font-semibold uppercase tracking-widest text-sb-muted">My Account</div>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="/studio/artworks">
<i class="fa-solid fa-palette w-4 text-center text-sb-muted"></i>Studio
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('dashboard.favorites') ? route('dashboard.favorites') : '/dashboard/favorites' }}">
<i class="fa-solid fa-heart w-4 text-center text-sb-muted"></i>My Favorites
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ $mobileProfile }}">
<i class="fa-solid fa-circle-user w-4 text-center text-sb-muted"></i>View Profile
</a>
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ Route::has('dashboard.profile') ? route('dashboard.profile') : (Route::has('settings') ? route('settings') : '/settings') }}">
<i class="fa-solid fa-cog w-4 text-center text-sb-muted"></i>Settings
</a>
@if(in_array(strtolower((string) (Auth::user()->role ?? '')), ['admin', 'moderator'], true))
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg hover:bg-white/5" href="{{ route('admin.usernames.moderation') }}">
<i class="fa-solid fa-user-shield w-4 text-center text-sb-muted"></i>Moderation
</a>
@endif
@endauth
</div>
</div>

View File

@@ -0,0 +1,31 @@
{{-- Reusable article card partial --}}
<div class="card h-100 border-0 shadow-sm news-card">
@if($article->cover_url)
<a href="{{ route('news.show', $article->slug) }}">
<img src="{{ $article->cover_url }}" class="card-img-top"
alt="{{ $article->title }}"
style="height:180px;object-fit:cover;">
</a>
@endif
<div class="card-body d-flex flex-column">
@if($article->category)
<a href="{{ route('news.category', $article->category->slug) }}"
class="badge badge-primary mb-2 align-self-start">{{ $article->category->name }}</a>
@endif
<h6 class="card-title mb-1">
<a href="{{ route('news.show', $article->slug) }}" class="text-dark text-decoration-none">
{{ $article->title }}
</a>
</h6>
@if($article->excerpt)
<p class="card-text text-muted small flex-grow-1">{{ Str::limit($article->excerpt, 100) }}</p>
@endif
<div class="text-muted small mt-2 d-flex justify-content-between">
<span>{{ $article->published_at?->format('d M Y') }}</span>
<span><i class="fas fa-eye mr-1"></i>{{ number_format($article->views) }}</span>
</div>
</div>
</div>

View File

@@ -0,0 +1,59 @@
{{-- Sidebar partial for news frontend --}}
{{-- Categories widget --}}
@if(!empty($categories) && $categories->isNotEmpty())
<div class="card mb-4">
<div class="card-header"><strong>Categories</strong></div>
<div class="list-group list-group-flush">
@foreach($categories as $cat)
<a href="{{ route('news.category', $cat->slug) }}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
{{ $cat->name }}
<span class="badge badge-secondary badge-pill">{{ $cat->published_articles_count ?? 0 }}</span>
</a>
@endforeach
</div>
</div>
@endif
{{-- Trending articles --}}
@if(!empty($trending) && $trending->isNotEmpty())
<div class="card mb-4">
<div class="card-header"><strong><i class="fas fa-fire mr-1 text-danger"></i> Trending</strong></div>
<div class="list-group list-group-flush">
@foreach($trending as $item)
<a href="{{ route('news.show', $item->slug) }}"
class="list-group-item list-group-item-action py-2">
<div class="d-flex justify-content-between align-items-start">
<span class="font-weight-bold small">{{ Str::limit($item->title, 55) }}</span>
<span class="badge badge-info badge-pill ml-2">{{ number_format($item->views) }}</span>
</div>
<small class="text-muted">{{ $item->published_at?->diffForHumans() }}</small>
</a>
@endforeach
</div>
</div>
@endif
{{-- Tags cloud --}}
@if(!empty($tags) && $tags->isNotEmpty())
<div class="card mb-4">
<div class="card-header"><strong><i class="fas fa-tags mr-1"></i> Tags</strong></div>
<div class="card-body">
@foreach($tags as $tag)
<a href="{{ route('news.tag', $tag->slug) }}" class="badge badge-secondary mr-1 mb-1">
{{ $tag->name }}
</a>
@endforeach
</div>
</div>
@endif
{{-- RSS link --}}
<div class="card mb-4">
<div class="card-body text-center">
<a href="{{ route('news.rss') }}" class="btn btn-outline-warning btn-sm" target="_blank">
<i class="fas fa-rss mr-1"></i> RSS Feed
</a>
</div>
</div>

View File

@@ -0,0 +1,30 @@
@extends('news.layout', [
'metaTitle' => $category->name . ' — News',
])
@section('news_content')
<div class="container py-5">
<h1 class="mb-1">{{ $category->name }}</h1>
@if($category->description)
<p class="text-muted mb-4">{{ $category->description }}</p>
@endif
<div class="row">
<div class="col-lg-8">
<div class="row">
@forelse($articles as $article)
<div class="col-sm-6 mb-4">
@include('news._article_card', ['article' => $article])
</div>
@empty
<div class="col-12 text-center text-muted py-5">No articles in this category.</div>
@endforelse
</div>
<div class="mt-3">{{ $articles->links() }}</div>
</div>
<div class="col-lg-4">
@include('news._sidebar', ['categories' => $categories])
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,69 @@
@extends('news.layout', [
'metaTitle' => config('news.rss_title', 'News'),
'metaDescription' => config('news.rss_description', ''),
])
@section('news_content')
<div class="news-index">
<div class="container py-5">
{{-- Featured article --}}
@if($featured)
<section class="mb-5">
<a href="{{ route('news.show', $featured->slug) }}" class="text-decoration-none">
<div class="card border-0 shadow-sm overflow-hidden news-featured">
@if($featured->cover_url)
<img src="{{ $featured->cover_url }}" class="card-img" alt="{{ $featured->title }}"
style="height:400px;object-fit:cover;">
@endif
<div class="card-img-overlay d-flex align-items-end p-4"
style="background:linear-gradient(transparent,rgba(0,0,0,0.75))">
<div class="text-white">
@if($featured->category)
<span class="badge badge-primary mb-2">{{ $featured->category->name }}</span>
@endif
<h2 class="font-weight-bold">{{ $featured->title }}</h2>
<p class="mb-1">{{ Str::limit(strip_tags((string)$featured->excerpt), 180) }}</p>
<small>
{{ $featured->author?->name }} &middot;
{{ $featured->published_at?->format('d M Y') }} &middot;
{{ $featured->reading_time }} min read
</small>
</div>
</div>
</div>
</a>
</section>
@endif
<div class="row">
{{-- Articles grid --}}
<div class="col-lg-8">
<div class="row">
@forelse($articles as $article)
<div class="col-sm-6 mb-4">
@include('news._article_card', ['article' => $article])
</div>
@empty
<div class="col-12 text-center text-muted py-5">No news articles published yet.</div>
@endforelse
</div>
<div class="mt-3">
{{ $articles->links() }}
</div>
</div>
{{-- Sidebar --}}
<div class="col-lg-4">
@include('news._sidebar', [
'categories' => $categories,
'trending' => $trending,
'tags' => $tags,
])
</div>
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,15 @@
{{--
Frontend layout wrapper for the News section.
Extends the main app layout.
--}}
@extends('layouts.app')
@section('title', $metaTitle ?? config('news.rss_title', 'News'))
@if(isset($metaDescription))
@section('meta_description', $metaDescription)
@endif
@section('content')
@yield('news_content')
@endsection

View File

@@ -0,0 +1,113 @@
@extends('news.layout', [
'metaTitle' => $article->meta_title ?: $article->title,
'metaDescription' => $article->meta_description ?: Str::limit(strip_tags((string)$article->excerpt), 160),
])
@section('news_content')
{{-- OpenGraph meta --}}
@push('head')
<meta property="og:type" content="article">
<meta property="og:title" content="{{ $article->effective_og_title }}">
<meta property="og:description" content="{{ $article->effective_og_description }}">
@if($article->effective_og_image)
<meta property="og:image" content="{{ $article->effective_og_image }}">
@endif
<meta property="article:published_time" content="{{ $article->published_at?->toIso8601String() }}">
<meta property="article:author" content="{{ $article->author?->name }}">
@if($article->meta_keywords)
<meta name="keywords" content="{{ $article->meta_keywords }}">
@endif
@endpush
<div class="news-article">
<div class="container py-5">
<div class="row">
<div class="col-lg-8">
{{-- Cover image --}}
@if($article->cover_url)
<img src="{{ $article->cover_url }}" class="img-fluid rounded mb-4 w-100"
alt="{{ $article->title }}" style="max-height:450px;object-fit:cover;">
@endif
{{-- Meta --}}
<div class="d-flex align-items-center mb-3 text-muted small">
@if($article->category)
<a href="{{ route('news.category', $article->category->slug) }}"
class="badge badge-primary mr-2">{{ $article->category->name }}</a>
@endif
<span>{{ $article->author?->name }}</span>
<span class="mx-2">&middot;</span>
<span>{{ $article->published_at?->format('d M Y') }}</span>
<span class="mx-2">&middot;</span>
<span><i class="fas fa-clock mr-1"></i>{{ $article->reading_time }} min read</span>
<span class="mx-2">&middot;</span>
<span><i class="fas fa-eye mr-1"></i>{{ number_format($article->views) }}</span>
</div>
<h1 class="mb-3">{{ $article->title }}</h1>
@if($article->excerpt)
<p class="lead text-muted mb-4">{{ $article->excerpt }}</p>
@endif
<div class="news-content">
{!! $article->content !!}
</div>
{{-- Tags --}}
@if($article->tags->isNotEmpty())
<div class="mt-4">
<strong><i class="fas fa-tags mr-1"></i></strong>
@foreach($article->tags as $tag)
<a href="{{ route('news.tag', $tag->slug) }}"
class="badge badge-secondary mr-1">{{ $tag->name }}</a>
@endforeach
</div>
@endif
{{-- Share buttons --}}
<div class="mt-4 pt-4 border-top">
<strong>Share:</strong>
<a href="https://twitter.com/intent/tweet?url={{ urlencode(url()->current()) }}&text={{ urlencode($article->title) }}"
class="btn btn-sm btn-info ml-2" target="_blank" rel="noopener noreferrer">
<i class="fab fa-twitter"></i> Twitter
</a>
<a href="https://www.facebook.com/sharer/sharer.php?u={{ urlencode(url()->current()) }}"
class="btn btn-sm btn-primary ml-2" target="_blank" rel="noopener noreferrer">
<i class="fab fa-facebook"></i> Facebook
</a>
</div>
{{-- Forum discussion link --}}
@if($article->forum_thread_id)
<div class="mt-4 alert alert-secondary">
<i class="fas fa-comments mr-2"></i>
<strong>Join the discussion:</strong>
<a href="{{ url('/forum/thread/discussion-' . $article->slug) }}" class="ml-1">
Discussion: {{ $article->title }}
</a>
</div>
@endif
{{-- Related articles --}}
@if($related->isNotEmpty())
<div class="mt-5">
<h4 class="mb-3">Related Articles</h4>
<div class="row">
@foreach($related as $rel)
<div class="col-sm-6 mb-3">
@include('news._article_card', ['article' => $rel])
</div>
@endforeach
</div>
</div>
@endif
</div>{{-- col-lg-8 --}}
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,29 @@
@extends('news.layout', [
'metaTitle' => '#' . $tag->name . ' — News',
])
@section('news_content')
<div class="container py-5">
<h1 class="mb-4">
<i class="fas fa-tag mr-2"></i>#{{ $tag->name }}
</h1>
<div class="row">
<div class="col-lg-8">
<div class="row">
@forelse($articles as $article)
<div class="col-sm-6 mb-4">
@include('news._article_card', ['article' => $article])
</div>
@empty
<div class="col-12 text-center text-muted py-5">No articles with this tag.</div>
@endforelse
</div>
<div class="mt-3">{{ $articles->links() }}</div>
</div>
<div class="col-lg-4">
@include('news._sidebar', ['categories' => $categories])
</div>
</div>
</div>
@endsection

View File

@@ -6,73 +6,100 @@
@endpush
@section('content')
<div class="px-6 py-8 md:px-10" id="search-page" data-q="{{ $q ?? '' }}">
<div class="px-6 pt-10 pb-8 md:px-10" id="search-page" data-q="{{ $q ?? '' }}">
@php
$hasQuery = isset($q) && $q !== '';
$resultCount = method_exists($artworks, 'total') ? (int) $artworks->total() : 0;
$galleryArtworks = collect($artworks->items())->map(fn ($art) => [
'id' => $art->id ?? null,
'name' => $art->name ?? null,
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
'thumb_srcset' => $art->thumb_srcset ?? null,
'uname' => $art->uname ?? '',
'username' => $art->username ?? $art->uname ?? '',
'avatar_url' => $art->avatar_url ?? null,
'category_name' => $art->category_name ?? '',
'category_slug' => $art->category_slug ?? '',
'slug' => $art->slug ?? '',
'width' => $art->width ?? null,
'height' => $art->height ?? null,
'views' => $art->views ?? null,
'likes' => $art->likes ?? null,
'downloads' => $art->downloads ?? null,
])->values();
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
@endphp
{{-- Search header --}}
<div class="mb-8 max-w-2xl">
<h1 class="text-2xl font-bold text-white mb-2">Search</h1>
<form action="/search" method="GET" class="relative" role="search">
<div class="mb-8">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="mb-1 text-xs font-semibold uppercase tracking-widest text-white/30">Discover</p>
<h1 class="flex items-center gap-3 text-3xl font-bold leading-tight text-white">
<i class="fa-solid fa-magnifying-glass text-2xl text-sky-400"></i>
Search
</h1>
<p class="mt-1 text-sm text-white/50">
{{ $hasQuery ? 'Results for "' . $q . '"' : 'Find artworks, creators, and styles across Skinbase.' }}
</p>
</div>
</div>
<form action="/search" method="GET" class="relative mt-5 max-w-3xl" role="search">
<input
type="search"
name="q"
value="{{ $q ?? '' }}"
placeholder="Search artworks, artists, tags"
placeholder="Search artworks, artists, tags..."
autofocus
class="w-full bg-white/[0.05] border border-white/10 rounded-xl py-3 pl-4 pr-12 text-white placeholder-neutral-500 outline-none focus:border-sky-500 transition-colors"
class="w-full rounded-xl border border-white/10 bg-white/[0.05] py-3 pl-4 pr-12 text-white outline-none transition-colors placeholder:text-neutral-500 focus:border-sky-500"
>
<button type="submit" class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-sky-400 transition-colors">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/></svg>
@if($hasQuery)
<input type="hidden" name="sort" value="{{ $sort ?? 'latest' }}">
@endif
<button type="submit" class="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 transition-colors hover:text-sky-400">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
</svg>
</button>
</form>
</div>
@if(isset($q) && $q !== '')
{{-- Sort + filter bar --}}
<div class="flex flex-wrap items-center gap-3 mb-6">
@if($hasQuery)
<div class="mb-6 flex flex-wrap items-center gap-3">
<span class="text-sm text-neutral-400">Sort by:</span>
@foreach(['latest' => 'Newest', 'popular' => 'Most viewed', 'likes' => 'Most liked', 'downloads' => 'Most downloaded'] as $key => $label)
<a href="{{ request()->fullUrlWithQuery(['sort' => $key]) }}"
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors
{{ ($sort ?? 'latest') === $key ? 'bg-sky-500 text-white' : 'bg-white/5 text-neutral-400 hover:bg-white/10 hover:text-white' }}">
<a
href="{{ request()->fullUrlWithQuery(['sort' => $key, 'page' => null]) }}"
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors {{ ($sort ?? 'latest') === $key ? 'bg-sky-500 text-white' : 'bg-white/5 text-neutral-400 hover:bg-white/10 hover:text-white' }}"
>
{{ $label }}
</a>
@endforeach
@if($artworks->total() > 0)
<span class="ml-auto text-sm text-neutral-500">
{{ number_format($artworks->total()) }} {{ Str::plural('result', $artworks->total()) }}
</span>
@endif
</div>
{{-- Results grid --}}
@if($artworks->isEmpty())
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-14 text-center">
<p class="text-neutral-400 text-lg mb-2">No results for <span class="text-white">"{{ $q }}"</span></p>
<p class="text-sm text-neutral-500">Try a different keyword or browse by <a href="/browse" class="text-sky-400 hover:underline">category</a>.</p>
</div>
@else
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
@foreach($artworks as $artwork)
<x-artwork-card :art="$artwork" />
@endforeach
</div>
<div class="flex justify-center mt-10">
{{ $artworks->appends(request()->query())->links('pagination::tailwind') }}
</div>
@endif
@else
{{-- No query: show popular --}}
<div class="mb-4 flex items-center gap-2">
<span class="text-sm font-semibold text-white/70 uppercase tracking-wide">Popular right now</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-5">
@foreach($popular as $artwork)
<x-artwork-card :art="$artwork" />
@endforeach
<span class="ml-auto text-sm text-neutral-500">
{{ number_format($resultCount) }} {{ $resultCount === 1 ? 'result' : 'results' }}
</span>
</div>
@endif
@if($hasQuery && $resultCount === 0)
<div class="rounded-xl border border-white/[0.06] bg-white/[0.03] p-14 text-center">
<p class="mb-2 text-lg text-neutral-400">No results for <span class="text-white">"{{ $q }}"</span></p>
<p class="text-sm text-neutral-500">Try a different keyword or browse <a href="/discover/trending" class="text-sky-400 hover:underline">trending artworks</a>.</p>
</div>
@else
<div
data-react-masonry-gallery
data-artworks="{{ json_encode($galleryArtworks) }}"
data-gallery-type="search"
@if($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
data-limit="24"
class="min-h-32"
></div>
@endif
</div>
@endsection
@push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx')
@endpush

View File

@@ -15,7 +15,7 @@
</div>
<div class="flex gap-4 overflow-x-auto nb-scrollbar-none pb-2">
@foreach($spotlight as $item)
<a href="{{ $item->slug ? route('artwork.show', $item->slug) : '#' }}"
<a href="{{ !empty($item->id) ? route('art.show', ['id' => $item->id, 'slug' => $item->slug ?? null]) : '#' }}"
class="group relative flex-none w-44 md:w-52 rounded-xl overflow-hidden
bg-neutral-800 border border-white/10 hover:border-amber-400/40
hover:shadow-lg hover:shadow-amber-500/10 transition-all duration-200"

View File

@@ -0,0 +1,54 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = 'Story Analytics';
$hero_description = 'Performance metrics for "' . $story->title . '".';
@endphp
@section('page-content')
<div class="space-y-6">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<p class="text-xs uppercase tracking-wide text-gray-400">Views</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($metrics['views']) }}</p>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<p class="text-xs uppercase tracking-wide text-gray-400">Likes</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($metrics['likes']) }}</p>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<p class="text-xs uppercase tracking-wide text-gray-400">Comments</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($metrics['comments']) }}</p>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<p class="text-xs uppercase tracking-wide text-gray-400">Read Time</p>
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($metrics['read_time']) }} min</p>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
<div class="rounded-xl border border-gray-700 bg-gray-900/70 p-4 shadow-lg">
<p class="text-xs uppercase tracking-wide text-gray-400">Views (7 days)</p>
<p class="mt-2 text-xl font-semibold text-sky-300">{{ number_format($metrics['views_last_7_days']) }}</p>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-900/70 p-4 shadow-lg">
<p class="text-xs uppercase tracking-wide text-gray-400">Views (30 days)</p>
<p class="mt-2 text-xl font-semibold text-emerald-300">{{ number_format($metrics['views_last_30_days']) }}</p>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-900/70 p-4 shadow-lg">
<p class="text-xs uppercase tracking-wide text-gray-400">Estimated Read Minutes</p>
<p class="mt-2 text-xl font-semibold text-violet-300">{{ number_format($metrics['estimated_total_read_minutes']) }}</p>
</div>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<div class="flex flex-wrap gap-3 text-sm">
<a href="{{ route('creator.stories.edit', ['story' => $story->id]) }}" class="rounded-xl border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sky-200 transition hover:scale-[1.02]">Back to editor</a>
<a href="{{ route('creator.stories.preview', ['story' => $story->id]) }}" class="rounded-xl border border-gray-500/40 bg-gray-700/30 px-3 py-2 text-gray-100 transition hover:scale-[1.02]">Preview story</a>
@if($story->status === 'published' || $story->status === 'scheduled')
<a href="{{ route('stories.show', ['slug' => $story->slug]) }}" class="rounded-xl border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-emerald-200 transition hover:scale-[1.02]">Open published page</a>
@endif
</div>
</div>
</div>
@endsection

View File

@@ -0,0 +1,34 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = ucfirst(str_replace('_', ' ', $category)) . ' Stories';
$hero_description = 'Stories in the ' . str_replace('_', ' ', $category) . ' category.';
@endphp
@section('page-content')
<div class="space-y-8">
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-4">
<div class="flex flex-wrap gap-2">
@foreach($categories as $item)
<a href="{{ route('stories.category', $item['slug']) }}" class="rounded-lg border px-3 py-2 text-sm {{ $item['slug'] === $category ? 'border-sky-400/50 bg-sky-500/10 text-sky-200' : 'border-gray-700 bg-gray-800 text-gray-300 hover:text-white' }}">
{{ $item['name'] }}
</a>
@endforeach
</div>
</div>
@if($stories->isNotEmpty())
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach($stories as $story)
<x-story-card :story="$story" />
@endforeach
</div>
<div class="mt-8">{{ $stories->links() }}</div>
@else
<div class="rounded-xl border border-gray-700 bg-gray-800/50 px-8 py-16 text-center text-sm text-gray-300">
No stories found in this category.
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,65 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = 'Create Story';
$hero_description = 'Write a new creator story for your audience.';
@endphp
@section('page-content')
<div class="mx-auto max-w-3xl rounded-xl border border-gray-700 bg-gray-800/60 p-6">
<form method="POST" action="{{ route('creator.stories.store') }}" class="space-y-5">
@csrf
<div>
<label class="mb-1 block text-sm text-gray-200">Title</label>
<input name="title" value="{{ old('title') }}" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Cover image URL</label>
<input name="cover_image" value="{{ old('cover_image') }}" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" />
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Excerpt</label>
<textarea name="excerpt" rows="3" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">{{ old('excerpt') }}</textarea>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label class="mb-1 block text-sm text-gray-200">Story type</label>
<select name="story_type" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">
@foreach($storyTypes as $type)
<option value="{{ $type['slug'] }}">{{ $type['name'] }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Status</label>
<select name="status" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">
<option value="draft">Draft</option>
<option value="published">Published</option>
<option value="scheduled">Scheduled</option>
<option value="archived">Archived</option>
</select>
</div>
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Tags</label>
<select name="tags[]" multiple class="min-h-28 w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white">
@foreach($tags as $tag)
<option value="{{ $tag->id }}">{{ $tag->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="mb-1 block text-sm text-gray-200">Content (Markdown/HTML)</label>
<textarea name="content" rows="14" required class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-white" placeholder="Write your story...">{{ old('content') }}</textarea>
</div>
<button class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-4 py-2 text-sky-200 transition hover:scale-[1.02]">Save story</button>
</form>
</div>
@endsection

View File

@@ -0,0 +1,30 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = 'Stories by @' . $creator->username;
$hero_description = 'Creator stories published by @' . $creator->username . '.';
@endphp
@section('page-content')
<div class="space-y-8">
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-6">
<h2 class="text-xl font-semibold tracking-tight text-white">Creator Stories</h2>
<p class="mt-2 text-sm text-gray-300">Browse long-form stories from this creator.</p>
<a href="/{{ '@' . strtolower((string) $creator->username) }}" class="mt-3 inline-flex text-sm text-sky-300 hover:text-sky-200">View profile</a>
</div>
@if($stories->isNotEmpty())
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach($stories as $story)
<x-story-card :story="$story" />
@endforeach
</div>
<div class="mt-8">{{ $stories->links() }}</div>
@else
<div class="rounded-xl border border-gray-700 bg-gray-800/50 px-8 py-16 text-center text-sm text-gray-300">
No stories published by this creator yet.
</div>
@endif
</div>
@endsection

View File

@@ -0,0 +1,91 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = 'My Stories';
$hero_description = 'Drafts, published stories, and archived work in one creator dashboard.';
@endphp
@section('page-content')
<div class="space-y-8">
<div class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<div>
<h2 class="text-lg font-semibold text-white">Creator Story Dashboard</h2>
<p class="text-sm text-gray-300">Write, schedule, review, and track your stories.</p>
</div>
<a href="{{ route('creator.stories.create') }}" class="rounded-xl border border-sky-400/40 bg-sky-500/15 px-4 py-2 text-sm font-semibold text-sky-200 transition hover:scale-[1.02] hover:bg-sky-500/25">Write Story</a>
</div>
<section class="rounded-xl border border-gray-700 bg-gray-800/60 p-5 shadow-lg">
<h3 class="mb-4 text-base font-semibold text-white">Drafts</h3>
@if($drafts->isEmpty())
<p class="text-sm text-gray-400">No drafts yet.</p>
@else
<div class="grid gap-3 md:grid-cols-2">
@foreach($drafts as $story)
<article class="rounded-xl border border-gray-700 bg-gray-900/60 p-4 transition hover:scale-[1.02]">
<div class="flex items-start justify-between gap-3">
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
<span class="rounded-full border border-gray-600 px-2 py-1 text-xs uppercase tracking-wide text-gray-300">{{ str_replace('_', ' ', $story->status) }}</span>
</div>
<p class="mt-2 text-xs text-gray-400">Last edited {{ optional($story->updated_at)->diffForHumans() }}</p>
@if($story->rejected_reason)
<p class="mt-2 rounded-lg border border-rose-500/30 bg-rose-500/10 p-2 text-xs text-rose-200">Rejected: {{ \Illuminate\Support\Str::limit($story->rejected_reason, 180) }}</p>
@endif
<div class="mt-3 flex flex-wrap gap-3 text-xs">
<a href="{{ route('creator.stories.edit', ['story' => $story->id]) }}" class="text-sky-300 hover:text-sky-200">Edit</a>
<a href="{{ route('creator.stories.preview', ['story' => $story->id]) }}" class="text-gray-300 hover:text-white">Preview</a>
<form method="POST" action="{{ route('creator.stories.submit-review', ['story' => $story->id]) }}">
@csrf
<button class="text-amber-300 hover:text-amber-200">Submit Review</button>
</form>
</div>
</article>
@endforeach
</div>
@endif
</section>
<section class="rounded-xl border border-gray-700 bg-gray-800/60 p-5 shadow-lg">
<h3 class="mb-4 text-base font-semibold text-white">Published Stories</h3>
@if($publishedStories->isEmpty())
<p class="text-sm text-gray-400">No published stories yet.</p>
@else
<div class="grid gap-3 md:grid-cols-2">
@foreach($publishedStories as $story)
<article class="rounded-xl border border-gray-700 bg-gray-900/60 p-4 transition hover:scale-[1.02]">
<div class="flex items-start justify-between gap-3">
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
<span class="rounded-full border border-emerald-500/40 px-2 py-1 text-xs uppercase tracking-wide text-emerald-200">{{ str_replace('_', ' ', $story->status) }}</span>
</div>
<p class="mt-2 text-xs text-gray-400">{{ number_format((int) $story->views) }} views · {{ number_format((int) $story->likes_count) }} likes</p>
<div class="mt-3 flex flex-wrap gap-3 text-xs">
<a href="{{ route('stories.show', ['slug' => $story->slug]) }}" class="text-sky-300 hover:text-sky-200">View</a>
<a href="{{ route('creator.stories.edit', ['story' => $story->id]) }}" class="text-gray-300 hover:text-white">Edit</a>
<a href="{{ route('creator.stories.analytics', ['story' => $story->id]) }}" class="text-violet-300 hover:text-violet-200">Analytics</a>
</div>
</article>
@endforeach
</div>
@endif
</section>
<section class="rounded-xl border border-gray-700 bg-gray-800/60 p-5 shadow-lg">
<h3 class="mb-4 text-base font-semibold text-white">Archived Stories</h3>
@if($archivedStories->isEmpty())
<p class="text-sm text-gray-400">No archived stories.</p>
@else
<div class="grid gap-3 md:grid-cols-2">
@foreach($archivedStories as $story)
<article class="rounded-xl border border-gray-700 bg-gray-900/60 p-4 transition hover:scale-[1.02]">
<h4 class="text-sm font-semibold text-white">{{ $story->title }}</h4>
<p class="mt-2 text-xs text-gray-400">Archived {{ optional($story->updated_at)->diffForHumans() }}</p>
<div class="mt-3 flex flex-wrap gap-3 text-xs">
<a href="{{ route('creator.stories.edit', ['story' => $story->id]) }}" class="text-sky-300 hover:text-sky-200">Open</a>
</div>
</article>
@endforeach
</div>
@endif
</section>
</div>
@endsection

View File

@@ -0,0 +1,61 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = $mode === 'create' ? 'Write Story' : 'Edit Story';
$hero_description = 'Medium-style editor with autosave, slash commands, artwork embeds, and publishing workflow.';
@endphp
@section('page-content')
@php
$initialContent = $story->content;
if (is_string($initialContent)) {
$decodedContent = json_decode($initialContent, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decodedContent)) {
$initialContent = [
'type' => 'doc',
'content' => [
['type' => 'paragraph', 'content' => [['type' => 'text', 'text' => strip_tags($initialContent)]],],
],
];
} else {
$initialContent = $decodedContent;
}
}
$storyPayload = [
'id' => $story->id,
'title' => old('title', (string) $story->title),
'excerpt' => old('excerpt', (string) ($story->excerpt ?? '')),
'cover_image' => old('cover_image', (string) ($story->cover_image ?? '')),
'story_type' => old('story_type', (string) ($story->story_type ?? 'creator_story')),
'tags_csv' => old('tags_csv', (string) ($story->tags?->pluck('name')->implode(', ') ?? '')),
'meta_title' => old('meta_title', (string) ($story->meta_title ?? $story->title ?? '')),
'meta_description' => old('meta_description', (string) ($story->meta_description ?? $story->excerpt ?? '')),
'canonical_url' => old('canonical_url', (string) ($story->canonical_url ?? '')),
'og_image' => old('og_image', (string) ($story->og_image ?? $story->cover_image ?? '')),
'status' => old('status', (string) ($story->status ?? 'draft')),
'scheduled_for' => old('scheduled_for', optional($story->scheduled_for)->format('Y-m-d\\TH:i')),
'content' => $initialContent,
];
$endpointPayload = [
'create' => url('/api/stories/create'),
'update' => url('/api/stories/update'),
'autosave' => url('/api/stories/autosave'),
'uploadImage' => url('/api/story/upload-image'),
'artworks' => url('/api/story/artworks'),
'previewBase' => url('/creator/stories'),
'analyticsBase' => url('/creator/stories'),
];
@endphp
<div class="mx-auto max-w-3xl" id="story-editor-react-root"
data-mode="{{ $mode }}"
data-story='@json($storyPayload)'
data-story-types='@json($storyTypes)'
data-endpoints='@json($endpointPayload)'>
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-6 text-gray-200 shadow-lg">
Loading editor...
</div>
</div>
@endsection

View File

@@ -1,155 +1,62 @@
{{--
Stories index /stories
Uses ContentLayout.
--}}
@extends('layouts.nova.content-layout')
@php
$hero_title = 'Skinbase Stories';
$hero_description = 'Artist interviews, community spotlights, tutorials and announcements.';
$hero_title = 'Creator Stories';
$hero_description = 'Articles, tutorials, interviews, and project breakdowns from the Skinbase creator community.';
@endphp
@push('head')
{{-- WebSite / Blog structured data --}}
<script type="application/ld+json">
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'Blog',
'name' => 'Skinbase Stories',
'description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
'url' => url('/stories'),
'publisher' => ['@type' => 'Organization', 'name' => 'Skinbase', 'url' => url('/')],
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endpush
@section('page-content')
{{-- Featured story hero --}}
@if($featured)
<a href="{{ $featured->url }}"
class="group block rounded-2xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.04] transition-colors mb-10">
<div class="md:flex">
@if($featured->cover_url)
<div class="md:w-1/2 aspect-video md:aspect-auto overflow-hidden bg-nova-900">
<img src="{{ $featured->cover_url }}" alt="{{ $featured->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="eager" />
</div>
@else
<div class="md:w-1/2 aspect-video md:aspect-auto bg-gradient-to-br from-sky-900/40 to-purple-900/40 flex items-center justify-center">
<i class="fa-solid fa-star text-4xl text-white/20"></i>
</div>
@endif
<div class="md:w-1/2 p-8 flex flex-col justify-center">
<div class="mb-3">
<span class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium bg-yellow-400/10 text-yellow-300 border border-yellow-400/20">
<i class="fa-solid fa-star text-[10px]"></i> Featured
</span>
</div>
<h2 class="text-2xl font-bold text-white group-hover:text-sky-300 transition-colors leading-snug">
{{ $featured->title }}
</h2>
@if($featured->excerpt)
<p class="mt-3 text-sm text-white/50 line-clamp-3">{{ $featured->excerpt }}</p>
@endif
<div class="mt-5 flex items-center gap-3 text-xs text-white/30">
@if($featured->author)
<span class="flex items-center gap-1.5">
@if($featured->author->avatar_url)
<img src="{{ $featured->author->avatar_url }}" alt="{{ $featured->author->name }}"
class="w-5 h-5 rounded-full object-cover" />
@endif
{{ $featured->author->name }}
</span>
<span>·</span>
<div class="space-y-10">
@if($featured)
<section class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
<a href="{{ route('stories.show', $featured->slug) }}" class="grid gap-0 lg:grid-cols-2">
<div class="aspect-video overflow-hidden bg-gray-900">
@if($featured->cover_url)
<img src="{{ $featured->cover_url }}" alt="{{ $featured->title }}" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" />
@endif
@if($featured->published_at)
<time datetime="{{ $featured->published_at->toIso8601String() }}">
{{ $featured->published_at->format('M j, Y') }}
</time>
<span>·</span>
@endif
<span>{{ $featured->reading_time }} min read</span>
</div>
</div>
</div>
</a>
@endif
{{-- Stories grid --}}
@if($stories->isNotEmpty())
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@foreach($stories as $story)
@if($featured && $story->id === $featured->id)
@continue
@endif
<a href="{{ $story->url }}"
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
@if($story->cover_url)
<div class="aspect-video bg-nova-800 overflow-hidden">
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy" />
</div>
@else
<div class="aspect-video bg-gradient-to-br from-sky-900/20 to-indigo-900/20 flex items-center justify-center">
<i class="fa-solid fa-feather-pointed text-3xl text-white/15"></i>
</div>
@endif
<div class="p-5">
{{-- Tags --}}
@if($story->tags->isNotEmpty())
<div class="flex flex-wrap gap-1.5 mb-3">
@foreach($story->tags->take(3) as $tag)
<span class="rounded-full px-2 py-0.5 text-[11px] font-medium bg-sky-500/10 text-sky-400 border border-sky-500/20">
#{{ $tag->name }}
</span>
@endforeach
</div>
@endif
<h2 class="text-base font-semibold text-white group-hover:text-sky-300 transition-colors line-clamp-2 leading-snug">
{{ $story->title }}
</h2>
@if($story->excerpt)
<p class="mt-2 text-sm text-white/45 line-clamp-2">{{ $story->excerpt }}</p>
@endif
<div class="mt-4 flex items-center gap-2 text-xs text-white/30">
@if($story->author)
<span class="flex items-center gap-1.5">
@if($story->author->avatar_url)
<img src="{{ $story->author->avatar_url }}" alt="{{ $story->author->name }}"
class="w-4 h-4 rounded-full object-cover" />
@endif
{{ $story->author->name }}
</span>
<span>·</span>
@endif
@if($story->published_at)
<time datetime="{{ $story->published_at->toIso8601String() }}">
{{ $story->published_at->format('M j, Y') }}
</time>
<span>·</span>
@endif
<span>{{ $story->reading_time }} min read</span>
</div>
<div class="flex flex-col justify-center space-y-4 p-6">
<span class="inline-flex w-fit rounded-full border border-sky-400/30 bg-sky-500/10 px-3 py-1 text-xs font-semibold text-sky-300">Featured Story</span>
<h2 class="text-2xl font-bold text-white">{{ $featured->title }}</h2>
<p class="text-gray-300">{{ $featured->excerpt }}</p>
<p class="text-sm text-gray-400">by @{{ $featured->creator?->username ?? 'unknown' }} {{ $featured->reading_time }} min read {{ number_format((int) $featured->views) }} views</p>
</div>
</a>
@endforeach
</div>
</section>
@endif
<div class="mt-10 flex justify-center">
{{ $stories->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-16 text-center">
<i class="fa-solid fa-feather-pointed text-4xl text-white/20 mb-4 block"></i>
<p class="text-white/40 text-sm">No stories published yet. Check back soon!</p>
</div>
@endif
<section>
<div class="mb-5 flex items-center justify-between">
<h3 class="text-2xl font-semibold tracking-tight text-white">Trending Stories</h3>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach($trendingStories as $story)
<x-story-card :story="$story" />
@endforeach
</div>
</section>
<section>
<h3 class="mb-5 text-2xl font-semibold tracking-tight text-white">Categories</h3>
<div class="flex flex-wrap gap-2">
@foreach($categories as $category)
<a href="{{ route('stories.category', $category['slug']) }}" class="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 transition-colors hover:border-sky-500/40 hover:text-white">
{{ $category['name'] }}
</a>
@endforeach
</div>
</section>
<section>
<h3 class="mb-5 text-2xl font-semibold tracking-tight text-white">Latest Stories</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach($latestStories as $story)
<x-story-card :story="$story" />
@endforeach
</div>
<div class="mt-8">
{{ $latestStories->links() }}
</div>
</section>
</div>
@endsection

View File

@@ -0,0 +1,51 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = 'Story Preview';
$hero_description = 'This preview mirrors the final published layout.';
@endphp
@section('page-content')
<div class="mx-auto grid max-w-7xl gap-6 lg:grid-cols-12">
<article class="lg:col-span-8">
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
@if($story->cover_image)
<img src="{{ $story->cover_image }}" alt="{{ $story->title }}" class="h-72 w-full object-cover" />
@endif
<div class="p-6">
<div class="mb-4 flex flex-wrap items-center gap-3 text-xs text-gray-300">
<span class="rounded-full border border-gray-600 px-2 py-1">{{ str_replace('_', ' ', $story->story_type) }}</span>
<span>{{ (int) $story->reading_time }} min read</span>
<span class="rounded-full border border-amber-500/40 px-2 py-1 text-amber-200">Preview</span>
</div>
<h1 class="text-3xl font-semibold tracking-tight text-white">{{ $story->title }}</h1>
@if($story->excerpt)
<p class="mt-3 text-gray-300">{{ $story->excerpt }}</p>
@endif
<div class="prose prose-invert mt-6 max-w-none prose-a:text-sky-300 prose-pre:bg-gray-900">
{!! $safeContent !!}
</div>
</div>
</div>
</article>
<aside class="space-y-4 lg:col-span-4">
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Preview Actions</h3>
<div class="mt-3 flex flex-col gap-2 text-sm">
<a href="{{ route('creator.stories.edit', ['story' => $story->id]) }}" class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sky-200 transition hover:scale-[1.02]">Back to editor</a>
<a href="{{ route('creator.stories.analytics', ['story' => $story->id]) }}" class="rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 py-2 text-violet-200 transition hover:scale-[1.02]">Story analytics</a>
</div>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-800/70 p-4 shadow-lg">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Status</h3>
<p class="mt-2 text-sm text-gray-200">{{ str_replace('_', ' ', ucfirst($story->status)) }}</p>
@if($story->rejected_reason)
<p class="mt-2 rounded-lg border border-rose-500/30 bg-rose-500/10 p-2 text-xs text-rose-200">{{ $story->rejected_reason }}</p>
@endif
</div>
</aside>
</div>
@endsection

View File

@@ -1,229 +1,159 @@
{{--
Single story page /stories/{slug}
Uses ContentLayout.
Includes: Hero, Article content, Author box, Related stories, Share buttons.
--}}
@extends('layouts.nova.content-layout')
@php
$hero_title = $story->title;
$hero_description = $story->excerpt ?: \Illuminate\Support\Str::limit(strip_tags((string) $story->content), 160);
@endphp
@push('head')
{{-- OpenGraph --}}
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ $story->title }}" />
<meta property="og:description" content="{{ $story->meta_excerpt }}" />
@if($story->cover_url)
<meta property="og:image" content="{{ $story->cover_url }}" />
@php
$storyUrl = $story->canonical_url ?: route('stories.show', ['slug' => $story->slug]);
$creatorName = $story->creator?->display_name ?: $story->creator?->username ?: 'Unknown creator';
$metaDescription = $story->meta_description ?: $hero_description;
$metaTitle = $story->meta_title ?: $story->title;
$ogImage = $story->og_image ?: $story->cover_url;
@endphp
<meta property="og:type" content="article" />
<meta property="og:title" content="{{ $metaTitle }}" />
<meta property="og:description" content="{{ $metaDescription }}" />
<meta property="og:url" content="{{ $storyUrl }}" />
@if($ogImage)
<meta property="og:image" content="{{ $ogImage }}" />
@endif
<meta property="og:url" content="{{ $story->url }}" />
<meta property="og:site_name" content="Skinbase" />
@if($story->published_at)
<meta property="article:published_time" content="{{ $story->published_at->toIso8601String() }}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ $metaTitle }}" />
<meta name="twitter:description" content="{{ $metaDescription }}" />
@if($ogImage)
<meta name="twitter:image" content="{{ $ogImage }}" />
@endif
@if($story->updated_at)
<meta property="article:modified_time" content="{{ $story->updated_at->toIso8601String() }}" />
@endif
{{-- Twitter / X card --}}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ $story->title }}" />
<meta name="twitter:description" content="{{ $story->meta_excerpt }}" />
@if($story->cover_url)
<meta name="twitter:image" content="{{ $story->cover_url }}" />
@endif
{{-- Article structured data (schema.org) --}}
<script type="application/ld+json">
{!! json_encode(array_filter([
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $story->title,
'description' => $story->meta_excerpt,
'image' => $story->cover_url,
'datePublished' => $story->published_at?->toIso8601String(),
'dateModified' => $story->updated_at?->toIso8601String(),
'mainEntityOfPage' => $story->url,
'author' => $story->author ? [
'@type' => 'Person',
'name' => $story->author->name,
] : [
'@type' => 'Organization',
'name' => 'Skinbase',
],
'publisher' => [
'@type' => 'Organization',
'name' => 'Skinbase',
'url' => url('/'),
],
]), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
{!! json_encode([
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $story->title,
'description' => $metaDescription,
'image' => $ogImage,
'author' => [
'@type' => 'Person',
'name' => $creatorName,
],
'datePublished' => optional($story->published_at)->toIso8601String(),
'dateModified' => optional($story->updated_at)->toIso8601String(),
'mainEntityOfPage' => $storyUrl,
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}
</script>
@endpush
@section('page-content')
<div class="max-w-3xl mx-auto">
{{-- ── HERO ──────────────────────────────────────────────────────────────── --}}
@if($story->cover_url)
<div class="rounded-2xl overflow-hidden mb-8 aspect-video">
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}"
class="w-full h-full object-cover"
loading="eager" />
</div>
@endif
{{-- Meta bar --}}
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-white/40 mb-6">
@if($story->author)
<a href="{{ $story->author->profile_url }}"
class="flex items-center gap-2 hover:text-white/60 transition-colors">
@if($story->author->avatar_url)
<img src="{{ $story->author->avatar_url }}" alt="{{ $story->author->name }}"
class="w-6 h-6 rounded-full object-cover" />
@endif
<span>{{ $story->author->name }}</span>
</a>
<span>·</span>
@endif
@if($story->published_at)
<time datetime="{{ $story->published_at->toIso8601String() }}">
{{ $story->published_at->format('F j, Y') }}
</time>
<span>·</span>
@endif
<span>{{ $story->reading_time }} min read</span>
<span>·</span>
<span><i class="fa-regular fa-eye mr-1 text-xs"></i>{{ number_format($story->views) }}</span>
</div>
{{-- Tags --}}
@if($story->tags->isNotEmpty())
<div class="flex flex-wrap gap-2 mb-8">
@foreach($story->tags as $tag)
<a href="{{ $tag->url }}"
class="rounded-full px-3 py-1 text-xs font-medium bg-sky-500/10 text-sky-400 border border-sky-500/20 hover:bg-sky-500/20 transition-colors">
#{{ $tag->name }}
</a>
@endforeach
</div>
@endif
{{-- ── ARTICLE CONTENT ──────────────────────────────────────────────────── --}}
<article class="prose prose-invert prose-headings:text-white prose-a:text-sky-400 hover:prose-a:text-sky-300 prose-p:text-white/70 prose-strong:text-white prose-blockquote:border-sky-500 prose-blockquote:text-white/60 max-w-none">
@if($story->content)
{!! $story->content !!}
@else
<p class="text-white/40 italic">Content not available.</p>
@endif
</article>
{{-- ── SHARE BUTTONS ────────────────────────────────────────────────────── --}}
<div class="mt-12 pt-8 border-t border-white/[0.06]">
<p class="text-sm text-white/40 mb-4">Share this story</p>
<div class="flex flex-wrap gap-3">
<a href="https://twitter.com/intent/tweet?text={{ urlencode($story->title) }}&url={{ urlencode($story->url) }}"
target="_blank" rel="noopener"
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.03] px-4 py-2 text-sm text-white/60 hover:bg-white/[0.07] hover:text-white transition-colors">
<i class="fa-brands fa-x-twitter text-xs"></i> Share on X
</a>
<a href="https://www.reddit.com/submit?title={{ urlencode($story->title) }}&url={{ urlencode($story->url) }}"
target="_blank" rel="noopener"
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.03] px-4 py-2 text-sm text-white/60 hover:bg-white/[0.07] hover:text-white transition-colors">
<i class="fa-brands fa-reddit text-xs"></i> Reddit
</a>
<button type="button"
onclick="navigator.clipboard.writeText('{{ $story->url }}').then(() => { this.textContent = '✓ Copied!'; setTimeout(() => { this.innerHTML = '<i class=\'fa-regular fa-link text-xs\'></i> Copy link'; }, 2000); })"
class="inline-flex items-center gap-2 rounded-lg border border-white/[0.08] bg-white/[0.03] px-4 py-2 text-sm text-white/60 hover:bg-white/[0.07] hover:text-white transition-colors">
<i class="fa-regular fa-link text-xs"></i> Copy link
</button>
</div>
</div>
{{-- ── AUTHOR BOX ───────────────────────────────────────────────────────── --}}
@if($story->author)
<div class="mt-12 rounded-2xl border border-white/[0.06] bg-white/[0.02] p-6">
<div class="flex items-start gap-5">
@if($story->author->avatar_url)
<img src="{{ $story->author->avatar_url }}" alt="{{ $story->author->name }}"
class="w-16 h-16 rounded-full object-cover border border-white/10 flex-shrink-0" />
@else
<div class="w-16 h-16 rounded-full bg-nova-700 flex items-center justify-center flex-shrink-0">
<i class="fa-solid fa-user text-xl text-white/30"></i>
</div>
@endif
<div class="min-w-0 flex-1">
<p class="text-xs uppercase tracking-widest text-white/30 mb-1">About the author</p>
<h3 class="text-lg font-semibold text-white">{{ $story->author->name }}</h3>
@if($story->author->bio)
<p class="mt-2 text-sm text-white/55">{{ $story->author->bio }}</p>
<div class="mx-auto grid max-w-7xl gap-8 lg:grid-cols-12">
<article class="lg:col-span-8">
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70">
@if($story->cover_url)
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}" class="h-72 w-full object-cover" loading="lazy" />
@endif
<div class="p-6">
<div class="mb-4 flex flex-wrap items-center gap-3 text-xs text-gray-300">
<span class="rounded-full border border-gray-600 px-2 py-1">{{ str_replace('_', ' ', $story->story_type) }}</span>
@if($story->published_at)
<span>{{ $story->published_at->format('M d, Y') }}</span>
@endif
<div class="mt-4 flex flex-wrap gap-3">
@if($story->author->user)
<a href="{{ $story->author->profile_url }}"
class="inline-flex items-center gap-1.5 text-sm text-sky-400 hover:text-sky-300 transition-colors">
More from this artist <i class="fa-solid fa-arrow-right text-xs"></i>
</a>
@endif
<a href="/stories/author/{{ $story->author->user?->username ?? urlencode($story->author->name) }}"
class="inline-flex items-center gap-1.5 text-sm text-white/40 hover:text-white/60 transition-colors">
All stories
</a>
</div>
<span>{{ (int) $story->reading_time }} min read</span>
</div>
<h1 class="text-3xl font-semibold leading-tight tracking-tight text-white">{{ $story->title }}</h1>
@if($story->excerpt)
<p class="mt-3 text-gray-300">{{ $story->excerpt }}</p>
@endif
<div class="mt-6 prose prose-invert max-w-none prose-a:text-sky-300 prose-pre:bg-gray-900">
{!! $safeContent !!}
</div>
</div>
</div>
@endif
{{-- ── RELATED STORIES ─────────────────────────────────────────────────── --}}
@if($related->isNotEmpty())
<div class="mt-12">
<h2 class="text-lg font-semibold text-white mb-6">Related Stories</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
@foreach($related as $rel)
<a href="{{ $rel->url }}"
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
@if($rel->cover_url)
<div class="aspect-video overflow-hidden bg-nova-800">
<img src="{{ $rel->cover_url }}" alt="{{ $rel->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy" />
</div>
@else
<div class="aspect-video bg-gradient-to-br from-sky-900/20 to-indigo-900/20 flex items-center justify-center">
<i class="fa-solid fa-feather-pointed text-2xl text-white/15"></i>
</div>
@endif
<div class="p-4">
<h3 class="text-sm font-medium text-white group-hover:text-sky-300 transition-colors line-clamp-2 leading-snug">
{{ $rel->title }}
</h3>
<div class="mt-2 flex items-center gap-2 text-xs text-white/30">
@if($rel->published_at)
<time datetime="{{ $rel->published_at->toIso8601String() }}">
{{ $rel->published_at->format('M j, Y') }}
</time>
<span>·</span>
@endif
<span>{{ $rel->reading_time }} min read</span>
<section class="mt-8 rounded-xl border border-gray-700 bg-gray-800/60 p-6">
<h2 class="mb-5 text-xl font-semibold tracking-tight text-white">Discussion</h2>
@if($comments->isEmpty())
<p class="text-sm text-gray-400">No comments yet.</p>
@else
<div class="space-y-4">
@foreach($comments as $comment)
<div class="rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div class="mb-2 text-xs text-gray-400">
{{ $comment->author_username ?? 'User' }}
<span class="mx-1"></span>
{{ optional($comment->created_at)->diffForHumans() }}
</div>
<p class="text-sm leading-relaxed text-gray-200">{{ $comment->body }}</p>
</div>
</a>
@endforeach
@endforeach
</div>
@endif
@auth
@if($story->creator)
<form method="POST" action="{{ url('/@' . $story->creator->username . '/comment') }}" class="mt-5 space-y-3">
@csrf
<textarea name="body" rows="3" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white" placeholder="Add a comment for this creator"></textarea>
<button class="rounded-lg border border-sky-500/40 bg-sky-500/10 px-4 py-2 text-sm text-sky-200 transition hover:scale-[1.01]">Post comment</button>
</form>
@endif
@endauth
</section>
</article>
<aside class="space-y-8 lg:col-span-4">
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Creator</h3>
<a href="{{ route('stories.creator', ['username' => $story->creator?->username]) }}" class="mt-2 inline-flex items-center gap-2 text-base text-white hover:text-sky-300">
<span>{{ $story->creator?->display_name ?: $story->creator?->username }}</span>
</a>
@if($story->creator)
<form method="POST" action="{{ url('/@' . $story->creator->username . '/follow') }}" class="mt-4">
@csrf
<button class="w-full rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 py-2 text-sm text-sky-200 transition hover:scale-[1.01]">Follow Creator</button>
</form>
@endif
</div>
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Tags</h3>
<div class="mt-3 flex flex-wrap gap-2">
@forelse($story->tags as $tag)
<a href="{{ route('stories.tag', ['tag' => $tag->slug]) }}" class="rounded-full border border-gray-600 px-2 py-1 text-xs text-gray-200 hover:border-sky-400 hover:text-sky-300">#{{ $tag->name }}</a>
@empty
<span class="text-sm text-gray-400">No tags</span>
@endforelse
</div>
</div>
@endif
{{-- Back link --}}
<div class="mt-12 pt-8 border-t border-white/[0.06]">
<a href="/stories" class="inline-flex items-center gap-2 text-sm text-sky-400 hover:text-sky-300 transition-colors">
<i class="fa-solid fa-arrow-left text-xs"></i>
Back to Stories
</a>
</div>
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-5">
<h3 class="text-sm font-semibold uppercase tracking-wide text-gray-300">Report Story</h3>
@auth
<form method="POST" action="{{ url('/api/reports') }}" class="mt-3 space-y-3">
@csrf
<input type="hidden" name="target_type" value="story" />
<input type="hidden" name="target_id" value="{{ $story->id }}" />
<textarea name="reason" rows="3" class="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-white" placeholder="Reason for report"></textarea>
<button class="w-full rounded-lg border border-rose-500/40 bg-rose-500/10 px-3 py-2 text-sm text-rose-200 transition hover:scale-[1.01]">Submit report</button>
</form>
@else
<p class="mt-3 text-sm text-gray-400">
<a href="{{ route('login') }}" class="text-sky-300 hover:text-sky-200">Sign in</a> to report this story.
</p>
@endauth
</div>
@if($relatedStories->isNotEmpty())
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-5">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-300">Related Stories</h3>
<div class="space-y-3">
@foreach($relatedStories as $item)
<a href="{{ route('stories.show', ['slug' => $item->slug]) }}" class="block rounded-lg border border-gray-700 bg-gray-900/50 p-3 text-sm text-gray-200 hover:border-sky-400 hover:text-white">{{ $item->title }}</a>
@endforeach
</div>
</div>
@endif
</aside>
</div>
@endsection

View File

@@ -5,64 +5,31 @@
@extends('layouts.nova.content-layout')
@php
$hero_title = '#' . $storyTag->name;
$hero_description = 'Stories tagged with "' . $storyTag->name . '" on Skinbase.';
$hero_title = '#' . $storyTag->name;
$hero_description = 'Stories tagged with "' . $storyTag->name . '".';
@endphp
@section('page-content')
@if($stories->isNotEmpty())
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@foreach($stories as $story)
<a href="{{ $story->url }}"
class="group block rounded-xl border border-white/[0.06] bg-white/[0.02] overflow-hidden hover:bg-white/[0.05] transition-colors">
@if($story->cover_url)
<div class="aspect-video bg-nova-800 overflow-hidden">
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy" />
</div>
@else
<div class="aspect-video bg-gradient-to-br from-sky-900/20 to-indigo-900/20 flex items-center justify-center">
<i class="fa-solid fa-feather-pointed text-3xl text-white/15"></i>
</div>
@endif
<div class="p-5">
<h2 class="text-base font-semibold text-white group-hover:text-sky-300 transition-colors line-clamp-2 leading-snug">
{{ $story->title }}
</h2>
@if($story->excerpt)
<p class="mt-2 text-sm text-white/45 line-clamp-2">{{ $story->excerpt }}</p>
@endif
<div class="mt-4 flex items-center gap-2 text-xs text-white/30">
@if($story->author)
<span>{{ $story->author->name }}</span>
<span>·</span>
@endif
@if($story->published_at)
<time datetime="{{ $story->published_at->toIso8601String() }}">
{{ $story->published_at->format('M j, Y') }}
</time>
<span>·</span>
@endif
<span>{{ $story->reading_time }} min read</span>
</div>
</div>
</a>
@endforeach
<div class="space-y-8">
<div class="rounded-xl border border-gray-700 bg-gray-800/60 p-6">
<h2 class="text-xl font-semibold tracking-tight text-white">Tagged Stories</h2>
<p class="mt-2 text-sm text-gray-300">Browsing stories tagged with <span class="text-sky-300">#{{ $storyTag->name }}</span>.</p>
</div>
<div class="mt-10 flex justify-center">
{{ $stories->withQueryString()->links() }}
</div>
@else
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-16 text-center">
<i class="fa-solid fa-tag text-4xl text-white/20 mb-4 block"></i>
<p class="text-white/40 text-sm">No stories found for this tag.</p>
<a href="/stories" class="mt-4 inline-block text-sm text-sky-400 hover:text-sky-300 transition-colors">
Browse all stories
</a>
</div>
@endif
@if($stories->isNotEmpty())
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
@foreach($stories as $story)
<x-story-card :story="$story" />
@endforeach
</div>
<div class="mt-8">{{ $stories->links() }}</div>
@else
<div class="rounded-xl border border-gray-700 bg-gray-800/50 px-8 py-16 text-center text-sm text-gray-300">
No stories found for this tag.
</div>
@endif
</div>
@endsection