feat(academy): prepare AI Academy v1 for production enablement
This commit is contained in:
@@ -449,6 +449,15 @@
|
||||
/* ─── TipTap rich text editor ─── */
|
||||
.tiptap {
|
||||
outline: none;
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tiptap .ProseMirror {
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
@@ -550,6 +559,14 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tiptap p {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.tiptap p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tiptap hr,
|
||||
.story-prose hr {
|
||||
position: relative;
|
||||
@@ -724,6 +741,113 @@
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed,
|
||||
.story-prose .news-embed {
|
||||
margin: 1.75rem 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 1.5rem;
|
||||
background: linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
|
||||
box-shadow: 0 18px 40px rgba(2, 6, 23, 0.22);
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed-link,
|
||||
.story-prose .news-embed-link {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed-caption,
|
||||
.story-prose .news-embed-caption {
|
||||
padding: 0.9rem 1rem 1rem;
|
||||
color: rgb(226 232 240);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed-social .news-embed-link,
|
||||
.story-prose .news-embed-social .news-embed-link {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed-badge,
|
||||
.story-prose .news-embed-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(125, 211, 252, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(56, 189, 248, 0.1);
|
||||
padding: 0.18rem 0.6rem;
|
||||
color: rgb(224 242 254);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed-url,
|
||||
.story-prose .news-embed-url {
|
||||
display: block;
|
||||
margin-top: 0.85rem;
|
||||
color: rgb(125 211 252);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.news-rich-text-editor .news-embed-video iframe,
|
||||
.story-prose .news-embed-video iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.news-editor-outline .ProseMirror :is(p, div, figure, blockquote, ul, ol, li, h2, h3, pre, hr) {
|
||||
position: relative;
|
||||
outline: 1px dashed rgba(56, 189, 248, 0.24);
|
||||
outline-offset: 0.15rem;
|
||||
}
|
||||
|
||||
.news-editor-outline .ProseMirror p::before,
|
||||
.news-editor-outline .ProseMirror div::before,
|
||||
.news-editor-outline .ProseMirror figure::before,
|
||||
.news-editor-outline .ProseMirror blockquote::before,
|
||||
.news-editor-outline .ProseMirror ul::before,
|
||||
.news-editor-outline .ProseMirror ol::before,
|
||||
.news-editor-outline .ProseMirror li::before,
|
||||
.news-editor-outline .ProseMirror h2::before,
|
||||
.news-editor-outline .ProseMirror h3::before,
|
||||
.news-editor-outline .ProseMirror pre::before,
|
||||
.news-editor-outline .ProseMirror hr::before {
|
||||
position: absolute;
|
||||
top: -0.55rem;
|
||||
left: 0.1rem;
|
||||
z-index: 2;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 0.35rem;
|
||||
background: rgba(15, 23, 42, 0.82);
|
||||
padding: 0.02rem 0.28rem;
|
||||
color: rgba(203, 213, 225, 0.72);
|
||||
font-size: 0.52rem;
|
||||
line-height: 1.15;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.news-editor-outline .ProseMirror p::before { content: 'p'; }
|
||||
.news-editor-outline .ProseMirror div::before { content: 'div'; }
|
||||
.news-editor-outline .ProseMirror figure::before { content: 'figure'; }
|
||||
.news-editor-outline .ProseMirror blockquote::before { content: 'blockquote'; }
|
||||
.news-editor-outline .ProseMirror ul::before { content: 'ul'; }
|
||||
.news-editor-outline .ProseMirror ol::before { content: 'ol'; }
|
||||
.news-editor-outline .ProseMirror li::before { content: 'li'; }
|
||||
.news-editor-outline .ProseMirror h2::before { content: 'h2'; }
|
||||
.news-editor-outline .ProseMirror h3::before { content: 'h3'; }
|
||||
.news-editor-outline .ProseMirror pre::before { content: 'pre'; }
|
||||
.news-editor-outline .ProseMirror hr::before { content: 'hr'; }
|
||||
|
||||
.story-prose pre[data-language]::before {
|
||||
content: attr(data-language);
|
||||
position: absolute;
|
||||
|
||||
@@ -22,6 +22,10 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
items: [
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
|
||||
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
|
||||
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
{ label: 'Academy Challenges', href: '/moderation/academy/challenges', icon: 'fa-solid fa-trophy' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
|
||||
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
|
||||
68
resources/js/Pages/Academy/ChallengeSubmit.jsx
Normal file
68
resources/js/Pages/Academy/ChallengeSubmit.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react'
|
||||
import { Head, useForm } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
export default function AcademyChallengeSubmit({ seo, challenge, artworks, submitUrl }) {
|
||||
const form = useForm({
|
||||
artwork_id: artworks[0]?.id || '',
|
||||
prompt_used: '',
|
||||
workflow_notes: '',
|
||||
ai_tool_used: '',
|
||||
is_ai_generated: false,
|
||||
is_ai_assisted: true,
|
||||
})
|
||||
|
||||
const artworkOptions = artworks.map((artwork) => ({ value: artwork.id, label: artwork.title }))
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title={`Submit to ${challenge.title}`} />
|
||||
<SeoHead seo={seo || {}} title={`Submit to ${challenge.title}`} description={challenge.excerpt || challenge.description} />
|
||||
|
||||
<div className="mx-auto max-w-[960px] space-y-6">
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Academy challenge</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Submit to {challenge.title}</h1>
|
||||
<p className="mt-4 text-base leading-8 text-slate-300">Attach one of your public artworks and optionally include the prompt or workflow notes used to create it.</p>
|
||||
</section>
|
||||
|
||||
<form onSubmit={(event) => { event.preventDefault(); form.post(submitUrl) }} className="space-y-5 rounded-[30px] border border-white/10 bg-white/[0.04] p-6">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">Artwork</label>
|
||||
<NovaSelect
|
||||
value={form.data.artwork_id}
|
||||
onChange={(nextValue) => form.setData('artwork_id', nextValue || '')}
|
||||
options={artworkOptions}
|
||||
placeholder="Select artwork"
|
||||
className="mt-2 rounded-2xl bg-black/20"
|
||||
/>
|
||||
{form.errors.artwork_id ? <p className="mt-2 text-sm text-rose-300">{form.errors.artwork_id}</p> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">Prompt used</label>
|
||||
<textarea value={form.data.prompt_used} onChange={(event) => form.setData('prompt_used', event.target.value)} rows={5} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">Workflow notes</label>
|
||||
<textarea value={form.data.workflow_notes} onChange={(event) => form.setData('workflow_notes', event.target.value)} rows={4} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">AI tool used</label>
|
||||
<input value={form.data.ai_tool_used} onChange={(event) => form.setData('ai_tool_used', event.target.value)} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200"><input type="checkbox" checked={form.data.is_ai_generated} onChange={(event) => form.setData('is_ai_generated', event.target.checked)} /> AI-generated</label>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200"><input type="checkbox" checked={form.data.is_ai_assisted} onChange={(event) => form.setData('is_ai_assisted', event.target.checked)} /> AI-assisted</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Submitting...' : 'Submit artwork'}</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
79
resources/js/Pages/Academy/Index.jsx
Normal file
79
resources/js/Pages/Academy/Index.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react'
|
||||
import { Link } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function academyHref(section, slug) {
|
||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function FeatureCard({ title, description, href, cta }) {
|
||||
return (
|
||||
<Link href={href} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Academy</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{description}</p>
|
||||
<span className="mt-5 inline-flex rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100">{cta}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredLessons, featuredPrompts, featuredChallenges }) {
|
||||
const jsonLd = [{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
name: 'Skinbase AI Academy',
|
||||
description: seo?.description,
|
||||
url: seo?.canonical,
|
||||
}]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.18),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.18),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title="Skinbase AI Academy" description={seo?.description} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-[1440px] space-y-8">
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.94),rgba(28,25,23,0.88)),radial-gradient(circle_at_top_right,rgba(251,191,36,0.2),transparent_26%)] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.38)] md:p-10 lg:p-12">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-end">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-4 max-w-4xl text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl xl:text-6xl">Learn how to turn prompts into wallpapers, digital art, skins, covers, and visual worlds.</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">Skinbase AI Academy is the creative learning hub for AI-assisted art on Skinbase. Start with free lessons, explore prompt templates, and unlock premium workflows later.</p>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link href={links.lessons} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-300/40 hover:bg-amber-300/18">Browse lessons</Link>
|
||||
<Link href={links.prompts} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open prompt library</Link>
|
||||
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">See plans</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[30px] border border-white/10 bg-black/20 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Launch status</p>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-300">
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3"><span>Challenges</span><span>{featureFlags?.challengesEnabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3"><span>Badges</span><span>{featureFlags?.badgesEnabled ? 'Enabled' : 'Disabled'}</span></div>
|
||||
<div className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3"><span>Payments</span><span>{featureFlags?.paymentsEnabled ? 'Preview only' : 'Disabled'}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
<FeatureCard title="Lessons" description="Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits." href={links.lessons} cta="Open lessons" />
|
||||
<FeatureCard title="Prompt Library" description="Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows." href={links.prompts} cta="Explore prompts" />
|
||||
<FeatureCard title="Challenges" description="Join Academy creative briefs and submit artworks once the challenge system is enabled for your account." href={links.challenges} cta="View challenges" />
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Lessons</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.lessonCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompts</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.promptCount || 0}</p></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Challenges</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.challengeCount || 0}</p></div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 xl:grid-cols-3">
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured lessons</p><div className="mt-4 space-y-3">{(featuredLessons || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('lessons', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured prompts</p><div className="mt-4 space-y-3">{(featuredPrompts || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('prompts', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-6"><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Current challenges</p><div className="mt-4 space-y-3">{(featuredChallenges || []).slice(0, 3).map((item) => <Link key={item.id} href={academyHref('challenges', item.slug)} className="block rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white">{item.title}</Link>)}</div></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
117
resources/js/Pages/Academy/List.jsx
Normal file
117
resources/js/Pages/Academy/List.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
function academyHref(section, slug) {
|
||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function QueryFilters({ pageType, filters, categories }) {
|
||||
if (pageType !== 'lessons' && pageType !== 'prompts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const categoryOptions = [{ value: '', label: 'All categories' }, ...(categories || []).map((category) => ({ value: category.slug, label: category.name }))]
|
||||
const difficultyOptions = [
|
||||
{ value: '', label: 'All levels' },
|
||||
{ value: 'beginner', label: 'Beginner' },
|
||||
{ value: 'intermediate', label: 'Intermediate' },
|
||||
{ value: 'advanced', label: 'Advanced' },
|
||||
{ value: 'pro', label: 'Pro' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 rounded-[28px] border border-white/10 bg-black/20 p-5 md:grid-cols-3">
|
||||
<input
|
||||
defaultValue={filters?.q || ''}
|
||||
placeholder={`Search ${pageType}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white outline-none placeholder:text-slate-500"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
router.get(window.location.pathname, { ...filters, q: event.currentTarget.value }, { preserveState: true, preserveScroll: true })
|
||||
}}
|
||||
/>
|
||||
<NovaSelect
|
||||
value={filters?.category || ''}
|
||||
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, category: nextValue || undefined }, { preserveState: true, preserveScroll: true })}
|
||||
options={categoryOptions}
|
||||
searchable={false}
|
||||
className="rounded-2xl bg-white/[0.04]"
|
||||
placeholder="All categories"
|
||||
/>
|
||||
<NovaSelect
|
||||
value={filters?.difficulty || ''}
|
||||
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, difficulty: nextValue || undefined }, { preserveState: true, preserveScroll: true })}
|
||||
options={difficultyOptions}
|
||||
searchable={false}
|
||||
className="rounded-2xl bg-white/[0.04]"
|
||||
placeholder="All levels"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LockBadge({ item }) {
|
||||
if (!item?.locked) return <span className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-emerald-100">{item.access_level}</span>
|
||||
|
||||
return <span className="rounded-full border border-amber-300/25 bg-amber-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100">Locked · {item.access_level}</span>
|
||||
}
|
||||
|
||||
function itemHref(pageType, item) {
|
||||
if (pageType === 'lessons') return academyHref('lessons', item.slug)
|
||||
if (pageType === 'prompts') return academyHref('prompts', item.slug)
|
||||
if (pageType === 'packs') return academyHref('packs', item.slug)
|
||||
return academyHref('challenges', item.slug)
|
||||
}
|
||||
|
||||
function AcademyCard({ pageType, item }) {
|
||||
return (
|
||||
<Link href={itemHref(pageType, item)} className="rounded-[28px] border border-white/10 bg-white/[0.04] p-5 transition hover:border-white/20 hover:bg-white/[0.06]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{pageType.slice(0, -1)}</p>
|
||||
<LockBadge item={item} />
|
||||
</div>
|
||||
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview || 'No description yet.'}</p>
|
||||
{pageType === 'prompts' && item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||
{pageType === 'challenges' ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.status} · {item.submission_count ?? 0} submissions</p> : null}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title={title} description={description} />
|
||||
|
||||
<div className="mx-auto max-w-[1360px] space-y-6">
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<div className="flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-300">{description}</p>
|
||||
</div>
|
||||
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Upgrade preview</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
<QueryFilters pageType={pageType} filters={filters} categories={categories} />
|
||||
|
||||
{(items?.data || []).length === 0 ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">Nothing matched this Academy view yet.</section>
|
||||
) : (
|
||||
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.data.map((item) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} />)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
47
resources/js/Pages/Academy/Pricing.jsx
Normal file
47
resources/js/Pages/Academy/Pricing.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function PlanCard({ plan, paymentsEnabled }) {
|
||||
return (
|
||||
<article className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-white">{plan.name}</h2>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">{plan.badge}</span>
|
||||
</div>
|
||||
<div className="mt-5 flex items-end gap-2">
|
||||
<span className="text-4xl font-semibold tracking-[-0.05em] text-white">{plan.price}</span>
|
||||
<span className="pb-1 text-sm text-slate-400">{plan.interval}</span>
|
||||
</div>
|
||||
<div className="mt-6 space-y-3 text-sm text-slate-300">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">{feature}</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" disabled className="mt-6 w-full rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 opacity-100">
|
||||
{paymentsEnabled ? 'Checkout coming next phase' : 'Payments disabled for this launch'}
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyPricing({ seo, plans, paymentsEnabled }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#111827_0%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title="Skinbase AI Academy Pricing" description={seo?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1320px] space-y-8">
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Plans</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Choose your AI Academy plan.</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-300">Start free, unlock Creator and Pro previews, and keep the billing flow disabled until Stripe and Cashier are introduced in the next phase.</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard key={plan.name} plan={plan} paymentsEnabled={paymentsEnabled} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
119
resources/js/Pages/Academy/Show.jsx
Normal file
119
resources/js/Pages/Academy/Show.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function LockedPanel({ pricingUrl, label }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-amber-300/20 bg-amber-300/10 p-6 text-amber-50">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Premium content</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em]">Unlock the full {label}.</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-amber-50/90">This preview is visible, but the full Academy content stays server-side until your account has the required Creator or Pro access.</p>
|
||||
<Link href={pricingUrl} className="mt-5 inline-flex rounded-full border border-amber-200/25 bg-white/10 px-5 py-3 text-sm font-semibold text-white">See Academy plans</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyShow({ pageType, item, seo, pricingUrl, completeUrl, completed: initialCompleted, saveUrl, unsaveUrl, saved: initialSaved, submitUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const [completed, setCompleted] = useState(Boolean(initialCompleted))
|
||||
const [saved, setSaved] = useState(Boolean(initialSaved))
|
||||
|
||||
const markComplete = () => {
|
||||
if (!completeUrl || completed) return
|
||||
router.post(completeUrl, {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => setCompleted(true),
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSave = () => {
|
||||
const url = saved ? unsaveUrl : saveUrl
|
||||
const method = saved ? router.delete : router.post
|
||||
method(url, {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => setSaved(!saved),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1200px] space-y-6">
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{item.title}</h1>
|
||||
<p className="mt-4 text-base leading-8 text-slate-300">{item.excerpt || item.description || item.prompt_preview || item.content_preview}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{completeUrl ? <button type="button" onClick={markComplete} className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100">{completed ? 'Completed' : 'Mark complete'}</button> : null}
|
||||
{saveUrl ? <button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{saved ? 'Saved' : 'Save prompt'}</button> : null}
|
||||
{submitUrl ? <Link href={submitUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Submit artwork</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
{item.locked ? <LockedPanel pricingUrl={pricingUrl} label={pageType} /> : null}
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6 text-slate-200">
|
||||
{pageType === 'lesson' ? <div className="whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.content || item.content_preview}</div> : null}
|
||||
{pageType === 'prompt' ? (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt</p>
|
||||
<pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.prompt || item.prompt_preview}</pre>
|
||||
</div>
|
||||
{item.negative_prompt ? <div><p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Negative prompt</p><pre className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{item.negative_prompt}</pre></div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'pack' ? (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-8 text-slate-200">{item.description}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{(item.prompts || []).map((prompt) => (
|
||||
<div key={prompt.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{prompt.title}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{prompt.excerpt || prompt.prompt_preview}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{pageType === 'challenge' ? (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Brief</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.brief || item.description}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Rules</p>
|
||||
<div className="mt-3 whitespace-pre-wrap text-sm leading-8 text-slate-200">{item.rules || 'No special rules posted yet.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{(item.submissions || []).length ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Approved submissions</p>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{item.submissions.map((submission) => (
|
||||
<div key={submission.id} className="rounded-2xl border border-white/10 bg-black/20 p-4">
|
||||
<h3 className="text-lg font-semibold text-white">{submission.artwork?.title || 'Submission'}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{submission.user?.name || 'Unknown creator'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
105
resources/js/Pages/Admin/Academy/CrudForm.jsx
Normal file
105
resources/js/Pages/Admin/Academy/CrudForm.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, useForm } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||
|
||||
function normalizePayload(fields, data) {
|
||||
const payload = { ...data }
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (field.type === 'csv') {
|
||||
payload[field.name] = String(payload[field.name] || '').split(',').map((item) => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
if (field.type === 'json') {
|
||||
try {
|
||||
payload[field.name] = payload[field.name] ? JSON.parse(payload[field.name]) : {}
|
||||
} catch {
|
||||
payload[field.name] = {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function Field({ field, form }) {
|
||||
const value = form.data[field.name]
|
||||
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
||||
<input type="checkbox" checked={Boolean(value)} onChange={(event) => form.setData(field.name, event.target.checked)} />
|
||||
{field.label}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return <textarea value={value || ''} onChange={(event) => form.setData(field.name, event.target.value)} rows={6} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<NovaSelect
|
||||
value={value ?? ''}
|
||||
onChange={(nextValue) => form.setData(field.name, nextValue ?? '')}
|
||||
options={field.options || []}
|
||||
searchable={false}
|
||||
className="mt-2 rounded-2xl bg-black/20"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (field.type === 'multiselect') {
|
||||
return (
|
||||
<NovaSelect
|
||||
multi
|
||||
value={value || []}
|
||||
onChange={(nextValue) => form.setData(field.name, Array.isArray(nextValue) ? nextValue : [])}
|
||||
options={field.options || []}
|
||||
className="mt-2 rounded-2xl bg-black/20"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <input type={field.type || 'text'} value={value ?? ''} onChange={(event) => form.setData(field.name, event.target.value)} className="mt-2 w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" />
|
||||
}
|
||||
|
||||
export default function AcademyCrudForm({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method }) {
|
||||
const form = useForm(record)
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
const payload = normalizePayload(fields, form.data)
|
||||
|
||||
if (method === 'patch') {
|
||||
form.transform(() => payload).patch(submitUrl)
|
||||
return
|
||||
}
|
||||
|
||||
form.transform(() => payload).post(submitUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
<form onSubmit={submit} className="space-y-5 rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
{fields.map((field) => (
|
||||
<div key={field.name}>
|
||||
{field.type !== 'checkbox' ? <label className="text-sm font-semibold text-white">{field.label}</label> : null}
|
||||
<Field field={field} form={form} />
|
||||
{form.errors[field.name] ? <p className="mt-2 text-sm text-rose-300">{form.errors[field.name]}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save'}</button>
|
||||
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
||||
{destroyUrl ? <button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
46
resources/js/Pages/Admin/Academy/CrudIndex.jsx
Normal file
46
resources/js/Pages/Admin/Academy/CrudIndex.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
export default function AcademyCrudIndex({ title, subtitle, items, columns, createUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create record</Link>
|
||||
</div>
|
||||
|
||||
{(items?.data || []).length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No records exist yet.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.data.map((item) => (
|
||||
<div key={item.id} className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{columns.map((column) => (
|
||||
<div key={column}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{column.replaceAll('_', ' ')}</p>
|
||||
<p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
42
resources/js/Pages/Admin/Academy/Dashboard.jsx
Normal file
42
resources/js/Pages/Admin/Academy/Dashboard.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyDashboard({ stats, links }) {
|
||||
return (
|
||||
<AdminLayout title="Academy Dashboard" subtitle="Overview of Academy content, challenge activity, and future billing placeholders.">
|
||||
<Head title="Admin · Academy Dashboard" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Lessons" value={stats.lessons} />
|
||||
<StatCard label="Prompts" value={stats.prompts} />
|
||||
<StatCard label="Prompt Packs" value={stats.packs} />
|
||||
<StatCard label="Challenges" value={stats.challenges} />
|
||||
<StatCard label="Submissions" value={stats.submissions} />
|
||||
<StatCard label="Badges" value={stats.badges} />
|
||||
<StatCard label="Creator Subscribers" value={stats.creator_subscribers} />
|
||||
<StatCard label="Pro Subscribers" value={stats.pro_subscribers} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Modules</p>
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Object.entries(links).map(([key, href]) => (
|
||||
<Link key={key} href={href} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4 text-sm font-semibold text-white transition hover:border-white/15 hover:bg-white/[0.05]">
|
||||
{key.replaceAll('_', ' ')}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
39
resources/js/Pages/Admin/Academy/Submissions.jsx
Normal file
39
resources/js/Pages/Admin/Academy/Submissions.jsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import { Head, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
export default function AcademySubmissions({ submissions }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Academy Challenge Submissions" subtitle="Approve or reject Academy challenge entries.">
|
||||
<Head title="Admin · Academy Challenge Submissions" />
|
||||
|
||||
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
{(submissions?.data || []).map((submission) => (
|
||||
<article key={submission.id} className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5">
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-start">
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80">{submission.moderation_status}</span>
|
||||
<span className="text-sm text-slate-400">{submission.challenge?.title || 'Challenge'}</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-white">{submission.artwork?.title || 'Artwork removed'}</h2>
|
||||
<p className="text-sm text-slate-400">{submission.user?.name || 'Unknown user'} · {submission.ai_tool_used || 'No tool noted'}</p>
|
||||
{submission.prompt_used ? <pre className="whitespace-pre-wrap rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-200">{submission.prompt_used}</pre> : null}
|
||||
{submission.workflow_notes ? <div className="rounded-2xl border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300">{submission.workflow_notes}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||
<button type="button" onClick={() => router.post(submission.approve_url, {}, { preserveScroll: true })} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button>
|
||||
<button type="button" onClick={() => router.post(submission.reject_url, {}, { preserveScroll: true })} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,11 @@ const pages = {
|
||||
'!./Pages/World/**/__tests__/**',
|
||||
'!./Pages/World/**/*.test.jsx',
|
||||
]),
|
||||
...import.meta.glob([
|
||||
'./Pages/Academy/**/*.jsx',
|
||||
'!./Pages/Academy/**/__tests__/**',
|
||||
'!./Pages/Academy/**/*.test.jsx',
|
||||
]),
|
||||
}
|
||||
|
||||
function resolvePage(name) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useEditor, EditorContent } from '@tiptap/react'
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Link from '@tiptap/extension-link'
|
||||
import Image from '@tiptap/extension-image'
|
||||
@@ -9,11 +11,13 @@ import Mention from '@tiptap/extension-mention'
|
||||
import mentionSuggestion from './mentionSuggestion'
|
||||
import EmojiPicker from './EmojiPicker'
|
||||
|
||||
/* ─── Toolbar button ─── */
|
||||
function ToolbarBtn({ onClick, active, disabled, title, children }) {
|
||||
function ToolbarBtn({ onClick, active, disabled, title, children, className = '' }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
@@ -23,7 +27,8 @@ function ToolbarBtn({ onClick, active, disabled, title, children }) {
|
||||
active
|
||||
? 'bg-sky-600/25 text-sky-300'
|
||||
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||||
disabled && 'opacity-30 pointer-events-none',
|
||||
disabled && 'pointer-events-none opacity-30',
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
@@ -35,8 +40,427 @@ function Divider() {
|
||||
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||||
}
|
||||
|
||||
/* ─── Toolbar ─── */
|
||||
function Toolbar({ editor }) {
|
||||
function normalizeHttpUrl(rawValue) {
|
||||
const trimmed = String(rawValue || '').trim()
|
||||
if (trimmed === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed.replace(/^\/+/, '')}`
|
||||
|
||||
try {
|
||||
const parsed = new URL(withProtocol)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVideoEmbedUrl(rawValue) {
|
||||
const normalized = normalizeHttpUrl(rawValue)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new URL(normalized)
|
||||
const host = parsed.hostname.replace(/^www\./i, '').toLowerCase()
|
||||
const path = parsed.pathname
|
||||
|
||||
if (host === 'youtu.be') {
|
||||
const videoId = path.replace(/^\//, '').split('/')[0]
|
||||
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized
|
||||
}
|
||||
|
||||
if (host === 'youtube.com' || host === 'm.youtube.com') {
|
||||
if (path === '/watch') {
|
||||
const videoId = parsed.searchParams.get('v')
|
||||
return videoId ? `https://www.youtube.com/embed/${videoId}` : normalized
|
||||
}
|
||||
|
||||
const pathMatch = path.match(/^\/(embed|shorts|live)\/([^/?#]+)/i)
|
||||
if (pathMatch?.[2]) {
|
||||
return `https://www.youtube.com/embed/${pathMatch[2]}`
|
||||
}
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function detectSocialPlatform(rawUrl) {
|
||||
const normalized = normalizeHttpUrl(rawUrl)
|
||||
if (!normalized) {
|
||||
return { platform: 'social', label: 'Social post', url: null }
|
||||
}
|
||||
|
||||
const host = new URL(normalized).hostname.replace(/^www\./i, '').toLowerCase()
|
||||
|
||||
if (host.includes('instagram.')) return { platform: 'instagram', label: 'Instagram post', url: normalized }
|
||||
if (host.includes('facebook.')) return { platform: 'facebook', label: 'Facebook post', url: normalized }
|
||||
if (host.includes('tiktok.')) return { platform: 'tiktok', label: 'TikTok post', url: normalized }
|
||||
if (host.includes('twitter.') || host.includes('x.com')) return { platform: 'x', label: 'X post', url: normalized }
|
||||
if (host.includes('linkedin.')) return { platform: 'linkedin', label: 'LinkedIn post', url: normalized }
|
||||
if (host.includes('threads.')) return { platform: 'threads', label: 'Threads post', url: normalized }
|
||||
if (host.includes('pinterest.')) return { platform: 'pinterest', label: 'Pinterest pin', url: normalized }
|
||||
|
||||
return { platform: 'social', label: 'Social post', url: normalized }
|
||||
}
|
||||
|
||||
const ArtworkEmbed = Node.create({
|
||||
name: 'artworkEmbed',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
title: { default: 'Artwork' },
|
||||
url: { default: '' },
|
||||
thumb: { default: '' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'figure[data-artwork-embed]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const preview = []
|
||||
|
||||
if (HTMLAttributes.thumb) {
|
||||
preview.push([
|
||||
'img',
|
||||
{
|
||||
src: HTMLAttributes.thumb,
|
||||
alt: HTMLAttributes.title || 'Artwork',
|
||||
class: 'block h-auto w-full object-cover',
|
||||
loading: 'lazy',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
preview.push([
|
||||
'figcaption',
|
||||
{ class: 'news-embed-caption' },
|
||||
HTMLAttributes.title || 'Artwork',
|
||||
])
|
||||
|
||||
return [
|
||||
'figure',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-artwork-embed': 'true',
|
||||
class: 'news-embed news-embed-artwork',
|
||||
}),
|
||||
[
|
||||
'a',
|
||||
{
|
||||
href: HTMLAttributes.url || '#',
|
||||
class: 'news-embed-link',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
target: '_blank',
|
||||
},
|
||||
...preview,
|
||||
],
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const VideoEmbed = Node.create({
|
||||
name: 'videoEmbed',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: '' },
|
||||
url: { default: '' },
|
||||
title: { default: 'Embedded video' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'figure[data-video-embed]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
'figure',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-video-embed': 'true',
|
||||
class: 'news-embed news-embed-video',
|
||||
}),
|
||||
[
|
||||
'iframe',
|
||||
{
|
||||
src: HTMLAttributes.src || '',
|
||||
title: HTMLAttributes.title || 'Embedded video',
|
||||
loading: 'lazy',
|
||||
frameborder: '0',
|
||||
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
|
||||
allowfullscreen: 'true',
|
||||
referrerpolicy: 'strict-origin-when-cross-origin',
|
||||
},
|
||||
],
|
||||
[
|
||||
'figcaption',
|
||||
{ class: 'news-embed-caption' },
|
||||
[
|
||||
'a',
|
||||
{
|
||||
href: HTMLAttributes.url || HTMLAttributes.src || '#',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
target: '_blank',
|
||||
},
|
||||
HTMLAttributes.title || 'Watch video',
|
||||
],
|
||||
],
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const SocialEmbed = Node.create({
|
||||
name: 'socialEmbed',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
url: { default: '' },
|
||||
platform: { default: 'social' },
|
||||
label: { default: 'Social post' },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'figure[data-social-embed]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const platform = String(HTMLAttributes.platform || 'social')
|
||||
const url = HTMLAttributes.url || '#'
|
||||
const label = HTMLAttributes.label || 'Social post'
|
||||
|
||||
if (platform === 'instagram') {
|
||||
return [
|
||||
'figure',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-social-embed': 'true',
|
||||
'data-platform': platform,
|
||||
class: 'news-embed news-embed-social',
|
||||
}),
|
||||
[
|
||||
'blockquote',
|
||||
{
|
||||
class: 'instagram-media',
|
||||
'data-instgrm-captioned': 'true',
|
||||
'data-instgrm-permalink': url,
|
||||
'data-instgrm-version': '14',
|
||||
},
|
||||
[
|
||||
'a',
|
||||
{ href: url, rel: 'noopener noreferrer nofollow', target: '_blank' },
|
||||
label,
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
if (platform === 'facebook') {
|
||||
return [
|
||||
'figure',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-social-embed': 'true',
|
||||
'data-platform': platform,
|
||||
class: 'news-embed news-embed-social',
|
||||
}),
|
||||
[
|
||||
'div',
|
||||
{ class: 'fb-post', 'data-href': url, 'data-show-text': 'true' },
|
||||
[
|
||||
'blockquote',
|
||||
{ cite: url, class: 'fb-xfbml-parse-ignore' },
|
||||
[
|
||||
'a',
|
||||
{ href: url, rel: 'noopener noreferrer nofollow', target: '_blank' },
|
||||
label,
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
if (platform === 'tiktok') {
|
||||
return [
|
||||
'figure',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-social-embed': 'true',
|
||||
'data-platform': platform,
|
||||
class: 'news-embed news-embed-social',
|
||||
}),
|
||||
[
|
||||
'blockquote',
|
||||
{ class: 'tiktok-embed', cite: url },
|
||||
[
|
||||
'section',
|
||||
null,
|
||||
[
|
||||
'a',
|
||||
{ href: url, rel: 'noopener noreferrer nofollow', target: '_blank' },
|
||||
label,
|
||||
],
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
if (platform === 'x') {
|
||||
return [
|
||||
'figure',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-social-embed': 'true',
|
||||
'data-platform': platform,
|
||||
class: 'news-embed news-embed-social',
|
||||
}),
|
||||
[
|
||||
'blockquote',
|
||||
{ class: 'twitter-tweet' },
|
||||
[
|
||||
'a',
|
||||
{ href: url, rel: 'noopener noreferrer nofollow', target: '_blank' },
|
||||
label,
|
||||
],
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
'figure',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-social-embed': 'true',
|
||||
'data-platform': platform,
|
||||
class: 'news-embed news-embed-social',
|
||||
}),
|
||||
[
|
||||
'a',
|
||||
{
|
||||
href: url,
|
||||
class: 'news-embed-link',
|
||||
rel: 'noopener noreferrer nofollow',
|
||||
target: '_blank',
|
||||
},
|
||||
['span', { class: 'news-embed-badge' }, label],
|
||||
['span', { class: 'news-embed-url' }, url],
|
||||
],
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
function ArtworkPickerDialog({
|
||||
open,
|
||||
query,
|
||||
items,
|
||||
loading,
|
||||
onQueryChange,
|
||||
onClose,
|
||||
onSearch,
|
||||
onSelect,
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose?.()
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="border-b border-white/[0.06] px-6 py-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Artwork embed</div>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">Choose artwork</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">Search existing artworks and insert a linked artwork card into the News article body.</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-white/[0.06] px-6 py-4">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange?.(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onSearch?.()
|
||||
}
|
||||
}}
|
||||
placeholder="Search by title, slug, or creator"
|
||||
className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
/>
|
||||
<button type="button" onClick={onSearch} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nova-scrollbar max-h-[60vh] overflow-y-auto px-6 py-5">
|
||||
{loading ? <div className="rounded-2xl border border-white/10 bg-black/20 p-4 text-sm text-slate-300">Searching artworks…</div> : null}
|
||||
{!loading && (!Array.isArray(items) || items.length === 0) ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No artworks found yet. Try a broader title or creator search.</div> : null}
|
||||
{!loading && Array.isArray(items) && items.length > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{items.map((item) => {
|
||||
const previewImage = item.image || item.avatar || ''
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${item.entity_type || 'artwork'}-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(item)}
|
||||
className="flex items-center gap-4 rounded-[24px] border border-white/10 bg-black/20 p-3 text-left transition hover:border-white/20 hover:bg-white/[0.04]"
|
||||
>
|
||||
<div className="h-20 w-28 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03]">
|
||||
{previewImage ? <img src={previewImage} alt={item.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-xs uppercase tracking-[0.18em] text-slate-500">No thumb</div>}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-white">{item.title}</div>
|
||||
{item.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{item.subtitle}</div> : null}
|
||||
{item.description ? <div className="mt-2 line-clamp-2 text-xs leading-5 text-slate-400">{item.description}</div> : null}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||
<button type="button" onClick={onClose} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function Toolbar({
|
||||
editor,
|
||||
advancedNews = false,
|
||||
sourceMode = false,
|
||||
showStructureOutlines = false,
|
||||
onToggleSourceMode,
|
||||
onToggleStructureOutlines,
|
||||
onInsertArtwork,
|
||||
onInsertSocialEmbed,
|
||||
onInsertVideoEmbed,
|
||||
onInsertHashtag,
|
||||
}) {
|
||||
if (!editor) return null
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
@@ -59,155 +483,97 @@ function Toolbar({ editor }) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
|
||||
{/* Text formatting */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold (Ctrl+B)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic (Ctrl+I)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
active={editor.isActive('underline')}
|
||||
title="Underline (Ctrl+U)"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Underline (Ctrl+U)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M6 3v7a6 6 0 006 6 6 6 0 006-6V3"/><line x1="4" y1="21" x2="20" y2="21"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
active={editor.isActive('strike')}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Strikethrough">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="4" y1="12" x2="20" y2="12"/><path d="M17.5 7.5c0-2-1.5-3.5-5.5-3.5S6.5 5.5 6.5 7.5c0 4 11 4 11 8 0 2-1.5 3.5-5.5 3.5s-5.5-1.5-5.5-3.5"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
active={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">
|
||||
<span className="text-xs font-bold">H2</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
active={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">
|
||||
<span className="text-xs font-bold">H3</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive('bulletList')}
|
||||
title="Bullet list"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1" fill="currentColor"/><circle cx="4.5" cy="12" r="1" fill="currentColor"/><circle cx="4.5" cy="18" r="1" fill="currentColor"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive('orderedList')}
|
||||
title="Numbered list"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Block elements */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive('blockquote')}
|
||||
title="Quote"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
active={editor.isActive('codeBlock')}
|
||||
title="Code block"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Code block">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
active={editor.isActive('code')}
|
||||
title="Inline code"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive('code')} title="Inline code">
|
||||
<span className="font-mono text-[11px] font-bold">{'{}'}</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Link & Image */}
|
||||
<ToolbarBtn
|
||||
onClick={addLink}
|
||||
active={editor.isActive('link')}
|
||||
title="Link"
|
||||
>
|
||||
<ToolbarBtn onClick={addLink} active={editor.isActive('link')} title="Link">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn onClick={addImage} title="Insert image">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Horizontal rule */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal rule"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Horizontal rule">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Emoji picker */}
|
||||
<EmojiPicker editor={editor} />
|
||||
|
||||
{/* Mention hint */}
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().insertContent('@').run()}
|
||||
title="Mention a user (type @username)"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().insertContent('@').run()} title="Mention a user (type @username)">
|
||||
<span className="text-xs font-bold">@</span>
|
||||
</ToolbarBtn>
|
||||
|
||||
{/* Undo / Redo */}
|
||||
{advancedNews ? (
|
||||
<>
|
||||
<Divider />
|
||||
<ToolbarBtn onClick={onToggleSourceMode} active={sourceMode} title="View or edit source HTML" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">HTML</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={onToggleStructureOutlines} active={showStructureOutlines} title="Outline blocks (p, div, figure, list)" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">DOM</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={onInsertArtwork} title="Embed artwork" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Art</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={onInsertSocialEmbed} title="Embed social post" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">Social</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={onInsertVideoEmbed} title="Embed YouTube" className="w-auto px-2.5">
|
||||
<span className="text-[11px] font-bold uppercase tracking-[0.14em]">YT</span>
|
||||
</ToolbarBtn>
|
||||
<ToolbarBtn onClick={onInsertHashtag} title="Insert hashtag" className="w-auto px-2.5">
|
||||
<span className="text-xs font-bold">#</span>
|
||||
</ToolbarBtn>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo (Ctrl+Z)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 102.13-9.36L1 10"/></svg>
|
||||
</ToolbarBtn>
|
||||
|
||||
<ToolbarBtn
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<ToolbarBtn onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo (Ctrl+Shift+Z)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10"/></svg>
|
||||
</ToolbarBtn>
|
||||
</div>
|
||||
@@ -215,18 +581,6 @@ function Toolbar({ editor }) {
|
||||
)
|
||||
}
|
||||
|
||||
/* ─── Main editor component ─── */
|
||||
|
||||
/**
|
||||
* Rich text editor for forum posts & replies.
|
||||
*
|
||||
* @prop {string} content – initial HTML content
|
||||
* @prop {function} onChange – called with HTML string on every change
|
||||
* @prop {string} placeholder – placeholder text
|
||||
* @prop {string} error – validation error message
|
||||
* @prop {number} minHeight – min height in rem (default 12)
|
||||
* @prop {boolean} autofocus – focus on mount
|
||||
*/
|
||||
export default function RichTextEditor({
|
||||
content = '',
|
||||
onChange,
|
||||
@@ -234,9 +588,20 @@ export default function RichTextEditor({
|
||||
error,
|
||||
minHeight = 12,
|
||||
autofocus = false,
|
||||
advancedNews = false,
|
||||
searchEntities = null,
|
||||
}) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
const [sourceMode, setSourceMode] = useState(false)
|
||||
const [sourceValue, setSourceValue] = useState(String(content || ''))
|
||||
const [showStructureOutlines, setShowStructureOutlines] = useState(false)
|
||||
const [helperMessage, setHelperMessage] = useState('')
|
||||
const [artworkPickerOpen, setArtworkPickerOpen] = useState(false)
|
||||
const [artworkQuery, setArtworkQuery] = useState('')
|
||||
const [artworkResults, setArtworkResults] = useState([])
|
||||
const [artworkLoading, setArtworkLoading] = useState(false)
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const base = [
|
||||
StarterKit.configure({
|
||||
link: false,
|
||||
underline: false,
|
||||
@@ -263,7 +628,17 @@ export default function RichTextEditor({
|
||||
},
|
||||
suggestion: mentionSuggestion,
|
||||
}),
|
||||
],
|
||||
]
|
||||
|
||||
if (advancedNews) {
|
||||
base.push(ArtworkEmbed, VideoEmbed, SocialEmbed)
|
||||
}
|
||||
|
||||
return base
|
||||
}, [advancedNews, placeholder])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
immediatelyRender: false,
|
||||
content,
|
||||
autofocus,
|
||||
@@ -285,39 +660,248 @@ export default function RichTextEditor({
|
||||
style: `min-height: ${minHeight}rem`,
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onChange?.(e.getHTML())
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
if (!sourceMode) {
|
||||
onChange?.(currentEditor.getHTML())
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Sync content from outside (e.g. prefill / quote)
|
||||
useEffect(() => {
|
||||
if (editor && content && !editor.getHTML().includes(content.slice(0, 30))) {
|
||||
editor.commands.setContent(content, false)
|
||||
// Keep the parent form state in sync with what we just rendered.
|
||||
// setContent with emitUpdate=false silently resets TipTap without
|
||||
// calling onUpdate, so form.data.content would lag behind the editor.
|
||||
onChange?.(content)
|
||||
if (!helperMessage) {
|
||||
return undefined
|
||||
}
|
||||
}, [content]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const timeout = window.setTimeout(() => setHelperMessage(''), 2200)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [helperMessage])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (sourceMode) return
|
||||
if ((content || '') === editor.getHTML()) return
|
||||
|
||||
editor.commands.setContent(content || '', false)
|
||||
|
||||
const normalizedHtml = editor.getHTML()
|
||||
if (normalizedHtml !== (content || '')) {
|
||||
onChange?.(normalizedHtml)
|
||||
}
|
||||
}, [content, editor, onChange, sourceMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceMode) {
|
||||
setSourceValue(String(content || editor?.getHTML() || ''))
|
||||
}
|
||||
}, [content, editor, sourceMode])
|
||||
|
||||
const pushHelperMessage = useCallback((message) => {
|
||||
setHelperMessage(message)
|
||||
}, [])
|
||||
|
||||
const handleToggleSourceMode = useCallback(() => {
|
||||
if (sourceMode) {
|
||||
setSourceMode(false)
|
||||
if (editor) {
|
||||
editor.commands.setContent(sourceValue || '', false)
|
||||
}
|
||||
pushHelperMessage('Returned to visual editor.')
|
||||
return
|
||||
}
|
||||
|
||||
setSourceValue(editor?.getHTML() || String(content || ''))
|
||||
setSourceMode(true)
|
||||
}, [content, editor, pushHelperMessage, sourceMode, sourceValue])
|
||||
|
||||
const insertArtworkEmbed = useCallback((item) => {
|
||||
if (!editor || !item) return
|
||||
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'artworkEmbed',
|
||||
attrs: {
|
||||
title: item.title || 'Embedded artwork',
|
||||
url: item.url || '#',
|
||||
thumb: item.image || item.avatar || '',
|
||||
},
|
||||
}).run()
|
||||
}, [editor])
|
||||
|
||||
const runArtworkSearch = useCallback(async () => {
|
||||
if (typeof searchEntities !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
setArtworkLoading(true)
|
||||
|
||||
try {
|
||||
const items = await searchEntities('artwork', artworkQuery)
|
||||
setArtworkResults(Array.isArray(items) ? items : [])
|
||||
} finally {
|
||||
setArtworkLoading(false)
|
||||
}
|
||||
}, [artworkQuery, searchEntities])
|
||||
|
||||
useEffect(() => {
|
||||
if (!artworkPickerOpen || typeof searchEntities !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
runArtworkSearch()
|
||||
}, [artworkPickerOpen, runArtworkSearch, searchEntities])
|
||||
|
||||
const handleInsertArtwork = useCallback(() => {
|
||||
if (!editor) return
|
||||
|
||||
if (typeof searchEntities === 'function') {
|
||||
setArtworkPickerOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const url = normalizeHttpUrl(window.prompt('Artwork URL', 'https://skinbase.org/art/') || '')
|
||||
if (!url) {
|
||||
pushHelperMessage('Artwork URL is required.')
|
||||
return
|
||||
}
|
||||
|
||||
const title = window.prompt('Artwork title', 'Embedded artwork')
|
||||
if (title === null) return
|
||||
const thumb = normalizeHttpUrl(window.prompt('Artwork thumbnail URL (optional)', '') || '') || ''
|
||||
|
||||
insertArtworkEmbed({
|
||||
title: title || 'Embedded artwork',
|
||||
url,
|
||||
image: thumb,
|
||||
})
|
||||
}, [editor, insertArtworkEmbed, pushHelperMessage, searchEntities])
|
||||
|
||||
const handleInsertSocialEmbed = useCallback(() => {
|
||||
if (!editor) return
|
||||
|
||||
const detected = detectSocialPlatform(window.prompt('Social post URL', 'https://') || '')
|
||||
if (!detected.url) {
|
||||
pushHelperMessage('Social post URL is required.')
|
||||
return
|
||||
}
|
||||
|
||||
const label = window.prompt('Label (optional)', detected.label)
|
||||
if (label === null) return
|
||||
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'socialEmbed',
|
||||
attrs: {
|
||||
url: detected.url,
|
||||
platform: detected.platform,
|
||||
label: label || detected.label,
|
||||
},
|
||||
}).run()
|
||||
}, [editor, pushHelperMessage])
|
||||
|
||||
const handleInsertVideoEmbed = useCallback(() => {
|
||||
if (!editor) return
|
||||
|
||||
const rawUrl = window.prompt('YouTube URL', 'https://www.youtube.com/watch?v=') || ''
|
||||
const embedUrl = normalizeVideoEmbedUrl(rawUrl)
|
||||
const sourceUrl = normalizeHttpUrl(rawUrl)
|
||||
|
||||
if (!embedUrl || !sourceUrl) {
|
||||
pushHelperMessage('A valid YouTube URL is required.')
|
||||
return
|
||||
}
|
||||
|
||||
const title = window.prompt('Video title (optional)', 'Embedded YouTube video')
|
||||
if (title === null) return
|
||||
|
||||
editor.chain().focus().insertContent({
|
||||
type: 'videoEmbed',
|
||||
attrs: {
|
||||
src: embedUrl,
|
||||
url: sourceUrl,
|
||||
title: title || 'Embedded YouTube video',
|
||||
},
|
||||
}).run()
|
||||
}, [editor, pushHelperMessage])
|
||||
|
||||
const handleInsertHashtag = useCallback(() => {
|
||||
if (!editor) return
|
||||
|
||||
const value = String(window.prompt('Hashtag', 'release') || '').trim().replace(/^#+/, '').replace(/\s+/g, '-')
|
||||
if (!value) return
|
||||
|
||||
editor.chain().focus().insertContent(`#${value}`).run()
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div
|
||||
className={[
|
||||
'overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
'news-rich-text-editor overflow-hidden rounded-xl border bg-white/[0.04] transition-colors',
|
||||
error
|
||||
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||
].join(' ')}
|
||||
>
|
||||
<Toolbar editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
<Toolbar
|
||||
editor={editor}
|
||||
advancedNews={advancedNews}
|
||||
sourceMode={sourceMode}
|
||||
showStructureOutlines={showStructureOutlines}
|
||||
onToggleSourceMode={handleToggleSourceMode}
|
||||
onToggleStructureOutlines={() => setShowStructureOutlines((current) => !current)}
|
||||
onInsertArtwork={handleInsertArtwork}
|
||||
onInsertSocialEmbed={handleInsertSocialEmbed}
|
||||
onInsertVideoEmbed={handleInsertVideoEmbed}
|
||||
onInsertHashtag={handleInsertHashtag}
|
||||
/>
|
||||
|
||||
{advancedNews && sourceMode ? (
|
||||
<div className="border-t border-white/[0.04] bg-black/10 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<span>Edit the stored article HTML directly. Saving while in this mode keeps the HTML exactly as written here.</span>
|
||||
<button type="button" onClick={handleToggleSourceMode} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
Back to visual
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={sourceValue}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setSourceValue(nextValue)
|
||||
onChange?.(nextValue)
|
||||
}}
|
||||
spellCheck={false}
|
||||
className="nova-scrollbar min-h-[20rem] w-full rounded-xl border border-white/10 bg-slate-950/85 px-4 py-3 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||
style={{ minHeight: `${Math.max(minHeight, 20)}rem` }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={advancedNews && showStructureOutlines ? 'news-editor-outline' : ''}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{advancedNews && helperMessage ? (
|
||||
<p className="text-xs text-sky-300">{helperMessage}</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
<ArtworkPickerDialog
|
||||
open={artworkPickerOpen}
|
||||
query={artworkQuery}
|
||||
items={artworkResults}
|
||||
loading={artworkLoading}
|
||||
onQueryChange={setArtworkQuery}
|
||||
onClose={() => setArtworkPickerOpen(false)}
|
||||
onSearch={runArtworkSearch}
|
||||
onSelect={(item) => {
|
||||
insertArtworkEmbed(item)
|
||||
setArtworkPickerOpen(false)
|
||||
pushHelperMessage(`Embedded artwork: ${item.title || 'Artwork'}.`)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,7 +198,14 @@ export default function DateTimePicker({
|
||||
const [viewYear, setViewYear] = useState(initialDate.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(initialDate.getMonth())
|
||||
const [draftDate, setDraftDate] = useState(initial.date)
|
||||
const [draftTime, setDraftTime] = useState(initial.time || '12:00')
|
||||
// default to current local time (HH:MM), clamped to min/max bounds when present
|
||||
const nowTime = `${pad(today.getHours())}:${pad(today.getMinutes())}`
|
||||
const defaultDraftTime = (function () {
|
||||
const baseDate = initial.date || toISODate(initialDate)
|
||||
const candidate = initial.time || nowTime
|
||||
return clampTimeToBounds(baseDate, candidate, minDateTime, maxDateTime)
|
||||
})()
|
||||
const [draftTime, setDraftTime] = useState(defaultDraftTime)
|
||||
const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time')
|
||||
|
||||
const triggerRef = useRef(null)
|
||||
@@ -208,7 +215,15 @@ export default function DateTimePicker({
|
||||
useEffect(() => {
|
||||
const next = splitDateTime(value)
|
||||
setDraftDate(next.date)
|
||||
setDraftTime(next.time || '12:00')
|
||||
|
||||
// prefer explicit time, otherwise use current time clamped to bounds for the chosen date
|
||||
const fallbackTime = (() => {
|
||||
const candidate = next.time || `${pad(new Date().getHours())}:${pad(new Date().getMinutes())}`
|
||||
const dateForClamp = next.date || toISODate(initialDate)
|
||||
return clampTimeToBounds(dateForClamp, candidate, minDateTime, maxDateTime)
|
||||
})()
|
||||
|
||||
setDraftTime(next.time || fallbackTime)
|
||||
|
||||
const nextDate = parseDatePart(next.date)
|
||||
if (nextDate) {
|
||||
@@ -294,8 +309,9 @@ export default function DateTimePicker({
|
||||
|
||||
const clearValue = (event) => {
|
||||
event.stopPropagation()
|
||||
const now = `${pad(new Date().getHours())}:${pad(new Date().getMinutes())}`
|
||||
setDraftDate('')
|
||||
setDraftTime('12:00')
|
||||
setDraftTime(now)
|
||||
onChange?.('')
|
||||
}
|
||||
|
||||
|
||||
85
resources/js/public/home.js
Normal file
85
resources/js/public/home.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const STORAGE_KEY = 'skinbase:hidden_homepage_announcements'
|
||||
|
||||
function readHiddenAnnouncements() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
const parsed = raw ? JSON.parse(raw) : {}
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeHiddenAnnouncements(payload) {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
function initAnnouncement() {
|
||||
const announcement = document.querySelector('[data-home-announcement]')
|
||||
const restoreButton = document.querySelector('[data-home-announcement-restore]')
|
||||
|
||||
if (!announcement || !restoreButton) {
|
||||
return
|
||||
}
|
||||
|
||||
const announcementId = announcement.getAttribute('data-announcement-id')
|
||||
const dismissVersion = Number(announcement.getAttribute('data-dismiss-version') || '1')
|
||||
|
||||
if (!announcementId) {
|
||||
return
|
||||
}
|
||||
|
||||
const syncVisibility = () => {
|
||||
const hiddenAnnouncements = readHiddenAnnouncements()
|
||||
const isHidden = Number(hiddenAnnouncements[String(announcementId)] || 0) === dismissVersion
|
||||
|
||||
announcement.classList.toggle('hidden', isHidden)
|
||||
restoreButton.classList.toggle('hidden', !isHidden)
|
||||
restoreButton.classList.toggle('inline-flex', isHidden)
|
||||
}
|
||||
|
||||
announcement.querySelector('[data-home-announcement-dismiss]')?.addEventListener('click', () => {
|
||||
const hiddenAnnouncements = readHiddenAnnouncements()
|
||||
hiddenAnnouncements[String(announcementId)] = dismissVersion
|
||||
writeHiddenAnnouncements(hiddenAnnouncements)
|
||||
syncVisibility()
|
||||
})
|
||||
|
||||
restoreButton.addEventListener('click', () => {
|
||||
const hiddenAnnouncements = readHiddenAnnouncements()
|
||||
delete hiddenAnnouncements[String(announcementId)]
|
||||
writeHiddenAnnouncements(hiddenAnnouncements)
|
||||
syncVisibility()
|
||||
})
|
||||
|
||||
syncVisibility()
|
||||
}
|
||||
|
||||
function initMatureReveal() {
|
||||
document.querySelectorAll('[data-home-mature-toggle]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const targetId = button.getAttribute('data-home-mature-toggle')
|
||||
const image = targetId ? document.getElementById(targetId) : null
|
||||
const overlay = button.closest('[data-home-mature-overlay]')
|
||||
|
||||
if (!image || !overlay) {
|
||||
return
|
||||
}
|
||||
|
||||
image.classList.remove('blur-2xl', 'scale-[1.03]')
|
||||
overlay.remove()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initAnnouncement()
|
||||
initMatureReveal()
|
||||
}, { once: true })
|
||||
} else {
|
||||
initAnnouncement()
|
||||
initMatureReveal()
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,14 @@
|
||||
{!! $article->rendered_content !!}
|
||||
</div>
|
||||
|
||||
@php
|
||||
$renderedContent = (string) ($article->rendered_content ?? '');
|
||||
$needsInstagramEmbeds = str_contains($renderedContent, 'instagram-media');
|
||||
$needsFacebookEmbeds = str_contains($renderedContent, 'fb-post');
|
||||
$needsTikTokEmbeds = str_contains($renderedContent, 'tiktok-embed');
|
||||
$needsXEmbeds = str_contains($renderedContent, 'twitter-tweet');
|
||||
@endphp
|
||||
|
||||
@if($article->tags->isNotEmpty())
|
||||
<div class="mt-8 flex flex-wrap gap-2 border-t border-white/[0.06] pt-6">
|
||||
@foreach($article->tags as $tag)
|
||||
@@ -214,3 +222,22 @@
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
@if($needsFacebookEmbeds)
|
||||
<div id="fb-root"></div>
|
||||
<script async defer crossorigin="anonymous" src="https://connect.facebook.net/en_US/sdk.js#xfbml=1&version=v19.0"></script>
|
||||
@endif
|
||||
|
||||
@if($needsInstagramEmbeds)
|
||||
<script async src="https://www.instagram.com/embed.js"></script>
|
||||
@endif
|
||||
|
||||
@if($needsTikTokEmbeds)
|
||||
<script async src="https://www.tiktok.com/embed.js"></script>
|
||||
@endif
|
||||
|
||||
@if($needsXEmbeds)
|
||||
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@@ -22,288 +22,90 @@
|
||||
@section('content')
|
||||
<?php
|
||||
$isLoggedIn = ! empty($props['is_logged_in']);
|
||||
$artFallback = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$avatarFallback = 'https://files.skinbase.org/default/avatar_default.webp';
|
||||
$guestArtworkSections = [
|
||||
[
|
||||
'title' => 'Rising Now',
|
||||
'href' => '/discover/rising',
|
||||
'link_label' => 'See all',
|
||||
'items' => is_array($props['rising'] ?? null) ? $props['rising'] : [],
|
||||
'layout' => 'rail',
|
||||
'columns' => 'lg:grid-cols-5',
|
||||
'badge' => 'Rising',
|
||||
'badge_class' => 'bg-emerald-500/80 text-white',
|
||||
],
|
||||
[
|
||||
'title' => 'Trending Now',
|
||||
'title' => 'Trending This Week',
|
||||
'href' => '/discover/trending',
|
||||
'link_label' => 'See all',
|
||||
'items' => is_array($props['trending'] ?? null) ? $props['trending'] : [],
|
||||
'badge' => 'Trending',
|
||||
'badge_class' => 'bg-sky-500/80 text-white',
|
||||
'layout' => 'grid',
|
||||
'columns' => 'xl:grid-cols-4',
|
||||
],
|
||||
[
|
||||
'title' => 'Community Favorites',
|
||||
'href' => '/explore?sort=top-rated',
|
||||
'link_label' => 'See all',
|
||||
'items' => is_array($props['community_favorites'] ?? null) ? $props['community_favorites'] : [],
|
||||
'badge' => 'Favorites',
|
||||
'badge_class' => 'bg-amber-500/85 text-slate-950',
|
||||
'layout' => 'grid',
|
||||
'columns' => 'xl:grid-cols-4',
|
||||
'description' => 'Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal.',
|
||||
],
|
||||
[
|
||||
'title' => 'Hall of Fame',
|
||||
'href' => '/explore/best',
|
||||
'items' => is_array($props['hall_of_fame'] ?? null) ? $props['hall_of_fame'] : [],
|
||||
'layout' => 'grid',
|
||||
'columns' => 'xl:grid-cols-4',
|
||||
'description' => 'All-time medal standouts that keep being remembered long after publication.',
|
||||
],
|
||||
[
|
||||
'title' => 'Fresh Uploads',
|
||||
'href' => '/discover/fresh',
|
||||
'link_label' => 'See all',
|
||||
'items' => is_array($props['fresh'] ?? null) ? $props['fresh'] : [],
|
||||
'badge' => 'Fresh',
|
||||
'badge_class' => 'bg-fuchsia-500/80 text-white',
|
||||
'layout' => 'grid',
|
||||
'columns' => 'xl:grid-cols-4',
|
||||
],
|
||||
];
|
||||
$guestTags = is_array($props['tags'] ?? null) ? $props['tags'] : [];
|
||||
$guestCreators = is_array($props['creators'] ?? null) ? $props['creators'] : [];
|
||||
$guestNews = is_array($props['news'] ?? null) ? $props['news'] : [];
|
||||
?>
|
||||
|
||||
@include('web.home.hero', ['artwork' => $props['hero'] ?? null])
|
||||
|
||||
{{-- Inline props for the React component (avoids data-attribute length limits) --}}
|
||||
<script id="homepage-props" type="application/json">
|
||||
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||
</script>
|
||||
|
||||
<div id="homepage-root">
|
||||
<main class="pb-24">
|
||||
@include('web.home.announcement', ['announcement' => $props['announcement'] ?? null])
|
||||
|
||||
@if($isLoggedIn)
|
||||
@include('web.home.skeleton-sections', [
|
||||
'showWelcomeSpacer' => true,
|
||||
'variants' => ['gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'gallery', 'collections', 'groups', 'categories', 'creators', 'tags', 'creators', 'news', 'cta'],
|
||||
])
|
||||
@include('web.home.sections.welcome-row', ['userData' => $props['user_data'] ?? null])
|
||||
@include('web.home.sections.artwork-section', ['title' => 'From Creators You Follow', 'href' => '/discover/following', 'items' => $props['from_following'] ?? [], 'layout' => 'grid', 'columns' => 'lg:grid-cols-5', 'emptyMessage' => "You're not following anyone yet.", 'emptyDescription' => 'Follow creators you love to see their latest uploads here.', 'emptyCtaHref' => '/creators/top', 'emptyCtaLabel' => 'Discover creators'])
|
||||
@include('web.home.sections.artwork-section', ['eyebrow' => 'Personalized feed', 'title' => 'Picked For You', 'description' => !empty($props['preferences']['top_tags'][0]) ? 'Fresh recommendations informed by your recent interest in #' . $props['preferences']['top_tags'][0] . '.' : 'A live preview of your personalized discovery feed.', 'href' => '/discover/for-you', 'items' => $props['for_you'] ?? [], 'layout' => 'grid', 'columns' => 'xl:grid-cols-4'])
|
||||
@include('web.home.sections.artwork-section', ['title' => 'Rising Now', 'href' => '/discover/rising', 'items' => $props['rising'] ?? [], 'layout' => 'rail', 'columns' => 'lg:grid-cols-5', 'badge' => 'Rising', 'badge_class' => 'bg-emerald-500/80 text-white'])
|
||||
@include('web.home.sections.artwork-section', ['title' => 'Trending This Week', 'href' => '/discover/trending', 'items' => $props['trending'] ?? [], 'layout' => 'grid', 'columns' => 'xl:grid-cols-4'])
|
||||
@include('web.home.sections.artwork-section', ['title' => 'Community Favorites', 'href' => '/explore?sort=top-rated', 'items' => $props['community_favorites'] ?? [], 'layout' => 'grid', 'columns' => 'xl:grid-cols-4', 'description' => 'Recent medal momentum from the community. This rail highlights the strongest 30-day medal signal.'])
|
||||
@include('web.home.sections.artwork-section', ['title' => 'Hall of Fame', 'href' => '/explore/best', 'items' => $props['hall_of_fame'] ?? [], 'layout' => 'grid', 'columns' => 'xl:grid-cols-4', 'description' => 'All-time medal standouts that keep being remembered long after publication.'])
|
||||
@if(!empty($props['preferences']['top_tags'][0]))
|
||||
@include('web.home.sections.artwork-section', ['title' => 'Because You Like #' . $props['preferences']['top_tags'][0], 'href' => '/browse?tags=' . rawurlencode((string) $props['preferences']['top_tags'][0]), 'items' => $props['by_categories'] ?? [], 'layout' => 'grid', 'columns' => 'lg:grid-cols-5'])
|
||||
@endif
|
||||
@include('web.home.sections.artwork-section', ['title' => 'Fresh Uploads', 'href' => '/discover/fresh', 'items' => $props['fresh'] ?? [], 'layout' => 'grid', 'columns' => 'xl:grid-cols-4'])
|
||||
@include('web.home.sections.collections', ['collections' => $props['collections_trending'] ?: ($props['collections_featured'] ?: ($props['collections_recent'] ?? []))])
|
||||
@include('web.home.sections.world-spotlight', ['world' => $props['world_spotlight'] ?? null])
|
||||
@include('web.home.sections.groups', ['groups' => $props['groups'] ?? []])
|
||||
@include('web.home.sections.categories')
|
||||
@include('web.home.sections.suggested-creators', ['creators' => $props['suggested_creators'] ?? []])
|
||||
@include('web.home.sections.tags', ['tags' => $props['tags'] ?? []])
|
||||
@include('web.home.sections.creators', ['creators' => $props['creators'] ?? []])
|
||||
@include('web.home.sections.news', ['items' => $props['news'] ?? []])
|
||||
@include('web.home.sections.cta', ['isLoggedIn' => true])
|
||||
@else
|
||||
<div class="pb-24">
|
||||
@foreach ($guestArtworkSections as $section)
|
||||
@if (count($section['items']) > 0)
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-bold text-white">{{ $section['title'] }}</h2>
|
||||
<a href="{{ $section['href'] }}" class="text-sm text-nova-300 transition hover:text-white">
|
||||
{{ $section['link_label'] }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 lg:grid lg:grid-cols-5 lg:overflow-visible">
|
||||
@foreach (array_slice(array_values($section['items']), 0, 5) as $item)
|
||||
@php
|
||||
$itemTitle = (string) ($item['title'] ?? $item['name'] ?? 'Untitled');
|
||||
$itemUrl = (string) ($item['url'] ?? '#');
|
||||
$itemThumb = (string) ($item['thumb'] ?? $item['thumb_url'] ?? $artFallback);
|
||||
$itemAuthor = (string) ($item['author'] ?? 'Artist');
|
||||
$itemAuthorAvatar = (string) ($item['author_avatar'] ?? $item['avatar_url'] ?? $avatarFallback);
|
||||
$itemAuthorUsername = (string) ($item['author_username'] ?? $item['username'] ?? '');
|
||||
// Generate responsive srcset from md thumbnail (CDN has sm/md/lg variants)
|
||||
$itemThumbSm = str_contains($itemThumb, '/artworks/md/')
|
||||
? str_replace('/artworks/md/', '/artworks/sm/', $itemThumb)
|
||||
: '';
|
||||
$itemThumbLg = str_contains($itemThumb, '/artworks/md/')
|
||||
? str_replace('/artworks/md/', '/artworks/lg/', $itemThumb)
|
||||
: '';
|
||||
@endphp
|
||||
<article class="min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0">
|
||||
<a
|
||||
href="{{ $itemUrl }}"
|
||||
class="group relative block overflow-hidden rounded-2xl bg-black/20 shadow-lg shadow-black/40 ring-1 ring-white/5 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
>
|
||||
<div class="relative aspect-video overflow-hidden bg-neutral-900">
|
||||
<div class="pointer-events-none absolute inset-0 z-10 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div>
|
||||
<img
|
||||
src="{{ $itemThumb }}"
|
||||
@if($itemThumbSm !== '' && $itemThumbLg !== '')
|
||||
srcset="{{ $itemThumbSm }} 300w, {{ $itemThumb }} 500w, {{ $itemThumbLg }} 900w"
|
||||
sizes="(max-width: 640px) 72vw, (max-width: 1024px) 44vw, 240px"
|
||||
@endif
|
||||
alt="{{ $itemTitle }}"
|
||||
class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
|
||||
<div class="absolute left-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-bold ring-1 ring-white/10 backdrop-blur-sm {{ $section['badge_class'] }}">
|
||||
{{ $section['badge'] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div class="truncate text-sm font-semibold text-white">{{ $itemTitle }}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img
|
||||
src="{{ $itemAuthorAvatar }}"
|
||||
alt="{{ $itemAuthor }}"
|
||||
class="h-6 w-6 shrink-0 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span class="truncate">{{ $itemAuthor }}</span>
|
||||
@if ($itemAuthorUsername !== '')
|
||||
<span class="shrink-0 text-white/50">@{{ $itemAuthorUsername }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="sr-only">{{ $itemTitle }} by {{ $itemAuthor }}</span>
|
||||
</a>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@endforeach
|
||||
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(11,17,28,0.92),rgba(7,11,19,0.95))] px-6 py-8 ring-1 ring-white/5 sm:px-8">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="max-w-2xl">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-white/35">Browse the archive</p>
|
||||
<h2 class="mt-3 text-2xl font-bold text-white sm:text-3xl">Explore categories, wallpapers, skins, and creator collections.</h2>
|
||||
<p class="mt-3 text-sm leading-7 text-white/60">
|
||||
Dive into the full Skinbase directory to browse curated categories, trending artwork types, and classic collections.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/categories" class="rounded-full bg-sky-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-sky-400">Open categories</a>
|
||||
<a href="/wallpapers" class="rounded-full border border-white/10 bg-white/[0.04] px-5 py-2.5 text-sm font-semibold text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white">Wallpapers</a>
|
||||
<a href="/skins" class="rounded-full border border-white/10 bg-white/[0.04] px-5 py-2.5 text-sm font-semibold text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white">Skins</a>
|
||||
<a href="/photography" class="rounded-full border border-white/10 bg-white/[0.04] px-5 py-2.5 text-sm font-semibold text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white">Photography</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@if (count($guestTags) > 0)
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="mb-5 text-xl font-bold text-white">Popular Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($guestTags as $tag)
|
||||
<a
|
||||
href="/tag/{{ $tag['slug'] ?? '' }}"
|
||||
class="rounded-full bg-nova-800 px-4 py-1.5 text-sm font-medium text-nova-200 transition hover:bg-nova-700 hover:text-white"
|
||||
>
|
||||
{{ $tag['name'] ?? 'Tag' }}
|
||||
@if ((int) ($tag['count'] ?? 0) > 0)
|
||||
<span class="ml-1.5 text-xs text-soft">{{ number_format((int) ($tag['count'] ?? 0)) }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if (count($guestCreators) > 0)
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-bold text-white">Creator Spotlight</h2>
|
||||
<a href="/members" class="text-sm text-nova-300 transition hover:text-white">All creators</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
@foreach (array_slice(array_values($guestCreators), 0, 6) as $creator)
|
||||
@php
|
||||
$creatorName = (string) ($creator['name'] ?? 'Creator');
|
||||
$creatorUrl = (string) ($creator['url'] ?? '#');
|
||||
$creatorAvatar = (string) ($creator['avatar'] ?? $avatarFallback);
|
||||
$creatorBgThumb = (string) ($creator['bg_thumb'] ?? '');
|
||||
@endphp
|
||||
<a
|
||||
href="{{ $creatorUrl }}"
|
||||
aria-label="View {{ $creatorName }} profile"
|
||||
class="group relative flex min-h-[16rem] flex-col items-center overflow-hidden rounded-xl bg-panel p-5 text-center shadow-sm transition hover:ring-1 hover:ring-nova-500"
|
||||
@if ($creatorBgThumb !== '')
|
||||
style="background-image: linear-gradient(to top, rgba(13, 19, 28, 0.96), rgba(13, 19, 28, 0.7)), url('{{ $creatorBgThumb }}'); background-size: cover; background-position: center;"
|
||||
@endif
|
||||
>
|
||||
<img
|
||||
src="{{ $creatorAvatar }}"
|
||||
alt="{{ $creatorName }}"
|
||||
class="relative mx-auto h-16 w-16 rounded-full bg-nova-800/80 object-cover ring-4 ring-nova-800"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<h3 class="relative mt-2 text-sm font-semibold text-white">{{ $creatorName }}</h3>
|
||||
<p class="relative mt-1 flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-soft">
|
||||
<span>Uploads {{ number_format((int) ($creator['uploads'] ?? 0)) }}</span>
|
||||
@if ((int) ($creator['weekly_uploads'] ?? 0) > 0)
|
||||
<span class="font-semibold text-accent">{{ number_format((int) ($creator['weekly_uploads'] ?? 0)) }} this week</span>
|
||||
@endif
|
||||
<span>Views {{ number_format((int) ($creator['views'] ?? 0)) }}</span>
|
||||
@if ((int) ($creator['awards'] ?? 0) > 0)
|
||||
<span>Awards {{ number_format((int) ($creator['awards'] ?? 0)) }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<span class="relative mt-3 inline-flex items-center justify-center rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition group-hover:bg-nova-600">
|
||||
View profile
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if (count($guestNews) > 0)
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-bold text-white">News and Updates</h2>
|
||||
<a href="/news" class="text-sm text-nova-300 transition hover:text-white">All news</a>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden rounded-[24px] border border-white/10 bg-panel divide-y divide-nova-800">
|
||||
@foreach (array_slice(array_values($guestNews), 0, 6) as $item)
|
||||
<a
|
||||
href="{{ $item['url'] ?? '#' }}"
|
||||
class="grid gap-3 px-5 py-4 transition hover:bg-nova-800 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
@if (!empty($item['eyebrow']))
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-nova-300">{{ $item['eyebrow'] }}</div>
|
||||
@endif
|
||||
<div class="mt-1 line-clamp-2 text-sm font-medium text-white">{{ $item['title'] ?? 'News item' }}</div>
|
||||
@if (!empty($item['excerpt']))
|
||||
<p class="mt-2 line-clamp-2 text-sm leading-6 text-soft">{{ $item['excerpt'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (!empty($item['date']))
|
||||
<span class="shrink-0 text-xs text-soft">{{ $item['date'] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/20 via-nova-800 to-nova-900 px-8 py-12 text-center ring-1 ring-white/5">
|
||||
<div class="pointer-events-none absolute -right-12 -top-12 h-40 w-40 rounded-full bg-accent/10 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-10 -left-10 h-32 w-32 rounded-full bg-sky-500/10 blur-2xl"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-accent">Join the community</p>
|
||||
<h2 class="mt-2 text-2xl font-bold text-white sm:text-3xl">Ready to share your creativity?</h2>
|
||||
<p class="mx-auto mt-3 max-w-md text-sm text-nova-300">
|
||||
Upload your artworks, wallpapers, and skins to reach thousands of enthusiasts around the world.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a href="/login?redirect=/upload" class="btn-accent-solid rounded-xl px-6 py-2.5 text-sm font-semibold">
|
||||
Upload your artwork
|
||||
</a>
|
||||
<a href="/register" class="rounded-xl border border-white/10 bg-nova-700 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-nova-600">
|
||||
Create account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@foreach ($guestArtworkSections as $section)
|
||||
@include('web.home.sections.artwork-section', $section)
|
||||
@endforeach
|
||||
@include('web.home.sections.collections', ['collections' => $props['collections_trending'] ?: ($props['collections_featured'] ?: ($props['collections_editorial'] ?? []))])
|
||||
@include('web.home.sections.world-spotlight', ['world' => $props['world_spotlight'] ?? null])
|
||||
@include('web.home.sections.groups', ['groups' => $props['groups'] ?? []])
|
||||
@include('web.home.sections.categories')
|
||||
@include('web.home.sections.tags', ['tags' => $props['tags'] ?? []])
|
||||
@include('web.home.sections.creators', ['creators' => $props['creators'] ?? []])
|
||||
@include('web.home.sections.news', ['items' => $props['news'] ?? []])
|
||||
@include('web.home.sections.cta', ['isLoggedIn' => false])
|
||||
@endif
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@vite(['resources/js/Pages/Home/HomePage.jsx'])
|
||||
@vite(['resources/js/public/home.js'])
|
||||
@endsection
|
||||
|
||||
|
||||
@@ -1,58 +1,93 @@
|
||||
@php
|
||||
$homepageAnnouncement = is_array($announcement ?? null) ? $announcement : null;
|
||||
$overlayOpacity = max(0, min(100, (int) ($homepageAnnouncement['overlay_opacity'] ?? 55)));
|
||||
$announcementId = (int) ($homepageAnnouncement['id'] ?? 0);
|
||||
$dismissVersion = (int) ($homepageAnnouncement['dismiss_version'] ?? 1);
|
||||
@endphp
|
||||
|
||||
@if ($homepageAnnouncement)
|
||||
<section class="px-4 pt-8 sm:px-6 lg:px-8">
|
||||
<div class="relative mx-auto max-w-7xl overflow-hidden rounded-[2rem] border border-cyan-300/15 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.26),transparent_30%),radial-gradient(circle_at_80%_20%,rgba(168,85,247,0.22),transparent_28%),linear-gradient(135deg,rgba(6,12,24,0.96),rgba(10,17,34,0.9))] text-white shadow-[0_28px_90px_rgba(0,0,0,0.35)]">
|
||||
@if (!empty($homepageAnnouncement['background_image_url']))
|
||||
<div class="absolute inset-0">
|
||||
<img src="{{ $homepageAnnouncement['background_image_url'] }}" alt="" class="h-full w-full object-cover">
|
||||
<div class="absolute inset-0 bg-slate-950" style="opacity: {{ $overlayOpacity / 100 }}"></div>
|
||||
</div>
|
||||
@endif
|
||||
<section class="px-4 pt-8 sm:px-6 lg:px-8" data-home-announcement-wrapper>
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<button
|
||||
type="button"
|
||||
class="hidden items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-sm font-semibold text-white/90 transition hover:border-white/20 hover:bg-white/[0.1]"
|
||||
data-home-announcement-restore
|
||||
data-announcement-id="{{ $announcementId }}"
|
||||
data-dismiss-version="{{ $dismissVersion }}"
|
||||
>
|
||||
<span aria-hidden="true">✨</span>
|
||||
<span>Show Skinbase announcement</span>
|
||||
</button>
|
||||
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-indigo-400/20 via-sky-400/12 to-transparent"></div>
|
||||
<div class="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),transparent_22%,rgba(2,6,23,0.15)_100%)]"></div>
|
||||
<div
|
||||
class="relative overflow-hidden rounded-[2rem] border border-cyan-300/15 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.26),transparent_30%),radial-gradient(circle_at_80%_20%,rgba(168,85,247,0.22),transparent_28%),linear-gradient(135deg,rgba(6,12,24,0.96),rgba(10,17,34,0.9))] text-white shadow-[0_28px_90px_rgba(0,0,0,0.35)]"
|
||||
data-home-announcement
|
||||
data-announcement-id="{{ $announcementId }}"
|
||||
data-dismiss-version="{{ $dismissVersion }}"
|
||||
>
|
||||
@if (!empty($homepageAnnouncement['background_image_url']))
|
||||
<div class="absolute inset-0">
|
||||
<img src="{{ $homepageAnnouncement['background_image_url'] }}" alt="" class="h-full w-full object-cover">
|
||||
<div class="absolute inset-0 bg-slate-950" style="opacity: {{ $overlayOpacity / 100 }}"></div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="relative grid gap-8 px-6 py-7 sm:px-8 lg:grid-cols-[minmax(0,1.25fr)_auto] lg:items-end lg:px-10 lg:py-10">
|
||||
<div class="max-w-3xl">
|
||||
@if (!empty($homepageAnnouncement['badge_text']))
|
||||
<div class="inline-flex rounded-full border border-cyan-200/20 bg-cyan-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-cyan-100">
|
||||
{{ $homepageAnnouncement['badge_text'] }}
|
||||
</div>
|
||||
@endif
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-indigo-400/20 via-sky-400/12 to-transparent"></div>
|
||||
<div class="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),transparent_22%,rgba(2,6,23,0.15)_100%)]"></div>
|
||||
|
||||
<h2 class="mt-4 text-3xl font-semibold tracking-[-0.05em] sm:text-4xl lg:text-[3.15rem]">
|
||||
{{ $homepageAnnouncement['title'] ?? '' }}
|
||||
</h2>
|
||||
@if (!empty($homepageAnnouncement['is_dismissible']))
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-5 top-5 z-10 inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/70 transition hover:border-white/20 hover:bg-black/45 hover:text-white sm:right-6 sm:top-6 lg:right-8 lg:top-8"
|
||||
data-home-announcement-dismiss
|
||||
aria-label="Dismiss homepage announcement"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M3.5 3.5 12.5 12.5"></path>
|
||||
<path d="M12.5 3.5 3.5 12.5"></path>
|
||||
</svg>
|
||||
Dismiss
|
||||
</button>
|
||||
@endif
|
||||
|
||||
@if (!empty($homepageAnnouncement['subtitle']))
|
||||
<p class="mt-3 max-w-2xl text-base leading-7 text-white/80 sm:text-lg">
|
||||
{{ $homepageAnnouncement['subtitle'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (!empty($homepageAnnouncement['content_html']))
|
||||
<div class="prose prose-invert mt-5 max-w-2xl text-sm leading-7 prose-a:text-cyan-200 prose-strong:text-white [&_blockquote]:my-5 [&_h2]:mb-3 [&_h2]:mt-7 [&_h3]:mb-2 [&_h3]:mt-6 [&_li+li]:mt-1.5 [&_ol]:my-5 [&_p]:my-0 [&_p+p]:mt-6 [&_ul]:my-5 sm:text-base">
|
||||
{!! $homepageAnnouncement['content_html'] !!}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-3 lg:items-end">
|
||||
<div class="flex flex-wrap gap-3 lg:justify-end">
|
||||
@if (!empty($homepageAnnouncement['primary_link']['url']) && !empty($homepageAnnouncement['primary_link']['label']))
|
||||
<a href="{{ $homepageAnnouncement['primary_link']['url'] }}" class="inline-flex items-center justify-center rounded-full border border-cyan-300/30 bg-cyan-300/15 px-5 py-3 text-sm font-semibold text-cyan-50 transition hover:bg-cyan-300/22">
|
||||
{{ $homepageAnnouncement['primary_link']['label'] }}
|
||||
</a>
|
||||
<div class="relative grid gap-8 px-6 py-7 sm:px-8 lg:grid-cols-[minmax(0,1.25fr)_auto] lg:items-end lg:px-10 lg:py-10">
|
||||
<div class="max-w-3xl">
|
||||
@if (!empty($homepageAnnouncement['badge_text']))
|
||||
<div class="inline-flex rounded-full border border-cyan-200/20 bg-cyan-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-cyan-100">
|
||||
{{ $homepageAnnouncement['badge_text'] }}
|
||||
</div>
|
||||
@endif
|
||||
@if (!empty($homepageAnnouncement['secondary_link']['url']) && !empty($homepageAnnouncement['secondary_link']['label']))
|
||||
<a href="{{ $homepageAnnouncement['secondary_link']['url'] }}" class="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white/85 transition hover:bg-white/[0.1]">
|
||||
{{ $homepageAnnouncement['secondary_link']['label'] }}
|
||||
</a>
|
||||
|
||||
<h2 class="mt-4 text-3xl font-semibold tracking-[-0.05em] sm:text-4xl lg:text-[3.15rem]">
|
||||
{{ $homepageAnnouncement['title'] ?? '' }}
|
||||
</h2>
|
||||
|
||||
@if (!empty($homepageAnnouncement['subtitle']))
|
||||
<p class="mt-3 max-w-2xl text-base leading-7 text-white/80 sm:text-lg">
|
||||
{{ $homepageAnnouncement['subtitle'] }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (!empty($homepageAnnouncement['content_html']))
|
||||
<div class="prose prose-invert mt-5 max-w-2xl text-sm leading-7 prose-a:text-cyan-200 prose-strong:text-white [&_blockquote]:my-5 [&_h2]:mb-3 [&_h2]:mt-7 [&_h3]:mb-2 [&_h3]:mt-6 [&_li+li]:mt-1.5 [&_ol]:my-5 [&_p]:my-0 [&_p+p]:mt-6 [&_ul]:my-5 sm:text-base">
|
||||
{!! $homepageAnnouncement['content_html'] !!}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-start gap-3 lg:items-end">
|
||||
<div class="flex flex-wrap gap-3 lg:justify-end">
|
||||
@if (!empty($homepageAnnouncement['primary_link']['url']) && !empty($homepageAnnouncement['primary_link']['label']))
|
||||
<a href="{{ $homepageAnnouncement['primary_link']['url'] }}" class="inline-flex items-center justify-center rounded-full border border-cyan-300/30 bg-cyan-300/15 px-5 py-3 text-sm font-semibold text-cyan-50 transition hover:bg-cyan-300/22">
|
||||
{{ $homepageAnnouncement['primary_link']['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
@if (!empty($homepageAnnouncement['secondary_link']['url']) && !empty($homepageAnnouncement['secondary_link']['label']))
|
||||
<a href="{{ $homepageAnnouncement['secondary_link']['url'] }}" class="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white/85 transition hover:bg-white/[0.1]">
|
||||
{{ $homepageAnnouncement['secondary_link']['label'] }}
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
112
resources/views/web/home/sections/artwork-card.blade.php
Normal file
112
resources/views/web/home/sections/artwork-card.blade.php
Normal file
@@ -0,0 +1,112 @@
|
||||
@php
|
||||
$artFallback = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$avatarFallback = 'https://files.skinbase.org/default/avatar_default.webp';
|
||||
$artwork = is_array($item ?? null) ? $item : [];
|
||||
$titleText = (string) ($artwork['title'] ?? $artwork['name'] ?? 'Untitled');
|
||||
$artworkUrl = (string) ($artwork['url'] ?? '#');
|
||||
$thumbUrl = (string) ($artwork['thumb'] ?? $artwork['thumb_url'] ?? $artFallback);
|
||||
$authorName = (string) ($artwork['author'] ?? 'Artist');
|
||||
$authorUsername = (string) ($artwork['author_username'] ?? $artwork['username'] ?? '');
|
||||
$authorAvatar = (string) ($artwork['author_avatar'] ?? $artwork['avatar_url'] ?? $avatarFallback);
|
||||
$authorUrl = !empty($artwork['publisher']['profile_url'] ?? null)
|
||||
? (string) $artwork['publisher']['profile_url']
|
||||
: ($authorUsername !== '' ? route('profile.show', ['username' => strtolower($authorUsername)]) : null);
|
||||
$metricBadge = is_array($artwork['metric_badge'] ?? null) ? $artwork['metric_badge'] : null;
|
||||
$maturity = is_array($artwork['maturity'] ?? null) ? $artwork['maturity'] : [];
|
||||
$shouldBlur = (bool) ($maturity['should_blur'] ?? false);
|
||||
$cardImageId = ($idPrefix ?? 'artwork') . '-image-' . ($index ?? 0);
|
||||
$medalScore = (int) data_get($artwork, 'medals.score_30d', data_get($artwork, 'medals.score', 0));
|
||||
@endphp
|
||||
|
||||
<article class="{{ ($layout ?? 'grid') === 'rail' ? 'min-w-[72%] snap-start sm:min-w-[44%] lg:min-w-0' : 'min-w-0' }}">
|
||||
<div class="group overflow-hidden rounded-2xl bg-black/20 shadow-lg shadow-black/40 ring-1 ring-white/5 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-within:ring-2 focus-within:ring-sky-300/70">
|
||||
<a href="{{ $artworkUrl }}" class="relative block overflow-hidden">
|
||||
<div class="relative aspect-video overflow-hidden bg-neutral-900">
|
||||
<div class="pointer-events-none absolute inset-0 z-10 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div>
|
||||
<img
|
||||
id="{{ $cardImageId }}"
|
||||
src="{{ $thumbUrl }}"
|
||||
@if (!empty($artwork['thumb_srcset']))
|
||||
srcset="{{ $artwork['thumb_srcset'] }}"
|
||||
sizes="{{ $sizes ?? '100vw' }}"
|
||||
@endif
|
||||
alt="{{ $titleText }}"
|
||||
width="{{ max(1, (int) ($artwork['width'] ?? 1600)) }}"
|
||||
height="{{ max(1, (int) ($artwork['height'] ?? 900)) }}"
|
||||
class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04] {{ $shouldBlur ? 'blur-2xl scale-[1.03]' : '' }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
|
||||
@if (!empty($badge))
|
||||
<div class="absolute left-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-md px-2 py-1 text-[11px] font-bold ring-1 ring-white/10 backdrop-blur-sm {{ $badgeClass ?? 'bg-sky-500/80 text-white' }}">
|
||||
{{ $badge }}
|
||||
</span>
|
||||
</div>
|
||||
@elseif ($metricBadge && !empty($metricBadge['label']))
|
||||
<div class="absolute left-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-full border border-sky-300/30 bg-sky-500/14 px-2.5 py-1 text-[11px] font-semibold text-sky-100 ring-1 ring-sky-300/20 backdrop-blur-sm">
|
||||
{{ $metricBadge['label'] }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($medalScore > 0)
|
||||
<div class="absolute right-3 top-3 z-30">
|
||||
<span class="inline-flex items-center rounded-full border border-amber-300/20 bg-amber-300/12 px-2.5 py-1 text-[11px] font-semibold text-amber-100 ring-1 ring-amber-300/20 backdrop-blur-sm">
|
||||
Medal {{ number_format($medalScore) }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($shouldBlur)
|
||||
<div class="absolute inset-0 z-20 flex items-center justify-center bg-slate-950/55 p-4" data-home-mature-overlay>
|
||||
<div class="rounded-2xl border border-white/10 bg-black/45 px-4 py-4 text-center shadow-2xl backdrop-blur-md">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-white/70">Mature content</p>
|
||||
<p class="mt-2 max-w-[16rem] text-sm text-white/90">This artwork may contain mature material.</p>
|
||||
<button
|
||||
type="button"
|
||||
data-home-mature-toggle="{{ $cardImageId }}"
|
||||
class="mt-4 inline-flex items-center rounded-full border border-white/15 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/20"
|
||||
>
|
||||
Reveal image
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
|
||||
<div class="truncate text-sm font-semibold text-white">{{ $titleText }}</div>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-white/80">
|
||||
<img src="{{ $authorAvatar }}" alt="{{ $authorName }}" class="h-6 w-6 shrink-0 rounded-full object-cover" loading="lazy" decoding="async">
|
||||
<span class="truncate">{{ $authorName }}</span>
|
||||
@if ($authorUsername !== '')
|
||||
<span class="shrink-0 text-white/50">@{{ $authorUsername }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="flex items-start justify-between gap-3 border-t border-white/5 bg-slate-950/40 px-3 py-3">
|
||||
<div class="min-w-0">
|
||||
<a href="{{ $artworkUrl }}" class="block truncate text-sm font-semibold text-white transition hover:text-sky-100">{{ $titleText }}</a>
|
||||
<div class="mt-1 flex items-center gap-2 text-xs text-soft">
|
||||
@if ($authorUrl)
|
||||
<a href="{{ $authorUrl }}" class="truncate text-nova-200 transition hover:text-white">{{ $authorName }}</a>
|
||||
@else
|
||||
<span class="truncate">{{ $authorName }}</span>
|
||||
@endif
|
||||
@if (!empty($artwork['category_name']))
|
||||
<span class="shrink-0 text-white/35">•</span>
|
||||
<span class="truncate">{{ $artwork['category_name'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ $artworkUrl }}" class="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white/80 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white">
|
||||
View
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
59
resources/views/web/home/sections/artwork-section.blade.php
Normal file
59
resources/views/web/home/sections/artwork-section.blade.php
Normal file
@@ -0,0 +1,59 @@
|
||||
@php
|
||||
$sectionItems = collect(is_array($items ?? null) ? $items : [])
|
||||
->filter(fn ($item) => !data_get($item, 'maturity.should_hide', false))
|
||||
->values();
|
||||
$sectionLayout = $layout ?? 'grid';
|
||||
$sectionColumns = $columns ?? 'xl:grid-cols-4';
|
||||
$shouldRenderEmpty = !empty($emptyMessage);
|
||||
@endphp
|
||||
|
||||
@if ($sectionItems->isNotEmpty() || $shouldRenderEmpty)
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
@if (!empty($eyebrow))
|
||||
<p class="text-[0.7rem] font-semibold uppercase tracking-[0.28em] text-sky-200/70">{{ $eyebrow }}</p>
|
||||
@endif
|
||||
<h2 class="text-xl font-bold text-white">{{ $title }}</h2>
|
||||
@if (!empty($description))
|
||||
<p class="mt-1 max-w-2xl text-sm text-slate-300">{{ $description }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (!empty($href))
|
||||
<a href="{{ $href }}" class="text-sm text-nova-300 transition hover:text-white">
|
||||
See all →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($sectionItems->isNotEmpty())
|
||||
<div class="{{ $sectionLayout === 'rail' ? 'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-3 ' . $sectionColumns . ' lg:grid lg:overflow-visible' : 'grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 ' . $sectionColumns }}">
|
||||
@foreach ($sectionItems as $index => $item)
|
||||
@include('web.home.sections.artwork-card', [
|
||||
'item' => $item,
|
||||
'layout' => $sectionLayout,
|
||||
'badge' => $badge ?? null,
|
||||
'badgeClass' => $badge_class ?? null,
|
||||
'sizes' => $sectionLayout === 'rail'
|
||||
? '(max-width: 640px) 72vw, (max-width: 1024px) 44vw, 240px'
|
||||
: '(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw',
|
||||
'idPrefix' => Str::slug((string) $title, '-'),
|
||||
'index' => $index,
|
||||
])
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-2xl border border-white/5 bg-nova-800/40 px-6 py-10 text-center">
|
||||
<p class="text-sm text-soft">{{ $emptyMessage }}</p>
|
||||
@if (!empty($emptyDescription))
|
||||
<p class="mt-1 text-xs text-nova-400">{{ $emptyDescription }}</p>
|
||||
@endif
|
||||
@if (!empty($emptyCtaHref) && !empty($emptyCtaLabel))
|
||||
<a href="{{ $emptyCtaHref }}" class="mt-4 inline-flex items-center rounded-xl bg-nova-700 px-4 py-2 text-sm font-medium text-white transition hover:bg-nova-600">
|
||||
{{ $emptyCtaLabel }} →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endif
|
||||
32
resources/views/web/home/sections/categories.blade.php
Normal file
32
resources/views/web/home/sections/categories.blade.php
Normal file
@@ -0,0 +1,32 @@
|
||||
@php
|
||||
$categories = [
|
||||
['label' => 'Wallpapers', 'description' => 'Desktop & mobile backgrounds', 'href' => '/wallpapers', 'mascot' => '/gfx/mascot_wallpapers.webp', 'color' => 'from-sky-500/20 to-sky-900/40'],
|
||||
['label' => 'Photography', 'description' => 'Real-world captures & edits', 'href' => '/photography', 'mascot' => '/gfx/mascot_photography.webp', 'color' => 'from-emerald-500/20 to-emerald-900/40'],
|
||||
['label' => 'Skins', 'description' => 'App & game skins', 'href' => '/skins', 'mascot' => '/gfx/mascot_skins.webp', 'color' => 'from-purple-500/20 to-purple-900/40'],
|
||||
['label' => 'Digital Art', 'description' => 'Illustrations & concept art', 'href' => '/digital-art', 'mascot' => '/gfx/mascot_other.webp', 'color' => 'from-rose-500/20 to-rose-900/40'],
|
||||
['label' => 'Tags Hub', 'description' => 'Browse by theme or style', 'href' => '/tags', 'mascot' => '/gfx/mascot_other.webp', 'color' => 'from-amber-500/20 to-amber-900/40'],
|
||||
];
|
||||
@endphp
|
||||
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Explore Categories</h2>
|
||||
<a href="/browse" class="text-sm text-nova-300 transition hover:text-white">Browse all →</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
@foreach ($categories as $category)
|
||||
<a
|
||||
href="{{ $category['href'] }}"
|
||||
class="group relative flex min-h-[7rem] flex-col justify-end overflow-hidden rounded-2xl bg-gradient-to-br {{ $category['color'] }} ring-1 ring-white/5 transition hover:-translate-y-1 hover:ring-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-0 bg-nova-900/20 transition group-hover:bg-nova-900/10"></div>
|
||||
<img src="{{ $category['mascot'] }}" alt="" aria-hidden="true" class="pointer-events-none absolute bottom-0 right-0 h-24 w-auto translate-y-2 object-contain drop-shadow-xl transition-transform duration-300 group-hover:-translate-y-1 group-hover:scale-105" loading="lazy">
|
||||
<div class="relative z-10 p-3 pr-24">
|
||||
<p class="font-semibold leading-tight text-white">{{ $category['label'] }}</p>
|
||||
<p class="mt-0.5 text-xs text-nova-300">{{ $category['description'] }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
67
resources/views/web/home/sections/collections.blade.php
Normal file
67
resources/views/web/home/sections/collections.blade.php
Normal file
@@ -0,0 +1,67 @@
|
||||
@php
|
||||
$collectionItems = collect(is_array($collections ?? null) ? $collections : [])->filter()->take(3)->values();
|
||||
$coverFallback = 'https://files.skinbase.org/default/missing_md.webp';
|
||||
@endphp
|
||||
|
||||
@if ($collectionItems->isNotEmpty())
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">Trending Collections</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-nova-300">Collections getting the strongest mix of follows, saves, and engagement right now.</p>
|
||||
</div>
|
||||
<a href="/collections/trending" class="shrink-0 text-sm text-nova-300 transition hover:text-white">All collections →</a>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach ($collectionItems as $collection)
|
||||
<article class="group overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(12,19,30,0.96),rgba(5,10,18,0.96))] shadow-[0_22px_70px_rgba(2,6,23,0.28)] transition hover:border-white/15">
|
||||
<a href="{{ $collection['url'] ?? '#' }}" class="block">
|
||||
<div class="relative aspect-[16/10] overflow-hidden bg-slate-900">
|
||||
<img src="{{ $collection['cover_image'] ?? $coverFallback }}" alt="{{ $collection['title'] ?? 'Collection' }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" loading="lazy" decoding="async">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-950 via-slate-950/30 to-transparent"></div>
|
||||
<div class="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
@if (!empty($collection['badge_label']))
|
||||
<span class="rounded-full border border-amber-300/20 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">{{ $collection['badge_label'] }}</span>
|
||||
@endif
|
||||
@if (!empty($collection['type']))
|
||||
<span class="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/75">{{ str_replace('_', ' ', $collection['type']) }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="px-5 py-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<a href="{{ $collection['url'] ?? '#' }}" class="block truncate text-lg font-semibold text-white transition hover:text-sky-100">{{ $collection['title'] ?? 'Collection' }}</a>
|
||||
@if (!empty($collection['subtitle']))
|
||||
<p class="mt-1 truncate text-sm text-slate-300">{{ $collection['subtitle'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (!empty(data_get($collection, 'owner.username')))
|
||||
<span class="shrink-0 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100/80">@{{ data_get($collection, 'owner.username') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (!empty($collection['description_excerpt']))
|
||||
<p class="mt-3 text-sm leading-6 text-slate-300">{{ $collection['description_excerpt'] }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-3 text-xs text-slate-400">
|
||||
@if ((int) ($collection['artworks_count'] ?? 0) > 0)
|
||||
<span>{{ number_format((int) ($collection['artworks_count'] ?? 0)) }} artworks</span>
|
||||
@endif
|
||||
@if ((int) ($collection['followers_count'] ?? 0) > 0)
|
||||
<span>{{ number_format((int) ($collection['followers_count'] ?? 0)) }} followers</span>
|
||||
@endif
|
||||
@if ((int) ($collection['saves_count'] ?? 0) > 0)
|
||||
<span>{{ number_format((int) ($collection['saves_count'] ?? 0)) }} saves</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
42
resources/views/web/home/sections/creators.blade.php
Normal file
42
resources/views/web/home/sections/creators.blade.php
Normal file
@@ -0,0 +1,42 @@
|
||||
@php
|
||||
$creatorItems = collect(is_array($creators ?? null) ? $creators : [])->filter()->take(6)->values();
|
||||
$avatarFallback = 'https://files.skinbase.org/default/avatar_default.webp';
|
||||
@endphp
|
||||
|
||||
@if ($creatorItems->isNotEmpty())
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-bold text-white">Creator Spotlight</h2>
|
||||
<a href="/members" class="text-sm text-nova-300 transition hover:text-white">All creators</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
@foreach ($creatorItems as $creator)
|
||||
<a
|
||||
href="{{ $creator['url'] ?? '#' }}"
|
||||
aria-label="View {{ $creator['name'] ?? 'Creator' }} profile"
|
||||
class="group relative flex min-h-[16rem] flex-col items-center overflow-hidden rounded-xl bg-panel p-5 text-center shadow-sm transition hover:ring-1 hover:ring-nova-500"
|
||||
@if (!empty($creator['bg_thumb']))
|
||||
style="background-image: linear-gradient(to top, rgba(13, 19, 28, 0.96), rgba(13, 19, 28, 0.7)), url('{{ $creator['bg_thumb'] }}'); background-size: cover; background-position: center;"
|
||||
@endif
|
||||
>
|
||||
<img src="{{ $creator['avatar'] ?? $avatarFallback }}" alt="{{ $creator['name'] ?? 'Creator' }}" class="relative mx-auto h-16 w-16 rounded-full bg-nova-800/80 object-cover ring-4 ring-nova-800" loading="lazy" decoding="async">
|
||||
<h3 class="relative mt-2 text-sm font-semibold text-white">{{ $creator['name'] ?? 'Creator' }}</h3>
|
||||
<p class="relative mt-1 flex flex-wrap justify-center gap-x-3 gap-y-1 text-xs text-soft">
|
||||
<span>Uploads {{ number_format((int) ($creator['uploads'] ?? 0)) }}</span>
|
||||
@if ((int) ($creator['weekly_uploads'] ?? 0) > 0)
|
||||
<span class="font-semibold text-accent">{{ number_format((int) ($creator['weekly_uploads'] ?? 0)) }} this week</span>
|
||||
@endif
|
||||
<span>Views {{ number_format((int) ($creator['views'] ?? 0)) }}</span>
|
||||
@if ((int) ($creator['awards'] ?? 0) > 0)
|
||||
<span>Awards {{ number_format((int) ($creator['awards'] ?? 0)) }}</span>
|
||||
@endif
|
||||
</p>
|
||||
<span class="relative mt-3 inline-flex items-center justify-center rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition group-hover:bg-nova-600">
|
||||
View profile
|
||||
</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
28
resources/views/web/home/sections/cta.blade.php
Normal file
28
resources/views/web/home/sections/cta.blade.php
Normal file
@@ -0,0 +1,28 @@
|
||||
@php
|
||||
$uploadHref = !empty($isLoggedIn) ? '/upload' : '/login?redirect=/upload';
|
||||
@endphp
|
||||
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-br from-accent/20 via-nova-800 to-nova-900 px-8 py-12 text-center ring-1 ring-white/5">
|
||||
<div class="pointer-events-none absolute -right-12 -top-12 h-40 w-40 rounded-full bg-accent/10 blur-3xl"></div>
|
||||
<div class="pointer-events-none absolute -bottom-10 -left-10 h-32 w-32 rounded-full bg-sky-500/10 blur-2xl"></div>
|
||||
|
||||
<div class="relative z-10">
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-accent">Join the community</p>
|
||||
<h2 class="mt-2 text-2xl font-bold text-white sm:text-3xl">Ready to share your creativity?</h2>
|
||||
<p class="mx-auto mt-3 max-w-md text-sm text-nova-300">
|
||||
Upload your artworks, wallpapers, and skins to reach thousands of enthusiasts around the world.
|
||||
</p>
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<a href="{{ $uploadHref }}" class="btn-accent-solid rounded-xl px-6 py-2.5 text-sm font-semibold">
|
||||
Upload your artwork
|
||||
</a>
|
||||
@if (empty($isLoggedIn))
|
||||
<a href="/register" class="rounded-xl border border-white/10 bg-nova-700 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-nova-600">
|
||||
Create account
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
74
resources/views/web/home/sections/groups.blade.php
Normal file
74
resources/views/web/home/sections/groups.blade.php
Normal file
@@ -0,0 +1,74 @@
|
||||
@php
|
||||
$groupPool = collect([
|
||||
data_get($groups, 'spotlight'),
|
||||
...array_values(is_array(data_get($groups, 'featured')) ? data_get($groups, 'featured') : []),
|
||||
...array_values(is_array(data_get($groups, 'recruiting')) ? data_get($groups, 'recruiting') : []),
|
||||
...array_values(is_array(data_get($groups, 'rising')) ? data_get($groups, 'rising') : []),
|
||||
])->filter();
|
||||
|
||||
$groupItems = $groupPool->unique(fn ($group) => (int) ($group['id'] ?? 0))->take(4)->values();
|
||||
@endphp
|
||||
|
||||
@if ($groupItems->isNotEmpty())
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Group Spotlight</h2>
|
||||
<a href="/groups" class="text-sm text-nova-300 transition hover:text-white">All groups →</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@foreach ($groupItems as $group)
|
||||
@php
|
||||
$stats = collect([
|
||||
['key' => 'artworks', 'label' => 'artworks', 'value' => (int) data_get($group, 'counts.artworks', 0)],
|
||||
['key' => 'members', 'label' => 'members', 'value' => (int) data_get($group, 'counts.members', 0)],
|
||||
['key' => 'followers', 'label' => 'followers', 'value' => (int) data_get($group, 'counts.followers', 0)],
|
||||
])->filter(fn ($item) => $item['value'] > 0)->values();
|
||||
@endphp
|
||||
<article class="group relative flex flex-col overflow-hidden rounded-xl bg-panel p-5 shadow-sm transition hover:ring-1 hover:ring-nova-500">
|
||||
@if (!empty($group['banner_url']))
|
||||
<img src="{{ $group['banner_url'] }}" alt="" aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-40 transition duration-500 group-hover:scale-105 group-hover:opacity-20" loading="lazy" decoding="async">
|
||||
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/85 to-panel/70"></div>
|
||||
@endif
|
||||
|
||||
<a href="{{ data_get($group, 'urls.public', '/groups') }}" class="relative block">
|
||||
<div class="flex h-16 w-16 items-center justify-center overflow-hidden rounded-2xl bg-nova-800/80 ring-4 ring-nova-800">
|
||||
@if (!empty($group['avatar_url']))
|
||||
<img src="{{ $group['avatar_url'] }}" alt="" class="h-full w-full object-cover" loading="lazy" decoding="async">
|
||||
@else
|
||||
<span class="text-2xl text-white">G</span>
|
||||
@endif
|
||||
</div>
|
||||
<h3 class="mt-3 text-base font-semibold text-white">{{ $group['name'] ?? 'Group' }}</h3>
|
||||
</a>
|
||||
|
||||
<p class="relative mt-2 line-clamp-3 text-sm text-soft">{{ $group['headline'] ?? $group['bio_excerpt'] ?? 'Shared publishing identity for collaborative releases and artwork.' }}</p>
|
||||
|
||||
<div class="relative mt-3 flex flex-wrap gap-2 text-xs text-soft">
|
||||
@if (!empty($group['is_recruiting']))
|
||||
<span class="rounded-full bg-emerald-400/15 px-2.5 py-1 font-semibold text-emerald-200">Recruiting</span>
|
||||
@endif
|
||||
@if (!empty($group['is_verified']))
|
||||
<span class="rounded-full bg-sky-400/15 px-2.5 py-1 font-semibold text-sky-200">Verified</span>
|
||||
@endif
|
||||
@if (!empty(data_get($group, 'owner.username')) || !empty(data_get($group, 'owner.name')))
|
||||
<span>Led by {{ data_get($group, 'owner.username') ?: data_get($group, 'owner.name') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($stats->isNotEmpty())
|
||||
<div class="relative mt-4 flex flex-wrap gap-3 text-xs text-soft">
|
||||
@foreach ($stats as $stat)
|
||||
<span>{{ number_format($stat['value']) }} {{ $stat['label'] }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<a href="{{ data_get($group, 'urls.public', '/groups') }}" class="relative mt-4 inline-flex w-fit rounded-lg bg-nova-700 px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-nova-600">
|
||||
View Group
|
||||
</a>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
31
resources/views/web/home/sections/news.blade.php
Normal file
31
resources/views/web/home/sections/news.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
@php
|
||||
$newsItems = collect(is_array($items ?? null) ? $items : [])->filter()->take(6)->values();
|
||||
@endphp
|
||||
|
||||
@if ($newsItems->isNotEmpty())
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-bold text-white">News & Updates</h2>
|
||||
<a href="/news" class="text-sm text-nova-300 transition hover:text-white">All news</a>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-nova-800 overflow-hidden rounded-[24px] border border-white/10 bg-panel">
|
||||
@foreach ($newsItems as $item)
|
||||
<a href="{{ $item['url'] ?? '#' }}" class="grid gap-3 px-5 py-4 transition hover:bg-nova-800 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
|
||||
<div class="min-w-0">
|
||||
@if (!empty($item['eyebrow']))
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.16em] text-nova-300">{{ $item['eyebrow'] }}</div>
|
||||
@endif
|
||||
<div class="mt-1 line-clamp-2 text-sm font-medium text-white">{{ $item['title'] ?? 'News item' }}</div>
|
||||
@if (!empty($item['excerpt']))
|
||||
<p class="mt-2 line-clamp-2 text-sm leading-6 text-soft">{{ $item['excerpt'] }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@if (!empty($item['date']))
|
||||
<span class="shrink-0 text-xs text-soft">{{ $item['date'] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
@@ -0,0 +1,43 @@
|
||||
@php
|
||||
$creatorItems = collect(is_array($creators ?? null) ? $creators : [])->filter()->take(4)->values();
|
||||
$avatarFallback = 'https://files.skinbase.org/default/avatar_default.webp';
|
||||
@endphp
|
||||
|
||||
@if ($creatorItems->isNotEmpty())
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-white">Suggested Creators</h2>
|
||||
<p class="mt-0.5 text-xs text-nova-400">Creators you might enjoy following</p>
|
||||
</div>
|
||||
<a href="/creators/top" class="text-sm text-nova-300 transition hover:text-white">Explore all →</a>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4">
|
||||
@foreach ($creatorItems as $creator)
|
||||
<article class="group flex flex-col items-center rounded-2xl bg-nova-800/60 p-5 ring-1 ring-white/5 transition hover:bg-nova-800 hover:ring-white/10">
|
||||
<a href="{{ $creator['url'] ?? '#' }}" class="block">
|
||||
<img src="{{ $creator['avatar'] ?? $avatarFallback }}" alt="{{ $creator['name'] ?? 'Creator' }}" class="mx-auto h-14 w-14 rounded-full object-cover ring-2 ring-white/10 transition group-hover:ring-accent/50" loading="lazy" decoding="async">
|
||||
</a>
|
||||
<div class="mt-3 w-full text-center">
|
||||
<a href="{{ $creator['url'] ?? '#' }}" class="block truncate text-sm font-semibold text-white transition hover:text-accent">{{ $creator['name'] ?? 'Creator' }}</a>
|
||||
@if (!empty($creator['username']))
|
||||
<p class="truncate text-xs text-nova-400">@{{ $creator['username'] }}</p>
|
||||
@endif
|
||||
<div class="mt-2 flex items-center justify-center gap-3 text-xs text-nova-500">
|
||||
@if ((int) ($creator['followers_count'] ?? 0) > 0)
|
||||
<span>{{ number_format((int) ($creator['followers_count'] ?? 0)) }} followers</span>
|
||||
@endif
|
||||
@if ((int) ($creator['artworks_count'] ?? 0) > 0)
|
||||
<span>{{ number_format((int) ($creator['artworks_count'] ?? 0)) }} artworks</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ $creator['url'] ?? '#' }}" class="mt-4 w-full rounded-lg bg-nova-700 py-1.5 text-center text-xs font-medium text-white transition hover:bg-nova-600">
|
||||
View Profile
|
||||
</a>
|
||||
</article>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
20
resources/views/web/home/sections/tags.blade.php
Normal file
20
resources/views/web/home/sections/tags.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
@php
|
||||
$tagItems = collect(is_array($tags ?? null) ? $tags : [])->filter()->take(16)->values();
|
||||
@endphp
|
||||
|
||||
@if ($tagItems->isNotEmpty())
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="mb-5 text-xl font-bold text-white">Popular Tags</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach ($tagItems as $tag)
|
||||
<a href="/tag/{{ $tag['slug'] ?? '' }}" class="rounded-full bg-nova-800 px-4 py-1.5 text-sm font-medium text-nova-200 transition hover:bg-nova-700 hover:text-white">
|
||||
{{ $tag['name'] ?? 'Tag' }}
|
||||
@if ((int) ($tag['count'] ?? 0) > 0)
|
||||
<span class="ml-1.5 text-xs text-soft">{{ number_format((int) ($tag['count'] ?? 0)) }}</span>
|
||||
@endif
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
47
resources/views/web/home/sections/welcome-row.blade.php
Normal file
47
resources/views/web/home/sections/welcome-row.blade.php
Normal file
@@ -0,0 +1,47 @@
|
||||
@php
|
||||
$userData = is_array($userData ?? null) ? $userData : null;
|
||||
$avatarFallback = 'https://files.skinbase.org/default/avatar_default.webp';
|
||||
$firstName = trim((string) Str::of((string) ($userData['name'] ?? 'there'))->before(' '));
|
||||
@endphp
|
||||
|
||||
@if ($userData)
|
||||
<section class="border-b border-white/5 bg-nova-900/60 backdrop-blur-sm">
|
||||
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ $userData['url'] ?? '/profile' }}">
|
||||
<img
|
||||
src="{{ $userData['avatar'] ?? $avatarFallback }}"
|
||||
alt="{{ $userData['name'] ?? 'Member' }}"
|
||||
class="h-9 w-9 rounded-full object-cover ring-2 ring-white/10 transition hover:ring-accent/60"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
</a>
|
||||
<div>
|
||||
<p class="text-sm text-soft">Welcome back,</p>
|
||||
<p class="text-sm font-semibold leading-tight text-white">{{ $firstName !== '' ? $firstName : ($userData['name'] ?? 'there') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if ((int) ($userData['messages_unread'] ?? 0) > 0)
|
||||
<a href="/messages" class="inline-flex items-center gap-1.5 rounded-lg bg-nova-800 px-3 py-1.5 text-xs font-medium text-white ring-1 ring-white/10 transition hover:bg-nova-700">
|
||||
<span>{{ (int) $userData['messages_unread'] }} new</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if ((int) ($userData['notifications_unread'] ?? 0) > 0)
|
||||
<a href="/dashboard/notifications" class="inline-flex items-center gap-1.5 rounded-lg bg-nova-800 px-3 py-1.5 text-xs font-medium text-white ring-1 ring-white/10 transition hover:bg-nova-700">
|
||||
<span>{{ (int) $userData['notifications_unread'] }}</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
<a href="/upload" class="btn-accent-solid inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold">
|
||||
Upload
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
76
resources/views/web/home/sections/world-spotlight.blade.php
Normal file
76
resources/views/web/home/sections/world-spotlight.blade.php
Normal file
@@ -0,0 +1,76 @@
|
||||
@php
|
||||
$primaryWorld = is_array(data_get($world, 'primary')) ? data_get($world, 'primary') : (is_array($world ?? null) ? $world : null);
|
||||
$secondaryWorlds = collect(is_array(data_get($world, 'secondary')) ? data_get($world, 'secondary') : [])->take(3)->values();
|
||||
@endphp
|
||||
|
||||
@if ($primaryWorld)
|
||||
<section class="mt-14 px-4 sm:px-6 lg:px-8">
|
||||
<div class="overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(145deg,rgba(4,10,20,0.98),rgba(10,17,34,0.94))] shadow-[0_32px_110px_rgba(2,6,23,0.45)]">
|
||||
<div class="grid gap-0 lg:grid-cols-[minmax(0,1.2fr)_26rem]">
|
||||
<div class="relative min-h-[22rem] overflow-hidden">
|
||||
@if (!empty($primaryWorld['cover_url']))
|
||||
<img src="{{ $primaryWorld['cover_url'] }}" alt="{{ $primaryWorld['headline'] ?? $primaryWorld['title'] ?? 'World spotlight' }}" class="absolute inset-0 h-full w-full object-cover" loading="lazy" decoding="async">
|
||||
@endif
|
||||
<div class="absolute inset-0 bg-[linear-gradient(90deg,rgba(2,6,23,0.94),rgba(2,6,23,0.65),rgba(2,6,23,0.5))]"></div>
|
||||
<div class="relative z-10 flex h-full flex-col justify-end px-6 py-7 sm:px-8 lg:px-10">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="inline-flex rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">Homepage spotlight</span>
|
||||
@if (!empty($primaryWorld['campaign_label']))
|
||||
<span class="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/75">{{ $primaryWorld['campaign_label'] }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<h2 class="mt-4 max-w-2xl text-3xl font-semibold tracking-[-0.05em] text-white sm:text-4xl">{{ $primaryWorld['headline'] ?? $primaryWorld['title'] ?? 'World spotlight' }}</h2>
|
||||
@if (!empty($primaryWorld['summary']))
|
||||
<p class="mt-3 max-w-2xl text-sm leading-7 text-slate-200/85 sm:text-base">{{ $primaryWorld['summary'] }}</p>
|
||||
@endif
|
||||
<div class="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
|
||||
@if (!empty($primaryWorld['timeframe_label']))
|
||||
<span>{{ $primaryWorld['timeframe_label'] }}</span>
|
||||
@endif
|
||||
@if ((int) ($primaryWorld['live_submission_count'] ?? 0) > 0)
|
||||
<span>{{ number_format((int) ($primaryWorld['live_submission_count'] ?? 0)) }} live submissions</span>
|
||||
@endif
|
||||
@if ((int) ($primaryWorld['featured_submission_count'] ?? 0) > 0)
|
||||
<span>{{ number_format((int) ($primaryWorld['featured_submission_count'] ?? 0)) }} featured</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
<a href="{{ $primaryWorld['cta_url'] ?? $primaryWorld['public_url'] ?? '#' }}" class="inline-flex items-center justify-center rounded-full border border-sky-300/30 bg-sky-300/15 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:bg-sky-300/22">
|
||||
{{ $primaryWorld['cta_label'] ?? 'Explore world' }}
|
||||
</a>
|
||||
<a href="{{ data_get($world, 'index_url', '/worlds') }}" class="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white/85 transition hover:bg-white/[0.1]">
|
||||
More worlds
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-white/10 bg-black/20 lg:border-l lg:border-t-0">
|
||||
<div class="px-6 py-6 sm:px-8">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/45">More live worlds</p>
|
||||
<div class="mt-5 space-y-4">
|
||||
@foreach ($secondaryWorlds as $secondary)
|
||||
<a href="{{ $secondary['public_url'] ?? '#' }}" class="group block overflow-hidden rounded-2xl border border-white/8 bg-white/[0.03] p-4 transition hover:border-white/15 hover:bg-white/[0.05]">
|
||||
<div class="flex items-start gap-4">
|
||||
@if (!empty($secondary['cover_url']))
|
||||
<img src="{{ $secondary['cover_url'] }}" alt="{{ $secondary['title'] ?? 'World' }}" class="h-20 w-24 rounded-xl object-cover" loading="lazy" decoding="async">
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold text-white">{{ $secondary['teaser_title'] ?? $secondary['title'] ?? 'World' }}</div>
|
||||
@if (!empty($secondary['summary']))
|
||||
<p class="mt-1 line-clamp-2 text-xs leading-5 text-slate-300">{{ $secondary['summary'] }}</p>
|
||||
@endif
|
||||
@if (!empty($secondary['timeframe_label']))
|
||||
<div class="mt-2 text-[11px] uppercase tracking-[0.16em] text-sky-200/70">{{ $secondary['timeframe_label'] }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endif
|
||||
Reference in New Issue
Block a user