Allow heading tags (h1-h6) in ContentSanitizer so news editor headings render
This commit is contained in:
@@ -23,6 +23,7 @@ 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: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
|
||||
{ label: 'Web Stories', href: '/moderation/web-stories', icon: 'fa-solid fa-book-open-reader' },
|
||||
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
|
||||
|
||||
@@ -4,8 +4,90 @@ import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import { trackAcademySearchResultClick, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
|
||||
|
||||
function CourseCard({ course, variant = 'default', analytics = null, searchContext = null, position = null }) {
|
||||
const isFeatured = variant === 'featured'
|
||||
function Breadcrumbs({ items = [] }) {
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${item.label}-${index}`}>
|
||||
{isLast ? (
|
||||
<span className="text-white/80">{item.label}</span>
|
||||
) : (
|
||||
<Link href={item.href} className="transition hover:text-white">{item.label}</Link>
|
||||
)}
|
||||
{!isLast ? <span className="text-slate-600">/</span> : null}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAccessDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(parsed)
|
||||
}
|
||||
|
||||
function academyAccessHeading(access) {
|
||||
switch (access?.status) {
|
||||
case 'staff_access':
|
||||
return 'You currently have full staff access to the Academy.'
|
||||
case 'grace_period':
|
||||
return `${access.tierLabel} access is still active.`
|
||||
case 'trialing':
|
||||
return `${access.tierLabel} trial is active right now.`
|
||||
case 'active':
|
||||
return access?.hasPaidAccess ? `${access.tierLabel} access is active.` : 'Your Academy access is active.'
|
||||
case 'free':
|
||||
return 'You currently have Free access to the Academy.'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function academyAccessMeta(access) {
|
||||
if (!access?.signedIn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: 'Current tier', value: access?.tierLabel || 'Free' },
|
||||
{ label: 'Status', value: access?.statusLabel || 'Free access' },
|
||||
]
|
||||
|
||||
const formattedDate = formatAccessDate(access?.expiresAt)
|
||||
|
||||
if (formattedDate && access?.dateLabel) {
|
||||
items.push({ label: access.dateLabel, value: formattedDate })
|
||||
} else if (access?.renewsAutomatically) {
|
||||
items.push({ label: 'Billing', value: 'Renews automatically' })
|
||||
} else if (!access?.hasPaidAccess) {
|
||||
items.push({ label: 'Upgrade', value: 'Creator and Pro unlock premium lessons and courses' })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function CourseCard({ course, analytics = null, searchContext = null, position = null }) {
|
||||
const progress = course?.progress || null
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
const trackSearchClick = () => {
|
||||
@@ -29,48 +111,56 @@ function CourseCard({ course, variant = 'default', analytics = null, searchConte
|
||||
data-academy-search-query={searchContext?.query || undefined}
|
||||
data-academy-search-results-count={searchContext?.resultsCount || undefined}
|
||||
data-academy-search-position={position || undefined}
|
||||
className={[
|
||||
'group overflow-hidden rounded-[30px] border border-white/10 transition hover:border-sky-300/25 hover:bg-white/[0.06]',
|
||||
isFeatured ? 'bg-[linear-gradient(135deg,rgba(14,165,233,0.14),rgba(15,23,42,0.92))]' : 'bg-white/[0.04]',
|
||||
].join(' ')}
|
||||
className="group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-amber-200/24 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]"
|
||||
>
|
||||
<div className="relative">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className={`w-full object-cover ${isFeatured ? 'h-56' : 'h-44'}`} /> : <div className={`w-full bg-[linear-gradient(135deg,rgba(14,165,233,0.22),rgba(15,23,42,0.92))] ${isFeatured ? 'h-56' : 'h-44'}`} />}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
<div className="absolute left-5 top-5 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">{course.difficulty}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200">{course.access_level}</span>
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{course?.difficulty ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{course.difficulty}</span> : null}
|
||||
{course?.access_level ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{course.access_level}</span> : null}
|
||||
{course.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100">Featured</span> : null}
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{course?.lessons_count || 0} lessons</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{course?.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible pace'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<h2 className={`font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100 ${isFeatured ? 'text-3xl' : 'text-2xl'}`}>{course.title}</h2>
|
||||
{course.subtitle ? <p className="mt-2 text-sm font-medium uppercase tracking-[0.18em] text-slate-400">{course.subtitle}</p> : null}
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Structured Academy course.'}</p>
|
||||
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Lessons</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{course.lessons_count || 0}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Duration</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Progress</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{progress ? `${progress.percent}%` : 'Start fresh'}</p>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Academy course</p>
|
||||
{course?.subtitle ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{course.subtitle}</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-amber-50">{course.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Structured Academy course.'}</p>
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{progress ? `${progress.percent}% complete` : 'Start fresh'}{course?.access_level ? ` · ${course.access_level}` : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl, analytics }) {
|
||||
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl, lessonsUrl, promptLibraryUrl, academyAccess = null, analytics }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const breadcrumbs = [
|
||||
{ label: 'Academy', href: '/academy' },
|
||||
{ label: 'Courses', href: '/academy/courses' },
|
||||
]
|
||||
const visibleItems = Array.isArray(items?.data) ? items.data : []
|
||||
const totalCourses = Number(items?.total || items?.data?.length || 0)
|
||||
const featuredCount = featuredCourses.length
|
||||
const featuredCourse = featuredCourses.find((course) => course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image) || featuredCourses[0] || visibleItems[0] || null
|
||||
const featuredCover = featuredCourse?.cover_image_url || featuredCourse?.teaser_image_url || featuredCourse?.cover_image || featuredCourse?.teaser_image || ''
|
||||
const showSignedInAccess = Boolean(academyAccess?.signedIn)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = showSignedInAccess && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const primaryActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'See plans'
|
||||
const primaryActionIcon = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-sliders') : 'fa-solid fa-arrow-up-right-from-square'
|
||||
const primaryActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
const searchContext = analytics?.search ? {
|
||||
query: analytics.search.query,
|
||||
normalizedQuery: analytics.search.normalizedQuery,
|
||||
@@ -90,34 +180,137 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
|
||||
{ value: 'mixed', label: 'Mixed' },
|
||||
]
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'academy_courses_index_hero_primary' })
|
||||
}
|
||||
}
|
||||
|
||||
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,_#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-[1400px] space-y-6">
|
||||
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.96),rgba(14,165,233,0.12))] p-8 shadow-[0_24px_90px_rgba(2,6,23,0.36)] md:p-10 lg:p-12">
|
||||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||||
<div className="max-w-4xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">Skinbase AI Academy</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{title}</h1>
|
||||
<p className="mt-5 text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-amber-200/12 bg-[linear-gradient(155deg,rgba(251,191,36,0.14),rgba(15,23,42,0.96)_36%,rgba(14,165,233,0.14))] p-5 shadow-[0_32px_96px_rgba(2,6,23,0.32)] md:p-6 xl:p-7">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(253,230,138,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-12 h-36 w-36 rounded-full bg-amber-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-sky-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<Breadcrumbs items={breadcrumbs} />
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-200/18 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-50/90">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-200">Courses</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{totalCourses} guided paths</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[14ch] md:text-[4.2rem] xl:max-w-[15ch] xl:text-[4.5rem]">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-amber-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-route" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Library</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{totalCourses} guided courses</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Focus</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">Sequenced learning + tracked completion</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Structured progression</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Tracked completion</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Reusable lesson paths</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href={lessonsUrl || '/academy/lessons'} className="inline-flex items-center gap-2 rounded-full border border-amber-200/26 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-50 transition hover:border-amber-200/36 hover:bg-amber-300/18">
|
||||
<i className="fa-solid fa-book-open-reader text-xs" />
|
||||
Browse lessons
|
||||
</Link>
|
||||
<Link href={promptLibraryUrl || '/academy/prompts'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
|
||||
Prompt library
|
||||
</Link>
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/20 bg-sky-300/10 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:border-sky-200/30 hover:bg-sky-300/16">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Featured course</p>
|
||||
{featuredCourse?.difficulty ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80">{featuredCourse.difficulty}</span> : null}
|
||||
</div>
|
||||
<Link href={featuredCourse?.public_url || '#academy-courses-grid'} className="group mt-4 block overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] transition hover:border-amber-200/24 hover:bg-white/[0.06]">
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{featuredCover ? <img src={featuredCover} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.78))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{featuredCourse?.access_level ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{featuredCourse.access_level}</span> : null}
|
||||
{featuredCourse?.is_featured ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">Spotlight</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{featuredCourse?.subtitle || 'Guided learning path'}</p>
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white transition group-hover:text-amber-50">{featuredCourse?.title || 'Explore courses'}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{featuredCourse?.excerpt || featuredCourse?.description || 'Open a structured Academy course built from reusable lessons.'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2">
|
||||
<div className="rounded-[28px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 shadow-[0_18px_45px_rgba(2,6,23,0.2)] md:p-5 lg:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">{showSignedInAccess ? 'Your Academy access' : 'Upgrade for full access'}</p>
|
||||
<p className="mt-1 max-w-4xl text-lg font-semibold text-white md:text-[1.85rem] md:leading-[1.45]">{showSignedInAccess ? accessHeading : 'Unlock the full course library, premium lesson paths, and the broader Academy learning track.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showSignedInAccess ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:px-5 md:py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white md:text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/30 bg-sky-200/15 px-5 py-2.5 text-sm font-semibold text-sky-50 transition hover:bg-sky-200/22">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={lessonsUrl || '/academy/lessons'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-2.5 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-book-open-reader text-xs" />
|
||||
Browse lessons
|
||||
</Link>
|
||||
</div>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: 'academy_courses_index_hero' })} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See Academy plans</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}
|
||||
|
||||
{featuredCourses.length ? (
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
<CourseCard course={featuredCourses[0]} variant="featured" analytics={analytics} searchContext={searchContext} position={1} />
|
||||
<div className="grid gap-5">
|
||||
{featuredCourses.slice(1, 3).map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 2} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="grid gap-3 rounded-[30px] border border-white/10 bg-black/20 p-5 md:grid-cols-2">
|
||||
<NovaSelect
|
||||
label="Difficulty"
|
||||
@@ -137,11 +330,11 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
|
||||
/>
|
||||
</section>
|
||||
|
||||
{(items?.data || []).length === 0 ? (
|
||||
{visibleItems.length === 0 ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">No published Academy courses matched these filters.</section>
|
||||
) : (
|
||||
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.data.map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
||||
<section id="academy-courses-grid" className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleItems.map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -64,35 +64,35 @@ function LessonChip({ lesson }) {
|
||||
const isCompleted = Boolean(lesson?.completed)
|
||||
const readingMinutes = Number(lesson?.reading_minutes || 0)
|
||||
const ctaLabel = isCompleted ? 'Review lesson' : 'Open lesson'
|
||||
const difficultyLabel = lesson?.difficulty || 'lesson'
|
||||
const accessLabel = lesson?.access_level || 'free'
|
||||
const lessonTypeLabel = lesson?.lesson_type || 'article'
|
||||
const statusLabel = isCompleted ? 'Completed' : lesson?.is_required ? 'Required next' : 'Optional read'
|
||||
const supportCopy = isCompleted ? 'You already finished this lesson.' : lesson?.is_required ? 'Recommended as the next required step in this course.' : 'Optional depth you can take at your own pace.'
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={lesson.course_url || `/academy/lessons/${lesson.slug}`}
|
||||
className={[
|
||||
'group relative overflow-hidden rounded-[32px] border bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.64))] shadow-[0_24px_50px_rgba(2,6,23,0.2)] transition duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:shadow-[0_28px_70px_rgba(14,165,233,0.12)]',
|
||||
'group relative overflow-hidden rounded-[34px] border bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(15,23,42,0.78))] shadow-[0_24px_54px_rgba(2,6,23,0.22)] transition duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:shadow-[0_28px_74px_rgba(14,165,233,0.16)]',
|
||||
isCompleted ? 'border-emerald-300/25' : 'border-white/10',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.09),transparent_24%),linear-gradient(135deg,transparent,rgba(255,255,255,0.02))] opacity-70 transition duration-200 group-hover:opacity-100" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.12),transparent_24%),radial-gradient(circle_at_bottom_left,rgba(251,191,36,0.08),transparent_28%),linear-gradient(135deg,transparent,rgba(255,255,255,0.02))] opacity-80 transition duration-200 group-hover:opacity-100" />
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(125,211,252,0.42),transparent)]" />
|
||||
|
||||
<div className="relative grid gap-0 lg:grid-cols-[172px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-white/10 bg-slate-950 lg:border-b-0 lg:border-r">
|
||||
<div className="relative grid gap-0 lg:grid-cols-[188px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-white/10 bg-slate-950/90 lg:border-b-0 lg:border-r lg:border-white/10">
|
||||
{thumbnail ? (
|
||||
<img src={thumbnail} alt="" aria-hidden="true" className="h-40 w-full object-cover lg:h-full" />
|
||||
<img src={thumbnail} alt="" aria-hidden="true" className="h-44 w-full object-cover lg:h-full" />
|
||||
) : (
|
||||
<div className="h-40 w-full bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] lg:h-full" />
|
||||
<div className="h-44 w-full bg-[linear-gradient(135deg,rgba(56,189,248,0.2),rgba(15,23,42,0.96))] lg:h-full" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.84))]" />
|
||||
<div className="absolute inset-x-3 top-3 flex items-start justify-between gap-3">
|
||||
{lesson.is_required ? (
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/80 backdrop-blur">
|
||||
Required
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/65 backdrop-blur">
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.08),rgba(2,6,23,0.42)_42%,rgba(2,6,23,0.9))]" />
|
||||
<div className="absolute inset-x-4 top-4 flex items-start justify-between gap-3">
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] backdrop-blur ${lesson.is_required ? 'border-white/10 bg-black/40 text-white/82' : 'border-white/10 bg-black/30 text-white/62'}`}>
|
||||
{lesson.is_required ? 'Required' : 'Optional'}
|
||||
</span>
|
||||
{isCompleted ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/25 bg-emerald-300/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100 backdrop-blur">
|
||||
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
|
||||
@@ -103,50 +103,55 @@ function LessonChip({ lesson }) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="absolute inset-x-4 bottom-4 flex items-end justify-between gap-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/30 px-3 py-2 backdrop-blur-sm">
|
||||
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-sky-100/80">{stepLabel}</p> : null}
|
||||
{stepNumber > 0 ? <p className="mt-1 text-5xl font-semibold tracking-[-0.1em] text-white">{String(stepNumber).padStart(2, '0')}</p> : null}
|
||||
{!stepNumber && lesson.formatted_lesson_number ? <p className="mt-1 text-sm font-semibold uppercase tracking-[0.16em] text-white/80">{lesson.formatted_lesson_number}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 md:p-6">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_200px] xl:items-start">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_240px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100">{stepLabel}</p> : null}
|
||||
{lesson.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.formatted_lesson_number}</span> : null}
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.difficulty || 'lesson'}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.access_level || 'free'}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{difficultyLabel}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{accessLabel}</span>
|
||||
{readingMinutes > 0 ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{readingMinutes} min</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 max-w-3xl text-[1.65rem] font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100">{lesson.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{isCompleted ? 'You already finished this lesson.' : 'Follow this step next in the course path.'}</p>
|
||||
<p className="mt-2 text-sm text-slate-400">{supportCopy}</p>
|
||||
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<div className="mt-4 rounded-[26px] border border-white/10 bg-[linear-gradient(180deg,rgba(2,6,23,0.36),rgba(2,6,23,0.18))] px-4 py-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
||||
<p className="text-sm leading-7 text-slate-300">{lesson.excerpt || lesson.content_preview || 'Open this lesson inside the course.'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.lesson_type || 'article'}</span>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lessonTypeLabel}</span>
|
||||
{lesson.category_name ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.category_name}</span> : null}
|
||||
<span className="text-slate-500">Course flow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full flex-col justify-between gap-4 xl:border-l xl:border-white/10 xl:pl-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p className={`mt-2 text-sm font-semibold ${isCompleted ? 'text-emerald-100' : 'text-white'}`}>{isCompleted ? 'Completed' : 'Up next'}</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Access</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{lesson.access_level || 'Free'}</p>
|
||||
</div>
|
||||
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read time</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">{readingMinutes > 0 ? `${readingMinutes} min` : 'Quick read'}</p>
|
||||
<div className="flex h-full flex-col justify-between gap-4 xl:border-l xl:border-white/10 xl:pl-6">
|
||||
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-500">Lesson path</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-white/10 bg-black/20 px-3 py-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</span>
|
||||
<span className={`text-sm font-semibold ${isCompleted ? 'text-emerald-100' : 'text-white'}`}>{statusLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-white/10 bg-black/20 px-3 py-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Access</span>
|
||||
<span className="text-sm font-semibold text-white">{accessLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-white/10 bg-black/20 px-3 py-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read time</span>
|
||||
<span className="text-sm font-semibold text-white">{readingMinutes > 0 ? `${readingMinutes} min` : 'Quick read'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,26 +175,32 @@ function LessonChip({ lesson }) {
|
||||
function SectionBlock({ section, isActive = false }) {
|
||||
if (!section?.is_visible) return null
|
||||
|
||||
const lessonCount = section.lessons?.length || 0
|
||||
const requiredCount = (section.lessons || []).filter((lesson) => lesson?.is_required).length
|
||||
|
||||
return (
|
||||
<section className={`rounded-[32px] border p-6 transition md:p-7 ${isActive ? 'border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.04))] shadow-[0_22px_50px_rgba(14,165,233,0.08)]' : 'border-white/10 bg-white/[0.04]'}`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<section className={`relative overflow-hidden rounded-[34px] border p-6 transition md:p-7 ${isActive ? 'border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.04))] shadow-[0_24px_56px_rgba(14,165,233,0.08)]' : 'border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))]'}`}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.08),transparent_28%),linear-gradient(135deg,transparent,rgba(255,255,255,0.015))] opacity-80" />
|
||||
<div className="relative flex flex-wrap items-start justify-between gap-5">
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Course section</p>
|
||||
<span className={`rounded-full border px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${isActive ? 'border-sky-300/20 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300'}`}>
|
||||
{section.order_num + 1}
|
||||
</span>
|
||||
{requiredCount > 0 ? <span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">{requiredCount} required</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{section.title}</h2>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.05em] text-white md:text-[2rem]">{section.title}</h2>
|
||||
{section.description ? <p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{section.description}</p> : null}
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{lessonCount} lessons mapped in this section</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{section.lessons?.length || 0} lessons</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{lessonCount} lessons</span>
|
||||
{isActive ? <span className="rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Reading now</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-6">
|
||||
<div className="relative mt-6 space-y-6">
|
||||
{(section.lessons || []).map((lesson) => (
|
||||
<LessonChip key={lesson.course_lesson_id || lesson.id} lesson={lesson} />
|
||||
))}
|
||||
@@ -202,20 +213,24 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || ''
|
||||
const heroBackground = course?.teaser_image_url || course?.teaser_image || course?.cover_image_url || course?.cover_image || ''
|
||||
const progress = course?.progress || null
|
||||
const [liked, setLiked] = useState(Boolean(interaction?.liked))
|
||||
const [saved, setSaved] = useState(Boolean(interaction?.saved))
|
||||
const [likesCount, setLikesCount] = useState(Number(interaction?.likes_count || 0))
|
||||
const [savesCount, setSavesCount] = useState(Number(interaction?.saves_count || 0))
|
||||
const visibleSections = sections.filter((section) => section?.is_visible)
|
||||
const totalLessons = Number(course?.lessons_count || (unsectionedLessons.length + visibleSections.reduce((sum, section) => sum + (section.lessons || []).length, 0)))
|
||||
const totalSections = visibleSections.length + (unsectionedLessons.length ? 1 : 0)
|
||||
const estimatedMinutes = course?.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible pace'
|
||||
|
||||
const sectionJumpItems = useMemo(
|
||||
() => [
|
||||
...(unsectionedLessons.length ? [{ id: 'course-outline-core', label: 'Core lessons', count: unsectionedLessons.length }] : []),
|
||||
...sections
|
||||
.filter((section) => section?.is_visible)
|
||||
...visibleSections
|
||||
.map((section) => ({ id: `section-${section.id}`, label: section.title, count: (section.lessons || []).length })),
|
||||
],
|
||||
[sections, unsectionedLessons],
|
||||
[unsectionedLessons, visibleSections],
|
||||
)
|
||||
|
||||
const [activeJumpId, setActiveJumpId] = useState(sectionJumpItems[0]?.id || null)
|
||||
@@ -316,69 +331,95 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
{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}
|
||||
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(2,6,23,0.34)]">
|
||||
<div className="grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<div className="relative overflow-hidden p-6 md:p-8 lg:p-10 xl:p-12">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-[0.18]" /> : null}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(125,211,252,0.18),_transparent_28%),radial-gradient(circle_at_78%_26%,_rgba(251,191,36,0.12),_transparent_20%),linear-gradient(135deg,_rgba(2,6,23,0.98),_rgba(15,23,42,0.85))]" />
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-sky-200/12 bg-[linear-gradient(145deg,rgba(14,165,233,0.16),rgba(15,23,42,0.95)_38%,rgba(251,191,36,0.14))] shadow-[0_24px_90px_rgba(2,6,23,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(125,211,252,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-70" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-sky-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-amber-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-6 p-5 md:p-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:p-7">
|
||||
<div className="min-w-0">
|
||||
{heroBackground ? <img src={heroBackground} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-[0.08]" /> : null}
|
||||
<div className="relative z-10 max-w-5xl">
|
||||
<CourseBreadcrumbs items={breadcrumbs} />
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2.5">
|
||||
<span className="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">Academy course</span>
|
||||
<span className="rounded-full border border-sky-200/18 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-50/90">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-200">Course path</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.difficulty}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.access_level}</span>
|
||||
{progress?.percent ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100">{progress.percent}% complete</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h1 className="text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.75rem]">{course?.title}</h1>
|
||||
{course?.subtitle ? <p className="mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{course?.excerpt || course?.description}</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={startCourse} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{progress?.percent ? 'Continue course' : 'Start course'}</button>
|
||||
<button type="button" onClick={toggleLike} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`}</button>
|
||||
<button type="button" onClick={toggleSave} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`}</button>
|
||||
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: 'academy_course_header' })} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See plans</Link>
|
||||
<div className="mt-5 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
{course?.subtitle ? <p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.9rem]">{course?.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95 md:text-lg">{course?.excerpt || course?.description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-sky-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-route" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/80 shadow-[0_24px_60px_rgba(2,6,23,0.32)]">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" aria-hidden="true" className="w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] px-6 text-center text-sm text-slate-400">
|
||||
No course cover image yet
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={startCourse} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{progress?.percent ? 'Continue course' : 'Start course'}</button>
|
||||
<button type="button" onClick={toggleLike} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`}</button>
|
||||
<button type="button" onClick={toggleSave} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`}</button>
|
||||
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: 'academy_course_header' })} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See plans</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Library</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{totalLessons} lessons</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Structure</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{totalSections} sections</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Pace</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{estimatedMinutes}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-4 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/75">Status</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{progress?.percent ? `${progress.percent}% complete` : 'Ready to start'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-white/10 bg-white/[0.03] p-6 xl:border-l xl:border-t-0 xl:p-8">
|
||||
<div className="space-y-4 xl:sticky xl:top-6">
|
||||
<ProgressMeter progress={progress} />
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Jump through the course</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{sectionJumpItems.length ? (
|
||||
sectionJumpItems.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
onClick={() => setActiveJumpId(item.id)}
|
||||
className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${activeJumpId === item.id ? 'border-sky-300/25 bg-sky-300/12 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20 hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">{item.count}</span>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">No course outline items are available yet.</p>
|
||||
)}
|
||||
<aside className="grid gap-4 self-start xl:pt-2">
|
||||
<div className="overflow-hidden rounded-[30px] border border-white/10 bg-black/20 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
{cover ? (
|
||||
<img src={cover} alt={course?.title} className="h-56 w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-56 items-center justify-center bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] px-6 text-center text-sm text-slate-400">
|
||||
No course cover image yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProgressMeter progress={progress} />
|
||||
|
||||
<div className="rounded-[30px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Jump through the course</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{sectionJumpItems.length ? (
|
||||
sectionJumpItems.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
onClick={() => setActiveJumpId(item.id)}
|
||||
className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${activeJumpId === item.id ? 'border-sky-300/25 bg-sky-300/12 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20 hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">{item.count}</span>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">No course outline items are available yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -7,22 +7,110 @@ function academyHref(section, slug) {
|
||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function FeatureCard({ title, description, href, cta }) {
|
||||
function formatStatValue(value, singular, plural = `${singular}s`) {
|
||||
const numericValue = Number(value || 0)
|
||||
return `${numericValue.toLocaleString()} ${numericValue === 1 ? singular : plural}`
|
||||
}
|
||||
|
||||
function FeatureCard({ title, description, href, cta, icon, eyebrow, highlights = [], tags = [], meta, theme }) {
|
||||
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 href={href} className={`group relative overflow-hidden rounded-[32px] border p-6 shadow-[0_24px_80px_rgba(2,6,23,0.22)] transition hover:-translate-y-1 hover:shadow-[0_30px_95px_rgba(2,6,23,0.32)] ${theme.shell}`}>
|
||||
<div className={`absolute inset-0 ${theme.backdrop}`} />
|
||||
<div className={`absolute inset-0 opacity-60 ${theme.pattern}`} />
|
||||
<div className={`absolute -right-14 top-6 h-32 w-32 rounded-full blur-3xl ${theme.glow}`} />
|
||||
<div className="relative flex min-h-[290px] flex-col">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className={`text-[11px] font-semibold uppercase tracking-[0.24em] ${theme.eyebrow}`}>{eyebrow}</p>
|
||||
<h2 className="mt-4 text-[2rem] font-semibold tracking-[-0.05em] text-white">{title}</h2>
|
||||
</div>
|
||||
<span className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-[18px] border text-lg shadow-[0_14px_34px_rgba(2,6,23,0.28)] transition group-hover:scale-105 ${theme.iconWrap}`}>
|
||||
<i className={icon} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 max-w-[34ch] text-sm leading-7 text-slate-200/95">{description}</p>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{highlights.map((item) => (
|
||||
<div key={`${title}-${item.label}`} className={`rounded-[22px] border px-4 py-3 backdrop-blur-sm ${theme.highlightCard}`}>
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.highlightLabel}`}>{item.label}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span key={`${title}-${tag}`} className={`rounded-full border px-3 py-1 text-[11px] font-semibold tracking-[0.12em] ${theme.tag}`}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-4 pt-6">
|
||||
<span className={`inline-flex rounded-full border px-4 py-2 text-sm font-semibold transition group-hover:translate-x-1 ${theme.cta}`}>{cta}</span>
|
||||
<span className={`text-right text-[11px] font-semibold uppercase tracking-[0.2em] ${theme.meta}`}>{meta}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureRailCard({ eyebrow, title, description, icon, items = [], emptyText, actionHref = null, actionLabel = null, theme, renderItem }) {
|
||||
return (
|
||||
<section className={`relative overflow-hidden rounded-[30px] border p-6 shadow-[0_22px_70px_rgba(2,6,23,0.26)] ${theme.shell}`}>
|
||||
<div className={`absolute inset-0 ${theme.backdrop}`} />
|
||||
<div className={`absolute inset-0 opacity-60 ${theme.pattern}`} />
|
||||
<div className={`absolute right-0 top-0 h-28 w-28 translate-x-8 -translate-y-6 rounded-full blur-3xl ${theme.glow}`} />
|
||||
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="max-w-[30ch]">
|
||||
<p className={`text-[11px] font-semibold uppercase tracking-[0.24em] ${theme.eyebrow}`}>{eyebrow}</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<span className={`flex h-11 w-11 items-center justify-center rounded-[16px] border text-sm ${theme.iconWrap}`}>
|
||||
<i className={icon} />
|
||||
</span>
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.04em] text-white">{title}</h3>
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-200/92">{description}</p>
|
||||
</div>
|
||||
|
||||
{actionHref && actionLabel ? (
|
||||
<Link href={actionHref} className={`inline-flex shrink-0 rounded-full border px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] transition ${theme.action}`}>
|
||||
{actionLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{items.length > 0 ? items.map((item, index) => renderItem(item, index)) : (
|
||||
<div className={`rounded-[22px] border px-4 py-4 text-sm ${theme.empty}`}>
|
||||
{emptyText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, accent }) {
|
||||
return (
|
||||
<div className={`rounded-[24px] border px-5 py-5 backdrop-blur-sm ${accent.shell}`}>
|
||||
<p className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${accent.label}`}>{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white md:text-[2.4rem]">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturedCourseCard({ course }) {
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
|
||||
return (
|
||||
<Link href={course.public_url} className="group overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.04] transition hover:border-sky-300/25 hover:bg-white/[0.06]">
|
||||
<Link href={course.public_url} className="group relative overflow-hidden rounded-[28px] border border-sky-200/12 bg-[linear-gradient(180deg,rgba(15,23,42,0.9),rgba(15,23,42,0.72))] transition hover:-translate-y-1 hover:border-sky-300/24 hover:shadow-[0_24px_72px_rgba(2,6,23,0.3)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.14),transparent_24%),linear-gradient(135deg,transparent_0%,transparent_48%,rgba(125,211,252,0.05)_48%,rgba(125,211,252,0.05)_52%,transparent_52%,transparent_100%)] opacity-80" />
|
||||
<div className="relative h-44 overflow-hidden bg-[linear-gradient(135deg,rgba(14,165,233,0.24),rgba(15,23,42,0.92))]">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
@@ -31,17 +119,316 @@ function FeaturedCourseCard({ course }) {
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">{course.access_level}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="relative p-5">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-sky-100/75">Guided course</p>
|
||||
<h3 className="text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{course.title}</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Guided Academy course.'}</p>
|
||||
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{course.lessons_count || 0} lessons · {course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible duration'}</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">{course.lessons_count || 0} lessons · {course.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible duration'}</p>
|
||||
<span className="inline-flex rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100 transition group-hover:translate-x-1">Open path</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges, analytics }) {
|
||||
function formatAccessDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(parsed)
|
||||
}
|
||||
|
||||
function academyAccessHeading(access) {
|
||||
switch (access?.status) {
|
||||
case 'staff_access':
|
||||
return 'You currently have full staff access to the Academy.'
|
||||
case 'grace_period':
|
||||
return `${access.tierLabel} access is still active.`
|
||||
case 'trialing':
|
||||
return `${access.tierLabel} trial is active right now.`
|
||||
case 'active':
|
||||
return access?.hasPaidAccess ? `${access.tierLabel} access is active.` : 'Your Academy access is active.'
|
||||
case 'free':
|
||||
return 'You currently have Free access to the Academy.'
|
||||
default:
|
||||
return 'Preview the Academy before you upgrade.'
|
||||
}
|
||||
}
|
||||
|
||||
function academyAccessMeta(access) {
|
||||
const items = [
|
||||
{ label: 'Current tier', value: access?.tierLabel || 'Guest' },
|
||||
{ label: 'Status', value: access?.statusLabel || 'Preview access only' },
|
||||
]
|
||||
|
||||
const formattedDate = formatAccessDate(access?.expiresAt)
|
||||
|
||||
if (formattedDate && access?.dateLabel) {
|
||||
items.push({ label: access.dateLabel, value: formattedDate })
|
||||
} else if (access?.renewsAutomatically) {
|
||||
items.push({ label: 'Billing', value: 'Renews automatically' })
|
||||
} else if (access?.signedIn && !access?.hasPaidAccess) {
|
||||
items.push({ label: 'Upgrade', value: 'Creator and Pro unlock premium workflows' })
|
||||
} else if (!access?.signedIn) {
|
||||
items.push({ label: 'Upgrade', value: 'Sign in to track access and unlock premium content' })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, academyAccess = null, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges, analytics }) {
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = academyAccess?.signedIn && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const accessActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'See plans'
|
||||
const accessActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
const academySections = [
|
||||
{
|
||||
title: 'Courses',
|
||||
description: 'Follow guided learning paths that stitch reusable Academy lessons into a clean progression with completion tracking.',
|
||||
href: links.courses,
|
||||
cta: 'Browse courses',
|
||||
icon: 'fa-solid fa-route',
|
||||
eyebrow: 'Academy paths',
|
||||
highlights: [
|
||||
{ label: 'Library', value: formatStatValue(stats?.courseCount, 'course') },
|
||||
{ label: 'Includes', value: formatStatValue(stats?.lessonCount, 'lesson') },
|
||||
],
|
||||
tags: ['Progress tracked', 'Learning paths', 'Skill ladders'],
|
||||
meta: 'Structured progression',
|
||||
theme: {
|
||||
shell: 'border-sky-300/18 bg-slate-950/40 hover:border-sky-300/30',
|
||||
backdrop: 'bg-[linear-gradient(145deg,rgba(14,165,233,0.16),rgba(15,23,42,0.95)_42%,rgba(16,185,129,0.18))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.16),transparent_30%),linear-gradient(125deg,transparent_0%,transparent_45%,rgba(125,211,252,0.08)_45%,rgba(125,211,252,0.08)_52%,transparent_52%,transparent_100%)]',
|
||||
glow: 'bg-sky-300/25',
|
||||
eyebrow: 'text-sky-100/80',
|
||||
iconWrap: 'border-sky-200/20 bg-sky-300/12 text-sky-100',
|
||||
highlightCard: 'border-sky-200/12 bg-slate-950/40',
|
||||
highlightLabel: 'text-sky-100/75',
|
||||
tag: 'border-sky-200/12 bg-sky-300/10 text-sky-100',
|
||||
cta: 'border-sky-300/25 bg-sky-300/12 text-sky-100',
|
||||
meta: 'text-sky-100/75',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Lessons',
|
||||
description: 'Structured tutorials for prompt writing, cleanup workflows, AI ethics, and Skinbase-native publishing habits.',
|
||||
href: links.lessons,
|
||||
cta: 'Open lessons',
|
||||
icon: 'fa-solid fa-book-open-reader',
|
||||
eyebrow: 'Focused tutorials',
|
||||
highlights: [
|
||||
{ label: 'Depth', value: formatStatValue(stats?.lessonCount, 'lesson') },
|
||||
{ label: 'Coverage', value: 'Prompt craft + workflow cleanup' },
|
||||
],
|
||||
tags: ['Short wins', 'Creative habits', 'Practical steps'],
|
||||
meta: 'Skill-by-skill learning',
|
||||
theme: {
|
||||
shell: 'border-amber-300/18 bg-slate-950/40 hover:border-amber-300/30',
|
||||
backdrop: 'bg-[linear-gradient(160deg,rgba(251,191,36,0.18),rgba(15,23,42,0.95)_40%,rgba(249,115,22,0.14))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_right,rgba(253,230,138,0.14),transparent_28%),linear-gradient(180deg,transparent_0%,transparent_54%,rgba(251,191,36,0.08)_54%,rgba(251,191,36,0.08)_58%,transparent_58%,transparent_100%)]',
|
||||
glow: 'bg-amber-300/20',
|
||||
eyebrow: 'text-amber-100/85',
|
||||
iconWrap: 'border-amber-200/20 bg-amber-300/12 text-amber-100',
|
||||
highlightCard: 'border-amber-200/12 bg-slate-950/42',
|
||||
highlightLabel: 'text-amber-100/75',
|
||||
tag: 'border-amber-200/12 bg-amber-300/10 text-amber-100',
|
||||
cta: 'border-amber-300/25 bg-amber-300/12 text-amber-100',
|
||||
meta: 'text-amber-100/75',
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Prompt Library',
|
||||
description: 'Discover reusable prompt templates, locked premium previews, and creator-focused visual workflows.',
|
||||
href: links.prompts,
|
||||
cta: 'Explore prompts',
|
||||
icon: 'fa-solid fa-wand-magic-sparkles',
|
||||
eyebrow: 'Reusable prompt kits',
|
||||
highlights: [
|
||||
{ label: 'Templates', value: formatStatValue(stats?.promptCount, 'prompt') },
|
||||
{ label: 'Use case', value: 'Reusable systems + premium previews' },
|
||||
],
|
||||
tags: ['Fast starts', 'Visual workflows', 'Copy + adapt'],
|
||||
meta: 'High-speed ideation',
|
||||
theme: {
|
||||
shell: 'border-rose-300/18 bg-slate-950/40 hover:border-rose-300/30',
|
||||
backdrop: 'bg-[linear-gradient(150deg,rgba(244,63,94,0.16),rgba(15,23,42,0.95)_38%,rgba(45,212,191,0.16))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_20%_15%,rgba(251,113,133,0.16),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,22px_22px,22px_22px]',
|
||||
glow: 'bg-rose-300/20',
|
||||
eyebrow: 'text-rose-100/85',
|
||||
iconWrap: 'border-rose-200/20 bg-rose-300/12 text-rose-100',
|
||||
highlightCard: 'border-rose-200/12 bg-slate-950/42',
|
||||
highlightLabel: 'text-rose-100/75',
|
||||
tag: 'border-rose-200/12 bg-rose-300/10 text-rose-100',
|
||||
cta: 'border-rose-300/25 bg-rose-300/12 text-rose-100',
|
||||
meta: 'text-rose-100/75',
|
||||
},
|
||||
},
|
||||
]
|
||||
const academyFeatureRails = [
|
||||
{
|
||||
key: 'lessons',
|
||||
eyebrow: 'Featured lessons',
|
||||
title: 'Jump-in tutorials',
|
||||
description: 'Shorter Academy pieces for specific prompt problems, cleanup workflows, and publishing habits.',
|
||||
icon: 'fa-solid fa-book-open-reader',
|
||||
actionHref: links.lessons,
|
||||
actionLabel: 'All lessons',
|
||||
items: (featuredLessons || []).slice(0, 3),
|
||||
emptyText: 'Featured lessons will appear here when the Academy team highlights a new tutorial.',
|
||||
theme: {
|
||||
shell: 'border-amber-300/16 bg-slate-950/45',
|
||||
backdrop: 'bg-[linear-gradient(160deg,rgba(251,191,36,0.15),rgba(15,23,42,0.96)_42%,rgba(249,115,22,0.14))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_right,rgba(253,230,138,0.12),transparent_24%),linear-gradient(180deg,transparent_0%,transparent_52%,rgba(251,191,36,0.08)_52%,rgba(251,191,36,0.08)_56%,transparent_56%,transparent_100%)]',
|
||||
glow: 'bg-amber-300/18',
|
||||
eyebrow: 'text-amber-100/82',
|
||||
iconWrap: 'border-amber-200/20 bg-amber-300/12 text-amber-100',
|
||||
action: 'border-amber-300/22 bg-amber-300/10 text-amber-100 hover:border-amber-300/34 hover:bg-amber-300/16',
|
||||
item: 'border-amber-200/10 bg-slate-950/38 hover:border-amber-200/18 hover:bg-slate-950/52',
|
||||
itemEyebrow: 'text-amber-100/75',
|
||||
itemMeta: 'text-amber-100/70',
|
||||
empty: 'border-amber-200/10 bg-slate-950/30 text-amber-50/80',
|
||||
},
|
||||
renderItem: (item, index, theme) => (
|
||||
<Link key={item.id} href={academyHref('lessons', item.slug)} className={`group block rounded-[22px] border px-4 py-4 transition ${theme.item}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold ${theme.iconWrap}`}>{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<span className={`block text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.itemEyebrow}`}>{item.lesson_label || 'Featured lesson'}</span>
|
||||
<span className="mt-2 block text-sm font-semibold text-white transition group-hover:text-amber-50">{item.title}</span>
|
||||
<span className={`mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] ${theme.itemMeta}`}>Practical tutorial</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'prompts',
|
||||
eyebrow: 'Featured prompts',
|
||||
title: 'Reusable prompt packs',
|
||||
description: 'Template-driven prompt entries designed for fast reuse, remixing, and premium workflow previews.',
|
||||
icon: 'fa-solid fa-wand-magic-sparkles',
|
||||
actionHref: links.promptPopular,
|
||||
actionLabel: 'Top prompts',
|
||||
items: (featuredPrompts || []).slice(0, 3),
|
||||
emptyText: 'Featured prompts will appear here when reusable prompt templates are promoted on the homepage.',
|
||||
theme: {
|
||||
shell: 'border-rose-300/16 bg-slate-950/45',
|
||||
backdrop: 'bg-[linear-gradient(155deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_40%,rgba(45,212,191,0.14))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_20%_18%,rgba(251,113,133,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,22px_22px,22px_22px]',
|
||||
glow: 'bg-rose-300/18',
|
||||
eyebrow: 'text-rose-100/82',
|
||||
iconWrap: 'border-rose-200/20 bg-rose-300/12 text-rose-100',
|
||||
action: 'border-rose-300/22 bg-rose-300/10 text-rose-100 hover:border-rose-300/34 hover:bg-rose-300/16',
|
||||
item: 'border-rose-200/10 bg-slate-950/38 hover:border-rose-200/18 hover:bg-slate-950/52',
|
||||
itemEyebrow: 'text-rose-100/75',
|
||||
itemMeta: 'text-rose-100/70',
|
||||
empty: 'border-rose-200/10 bg-slate-950/30 text-rose-50/80',
|
||||
},
|
||||
renderItem: (item, index, theme) => (
|
||||
<Link key={item.id} href={academyHref('prompts', item.slug)} className={`group block rounded-[22px] border px-4 py-4 transition ${theme.item}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<span className={`block text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.itemEyebrow}`}>Prompt template #{index + 1}</span>
|
||||
<span className="mt-2 block text-sm font-semibold text-white transition group-hover:text-rose-50">{item.title}</span>
|
||||
</div>
|
||||
<span className={`rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${theme.iconWrap}`}>Template</span>
|
||||
</div>
|
||||
<span className={`mt-3 block text-[11px] font-semibold uppercase tracking-[0.16em] ${theme.itemMeta}`}>Reusable workflow</span>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'challenges',
|
||||
eyebrow: 'Current challenges',
|
||||
title: 'Build around a brief',
|
||||
description: 'Academy challenges turn lessons and prompt systems into practical output with a clear creative objective.',
|
||||
icon: 'fa-solid fa-trophy',
|
||||
items: (featuredChallenges || []).slice(0, 3),
|
||||
emptyText: 'Current challenges will appear here when the Academy team launches a new guided brief.',
|
||||
theme: {
|
||||
shell: 'border-emerald-300/16 bg-slate-950/45',
|
||||
backdrop: 'bg-[linear-gradient(155deg,rgba(16,185,129,0.14),rgba(15,23,42,0.96)_42%,rgba(56,189,248,0.12))]',
|
||||
pattern: 'bg-[radial-gradient(circle_at_top_left,rgba(110,231,183,0.14),transparent_24%),linear-gradient(135deg,transparent_0%,transparent_48%,rgba(16,185,129,0.08)_48%,rgba(16,185,129,0.08)_56%,transparent_56%,transparent_100%)]',
|
||||
glow: 'bg-emerald-300/18',
|
||||
eyebrow: 'text-emerald-100/82',
|
||||
iconWrap: 'border-emerald-200/20 bg-emerald-300/12 text-emerald-100',
|
||||
action: 'border-emerald-300/22 bg-emerald-300/10 text-emerald-100 hover:border-emerald-300/34 hover:bg-emerald-300/16',
|
||||
item: 'border-emerald-200/10 bg-slate-950/38 hover:border-emerald-200/18 hover:bg-slate-950/52',
|
||||
itemEyebrow: 'text-emerald-100/75',
|
||||
itemMeta: 'text-emerald-100/70',
|
||||
empty: 'border-emerald-200/10 bg-slate-950/30 text-emerald-50/80',
|
||||
},
|
||||
renderItem: (item, index, theme) => (
|
||||
<Link key={item.id} href={academyHref('challenges', item.slug)} className={`group block rounded-[22px] border px-4 py-4 transition ${theme.item}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 flex h-8 min-w-8 items-center justify-center rounded-full border px-2 text-[10px] font-semibold ${theme.iconWrap}`}>#{index + 1}</span>
|
||||
<div className="min-w-0">
|
||||
<span className={`block text-[10px] font-semibold uppercase tracking-[0.18em] ${theme.itemEyebrow}`}>Active brief</span>
|
||||
<span className="mt-2 block text-sm font-semibold text-white transition group-hover:text-emerald-50">{item.title}</span>
|
||||
<span className={`mt-2 block text-[11px] font-semibold uppercase tracking-[0.16em] ${theme.itemMeta}`}>Apply what you learned</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
]
|
||||
const handleAccessAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'academy_home_hero' })
|
||||
}
|
||||
}
|
||||
const academyMetrics = [
|
||||
{
|
||||
key: 'courses',
|
||||
label: 'Courses',
|
||||
value: stats?.courseCount || 0,
|
||||
accent: {
|
||||
shell: 'border-sky-300/14 bg-sky-300/[0.08]',
|
||||
label: 'text-sky-100/78',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'lessons',
|
||||
label: 'Lessons',
|
||||
value: stats?.lessonCount || 0,
|
||||
accent: {
|
||||
shell: 'border-amber-300/14 bg-amber-300/[0.08]',
|
||||
label: 'text-amber-100/78',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'prompts',
|
||||
label: 'Prompts',
|
||||
value: stats?.promptCount || 0,
|
||||
accent: {
|
||||
shell: 'border-rose-300/14 bg-rose-300/[0.08]',
|
||||
label: 'text-rose-100/78',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'challenges',
|
||||
label: 'Challenges',
|
||||
value: stats?.challengeCount || 0,
|
||||
accent: {
|
||||
shell: 'border-emerald-300/14 bg-emerald-300/[0.08]',
|
||||
label: 'text-emerald-100/78',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const jsonLd = [{
|
||||
'@context': 'https://schema.org',
|
||||
@@ -52,68 +439,115 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta
|
||||
}]
|
||||
|
||||
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">
|
||||
<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-6 sm:px-6 sm:py-8 lg:px-8 lg:py-10">
|
||||
<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 className="mx-auto max-w-[1440px] space-y-6 md:space-y-8 xl:space-y-10">
|
||||
<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-6 shadow-[0_32px_100px_rgba(2,6,23,0.38)] md:p-7 lg:p-8">
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_300px] xl:items-center">
|
||||
<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>
|
||||
<h1 className="mt-3 max-w-[15ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[16ch] md:text-5xl xl:max-w-[19ch] xl:text-[3.2rem]">Learn how to turn prompts into wallpapers, digital art, skins, covers, and visual worlds.</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-7 text-slate-300 md:text-lg md:leading-8">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">
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<Link href={links.courses} 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">Browse courses</Link>
|
||||
<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={links.promptPopular} className="rounded-full border border-rose-300/25 bg-rose-300/12 px-5 py-3 text-sm font-semibold text-rose-100 transition hover:border-rose-300/40 hover:bg-rose-300/18">Top prompts</Link>
|
||||
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: 'academy_home_hero' })} 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 className="rounded-[30px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 md:p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Your Academy access</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{accessHeading}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
||||
<span className="text-slate-300">{item.label}</span>
|
||||
<span className="font-semibold text-white">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link href={accessActionHref} onClick={handleAccessAction} className="mt-4 inline-flex 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">{accessActionLabel}</Link>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
<FeatureCard title="Courses" description="Follow guided learning paths that stitch reusable Academy lessons into a clean progression with completion tracking." href={links.courses} cta="Browse courses" />
|
||||
<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" />
|
||||
<section className="space-y-4 md:space-y-5">
|
||||
<div className="flex flex-col gap-3 rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.6),rgba(15,23,42,0.22))] px-5 py-4 shadow-[0_16px_48px_rgba(2,6,23,0.18)] md:flex-row md:items-end md:justify-between md:px-6">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100/75">Choose your Academy lane</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.045em] text-white md:text-[2rem]">Start with the format that matches how you learn.</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">
|
||||
<span className="h-px flex-1 bg-gradient-to-r from-transparent via-sky-300/35 to-transparent md:min-w-24" />
|
||||
<span>Courses, lessons, prompts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-3">
|
||||
{academySections.map((section) => (
|
||||
<FeatureCard key={section.title} {...section} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-4">
|
||||
<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">Courses</p><p className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white">{stats?.courseCount || 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">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 className="overflow-hidden rounded-[34px] border border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.82),rgba(15,23,42,0.58)),radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_30%),radial-gradient(circle_at_bottom_right,rgba(251,191,36,0.12),transparent_28%)] p-3 shadow-[0_18px_56px_rgba(2,6,23,0.24)] sm:p-4">
|
||||
<div className="grid gap-3 lg:grid-cols-4">
|
||||
{academyMetrics.map((metric) => (
|
||||
<MetricCard key={metric.key} label={metric.label} value={metric.value} accent={metric.accent} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{featuredCourses?.length ? (
|
||||
<section className="space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Featured courses</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.045em] text-white">Guided Academy paths</h2>
|
||||
<section className="relative overflow-hidden rounded-[36px] border border-sky-200/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.82),rgba(3,7,18,0.94)),radial-gradient(circle_at_top_left,rgba(14,165,233,0.14),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(16,185,129,0.1),transparent_30%)] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.26)] md:p-6 lg:p-7">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(90deg,rgba(255,255,255,0.03)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[length:28px_28px] opacity-25" />
|
||||
<div className="relative space-y-4 md:space-y-5">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4 rounded-[28px] border border-white/10 bg-black/15 px-5 py-4 backdrop-blur-sm">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100/78">Featured courses</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.045em] text-white">Guided Academy paths</h2>
|
||||
<p className="mt-3 max-w-[54ch] text-sm leading-7 text-slate-300">Longer learning paths for people who want a clearer start-to-finish route instead of individual tutorials or standalone prompt templates.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-full border border-sky-300/18 bg-sky-300/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/85">
|
||||
{formatStatValue(featuredCourses.length, 'featured path')}
|
||||
</div>
|
||||
<Link href={links.courses} 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.08]">All courses</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
{featuredCourses.slice(0, 3).map((course) => <FeaturedCourseCard key={course.id} course={course} />)}
|
||||
</div>
|
||||
<Link href={links.courses} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">All courses</Link>
|
||||
</div>
|
||||
<div className="grid gap-5 xl:grid-cols-3">
|
||||
{featuredCourses.slice(0, 3).map((course) => <FeaturedCourseCard key={course.id} course={course} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<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"><span className="block text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{item.lesson_label || 'Featured lesson'}</span><span className="mt-1 block">{item.title}</span></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 className="grid gap-4 xl:grid-cols-3 xl:gap-5">
|
||||
{academyFeatureRails.map((rail) => (
|
||||
<FeatureRailCard
|
||||
key={rail.key}
|
||||
eyebrow={rail.eyebrow}
|
||||
title={rail.title}
|
||||
description={rail.description}
|
||||
icon={rail.icon}
|
||||
items={rail.items}
|
||||
emptyText={rail.emptyText}
|
||||
actionHref={rail.actionHref}
|
||||
actionLabel={rail.actionLabel}
|
||||
theme={rail.theme}
|
||||
renderItem={(item, index) => rail.renderItem(item, index, rail.theme)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -8,6 +8,31 @@ function academyHref(section, slug) {
|
||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||
}
|
||||
|
||||
function Breadcrumbs({ items = [] }) {
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{items.map((item, index) => {
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${item.label}-${index}`}>
|
||||
{isLast ? (
|
||||
<span className="text-white/80">{item.label}</span>
|
||||
) : (
|
||||
<Link href={item.href} className="transition hover:text-white">{item.label}</Link>
|
||||
)}
|
||||
{!isLast ? <span className="text-slate-600">/</span> : null}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function QueryFilters({ pageType, filters, categories }) {
|
||||
if (pageType !== 'lessons' && pageType !== 'prompts') {
|
||||
return null
|
||||
@@ -88,79 +113,455 @@ function promptPreviewAsset(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }) {
|
||||
const featuredImages = (items || [])
|
||||
.map((item) => promptPreviewAsset(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
function lessonPreviewAsset(item) {
|
||||
const src = item?.cover_image_url || item?.article_cover_image_url || item?.cover_image || item?.article_cover_image || ''
|
||||
|
||||
const primaryImage = featuredImages[0] || null
|
||||
const supportingImages = featuredImages.slice(1, 3)
|
||||
if (!src) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { src }
|
||||
}
|
||||
|
||||
function PromptSpotlightCard({ item }) {
|
||||
const preview = promptPreviewAsset(item)
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-[38px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(125,211,252,0.14),transparent_26%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_26%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] p-8 shadow-[0_28px_90px_rgba(2,6,23,0.28)] md:p-10 lg:p-12">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.15fr)_420px] xl:items-end">
|
||||
<Link href={academyHref('prompts', item.slug)} className="group rounded-[28px] border border-white/10 bg-white/[0.04] p-4 transition hover:border-sky-300/25 hover:bg-white/[0.06]">
|
||||
<div className="grid gap-4 sm:grid-cols-[104px_minmax(0,1fr)] sm:items-center">
|
||||
<div className="overflow-hidden rounded-[22px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(17,24,39,0.94))] aspect-square">
|
||||
{preview ? <img src={preview.src} srcSet={preview.srcSet || undefined} sizes="104px" alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{item?.spotlight?.eyebrow || 'Prompt pick'}</span>
|
||||
{item?.difficulty ? <span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{item.difficulty}</span> : null}
|
||||
</div>
|
||||
<h3 className="mt-3 text-lg font-semibold tracking-[-0.03em] text-white transition group-hover:text-sky-100">{item.title}</h3>
|
||||
<p className="mt-2 line-clamp-2 text-sm leading-6 text-slate-300">{item.excerpt || item.prompt_preview || item.description || 'Reusable prompt template.'}</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>{item?.category?.name || 'Academy'}</span>
|
||||
{item?.tags?.[0] ? <span>{item.tags[0]}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptDiscoverySection({ id, title, description, items = [], href, ctaLabel }) {
|
||||
if (!items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section id={id} className="rounded-[34px] border border-white/10 bg-black/20 p-6 md:p-7">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">Prompt Library</span>
|
||||
</div>
|
||||
<h1 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{title}</h1>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/80">Prompt discovery</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">{title}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300">{description}</p>
|
||||
</div>
|
||||
{href && ctaLabel ? <Link href={href} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.09]">{ctaLabel}</Link> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visual-first</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">Preview prompt results before opening the detail page.</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reusable</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">Templates for wallpapers, covers, worlds, portraits, and more.</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparison-ready</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white">See which prompts include provider-specific notes and outputs.</p>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 xl:grid-cols-2">
|
||||
{items.map((item) => <PromptSpotlightCard key={`spotlight-${item.id}`} item={item} />)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function PopularPromptPeriodTabs({ currentPeriod, periods = [] }) {
|
||||
if (!periods.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{periods.map((period) => (
|
||||
<Link
|
||||
key={period.value}
|
||||
href={period.href}
|
||||
className={`rounded-2xl border px-4 py-3 text-left transition ${period.active ? 'border-sky-200/35 bg-sky-200/15 text-sky-50' : 'border-white/10 bg-white/[0.04] text-white hover:bg-white/[0.08]'}`}
|
||||
>
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em]">{period.label}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-inherit/80">{period.description}</span>
|
||||
</Link>
|
||||
))}
|
||||
{currentPeriod?.description ? <div className="flex items-center rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-300">{currentPeriod.description}</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatAccessDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(parsed)
|
||||
}
|
||||
|
||||
function academyAccessHeading(access) {
|
||||
switch (access?.status) {
|
||||
case 'staff_access':
|
||||
return 'You currently have full staff access to the Academy.'
|
||||
case 'grace_period':
|
||||
return `${access.tierLabel} access is still active.`
|
||||
case 'trialing':
|
||||
return `${access.tierLabel} trial is active right now.`
|
||||
case 'active':
|
||||
return access?.hasPaidAccess ? `${access.tierLabel} access is active.` : 'Your Academy access is active.'
|
||||
case 'free':
|
||||
return 'You currently have Free access to the Academy.'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function academyAccessMeta(access) {
|
||||
if (!access?.signedIn) {
|
||||
return []
|
||||
}
|
||||
|
||||
const items = [
|
||||
{ label: 'Current tier', value: access?.tierLabel || 'Free' },
|
||||
{ label: 'Status', value: access?.statusLabel || 'Free access' },
|
||||
]
|
||||
|
||||
const formattedDate = formatAccessDate(access?.expiresAt)
|
||||
|
||||
if (formattedDate && access?.dateLabel) {
|
||||
items.push({ label: access.dateLabel, value: formattedDate })
|
||||
} else if (access?.renewsAutomatically) {
|
||||
items.push({ label: 'Billing', value: 'Renews automatically' })
|
||||
} else if (!access?.hasPaidAccess) {
|
||||
items.push({ label: 'Upgrade', value: 'Creator and Pro unlock premium prompts' })
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
function PromptLibraryHero({ promptView = 'library', title, description, items, pricingUrl, coursesUrl, packsUrl, promptPopularUrl, promptLibraryUrl, popularPeriod, popularPeriods = [], totalCount, analytics, hasPopularSection, academyAccess = null }) {
|
||||
const isPopularView = promptView === 'popular'
|
||||
const statLabel = isPopularView ? `ranked prompts ${currentPeriodStatSuffix(popularPeriod)}` : 'prompts available'
|
||||
const showSignedInAccess = Boolean(academyAccess?.signedIn)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = showSignedInAccess && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const primaryActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'Upgrade now'
|
||||
const primaryActionIcon = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-sliders') : 'fa-solid fa-arrow-up-right-from-square'
|
||||
const primaryActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
const secondaryAction = isPopularView
|
||||
? { href: promptLibraryUrl, label: 'Browse full library', icon: 'fa-solid fa-grid-2' }
|
||||
: (hasPopularSection
|
||||
? { href: promptPopularUrl, label: 'Top prompts', icon: 'fa-solid fa-fire' }
|
||||
: { href: coursesUrl, label: 'Explore courses', icon: 'fa-solid fa-graduation-cap' })
|
||||
const heroHighlights = [
|
||||
{
|
||||
label: isPopularView ? 'Ranking window' : 'Templates',
|
||||
value: isPopularView ? `${totalCount || 0} ranked prompts` : `${totalCount || 0} prompts`,
|
||||
},
|
||||
{
|
||||
label: 'Use case',
|
||||
value: isPopularView ? 'High-performing systems + trend tracking' : 'Reusable systems + premium previews',
|
||||
},
|
||||
]
|
||||
const heroTags = isPopularView
|
||||
? ['Momentum picks', 'Copy trends', 'Compare windows']
|
||||
: ['Fast starts', 'Visual workflows', 'Copy + adapt']
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'prompts_library_hero_primary' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] p-5 shadow-[0_32px_96px_rgba(2,6,23,0.32)] md:p-6 xl:p-7">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(251,113,133,0.15),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-12 h-36 w-36 rounded-full bg-rose-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-cyan-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-rose-200/18 bg-rose-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-50/90">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-200">{isPopularView ? 'Popular prompts' : 'Prompt Library'}</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{totalCount || 0} {statLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<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>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">{totalCount || 0} prompts available</span>
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[14ch] md:text-[4.2rem] xl:max-w-[15ch] xl:text-[4.5rem]">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-rose-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isPopularView ? <div className="mt-5"><PopularPromptPeriodTabs currentPeriod={popularPeriod} periods={popularPeriods} /></div> : null}
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{heroHighlights.map((item) => (
|
||||
<div key={item.label} className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-rose-100/75">{item.label}</p>
|
||||
<p className="mt-2 text-lg font-semibold leading-8 text-white">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
{heroTags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-rose-50/90">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-rose-200/26 bg-rose-300/12 px-5 py-3 text-sm font-semibold text-rose-50 transition hover:border-rose-200/36 hover:bg-rose-300/18">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={secondaryAction.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className={`${secondaryAction.icon} text-xs`} />
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
<Link href={packsUrl} className="inline-flex items-center gap-2 rounded-full border border-cyan-200/20 bg-cyan-300/10 px-5 py-3 text-sm font-semibold text-cyan-50 transition hover:border-cyan-200/30 hover:bg-cyan-300/16">
|
||||
<i className="fa-solid fa-box-open text-xs" />
|
||||
See prompt packs
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{primaryImage ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-[16/10]">
|
||||
<img src={primaryImage.src} srcSet={primaryImage.srcSet || undefined} sizes="(max-width: 1279px) calc(100vw - 4rem), 420px" alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
{supportingImages.length ? (
|
||||
<div className={`grid gap-3 ${supportingImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{supportingImages.map((image, index) => (
|
||||
<div key={`${image.src}-${index}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-square">
|
||||
<img src={image.src} srcSet={image.srcSet || undefined} sizes="(max-width: 1279px) calc(50vw - 2rem), 200px" alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="col-span-2 flex aspect-[16/10] items-center justify-center rounded-[28px] border border-white/10 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(17,24,39,0.92))] px-8 text-center text-sm font-semibold uppercase tracking-[0.24em] text-slate-300">
|
||||
Prompt preview images will appear here
|
||||
<div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Quick routes</p>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80">{totalCount || 0} total</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 grid gap-3">
|
||||
<Link href={isPopularView ? promptLibraryUrl : coursesUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>{isPopularView ? 'Browse full prompt library' : 'Browse Academy courses'}</span>
|
||||
<i className="fa-solid fa-arrow-right text-xs text-slate-400" />
|
||||
</Link>
|
||||
<Link href={packsUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>See prompt packs</span>
|
||||
<i className="fa-solid fa-box-open text-xs text-slate-400" />
|
||||
</Link>
|
||||
{isPopularView ? (
|
||||
<Link href={coursesUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>Explore Academy courses</span>
|
||||
<i className="fa-solid fa-graduation-cap text-xs text-slate-400" />
|
||||
</Link>
|
||||
) : hasPopularSection ? (
|
||||
<Link href={promptPopularUrl} className="flex items-center justify-between rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-[0.9rem] text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||
<span>Open top prompts page</span>
|
||||
<i className="fa-solid fa-fire text-xs text-slate-400" />
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-[0.9rem] text-sm font-semibold text-white/85">{isPopularView ? 'Use the period tabs to compare momentum windows.' : 'Jump straight into packs, courses, or ranked prompts.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2">
|
||||
<div className="rounded-[28px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 shadow-[0_18px_45px_rgba(2,6,23,0.2)] md:p-5 lg:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">{showSignedInAccess ? 'Your Academy access' : (isPopularView ? 'Turn rankings into results' : 'Upgrade for full access')}</p>
|
||||
<p className="mt-1 max-w-4xl text-lg font-semibold text-white md:text-[1.85rem] md:leading-[1.45]">{showSignedInAccess ? accessHeading : (isPopularView ? 'Open the highest-performing prompts, then unlock the full text, helper prompts, variants, and premium workflows.' : 'Unlock full prompt text, helper prompts, variants, and premium workflows.')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showSignedInAccess ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:px-5 md:py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white md:text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/30 bg-sky-200/15 px-5 py-2.5 text-sm font-semibold text-sky-50 transition hover:bg-sky-200/22">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={secondaryAction.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-2.5 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className={`${secondaryAction.icon} text-xs`} />
|
||||
{secondaryAction.label}
|
||||
</Link>
|
||||
</div>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function LessonsLibraryHero({ title, description, items = [], totalCount, pricingUrl, coursesUrl, promptLibraryUrl, academyAccess = null, analytics }) {
|
||||
const featuredLesson = items.find((item) => lessonPreviewAsset(item)) || items[0] || null
|
||||
const featuredPreview = lessonPreviewAsset(featuredLesson)
|
||||
const showSignedInAccess = Boolean(academyAccess?.signedIn)
|
||||
const accessHeading = academyAccessHeading(academyAccess)
|
||||
const accessMeta = academyAccessMeta(academyAccess)
|
||||
const useBillingAction = showSignedInAccess && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
||||
const primaryActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'See plans'
|
||||
const primaryActionIcon = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-sliders') : 'fa-solid fa-arrow-up-right-from-square'
|
||||
const primaryActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
||||
|
||||
const handlePrimaryAction = () => {
|
||||
if (!useBillingAction) {
|
||||
trackUpgradeClick(analytics, { source: 'lessons_library_hero_primary' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-amber-200/12 bg-[linear-gradient(155deg,rgba(251,191,36,0.14),rgba(15,23,42,0.96)_36%,rgba(14,165,233,0.14))] p-5 shadow-[0_32px_96px_rgba(2,6,23,0.32)] md:p-6 xl:p-7">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(253,230,138,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-12 h-36 w-36 rounded-full bg-amber-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-sky-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-full border border-amber-200/18 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-50/90">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-200">Lessons</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{totalCount || 0} tutorials</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[14ch] md:text-[4.2rem] xl:max-w-[15ch] xl:text-[4.5rem]">{title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{description}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-amber-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-book-open-reader" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Library</p>
|
||||
<p className="mt-2 text-lg font-semibold leading-8 text-white">{totalCount || 0} structured lessons</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Focus</p>
|
||||
<p className="mt-2 text-lg font-semibold leading-8 text-white">Prompt craft + workflow cleanup</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Short wins</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Creative habits</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Practical steps</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href={coursesUrl} className="inline-flex items-center gap-2 rounded-full border border-amber-200/26 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-50 transition hover:border-amber-200/36 hover:bg-amber-300/18">
|
||||
<i className="fa-solid fa-route text-xs" />
|
||||
Browse courses
|
||||
</Link>
|
||||
<Link href={promptLibraryUrl || '/academy/prompts'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
|
||||
Prompt library
|
||||
</Link>
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/20 bg-sky-300/10 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:border-sky-200/30 hover:bg-sky-300/16">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Latest lesson</p>
|
||||
{featuredLesson?.difficulty ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80">{featuredLesson.difficulty}</span> : null}
|
||||
</div>
|
||||
<Link href={featuredLesson ? academyHref('lessons', featuredLesson.slug) : coursesUrl} className="group mt-4 block overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] transition hover:border-amber-200/24 hover:bg-white/[0.06]">
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{featuredPreview ? <img src={featuredPreview.src} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.78))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{featuredLesson?.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{featuredLesson.formatted_lesson_number}</span> : null}
|
||||
{featuredLesson ? <LockBadge item={featuredLesson} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{String(featuredLesson?.series_name || featuredLesson?.category?.name || 'Academy lesson').trim()}</p>
|
||||
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white transition group-hover:text-amber-50">{featuredLesson?.title || 'Explore lessons'}</h3>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{featuredLesson?.excerpt || featuredLesson?.content_preview || featuredLesson?.description || 'Open a practical Academy lesson.'}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-2">
|
||||
<div className="rounded-[28px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 shadow-[0_18px_45px_rgba(2,6,23,0.2)] md:p-5 lg:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">{showSignedInAccess ? 'Your Academy access' : 'Upgrade for full access'}</p>
|
||||
<p className="mt-1 max-w-4xl text-lg font-semibold text-white md:text-[1.85rem] md:leading-[1.45]">{showSignedInAccess ? accessHeading : 'Unlock the full lesson library, premium workflows, and the broader Academy learning track.'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{showSignedInAccess ? (
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{accessMeta.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:px-5 md:py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.label}</p>
|
||||
<p className="mt-2 text-sm font-semibold text-white md:text-base">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/30 bg-sky-200/15 px-5 py-2.5 text-sm font-semibold text-sky-50 transition hover:bg-sky-200/22">
|
||||
<i className={`${primaryActionIcon} text-xs`} />
|
||||
{primaryActionLabel}
|
||||
</Link>
|
||||
<Link href={coursesUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-2.5 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
||||
<i className="fa-solid fa-route text-xs" />
|
||||
Browse courses
|
||||
</Link>
|
||||
</div>
|
||||
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function currentPeriodStatSuffix(popularPeriod) {
|
||||
if (!popularPeriod?.label) {
|
||||
return 'this month'
|
||||
}
|
||||
|
||||
return popularPeriod.label === '30 days' ? 'this month' : `for ${popularPeriod.label.toLowerCase()}`
|
||||
}
|
||||
|
||||
function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
const lessonSeries = String(item?.series_name || '').trim()
|
||||
const promptPreviewImage = item?.preview_image_thumb || item?.preview_image || ''
|
||||
const promptPreviewSrcSet = item?.preview_image_srcset || ''
|
||||
const lessonPreview = lessonPreviewAsset(item)
|
||||
const contentType = searchResultContentType(pageType)
|
||||
const href = itemHref(pageType, item)
|
||||
const trackSearchClick = () => {
|
||||
@@ -191,7 +592,7 @@ function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
{promptPreviewImage ? <img src={promptPreviewImage} srcSet={promptPreviewSrcSet || undefined} sizes="(max-width: 767px) calc(100vw - 2rem), (max-width: 1279px) calc(50vw - 2rem), 420px" alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">Prompt template</span>
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">{item?.ranking?.rank ? `#${item.ranking.rank} this month` : 'Prompt template'}</span>
|
||||
<LockBadge item={item} />
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
||||
@@ -209,6 +610,47 @@ function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-sky-100">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.prompt_preview || 'No description yet.'}</p>
|
||||
{item?.ranking ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-400">{item.ranking.prompt_copies > 0 ? `${item.ranking.prompt_copies} copies` : `${item.ranking.views} views`} · popularity {item.ranking.popularity_score}</p> : null}
|
||||
{item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
if (pageType === 'lessons') {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={trackSearchClick}
|
||||
data-academy-content-type={contentType || undefined}
|
||||
data-academy-content-id={item?.id || undefined}
|
||||
data-academy-search-query={searchContext?.query || undefined}
|
||||
data-academy-search-results-count={searchContext?.resultsCount || undefined}
|
||||
data-academy-search-position={position || undefined}
|
||||
className="group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-amber-200/24 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]"
|
||||
>
|
||||
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
||||
{lessonPreview ? <img src={lessonPreview.src} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
{item?.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{item.formatted_lesson_number}</span> : null}
|
||||
<LockBadge item={item} />
|
||||
</div>
|
||||
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item?.difficulty ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{item.difficulty}</span> : null}
|
||||
{item?.reading_minutes ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{item.reading_minutes} min</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">{item?.category?.name || 'Academy lesson'}</p>
|
||||
{lessonSeries ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{lessonSeries}</span> : null}
|
||||
</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-amber-50">{item.title}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || item.description || item.content_preview || 'No description yet.'}</p>
|
||||
{item.tags?.length ? <p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{item.tags.slice(0, 4).join(' · ')}</p> : null}
|
||||
</div>
|
||||
</Link>
|
||||
@@ -261,7 +703,7 @@ async function fetchAcademyPage(url) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl, analytics }) {
|
||||
export default function AcademyList({ pageType, promptView = 'library', title, description, seo, breadcrumbs = [], items, filters, categories, pricingUrl, coursesUrl, packsUrl, promptPopularUrl, promptLibraryUrl, popularPeriod = null, popularPeriods = [], featuredPrompts = [], popularPrompts = [], academyAccess = null, analytics }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const searchContext = analytics?.search ? {
|
||||
@@ -280,6 +722,11 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
})
|
||||
const [loadingMore, setLoadingMore] = React.useState(false)
|
||||
const sentinelRef = React.useRef(null)
|
||||
const hasActivePromptFilters = pageType === 'prompts' && promptView === 'library' && Boolean(filters?.q || filters?.category || filters?.difficulty || filters?.tag)
|
||||
const showPromptDiscovery = pageType === 'prompts' && promptView === 'library' && !hasActivePromptFilters
|
||||
const showPopularFeatured = pageType === 'prompts' && promptView === 'popular' && featuredPrompts.length > 0
|
||||
const infiniteLoadLabel = pageType === 'lessons' ? 'lessons' : 'prompts'
|
||||
const usesInfiniteLoad = (pageType === 'prompts' && promptView === 'library') || pageType === 'lessons'
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisibleItems(initialItems)
|
||||
@@ -292,11 +739,11 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
setLoadingMore(false)
|
||||
}, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, items?.prev_page_url, pageType])
|
||||
|
||||
const hasMorePages = pageType === 'prompts' && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl)
|
||||
const hasFallbackPagination = pageType === 'prompts' && pagination.lastPage > 1
|
||||
const hasMorePages = usesInfiniteLoad && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl)
|
||||
const hasFallbackPagination = usesInfiniteLoad && pagination.lastPage > 1
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (pageType !== 'prompts' || loadingMore || !pagination.nextPageUrl) {
|
||||
if (!usesInfiniteLoad || loadingMore || !pagination.nextPageUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -318,7 +765,7 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl, pagination.prevPageUrl])
|
||||
}, [loadingMore, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl, pagination.prevPageUrl, usesInfiniteLoad])
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
@@ -343,7 +790,9 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
<SeoHead seo={seo || {}} title={title} description={description} />
|
||||
|
||||
<div className="mx-auto max-w-[1360px] space-y-6">
|
||||
{pageType === 'prompts' ? <PromptLibraryHero title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} totalCount={Number(items?.total || visibleItems.length || 0)} /> : (
|
||||
{(pageType === 'prompts' || pageType === 'lessons') ? <Breadcrumbs items={breadcrumbs} /> : null}
|
||||
|
||||
{pageType === 'prompts' ? <PromptLibraryHero promptView={promptView} title={title} description={description} items={visibleItems} pricingUrl={pricingUrl} coursesUrl={coursesUrl} packsUrl={packsUrl} promptPopularUrl={promptPopularUrl} promptLibraryUrl={promptLibraryUrl} popularPeriod={popularPeriod} popularPeriods={popularPeriods} totalCount={Number(items?.total || visibleItems.length || 0)} analytics={analytics} hasPopularSection={popularPrompts.length > 0} academyAccess={academyAccess} /> : pageType === 'lessons' ? <LessonsLibraryHero title={title} description={description} items={visibleItems} totalCount={Number(items?.total || visibleItems.length || 0)} pricingUrl={pricingUrl} coursesUrl={coursesUrl} promptLibraryUrl={promptLibraryUrl} academyAccess={academyAccess} analytics={analytics} /> : (
|
||||
<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>
|
||||
@@ -359,7 +808,16 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
{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} />
|
||||
{promptView === 'library' ? <QueryFilters pageType={pageType} filters={filters} categories={categories} /> : null}
|
||||
|
||||
{showPromptDiscovery ? (
|
||||
<>
|
||||
<PromptDiscoverySection id="popular-prompts" title="Popular prompts right now" description="See which prompt templates are getting the most momentum from views and copies this month." items={popularPrompts} href={promptPopularUrl} ctaLabel="Open rankings" />
|
||||
<PromptDiscoverySection id="featured-prompts" title="Featured prompt picks" description="Hand-picked templates worth starting from if you want quick wins for wallpapers, worlds, portraits, and creator-style visuals." items={featuredPrompts} href={coursesUrl} ctaLabel="Browse courses" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{showPopularFeatured ? <PromptDiscoverySection id="featured-prompts" title="Featured picks to try next" description="Once you have reviewed the top-performing prompts, jump into a few curated templates that are worth adapting into your own workflow." items={featuredPrompts} href={promptLibraryUrl} ctaLabel="Browse full library" /> : null}
|
||||
|
||||
{visibleItems.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>
|
||||
@@ -369,11 +827,11 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
{visibleItems.map((item, index) => <AcademyCard key={`${pageType}-${item.id}`} pageType={pageType} item={item} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
||||
</section>
|
||||
|
||||
{pageType === 'prompts' ? (
|
||||
{usesInfiniteLoad ? (
|
||||
<div className="pt-2">
|
||||
<div ref={sentinelRef} className="h-10 w-full" aria-hidden="true" />
|
||||
{loadingMore ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-300">Loading more prompts...</div> : null}
|
||||
{!hasMorePages && visibleItems.length > initialItems.length ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-400">You have reached the end of the prompt library.</div> : null}
|
||||
{loadingMore ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-300">Loading more {infiniteLoadLabel}...</div> : null}
|
||||
{!hasMorePages && visibleItems.length > initialItems.length ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-400">You have reached the end of the {pageType === 'lessons' ? 'lesson library' : 'prompt library'}.</div> : null}
|
||||
{hasFallbackPagination ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Auto-load is primary. Pagination is available as a backup.</div>
|
||||
|
||||
@@ -681,6 +681,54 @@ function PromptVariantCard({ variant, analytics, contentId }) {
|
||||
function PromptVariantsSection({ variants, analytics, contentId }) {
|
||||
const visibleVariants = Array.isArray(variants) ? variants.filter((variant) => variant && typeof variant === 'object') : []
|
||||
const [activeVariantKey, setActiveVariantKey] = useState('')
|
||||
const variantsScrollRef = useRef(null)
|
||||
const [canScrollVariantsLeft, setCanScrollVariantsLeft] = useState(false)
|
||||
const [canScrollVariantsRight, setCanScrollVariantsRight] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const updateVariantScrollState = () => {
|
||||
const element = variantsScrollRef.current
|
||||
if (!element) {
|
||||
setCanScrollVariantsLeft(false)
|
||||
setCanScrollVariantsRight(false)
|
||||
return
|
||||
}
|
||||
|
||||
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
|
||||
setCanScrollVariantsLeft(element.scrollLeft > 6)
|
||||
setCanScrollVariantsRight(element.scrollLeft < maxScrollLeft - 6)
|
||||
}
|
||||
|
||||
updateVariantScrollState()
|
||||
|
||||
const element = variantsScrollRef.current
|
||||
if (!element) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
element.addEventListener('scroll', updateVariantScrollState, { passive: true })
|
||||
window.addEventListener('resize', updateVariantScrollState, { passive: true })
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('scroll', updateVariantScrollState)
|
||||
window.removeEventListener('resize', updateVariantScrollState)
|
||||
}
|
||||
}, [visibleVariants.length])
|
||||
|
||||
const scrollVariants = (direction) => {
|
||||
const element = variantsScrollRef.current
|
||||
if (!element) return
|
||||
|
||||
const amount = Math.max(260, Math.floor(element.clientWidth * 0.7))
|
||||
element.scrollBy({
|
||||
left: direction === 'left' ? -amount : amount,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleVariants.length) {
|
||||
@@ -712,8 +760,34 @@ function PromptVariantsSection({ variants, analytics, contentId }) {
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">Switch between safer, shorter, or more specialized prompt variants without losing the core creative direction.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-x-auto pb-2">
|
||||
<div className="inline-flex min-w-full gap-3" role="tablist" aria-label="Prompt variants">
|
||||
<div className="relative mt-6">
|
||||
<div className={`pointer-events-none absolute inset-y-0 left-0 z-10 w-14 bg-gradient-to-r from-[#182233] via-[#182233]/85 to-transparent transition ${canScrollVariantsLeft ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
|
||||
<div className={`pointer-events-none absolute inset-y-0 right-0 z-10 w-14 bg-gradient-to-l from-[#182233] via-[#182233]/85 to-transparent transition ${canScrollVariantsRight ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll prompt variants left"
|
||||
onClick={() => scrollVariants('left')}
|
||||
className={`absolute left-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollVariantsLeft ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll prompt variants right"
|
||||
onClick={() => scrollVariants('right')}
|
||||
className={`absolute right-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollVariantsRight ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-sm" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref={variantsScrollRef}
|
||||
className="flex gap-3 overflow-x-auto px-1 pb-3 pt-1 snap-x snap-mandatory scroll-smooth scrollbar-hide [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
aria-label="Prompt variants"
|
||||
>
|
||||
{visibleVariants.map((variant, index) => {
|
||||
const variantKey = String(variant?.slug || variant?.title || `variant-${index}`)
|
||||
const isActive = activeVariant === variant
|
||||
@@ -726,7 +800,7 @@ function PromptVariantsSection({ variants, analytics, contentId }) {
|
||||
aria-selected={isActive}
|
||||
onClick={() => setActiveVariantKey(variantKey)}
|
||||
className={[
|
||||
'min-w-[220px] rounded-[24px] border px-4 py-3 text-left transition',
|
||||
'w-[min(360px,calc(100vw-4.5rem))] shrink-0 snap-start rounded-[24px] border px-4 py-3 text-left transition sm:w-[320px]',
|
||||
isActive
|
||||
? 'border-sky-300/30 bg-sky-300/12 shadow-[0_16px_40px_rgba(2,6,23,0.18)]'
|
||||
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.05]',
|
||||
@@ -1024,8 +1098,6 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
const promptUnlockDetails = item?.unlock_description || promptUnlockDescription(item?.access_level)
|
||||
const promptFeaturedExamples = promptPreviewImage ? promptPublicExamples.slice(0, 2) : promptPublicExamples.slice(0, 4)
|
||||
const promptOverflowExamples = promptPublicExamples.slice(promptFeaturedExamples.length)
|
||||
const promptModelsCovered = (promptHasFullAccess && promptComparisons.length ? promptComparisons : promptPublicExamples)
|
||||
.map((entry, index) => entry.model_name || entry.provider || entry.title || `Model ${index + 1}`)
|
||||
const promptComparisonGalleryImages = promptComparisons
|
||||
.map((note, index) => {
|
||||
const src = note.image_url || note.thumb_url || ''
|
||||
@@ -1471,33 +1543,43 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
|
||||
{pageType === 'lesson' ? (
|
||||
<div className="space-y-8">
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="grid gap-0 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="relative overflow-hidden p-8 md:p-10 lg:p-12">
|
||||
{lessonCover ? <img src={lessonCover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-15" /> : null}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_34%),linear-gradient(135deg,_rgba(2,6,23,0.96),_rgba(15,23,42,0.78))]" />
|
||||
<div className="relative z-10 max-w-3xl">
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-sky-200/12 bg-[linear-gradient(145deg,rgba(14,165,233,0.16),rgba(15,23,42,0.95)_38%,rgba(251,191,36,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(125,211,252,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-70" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-sky-300/18 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-amber-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_360px] lg:p-7">
|
||||
<div className="relative overflow-hidden rounded-[34px] border border-white/10 bg-[linear-gradient(135deg,rgba(2,6,23,0.86),rgba(15,23,42,0.62))] p-5 shadow-[0_20px_46px_rgba(2,6,23,0.18)] md:p-6 lg:p-7">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(135deg,rgba(2,6,23,0.86),rgba(15,23,42,0.62))]" />
|
||||
<div className="relative z-10 max-w-4xl">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-sky-200/18 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-50/90">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-200">Lesson</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonCategory}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-300">{lessonDifficulty}</span>
|
||||
</div>
|
||||
|
||||
{item.lesson_label ? <p className="mt-5 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100">{item.lesson_label}</p> : null}
|
||||
|
||||
<h1 className="mt-5 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{item.title}</h1>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">{lessonSummary}</p>
|
||||
<div className="mt-5 flex items-start justify-between gap-4">
|
||||
<div className="max-w-3xl">
|
||||
{item.lesson_label ? <p className="text-sm font-semibold uppercase tracking-[0.24em] text-amber-100">{item.lesson_label}</p> : null}
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-[3.8rem]">{item.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95 md:text-lg">{lessonSummary}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-sky-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-book-open-reader" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{lessonTags.length ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<div className="mt-5 flex flex-wrap gap-2.5">
|
||||
{lessonTags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">{tag}</span>
|
||||
<span key={tag} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-sky-50/90">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{courseContext?.title ? (
|
||||
<div className="mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-black/25 p-5">
|
||||
<div className="mt-6 max-w-2xl rounded-[24px] border border-white/10 bg-slate-950/35 p-5 backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Part of course</p>
|
||||
<Link href={courseContext.showUrl} className="mt-2 inline-flex text-lg font-semibold text-sky-100 transition hover:text-white">{courseContext.title}</Link>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-300">{courseContext.subtitle || 'This lesson is being viewed inside a structured Academy course path.'}</p>
|
||||
@@ -1520,24 +1602,29 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-t border-white/10 bg-white/[0.03] p-6 lg:border-l lg:border-t-0 lg:p-8">
|
||||
<div className="space-y-5 lg:sticky lg:top-6">
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20">
|
||||
{lessonCover ? <img src={lessonCover} alt={item.title} className="h-52 w-full object-cover" /> : <div className="flex h-52 items-center justify-center bg-[linear-gradient(135deg,_rgba(14,165,233,0.18),_rgba(17,24,39,0.94))] text-sm font-semibold uppercase tracking-[0.24em] text-slate-300">Lesson cover</div>}
|
||||
<aside className="grid gap-4 self-start">
|
||||
<div className="relative overflow-hidden rounded-[32px] border border-white/10 bg-slate-950 shadow-[0_24px_56px_rgba(2,6,23,0.24)]">
|
||||
{lessonCover ? <img src={lessonCover} alt={item.title} className="h-[260px] w-full object-cover sm:h-[300px] lg:h-[320px]" /> : <div className="flex h-[260px] items-center justify-center bg-[linear-gradient(135deg,_rgba(14,165,233,0.22),_rgba(17,24,39,0.96))] text-sm font-semibold uppercase tracking-[0.24em] text-slate-300 sm:h-[300px] lg:h-[320px]">Lesson cover</div>}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.2)_48%,rgba(2,6,23,0.88))]" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 backdrop-blur-md">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Lesson cover</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{item.lesson_label || item.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<LessonInfoRow label="Series" value={lessonSeries} />
|
||||
{item.formatted_lesson_number ? <LessonInfoRow label="Lesson" value={item.formatted_lesson_number} /> : null}
|
||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||
<LessonInfoRow label="Reading time" value={lessonMinutes} />
|
||||
<LessonInfoRow label="Published" value={lessonUpdated} />
|
||||
</div>
|
||||
<div className="space-y-3 rounded-[30px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
<LessonInfoRow label="Series" value={lessonSeries} />
|
||||
{item.formatted_lesson_number ? <LessonInfoRow label="Lesson" value={item.formatted_lesson_number} /> : null}
|
||||
<LessonInfoRow label="Difficulty" value={lessonDifficulty} />
|
||||
<LessonInfoRow label="Reading time" value={lessonMinutes} />
|
||||
<LessonInfoRow label="Published" value={lessonUpdated} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.locked ? 'This lesson is partially locked for your account level.' : courseContext?.title ? 'This lesson is being tracked inside a course. Completion updates your course progress.' : 'Full lesson content is available below.'}</p>
|
||||
</div>
|
||||
<div className="rounded-[30px] border border-white/10 bg-slate-950/35 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Lesson status</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.locked ? 'This lesson is partially locked for your account level.' : courseContext?.title ? 'This lesson is being tracked inside a course. Completion updates your course progress.' : 'Full lesson content is available below.'}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -1731,28 +1818,99 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
) : pageType === 'prompt' ? (
|
||||
<div className="space-y-8">
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(4,10,20,0.98),rgba(15,23,42,0.9))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="grid gap-0 lg:grid-cols-[minmax(340px,0.8fr)_minmax(0,1.2fr)]">
|
||||
<div className="relative border-b border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_34%),radial-gradient(circle_at_bottom_right,rgba(255,183,139,0.18),transparent_32%),linear-gradient(180deg,rgba(5,10,20,0.98),rgba(10,17,30,0.94))] p-5 md:p-6 lg:min-h-[660px] lg:border-b-0 lg:border-r lg:border-white/10 lg:p-8">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_20%_20%,rgba(125,211,252,0.12),transparent_24%),radial-gradient(circle_at_80%_75%,rgba(255,207,191,0.12),transparent_28%)]" />
|
||||
<div className="relative flex h-full flex-col">
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(251,113,133,0.15),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-rose-300/16 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-cyan-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:p-7">
|
||||
<div className="min-w-0">
|
||||
{academyBreadcrumbs.length ? (
|
||||
<div className="mb-5">
|
||||
<AcademyBreadcrumbs items={academyBreadcrumbs} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-rose-200/18 bg-rose-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-rose-50/90">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-200">Prompt Library</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300">{lessonCategory}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300">{lessonDifficulty}</span>
|
||||
{item.aspect_ratio ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300">{item.aspect_ratio}</span> : null}
|
||||
{item.prompt_of_week ? <span className="rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-100">Prompt of the week</span> : null}
|
||||
{item.featured ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100">Featured</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-start justify-between gap-4">
|
||||
<div className="max-w-4xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-100/80">Prompt template</p>
|
||||
<h1 className="mt-3 max-w-[13ch] text-[clamp(2.6rem,5vw,4.8rem)] font-semibold leading-[0.95] tracking-[-0.05em] text-white">{item.title}</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{lessonSummary}</p>
|
||||
</div>
|
||||
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-rose-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
||||
<i className="fa-solid fa-wand-magic-sparkles" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={toggleLike} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`}</button>
|
||||
<button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">{saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`}</button>
|
||||
{promptHasFullAccess ? <PromptCopyButton prompt={item.prompt} analytics={analytics} contentId={item.id} eventType="academy_prompt_copy" metadata={{ copy_type: 'main_prompt', source: 'prompt_detail' }} /> : null}
|
||||
{promptHasFullAccess && item.negative_prompt ? <PromptCopyButton prompt={item.negative_prompt} label="Copy negative" analytics={analytics} contentId={item.id} eventType="academy_prompt_negative_copy" metadata={{ copy_type: 'negative_prompt', source: 'prompt_detail' }} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<PromptHeaderStat
|
||||
label="Category"
|
||||
value={lessonCategory}
|
||||
icon="fa-layer-group"
|
||||
accentClassName="border-sky-300/15 bg-sky-300/10 text-sky-100"
|
||||
valueClassName="text-white"
|
||||
/>
|
||||
<PromptHeaderStat
|
||||
label="Access"
|
||||
value={formatMetaDisplay(item.access_level || 'free')}
|
||||
icon={normalizePromptAccessLevel(item.access_level) === 'pro' ? 'fa-crown' : normalizePromptAccessLevel(item.access_level) === 'creator' ? 'fa-key' : 'fa-lock-open'}
|
||||
accentClassName={normalizePromptAccessLevel(item.access_level) === 'pro' ? 'border-amber-300/20 bg-amber-300/10 text-amber-100' : normalizePromptAccessLevel(item.access_level) === 'creator' ? 'border-violet-300/20 bg-violet-300/10 text-violet-100' : 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'}
|
||||
valueClassName={normalizePromptAccessLevel(item.access_level) === 'pro' ? 'text-amber-50' : normalizePromptAccessLevel(item.access_level) === 'creator' ? 'text-violet-50' : 'text-emerald-50'}
|
||||
/>
|
||||
<PromptHeaderStat
|
||||
label="Difficulty"
|
||||
value={formatMetaDisplay(lessonDifficulty)}
|
||||
icon={String(lessonDifficulty || '').toLowerCase() === 'advanced' ? 'fa-bolt' : String(lessonDifficulty || '').toLowerCase() === 'beginner' ? 'fa-seedling' : 'fa-compass-drafting'}
|
||||
accentClassName={String(lessonDifficulty || '').toLowerCase() === 'advanced' ? 'border-rose-300/20 bg-rose-300/10 text-rose-100' : String(lessonDifficulty || '').toLowerCase() === 'beginner' ? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100' : 'border-sky-300/20 bg-sky-300/10 text-sky-100'}
|
||||
valueClassName={String(lessonDifficulty || '').toLowerCase() === 'advanced' ? 'text-rose-50' : String(lessonDifficulty || '').toLowerCase() === 'beginner' ? 'text-emerald-50' : 'text-sky-50'}
|
||||
/>
|
||||
<PromptHeaderStat
|
||||
label="Updated"
|
||||
value={lessonUpdated}
|
||||
icon="fa-calendar-days"
|
||||
accentClassName="border-white/10 bg-white/[0.05] text-slate-200"
|
||||
valueClassName="text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:pt-2">
|
||||
<div className="flex h-full flex-col rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Preview artwork</p>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-100/80">Preview artwork</p>
|
||||
{promptPreviewImage ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">Click to zoom</span> : null}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPromptPreviewImage}
|
||||
className="group mt-3 flex-1 overflow-hidden rounded-[32px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35"
|
||||
className="group mt-4 flex min-h-[420px] flex-1 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35 lg:min-h-[640px]"
|
||||
disabled={!promptPreviewImage}
|
||||
aria-label={promptPreviewImage ? `Open preview image for ${item.title}` : 'Preview image unavailable'}
|
||||
>
|
||||
{promptPreviewImage ? (
|
||||
<div className="relative h-full min-h-[320px] overflow-hidden lg:min-h-[540px]">
|
||||
<img src={promptPreviewThumbImage} srcSet={promptPreviewSrcSet || undefined} sizes="(max-width: 1023px) calc(100vw - 3rem), 720px" alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.28))]" />
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-end justify-between gap-4 rounded-[24px] border border-white/10 bg-black/25 px-4 py-3 backdrop-blur-md">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
<img src={promptPreviewImage || promptPreviewThumbImage} srcSet={promptPreviewSrcSet || undefined} sizes="(max-width: 1023px) calc(100vw - 3rem), 34vw" alt={item.title} className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.36))]" />
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-end justify-between gap-4 rounded-[24px] border border-white/10 bg-black/30 px-4 py-3 backdrop-blur-md">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100/80">Prompt visual</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">Open full-size preview</p>
|
||||
@@ -1763,7 +1921,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(251,146,60,0.14),rgba(17,24,39,0.96))] px-8 text-center lg:min-h-[620px]">
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center bg-[linear-gradient(135deg,rgba(251,146,60,0.14),rgba(17,24,39,0.96))] px-8 text-center">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Visual placeholder</p>
|
||||
<p className="mt-4 text-lg font-semibold text-white">Preview image coming soon</p>
|
||||
@@ -1774,107 +1932,6 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden p-6 md:p-8 lg:p-9">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,183,139,0.14),_transparent_28%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_28%)]" />
|
||||
<div className="relative z-10 max-w-3xl">
|
||||
{academyBreadcrumbs.length ? (
|
||||
<div className="mb-5">
|
||||
<AcademyBreadcrumbs items={academyBreadcrumbs} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-[#fff0ea]">Skinbase AI Academy</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300">{lessonCategory}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300">{lessonDifficulty}</span>
|
||||
{item.aspect_ratio ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-slate-300">{item.aspect_ratio}</span> : null}
|
||||
{item.prompt_of_week ? <span className="rounded-full border border-amber-300/25 bg-amber-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-100">Prompt of the week</span> : null}
|
||||
{item.featured ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100">Featured</span> : null}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs font-semibold uppercase tracking-[0.22em] text-[#ffd8cd]">Prompt template</p>
|
||||
<h1 className="mt-3 max-w-3xl text-[clamp(2.4rem,4.8vw,4.5rem)] font-semibold leading-[0.95] tracking-[-0.05em] text-white">{item.title}</h1>
|
||||
<p className="mt-4 max-w-2xl text-[15px] leading-7 text-slate-300 md:text-base">{lessonSummary}</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={toggleLike} className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2.5 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`}</button>
|
||||
<button type="button" onClick={toggleSave} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">{saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`}</button>
|
||||
{promptHasFullAccess ? <PromptCopyButton prompt={item.prompt} analytics={analytics} contentId={item.id} eventType="academy_prompt_copy" metadata={{ copy_type: 'main_prompt', source: 'prompt_detail' }} /> : null}
|
||||
{promptHasFullAccess && item.negative_prompt ? <PromptCopyButton prompt={item.negative_prompt} label="Copy negative" analytics={analytics} contentId={item.id} eventType="academy_prompt_negative_copy" metadata={{ copy_type: 'negative_prompt', source: 'prompt_detail' }} /> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<PromptHeaderStat
|
||||
label="Category"
|
||||
value={lessonCategory}
|
||||
icon="fa-layer-group"
|
||||
accentClassName="border-sky-300/15 bg-sky-300/10 text-sky-100"
|
||||
valueClassName="text-white"
|
||||
/>
|
||||
<PromptHeaderStat
|
||||
label="Access"
|
||||
value={formatMetaDisplay(item.access_level || 'free')}
|
||||
icon={normalizePromptAccessLevel(item.access_level) === 'pro' ? 'fa-crown' : normalizePromptAccessLevel(item.access_level) === 'creator' ? 'fa-key' : 'fa-lock-open'}
|
||||
accentClassName={normalizePromptAccessLevel(item.access_level) === 'pro' ? 'border-amber-300/20 bg-amber-300/10 text-amber-100' : normalizePromptAccessLevel(item.access_level) === 'creator' ? 'border-violet-300/20 bg-violet-300/10 text-violet-100' : 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'}
|
||||
valueClassName={normalizePromptAccessLevel(item.access_level) === 'pro' ? 'text-amber-50' : normalizePromptAccessLevel(item.access_level) === 'creator' ? 'text-violet-50' : 'text-emerald-50'}
|
||||
/>
|
||||
<PromptHeaderStat
|
||||
label="Difficulty"
|
||||
value={formatMetaDisplay(lessonDifficulty)}
|
||||
icon={String(lessonDifficulty || '').toLowerCase() === 'advanced' ? 'fa-bolt' : String(lessonDifficulty || '').toLowerCase() === 'beginner' ? 'fa-seedling' : 'fa-compass-drafting'}
|
||||
accentClassName={String(lessonDifficulty || '').toLowerCase() === 'advanced' ? 'border-rose-300/20 bg-rose-300/10 text-rose-100' : String(lessonDifficulty || '').toLowerCase() === 'beginner' ? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100' : 'border-sky-300/20 bg-sky-300/10 text-sky-100'}
|
||||
valueClassName={String(lessonDifficulty || '').toLowerCase() === 'advanced' ? 'text-rose-50' : String(lessonDifficulty || '').toLowerCase() === 'beginner' ? 'text-emerald-50' : 'text-sky-50'}
|
||||
/>
|
||||
<PromptHeaderStat
|
||||
label="Updated"
|
||||
value={lessonUpdated}
|
||||
icon="fa-calendar-days"
|
||||
accentClassName="border-white/10 bg-white/[0.05] text-slate-200"
|
||||
valueClassName="text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lessonTags.length ? (
|
||||
<div className="mt-7 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-4 md:p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{lessonTags.map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-7 grid gap-4 xl:grid-cols-[minmax(0,1.08fr)_minmax(260px,0.92fr)]">
|
||||
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 md:p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Prompt status</p>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">
|
||||
{item.locked
|
||||
? `${promptAccessRequirement ? `${promptAccessRequirement} ` : ''}This page shows the prompt summary and public example results, but the reusable prompt system stays locked until your Academy access level matches the template.`
|
||||
: 'This template includes the main prompt, reuse guidance, and model-specific comparison notes in one place.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{promptModelsCovered.length ? (
|
||||
<div className="rounded-[28px] border border-[#ffcfbf]/12 bg-[linear-gradient(180deg,rgba(255,207,191,0.08),rgba(255,255,255,0.03))] p-4 md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-[#ffd8cd]">Compared with</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{promptModelsCovered.length} model{promptModelsCovered.length > 1 ? 's' : ''} documented for this prompt.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold text-white">{promptModelsCovered.length}</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{promptModelsCovered.map((model) => (
|
||||
<span key={model} className="rounded-full border border-[#ffcfbf]/15 bg-[#ffcfbf]/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">{model}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,7 +7,16 @@ function MetricCell({ value, suffix = '' }) {
|
||||
return <span className="font-semibold text-white">{value}{suffix}</span>
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, rows = [] }) {
|
||||
function StatCard({ label, value, suffix = '' }) {
|
||||
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}{suffix}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, summary = null, rows = [] }) {
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
@@ -20,6 +29,19 @@ export default function AcademyAnalyticsContent({ nav = [], range, title, subtit
|
||||
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Views" value={Number(summary.views || 0).toLocaleString()} />
|
||||
<StatCard label="Unique Visitors" value={Number(summary.uniqueVisitors || 0).toLocaleString()} />
|
||||
<StatCard label="Engaged Views" value={Number(summary.engagedViews || 0).toLocaleString()} />
|
||||
<StatCard label="Engagement Rate" value={Number(summary.engagementRate || 0).toLocaleString()} suffix="%" />
|
||||
<StatCard label="Avg Engaged Seconds" value={Number(summary.avgEngagedSeconds || 0).toLocaleString()} suffix="s" />
|
||||
<StatCard label="Scroll 50%" value={Number(summary.scroll50 || 0).toLocaleString()} />
|
||||
<StatCard label="Scroll 100%" value={Number(summary.scroll100 || 0).toLocaleString()} />
|
||||
<StatCard label="Deep Scroll Rate" value={Number(summary.deepScrollRate || 0).toLocaleString()} suffix="%" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/[0.08] bg-white/[0.03]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
|
||||
@@ -12,6 +12,99 @@ function StatCard({ label, value }) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatDelta(delta) {
|
||||
if (delta === null || delta === undefined) {
|
||||
return 'new'
|
||||
}
|
||||
|
||||
if (Number(delta) === 0) {
|
||||
return '0%'
|
||||
}
|
||||
|
||||
return `${Number(delta) > 0 ? '+' : ''}${Number(delta).toLocaleString()}%`
|
||||
}
|
||||
|
||||
function PromptLibraryTrend({ trend }) {
|
||||
const current = trend?.current || {}
|
||||
const deltas = trend?.deltas || {}
|
||||
|
||||
const items = [
|
||||
{ label: 'Views', value: Number(current.views || 0).toLocaleString(), delta: deltas.views },
|
||||
{ label: 'Unique Visitors', value: Number(current.uniqueVisitors || 0).toLocaleString(), delta: deltas.uniqueVisitors },
|
||||
{ label: 'Engaged Views', value: Number(current.engagedViews || 0).toLocaleString(), delta: deltas.engagedViews },
|
||||
{ label: 'Engagement Rate', value: `${Number(current.engagementRate || 0).toLocaleString()}%`, delta: deltas.engagementRate },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Prompt Library Trend</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{trend?.range?.current?.from} to {trend?.range?.current?.to} compared with {trend?.range?.previous?.from} to {trend?.range?.previous?.to}</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/[0.08] bg-black/20 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
Popularity {Number(current.popularityScore || 0).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/[0.08] bg-black/20 p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{item.label}</p>
|
||||
<p className="mt-3 text-2xl font-bold text-white">{item.value}</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-sky-200">{formatDelta(item.delta)} vs previous</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PopularPromptPeriodUsage({ usage }) {
|
||||
const periods = usage?.periods || []
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Popular Prompt Period Usage</p>
|
||||
<p className="mt-2 text-sm text-slate-300">Which ranking window people actually open on the public popular-prompts page.</p>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/[0.08] bg-black/20 px-4 py-2 text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
{Number(usage?.totalViews || 0).toLocaleString()} views · {Number(usage?.totalVisitors || 0).toLocaleString()} visitors
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
{periods.length ? periods.map((period) => (
|
||||
<div key={period.period} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{period.label}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{period.period}</p>
|
||||
</div>
|
||||
<div className="grid gap-2 text-right sm:grid-cols-3 sm:gap-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-100">{Number(period.views || 0).toLocaleString()}</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Views</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-100">{Number(period.uniqueVisitors || 0).toLocaleString()}</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Visitors</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-sky-100">{Number(period.share || 0).toLocaleString()}%</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">Share</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">No popular prompt period events have been tracked in this range yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentList({ title, items = [] }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
@@ -36,7 +129,7 @@ function ContentList({ title, items = [] }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsOverview({ nav = [], range, stats, topContent = [], topWeek = [] }) {
|
||||
export default function AcademyAnalyticsOverview({ nav = [], range, stats, promptLibraryTrend = null, popularPromptPeriodUsage = null, topContent = [], topWeek = [] }) {
|
||||
return (
|
||||
<AdminLayout title="Academy Analytics" subtitle="Daily rollup overview for Academy traffic, engagement, and subscription intent.">
|
||||
<Head title="Admin · Academy Analytics" />
|
||||
@@ -63,6 +156,9 @@ export default function AcademyAnalyticsOverview({ nav = [], range, stats, topCo
|
||||
<StatCard label="Upgrade Clicks" value={stats.upgradeClicks} />
|
||||
</div>
|
||||
|
||||
{promptLibraryTrend ? <PromptLibraryTrend trend={promptLibraryTrend} /> : null}
|
||||
{popularPromptPeriodUsage ? <PopularPromptPeriodUsage usage={popularPromptPeriodUsage} /> : null}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ContentList title="Top Content In Range" items={topContent} />
|
||||
<ContentList title="Top Content This Week" items={topWeek} />
|
||||
|
||||
@@ -51,6 +51,38 @@ function serializeStructuredJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function copyTextToClipboard(text) {
|
||||
const source = String(text || '')
|
||||
if (!source) return Promise.reject(new Error('Nothing to copy'))
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
return navigator.clipboard.writeText(source)
|
||||
}
|
||||
|
||||
if (typeof document === 'undefined' || !document.body) {
|
||||
return Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = source
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.top = '-1000px'
|
||||
textarea.style.left = '-1000px'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
try {
|
||||
if (document.execCommand('copy')) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea)
|
||||
}
|
||||
|
||||
return Promise.reject(new Error('Clipboard unavailable'))
|
||||
}
|
||||
|
||||
function getField(fields, name) {
|
||||
return fields.find((field) => field.name === name) || null
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function Dashboard({ stats }) {
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up', desc: 'Moderate pending artwork submissions' },
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed', desc: 'Browse all creator stories' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
|
||||
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
|
||||
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles', desc: 'Review generated creator biographies and moderation flags' },
|
||||
].map((item) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
172
resources/js/Pages/Enhance/Create.jsx
Normal file
172
resources/js/Pages/Enhance/Create.jsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react'
|
||||
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
|
||||
|
||||
export default function EnhanceCreate() {
|
||||
const { props } = usePage()
|
||||
const form = useForm({ image: null, scale: props.options?.scales?.[0]?.value || 2, mode: props.options?.modes?.[0]?.value || 'standard' })
|
||||
const [previewUrl, setPreviewUrl] = React.useState(null)
|
||||
const [sourceType, setSourceType] = React.useState(props.selectedArtwork ? 'artwork' : 'upload')
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
}, [previewUrl])
|
||||
|
||||
function handleFileChange(event) {
|
||||
const file = event.target.files?.[0] || null
|
||||
form.setData('image', file)
|
||||
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl)
|
||||
}
|
||||
|
||||
setPreviewUrl(file ? URL.createObjectURL(file) : null)
|
||||
}
|
||||
|
||||
function submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const action = sourceType === 'artwork' && props.selectedArtwork?.store_url
|
||||
? props.selectedArtwork.store_url
|
||||
: props.storeUrl
|
||||
|
||||
form.post(action, {
|
||||
forceFormData: sourceType !== 'artwork',
|
||||
preserveScroll: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title="Skinbase Enhance" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Skinbase Enhance</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Create an upscaled image</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Large images may take longer to process. The original image will stay unchanged.</p>
|
||||
</div>
|
||||
|
||||
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
<i className="fa-solid fa-arrow-left text-[10px]" />
|
||||
Back to jobs
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} className="mt-6" />
|
||||
|
||||
<form onSubmit={submit} className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.05fr)_380px]">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhance source</div>
|
||||
{props.selectedArtwork ? (
|
||||
<div className="inline-flex rounded-full border border-white/10 bg-white/[0.04] p-1 text-xs font-semibold text-slate-300">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('artwork')}
|
||||
className={`rounded-full px-4 py-2 transition ${sourceType === 'artwork' ? 'bg-sky-400/15 text-sky-50' : 'hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
Existing artwork
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSourceType('upload')}
|
||||
className={`rounded-full px-4 py-2 transition ${sourceType === 'upload' ? 'bg-sky-400/15 text-sky-50' : 'hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
Upload image
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{sourceType === 'artwork' && props.selectedArtwork ? (
|
||||
<div className="mt-4 rounded-[28px] border border-sky-300/20 bg-[linear-gradient(180deg,rgba(14,165,233,0.1),rgba(8,17,29,0.9))] p-6 text-left">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-200/80">Existing artwork source</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{props.selectedArtwork.title}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-slate-300">Use the current artwork source without re-uploading a file. The original artwork remains untouched and the enhanced result will be stored as a separate job output.</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<a href={props.selectedArtwork.show_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]">
|
||||
<i className="fa-solid fa-image text-[10px]" />
|
||||
View artwork
|
||||
</a>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">
|
||||
<i className="fa-solid fa-lock text-[10px]" />
|
||||
Original stays unchanged
|
||||
</span>
|
||||
</div>
|
||||
{form.errors.source ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{form.errors.source}</div> : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="mt-4 flex min-h-[420px] cursor-pointer flex-col items-center justify-center rounded-[28px] border border-dashed border-white/15 bg-black/20 px-6 py-8 text-center transition hover:border-sky-300/30 hover:bg-sky-400/[0.03]">
|
||||
{previewUrl ? <img src={previewUrl} alt="Selected for enhance" className="max-h-[420px] w-full rounded-[20px] object-contain" /> : <>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-white/10 bg-white/[0.05] text-white/70"><i className="fa-solid fa-cloud-arrow-up text-2xl" /></div>
|
||||
<div className="mt-4 text-lg font-semibold text-white">Choose a JPEG, PNG, or WebP image</div>
|
||||
<div className="mt-2 max-w-md text-sm text-slate-400">Upload an image up to {props.maxUploadMb} MB. SVG, GIF, and unsupported file types are rejected.</div>
|
||||
</>}
|
||||
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={handleFileChange} className="hidden" />
|
||||
</label>
|
||||
{form.errors.image ? <div className="mt-3 text-sm text-rose-300">{form.errors.image}</div> : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhance settings</div>
|
||||
|
||||
<div className="mt-5 space-y-5">
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">Scale</label>
|
||||
<div className="mt-3 grid grid-cols-2 gap-3">
|
||||
{(props.options?.scales || []).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => form.setData('scale', option.value)}
|
||||
className={`rounded-2xl border px-4 py-4 text-left transition ${Number(form.data.scale) === Number(option.value) ? 'border-sky-300/30 bg-sky-400/12 text-sky-50' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.08]'}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{option.label}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-current/70">Upscale size</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.errors.scale ? <div className="mt-2 text-sm text-rose-300">{form.errors.scale}</div> : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-semibold text-white">Mode</label>
|
||||
<div className="mt-3 space-y-3">
|
||||
{(props.options?.modes || []).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => form.setData('mode', option.value)}
|
||||
className={`w-full rounded-2xl border px-4 py-4 text-left transition ${String(form.data.mode) === String(option.value) ? 'border-sky-300/30 bg-sky-400/12 text-sky-50' : 'border-white/10 bg-white/[0.04] text-slate-300 hover:bg-white/[0.08]'}`}
|
||||
>
|
||||
<div className="text-sm font-semibold">{option.label}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-current/70">Optimized preset</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{form.errors.mode ? <div className="mt-2 text-sm text-rose-300">{form.errors.mode}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm leading-6 text-amber-50">
|
||||
The original file is preserved separately. Completed outputs can be reviewed and downloaded before you decide how to use them.
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={form.processing} className="mt-6 inline-flex w-full items-center justify-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:bg-sky-400/20 disabled:cursor-not-allowed disabled:opacity-60">
|
||||
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
|
||||
{form.processing ? 'Starting enhance…' : sourceType === 'artwork' ? 'Enhance artwork image' : 'Start enhance'}
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
resources/js/Pages/Enhance/Index.jsx
Normal file
135
resources/js/Pages/Enhance/Index.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage } from '@inertiajs/react'
|
||||
import EnhanceStatusBadge from '../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate, formatEnhanceInteger } from '../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
function JobCard({ job }) {
|
||||
return (
|
||||
<article className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
|
||||
<div className="aspect-square bg-black/30">
|
||||
{job.preview_url || job.source_url ? <img src={job.preview_url || job.source_url} alt="Enhance preview" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.03em] text-white">
|
||||
{job.artwork?.title ? `Artwork enhance: ${job.artwork.title}` : `Enhance job #${job.id}`}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">Created {formatDate(job.created_at)} {job.processing_seconds ? `• ${job.processing_seconds}s processing` : ''}</p>
|
||||
<div className="mt-2 text-sm text-slate-400">{job.input_width} × {job.input_height}{job.output_width && job.output_height ? ` → ${job.output_width} × ${job.output_height}` : ''}</div>
|
||||
|
||||
{job.error_message ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{job.error_message}</div> : null}
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Link href={job.show_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open job</Link>
|
||||
{job.artwork?.url ? <a href={job.artwork.url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open artwork</a> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EnhanceIndex() {
|
||||
const { props } = usePage()
|
||||
const jobs = props.jobs?.data || []
|
||||
const latestCompleted = props.latestCompleted || []
|
||||
const flash = props.flash || {}
|
||||
const enhanceConfig = props.enhanceConfig || {}
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title="Skinbase Enhance" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Skinbase Enhance</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Image Upscaler</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Improve older wallpapers, digital art, and photos with a clean upscaled version. Your original file is never replaced automatically.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={props.createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">
|
||||
<i className="fa-solid fa-sparkles text-[10px]" />
|
||||
Start enhance
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Daily limit</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(props.dailyLimit || 0)}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Total jobs</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(props.jobs?.total || jobs.length)}</div>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Completed outputs</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{formatEnhanceInteger(latestCompleted.length)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={enhanceConfig} className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
{latestCompleted.length > 0 ? (
|
||||
<section className="mt-8">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Latest completed</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Recent enhanced outputs</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{latestCompleted.map((job) => (
|
||||
<Link key={job.id} href={job.show_url} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] transition hover:border-sky-300/30 hover:bg-[#0b1524]">
|
||||
<div className="aspect-square bg-black/20">
|
||||
{job.output_url ? <img src={job.output_url} alt={`Enhance job ${job.id}`} className="h-full w-full object-cover" /> : null}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<div className="mt-3 text-sm font-semibold text-white">Job #{job.id}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-400">{job.scale}x • {job.mode}</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mt-8">
|
||||
<div className="mb-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">History</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">Your enhance jobs</h2>
|
||||
</div>
|
||||
|
||||
{jobs.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No enhance jobs yet. Upload an image to start your first upscale.</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{jobs.map((job) => <JobCard key={job.id} job={job} />)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
resources/js/Pages/Enhance/Show.jsx
Normal file
144
resources/js/Pages/Enhance/Show.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import BeforeAfterSlider from '../../components/enhance/BeforeAfterSlider'
|
||||
import EnhanceStatusBadge from '../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate } from '../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/[0.06] py-3 last:border-b-0 last:pb-0">
|
||||
<dt className="text-sm text-slate-400">{label}</dt>
|
||||
<dd className="text-right text-sm text-white">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function EnhanceShow() {
|
||||
const { props } = usePage()
|
||||
const job = props.job || {}
|
||||
const flash = props.flash || {}
|
||||
const errors = props.errors || {}
|
||||
const statusKey = String(job.status || '').toLowerCase()
|
||||
const statusCopy = {
|
||||
pending: 'Waiting to be queued.',
|
||||
queued: 'Waiting for processor.',
|
||||
processing: 'Enhancing image.',
|
||||
completed: 'Enhanced image ready.',
|
||||
failed: 'Enhancement failed.',
|
||||
cancelled: 'Cancelled.',
|
||||
expired: 'Enhanced output expired and cleaned files were removed.',
|
||||
}[statusKey] || 'Unknown status.'
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!['pending', 'queued', 'processing'].includes(statusKey)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
router.reload({ only: ['job', 'flash'], preserveScroll: true })
|
||||
}, 8000)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [statusKey])
|
||||
|
||||
const canCompare = Boolean(job.source_url && job.output_url && job.status === 'completed')
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title={`Enhance Job #${job.id || ''}`} />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.18),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Enhance job #{job.id}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">{statusCopy}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
<i className="fa-solid fa-arrow-left text-[10px]" />
|
||||
Back to jobs
|
||||
</Link>
|
||||
<Link href={props.createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">
|
||||
<i className="fa-solid fa-plus text-[10px]" />
|
||||
New enhance
|
||||
</Link>
|
||||
{job.download_url ? <a href={job.download_url} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-50 transition hover:bg-emerald-400/20">Download enhanced</a> : null}
|
||||
{job.can_retry ? <button type="button" onClick={() => router.post(job.retry_url, {}, { preserveScroll: true })} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/20">Retry</button> : null}
|
||||
{job.can_delete ? <button type="button" onClick={() => {
|
||||
if (!window.confirm('Delete this enhance job and its generated files?')) return
|
||||
router.delete(job.delete_url)
|
||||
}} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20">Delete</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
{errors.job ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{errors.job}</div> : null}
|
||||
{job.error_message ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{job.error_message}</div> : null}
|
||||
|
||||
{canCompare ? <div className="mt-8"><BeforeAfterSlider beforeUrl={job.source_url} afterUrl={job.output_url} /></div> : null}
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Original source</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.source_url ? <img src={job.source_url} alt="Original source" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Enhanced result</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.output_url ? <img src={job.output_url} alt="Enhanced output" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-hourglass-half text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Metadata</div>
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(job.metadata || {}, null, 2)}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Job details</div>
|
||||
<dl className="mt-4">
|
||||
<DetailRow label="Created" value={formatDate(job.created_at)} />
|
||||
<DetailRow label="Queued" value={formatDate(job.queued_at)} />
|
||||
<DetailRow label="Started" value={formatDate(job.started_at)} />
|
||||
<DetailRow label="Finished" value={formatDate(job.finished_at)} />
|
||||
<DetailRow label="Expires" value={formatDate(job.expires_at)} />
|
||||
<DetailRow label="Input size" value={job.input_filesize ? `${(job.input_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Input mime" value={job.input_mime || '—'} />
|
||||
<DetailRow label="Input dimensions" value={job.input_width && job.input_height ? `${job.input_width} × ${job.input_height}` : '—'} />
|
||||
<DetailRow label="Output size" value={job.output_filesize ? `${(job.output_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Output mime" value={job.output_mime || '—'} />
|
||||
<DetailRow label="Output dimensions" value={job.output_width && job.output_height ? `${job.output_width} × ${job.output_height}` : '—'} />
|
||||
<DetailRow label="Processing seconds" value={job.processing_seconds ?? '—'} />
|
||||
<DetailRow label="Artwork" value={job.artwork?.title ? <a href={job.artwork.url} className="text-sky-300 hover:text-sky-200">{job.artwork.title}</a> : 'Standalone upload'} />
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
110
resources/js/Pages/Moderation/Enhance/Index.jsx
Normal file
110
resources/js/Pages/Moderation/Enhance/Index.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import EnhanceStatusBadge from '../../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate } from '../../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
export default function ModerationEnhanceIndex() {
|
||||
const { props } = usePage()
|
||||
const [filters, setFilters] = React.useState(props.filters || {})
|
||||
const jobs = props.jobs?.data || []
|
||||
const flash = props.flash || {}
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilters(props.filters || {})
|
||||
}, [props.filters])
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(props.indexUrl, filters, { preserveScroll: true, preserveState: true, replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 pb-16 pt-8">
|
||||
<Head title="Enhance Jobs" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Enhance Jobs</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review queued, processing, failed, and completed image upscale jobs without changing original artwork assets.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-7">
|
||||
<select value={filters.status || 'all'} onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none">
|
||||
{(props.options?.statuses || []).map((option) => <option key={option} value={option}>{option === 'all' ? 'All statuses' : option}</option>)}
|
||||
</select>
|
||||
<select value={filters.engine || 'all'} onChange={(event) => setFilters((current) => ({ ...current, engine: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none">
|
||||
{(props.options?.engines || []).map((option) => <option key={option} value={option}>{option === 'all' ? 'All engines' : option}</option>)}
|
||||
</select>
|
||||
<select value={filters.mode || 'all'} onChange={(event) => setFilters((current) => ({ ...current, mode: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none">
|
||||
{(props.options?.modes || []).map((option) => <option key={option} value={option}>{option === 'all' ? 'All modes' : option}</option>)}
|
||||
</select>
|
||||
<select value={filters.scale || 'all'} onChange={(event) => setFilters((current) => ({ ...current, scale: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none">
|
||||
{(props.options?.scales || []).map((option) => <option key={String(option)} value={option}>{option === 'all' ? 'All scales' : `${option}x`}</option>)}
|
||||
</select>
|
||||
<input value={filters.user || ''} onChange={(event) => setFilters((current) => ({ ...current, user: event.target.value }))} placeholder="User name or username" className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" />
|
||||
<input type="date" value={filters.date_from || ''} onChange={(event) => setFilters((current) => ({ ...current, date_from: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" />
|
||||
<input type="date" value={filters.date_to || ''} onChange={(event) => setFilters((current) => ({ ...current, date_to: event.target.value }))} className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none" />
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1] xl:col-span-7">Apply filters</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} moderation className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
<th className="px-5 py-3.5">Preview</th>
|
||||
<th className="px-5 py-3.5">Job</th>
|
||||
<th className="px-5 py-3.5">User</th>
|
||||
<th className="px-5 py-3.5">Artwork</th>
|
||||
<th className="px-5 py-3.5">Status</th>
|
||||
<th className="px-5 py-3.5">Mode</th>
|
||||
<th className="px-5 py-3.5">Scale</th>
|
||||
<th className="px-5 py-3.5">Dimensions</th>
|
||||
<th className="px-5 py-3.5">Created</th>
|
||||
<th className="px-5 py-3.5 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.04]">
|
||||
{jobs.length === 0 ? <tr><td colSpan={10} className="px-5 py-12 text-center text-slate-400">No enhance jobs match the current filters.</td></tr> : null}
|
||||
{jobs.map((job) => (
|
||||
<tr key={job.id} className="transition hover:bg-white/[0.025]">
|
||||
<td className="px-5 py-4">
|
||||
<div className="h-16 w-16 overflow-hidden rounded-xl border border-white/10 bg-black/20">
|
||||
{job.preview_url || job.source_url ? <img src={job.preview_url || job.source_url} alt={`Enhance job ${job.id}`} className="h-full w-full object-cover" /> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-white">
|
||||
<div className="font-semibold">#{job.id}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{job.engine}</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.user?.name || '—'}{job.user?.username ? <div className="text-xs text-slate-500">@{job.user.username}</div> : null}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.artwork?.title ? <a href={job.artwork.url} className="text-sky-300 hover:text-sky-200">{job.artwork.title}</a> : '—'}</td>
|
||||
<td className="px-5 py-4"><EnhanceStatusBadge status={job.status} /></td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.mode}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.scale}x</td>
|
||||
<td className="px-5 py-4 text-slate-300">{job.input_width} × {job.input_height}{job.output_width && job.output_height ? <div className="text-xs text-slate-500">→ {job.output_width} × {job.output_height}</div> : null}</td>
|
||||
<td className="px-5 py-4 text-slate-400">{formatDate(job.created_at)}</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<Link href={job.show_url} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
resources/js/Pages/Moderation/Enhance/Show.jsx
Normal file
118
resources/js/Pages/Moderation/Enhance/Show.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import BeforeAfterSlider from '../../../components/enhance/BeforeAfterSlider'
|
||||
import EnhanceStatusBadge from '../../../components/enhance/EnhanceStatusBadge'
|
||||
import EnhanceStubWarning from '../../../components/enhance/EnhanceStubWarning'
|
||||
import { formatEnhanceDate } from '../../../utils/enhanceFormatting'
|
||||
|
||||
function formatDate(value) {
|
||||
return formatEnhanceDate(value)
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b border-white/[0.06] py-3 last:border-b-0 last:pb-0">
|
||||
<dt className="text-sm text-slate-400">{label}</dt>
|
||||
<dd className="text-right text-sm text-white">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ModerationEnhanceShow() {
|
||||
const { props } = usePage()
|
||||
const job = props.job || {}
|
||||
const flash = props.flash || {}
|
||||
const errors = props.errors || {}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 pb-16 pt-8">
|
||||
<Head title={`Enhance Job #${job.id || ''}`} />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EnhanceStatusBadge status={job.status} />
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.scale}x</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.mode}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{job.engine}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Enhance job #{job.id}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Created by {job.user?.name || 'Unknown user'} {job.user?.username ? `(@${job.user.username})` : ''}.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={props.indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
<i className="fa-solid fa-arrow-left text-[10px]" />
|
||||
Back to list
|
||||
</Link>
|
||||
{job.download_url ? <a href={job.download_url} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-50 transition hover:bg-emerald-400/20">Download output</a> : null}
|
||||
{job.can_retry ? <button type="button" onClick={() => router.post(job.retry_url)} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-amber-50 transition hover:bg-amber-400/20">Retry</button> : null}
|
||||
{job.can_mark_failed ? <button type="button" onClick={() => router.post(job.mark_failed_url)} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20">Mark failed</button> : null}
|
||||
<button type="button" onClick={() => {
|
||||
if (!window.confirm('Delete this enhance job and any owned enhance files?')) return
|
||||
router.delete(job.delete_url)
|
||||
}} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EnhanceStubWarning config={props.enhanceConfig} moderation className="mt-6" />
|
||||
|
||||
{flash.success ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
{errors.job ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{errors.job}</div> : null}
|
||||
|
||||
{job.source_url && job.output_url ? <div className="mt-8"><BeforeAfterSlider beforeUrl={job.source_url} afterUrl={job.output_url} /></div> : null}
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_380px]">
|
||||
<div className="space-y-6">
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Source image</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.source_url ? <img src={job.source_url} alt="Enhance source" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-image text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Output image</div>
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20">
|
||||
{job.output_url ? <img src={job.output_url} alt="Enhance output" className="w-full object-cover" /> : <div className="flex min-h-[280px] items-center justify-center text-white/20"><i className="fa-solid fa-hourglass-half text-4xl" /></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Metadata</div>
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-200">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-words">{JSON.stringify(job.metadata || {}, null, 2)}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{job.error_message ? <section className="rounded-[30px] border border-rose-300/20 bg-rose-400/10 p-6 text-sm text-rose-100 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">{job.error_message}</section> : null}
|
||||
</div>
|
||||
|
||||
<aside className="rounded-[30px] border border-white/10 bg-[#08111d] p-6 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Processing details</div>
|
||||
<dl className="mt-4">
|
||||
<DetailRow label="Created" value={formatDate(job.created_at)} />
|
||||
<DetailRow label="Queued" value={formatDate(job.queued_at)} />
|
||||
<DetailRow label="Started" value={formatDate(job.started_at)} />
|
||||
<DetailRow label="Finished" value={formatDate(job.finished_at)} />
|
||||
<DetailRow label="Expires" value={formatDate(job.expires_at)} />
|
||||
<DetailRow label="Input mime" value={job.input_mime || '—'} />
|
||||
<DetailRow label="Input size" value={job.input_filesize ? `${(job.input_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Input dimensions" value={job.input_width && job.input_height ? `${job.input_width} × ${job.input_height}` : '—'} />
|
||||
<DetailRow label="Output mime" value={job.output_mime || '—'} />
|
||||
<DetailRow label="Output size" value={job.output_filesize ? `${(job.output_filesize / 1024 / 1024).toFixed(2)} MB` : '—'} />
|
||||
<DetailRow label="Output dimensions" value={job.output_width && job.output_height ? `${job.output_width} × ${job.output_height}` : '—'} />
|
||||
<DetailRow label="Processing seconds" value={job.processing_seconds ?? '—'} />
|
||||
<DetailRow label="Artwork" value={job.artwork?.title ? <a href={job.artwork.url} className="text-sky-300 hover:text-sky-200">{job.artwork.title}</a> : 'Standalone upload'} />
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,20 +9,20 @@ function ensurePreviewOverlay() {
|
||||
}
|
||||
|
||||
previewOverlay = document.createElement('div')
|
||||
previewOverlay.className = 'fixed inset-0 z-[130] hidden items-center justify-center bg-[#020611e8] p-4 backdrop-blur-md'
|
||||
previewOverlay.className = 'fixed inset-0 z-[130] hidden items-center justify-center bg-[#020611bf] p-6 backdrop-blur-xl'
|
||||
previewOverlay.setAttribute('role', 'dialog')
|
||||
previewOverlay.setAttribute('aria-modal', 'true')
|
||||
previewOverlay.setAttribute('aria-label', 'Image preview')
|
||||
|
||||
const frame = document.createElement('div')
|
||||
frame.className = 'relative max-h-[92vh] max-w-6xl'
|
||||
frame.className = 'relative flex max-h-[92vh] w-full max-w-6xl flex-col items-center gap-4'
|
||||
|
||||
previewImage = document.createElement('img')
|
||||
previewImage.className = 'max-h-[92vh] max-w-full rounded-[28px] border border-white/10 shadow-[0_28px_90px_rgba(2,6,23,0.6)]'
|
||||
previewImage.className = 'max-h-[78vh] max-w-full rounded-[28px] border border-white/10 shadow-[0_28px_90px_rgba(2,6,23,0.45)]'
|
||||
previewImage.alt = 'Image preview'
|
||||
|
||||
previewCaption = document.createElement('div')
|
||||
previewCaption.className = 'absolute inset-x-0 bottom-0 rounded-b-[28px] bg-gradient-to-t from-black/80 to-transparent px-5 py-4 text-sm font-medium text-white/90'
|
||||
previewCaption.className = 'w-full max-w-4xl rounded-[24px] border border-white/10 bg-black/30 px-6 py-4 text-center text-sm font-medium leading-6 text-white/90 backdrop-blur-md'
|
||||
|
||||
const closeButton = document.createElement('button')
|
||||
closeButton.type = 'button'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
||||
import { usePage, Link } from '@inertiajs/react'
|
||||
import StudioLayout from '../../Layouts/StudioLayout'
|
||||
import RichTextEditor from '../../components/forum/RichTextEditor'
|
||||
import UploadDescriptionEditor from '../../components/upload/UploadDescriptionEditor'
|
||||
import TextInput from '../../components/ui/TextInput'
|
||||
import Button from '../../components/ui/Button'
|
||||
import Modal from '../../components/ui/Modal'
|
||||
@@ -12,6 +12,7 @@ import TagPicker from '../../components/tags/TagPicker'
|
||||
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'
|
||||
import ArtworkEvolutionSearchPicker from '../../components/artwork/ArtworkEvolutionSearchPicker'
|
||||
import WorldSubmissionSelector from '../../components/worlds/WorldSubmissionSelector'
|
||||
import { normalizeMarkdownLiteContent } from '../../utils/contentValidation'
|
||||
|
||||
const EDIT_SECTIONS = [
|
||||
{ id: 'taxonomy', label: 'Category', hint: 'Content type and category path' },
|
||||
@@ -302,7 +303,7 @@ export default function StudioArtworkEdit() {
|
||||
const [categoryId, setCategoryId] = useState(artwork?.parent_category_id || null)
|
||||
const [subCategoryId, setSubCategoryId] = useState(artwork?.sub_category_id || null)
|
||||
const [title, setTitle] = useState(artwork?.title || '')
|
||||
const [description, setDescription] = useState(artwork?.description || '')
|
||||
const [description, setDescription] = useState(() => normalizeMarkdownLiteContent(artwork?.description || ''))
|
||||
const [tagSlugs, setTagSlugs] = useState(() => (artwork?.tags || []).map((t) => t.slug || t.name))
|
||||
const [visibility, setVisibility] = useState(artwork?.visibility || (artwork?.is_public ? 'public' : 'private'))
|
||||
const [publishMode, setPublishMode] = useState(artwork?.publish_mode || (artwork?.artwork_status === 'scheduled' ? 'schedule' : 'now'))
|
||||
@@ -522,7 +523,7 @@ export default function StudioArtworkEdit() {
|
||||
const syncCurrentPayload = useCallback((current) => {
|
||||
if (!current) return
|
||||
setTitle(current.title || '')
|
||||
setDescription(current.description || '')
|
||||
setDescription(normalizeMarkdownLiteContent(current.description || ''))
|
||||
setTagSlugs(Array.isArray(current.tags) ? current.tags : [])
|
||||
setContentTypeId(current.content_type_id || null)
|
||||
setCategoryId(current.category_id || null)
|
||||
@@ -1473,13 +1474,13 @@ export default function StudioArtworkEdit() {
|
||||
/>
|
||||
|
||||
<FormField label={<FieldLabel label="Description" actionLabel="Description" onAction={() => requestAiIntent('description')} disabled={aiAction !== ''} loading={aiAction === 'analyze' || aiAction === 'regenerate'} />} htmlFor="artwork-description">
|
||||
<RichTextEditor
|
||||
content={description}
|
||||
<UploadDescriptionEditor
|
||||
id="artwork-description"
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
placeholder="Describe your artwork, tools, inspiration…"
|
||||
error={errors.description?.[0]}
|
||||
minHeight={12}
|
||||
autofocus={false}
|
||||
rows={10}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
|
||||
@@ -31,7 +31,13 @@ export default function StudioArtworks() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
defaultSort="published_desc"
|
||||
sortStorageKey="studio-artworks-sort"
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,14 @@ export default function StudioCardsIndex() {
|
||||
</section>
|
||||
|
||||
<section className="mt-8">
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter emptyTitle="No cards yet" emptyBody="Create your first Nova card and it will appear here alongside your other Creator Studio content." />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
sortStorageKey="studio-cards-sort"
|
||||
emptyTitle="No cards yet"
|
||||
emptyBody="Create your first Nova card and it will appear here alongside your other Creator Studio content."
|
||||
/>
|
||||
</section>
|
||||
</StudioLayout>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,12 @@ export default function StudioCollections() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
sortStorageKey="studio-collections-sort"
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -544,33 +544,53 @@ function NewsTagInput({ options, selectedIds, newTagNames, onSelectedIdsChange,
|
||||
}
|
||||
|
||||
function RelationCard({ relation, index, onChange, onRemove, onSearch, results, relationTypeOptions }) {
|
||||
const isSourceRelation = String(relation.entity_type || '').trim().toLowerCase() === 'source'
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="grid gap-4 lg:grid-cols-[180px_minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
|
||||
<NovaSelect value={relation.entity_type} onChange={(val) => onChange(index, { ...relation, entity_type: val, entity_id: '', external_url: '', preview: null, query: '' })} options={relationTypeOptions} searchable={false} />
|
||||
</div>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
|
||||
<div className="flex gap-2">
|
||||
<input value={relation.query || ''} onChange={(event) => onChange(index, { ...relation, query: event.target.value })} placeholder="Search by name, slug, or title" 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(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</label>
|
||||
{isSourceRelation ? (
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Source URL</span>
|
||||
<input
|
||||
value={relation.external_url || ''}
|
||||
onChange={(event) => onChange(index, { ...relation, external_url: event.target.value, query: event.target.value, entity_id: '' })}
|
||||
placeholder="https://example.com/original-article"
|
||||
className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Search entity</span>
|
||||
<div className="flex gap-2">
|
||||
<input value={relation.query || ''} onChange={(event) => onChange(index, { ...relation, query: event.target.value })} placeholder="Search by name, slug, or title" 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(index)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white">Search</button>
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
<button type="button" onClick={() => onRemove(index)} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100">Remove</button>
|
||||
</div>
|
||||
|
||||
{relation.preview ? (
|
||||
{!isSourceRelation && relation.preview ? (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<div className="font-semibold">Linked: {relation.preview.title}</div>
|
||||
{relation.preview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-emerald-100/70">{relation.preview.subtitle}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4">
|
||||
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
|
||||
</div>
|
||||
{isSourceRelation ? (
|
||||
<div className="mt-4 rounded-2xl border border-sky-300/15 bg-sky-400/10 px-4 py-3 text-sm text-sky-100/90">
|
||||
Source relations store a direct external URL instead of an internal Nova entity ID.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<SearchResultList items={results} onSelect={(item) => onChange(index, { ...relation, entity_id: item.id, preview: item, query: item.title })} emptyLabel="Search to attach a related entity." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="mt-4 grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Context label</span>
|
||||
@@ -584,6 +604,22 @@ function stripHtml(value) {
|
||||
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function unwrapMarkdownLinkUrl(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const markdownMatch = raw.match(/^\[[^\]]+\]\((https?:\/\/[^)]+)\)$/i)
|
||||
if (markdownMatch) {
|
||||
return String(markdownMatch[1] || '').trim()
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
function isSourceRelationType(entityType) {
|
||||
return String(entityType || '').trim().toLowerCase() === 'source'
|
||||
}
|
||||
|
||||
const NEWS_NEW_TAG_LIMIT = 30
|
||||
|
||||
function slugifyNewsTitle(value) {
|
||||
@@ -633,7 +669,8 @@ function buildSubmitPayload(data) {
|
||||
relations: Array.isArray(data.relations)
|
||||
? data.relations.map((relation) => ({
|
||||
entity_type: String(relation.entity_type || '').trim(),
|
||||
entity_id: relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
|
||||
entity_id: isSourceRelationType(relation.entity_type) || relation.entity_id === '' || relation.entity_id == null ? '' : Number(relation.entity_id),
|
||||
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
|
||||
context_label: String(relation.context_label || '').trim(),
|
||||
}))
|
||||
: [],
|
||||
@@ -682,10 +719,13 @@ function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}
|
||||
og_image: String(getDraftValue(oldInput, 'og_image', article.og_image || '')),
|
||||
relations: Array.isArray(getDraftValue(oldInput, 'relations', article.relations)) ? getDraftValue(oldInput, 'relations', article.relations).map((relation) => ({
|
||||
entity_type: relation.entity_type || 'group',
|
||||
entity_id: relation.entity_id || '',
|
||||
entity_id: isSourceRelationType(relation.entity_type) ? '' : (relation.entity_id || ''),
|
||||
external_url: isSourceRelationType(relation.entity_type) ? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '') : '',
|
||||
context_label: relation.context_label || '',
|
||||
preview: relation.preview || null,
|
||||
query: relation.preview?.title || '',
|
||||
query: isSourceRelationType(relation.entity_type)
|
||||
? unwrapMarkdownLinkUrl(relation.external_url || relation.entity_id || relation.query || '')
|
||||
: (relation.preview?.title || relation.query || ''),
|
||||
})) : [],
|
||||
}
|
||||
}
|
||||
@@ -808,7 +848,6 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
applyString('meta_keywords')
|
||||
applyString('og_title')
|
||||
applyString('og_description')
|
||||
applyString('og_image')
|
||||
|
||||
if (parsed.type != null) {
|
||||
const requested = String(parsed.type).trim().toLowerCase()
|
||||
@@ -869,13 +908,21 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
|
||||
if (Array.isArray(parsed.relations)) {
|
||||
next.relations = parsed.relations
|
||||
.map((relation) => ({
|
||||
entity_type: String(relation?.entity_type || relation?.type || 'group').trim(),
|
||||
entity_id: relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
|
||||
context_label: String(relation?.context_label || relation?.label || '').trim(),
|
||||
preview: null,
|
||||
query: String(relation?.query || relation?.title || '').trim(),
|
||||
}))
|
||||
.map((relation) => {
|
||||
const entityType = String(relation?.entity_type || relation?.type || 'group').trim()
|
||||
const externalUrl = isSourceRelationType(entityType)
|
||||
? unwrapMarkdownLinkUrl(relation?.external_url || relation?.url || relation?.entity_id || relation?.query || relation?.title || '')
|
||||
: ''
|
||||
|
||||
return {
|
||||
entity_type: entityType,
|
||||
entity_id: isSourceRelationType(entityType) || relation?.entity_id == null || relation?.entity_id === '' ? '' : Number(relation.entity_id),
|
||||
external_url: externalUrl,
|
||||
context_label: String(relation?.context_label || relation?.label || '').trim(),
|
||||
preview: null,
|
||||
query: isSourceRelationType(entityType) ? externalUrl : String(relation?.query || relation?.title || '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((relation) => relation.entity_type)
|
||||
applied.push('relations')
|
||||
}
|
||||
@@ -991,6 +1038,231 @@ function buildNewsMarkdownExport(data) {
|
||||
return lines.join('\n\n').trim()
|
||||
}
|
||||
|
||||
// ── News image prompt builder ────────────────────────────────────────────────
|
||||
|
||||
const NEWS_PROMPT_TYPE_MOODS = {
|
||||
announcement: 'Futuristic',
|
||||
release: 'Software Release',
|
||||
editorial: 'Editorial',
|
||||
opinion: 'Editorial',
|
||||
tutorial: 'Clean Instructional',
|
||||
platform_update: 'Modern Tech',
|
||||
event: 'Futuristic',
|
||||
challenge: 'Futuristic',
|
||||
interview: 'Editorial',
|
||||
spotlight: 'Editorial',
|
||||
archive: 'Retro Tech',
|
||||
industry_news: 'Modern Tech',
|
||||
review: 'Modern Tech',
|
||||
roundup: 'Modern Tech',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_TYPE_ADDONS = {
|
||||
release: 'Use a glossy software-release poster style with product UI panels, feature highlights, and a polished launch atmosphere.',
|
||||
announcement: 'Use a clean announcement-poster style with a strong headline, clear hero image, and supporting modules that communicate the main update quickly.',
|
||||
editorial: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
opinion: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
event: 'Use a conference or event-poster style with keynote energy, glowing screens, stage-like lighting, and a premium event atmosphere.',
|
||||
tutorial: 'Use a clear structured instructional poster style with organized UI panels, workflow callouts, and helpful visual hierarchy.',
|
||||
platform_update: 'Use a modern platform-update style with system UI visuals, feature modules, and a polished ecosystem presentation.',
|
||||
archive: 'Use a retro-tech editorial style inspired by early 2000s computer magazines, with classic hardware, vintage UI influences, and modern polished lighting.',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_KEYWORD_PATTERNS = [
|
||||
{
|
||||
keywords: ['apple', 'wwdc', 'ios', 'macos', 'iphone', 'ipad', 'swift'],
|
||||
addon: 'Use a sleek developer-conference atmosphere with modern device screens, app ecosystem visuals, and a premium keynote mood.',
|
||||
},
|
||||
{
|
||||
keywords: ['google', 'gemini', 'google i/o', 'android', 'pixel', 'tensorflow'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, image and video creation tools, search elements, and generative media visuals.',
|
||||
},
|
||||
{
|
||||
keywords: ['intel', 'amd', 'processor', 'cpu', 'gpu', 'nvidia', 'radeon', 'chip'],
|
||||
addon: 'Use a retro computing hardware feature style with processor chips, technical callouts, old-school PC references, and magazine-cover energy.',
|
||||
},
|
||||
{
|
||||
keywords: ['skin', 'theme', 'desktop', 'customize', 'customization', 'rainmeter', 'widget'],
|
||||
addon: 'Use a desktop customization promo style with theme previews, icon panels, widget windows, and a glossy desktop software aesthetic.',
|
||||
},
|
||||
{
|
||||
keywords: ['ai', 'artificial intelligence', 'llm', 'chatgpt', 'openai', 'midjourney', 'stable diffusion', 'generative'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, generative media outputs, neural network visuals, and advanced AI tool interfaces.',
|
||||
},
|
||||
]
|
||||
|
||||
function resolveNewsPromptHeadline(data) {
|
||||
return String(data.title || data.meta_title || '').trim() || 'Skinbase News'
|
||||
}
|
||||
|
||||
function resolveNewsPromptSubheadline(data) {
|
||||
const raw = String(data.excerpt || data.meta_description || '').replace(/<[^>]*>/g, '').trim()
|
||||
if (raw) {
|
||||
const words = raw.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ') + (words.length > 18 ? '…' : '')
|
||||
}
|
||||
const plain = String(data.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (plain) {
|
||||
const sentence = plain.split(/[.!?]/)[0].trim()
|
||||
if (sentence.length > 10) {
|
||||
const words = sentence.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ')
|
||||
}
|
||||
}
|
||||
return 'Latest technology and creative industry update'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTopic(data) {
|
||||
const parts = []
|
||||
const cat = String(data.category || '').trim()
|
||||
if (cat) parts.push(cat)
|
||||
const tagList = (Array.isArray(data.tag_names) ? data.tag_names : []).slice(0, 5).filter(Boolean)
|
||||
if (tagList.length) parts.push(tagList.join(', '))
|
||||
if (!parts.length) {
|
||||
const words = String(data.title || '').split(/\s+/).filter((w) => w.length > 3).slice(0, 4)
|
||||
if (words.length) parts.push(words.join(' '))
|
||||
}
|
||||
return parts.join(' · ') || 'Technology and digital culture news'
|
||||
}
|
||||
|
||||
function resolveNewsPromptType(data) {
|
||||
const raw = String(data.type || '').trim()
|
||||
if (!raw) return 'News'
|
||||
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function resolveNewsPromptHeroSubject(data) {
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const combined = `${title} ${tags}`
|
||||
const type = String(data.type || '').toLowerCase()
|
||||
|
||||
if (/apple|wwdc|ios|macos/.test(combined)) return 'sleek developer conference scene with modern Apple devices, app ecosystem screens, and a keynote stage atmosphere'
|
||||
if (/google|gemini|google i\/o/.test(combined)) return 'futuristic creative AI workspace with Google AI tools, image and video generation screens, and colorful generative panels'
|
||||
if (/intel|amd|cpu|processor|gpu|nvidia|radeon/.test(combined)) return 'high-detail processor chip and PC hardware setup with technical callouts and magazine-style editorial framing'
|
||||
if (/\bai\b|artificial intelligence|llm|chatgpt|openai|midjourney|stable diffusion/.test(combined)) return 'futuristic AI creative studio with generative media outputs, neural network interfaces, and glowing AI panels'
|
||||
if (/skin|theme|desktop|customiz|rainmeter|widget/.test(combined)) return 'polished desktop customization interface with theme previews, icon panels, and widget windows on a dark desktop'
|
||||
if (/game|gaming/.test(combined)) return 'immersive gaming setup or game UI with dynamic lighting, modern peripherals, and a premium game atmosphere'
|
||||
if (/microsoft|windows/.test(combined)) return 'modern Windows interface with system UI panels, taskbar, settings, and a polished OS environment'
|
||||
if (type === 'tutorial') return 'organized instructional workflow panel with step-by-step UI callouts and visual hierarchy'
|
||||
if (type === 'event') return 'keynote conference stage with large screens, glowing hall, and event atmosphere'
|
||||
if (type === 'archive') return 'retro computing hardware from the early 2000s with classic monitors and vintage PC aesthetic'
|
||||
return 'professional editorial tech workspace with software screens, feature panels, and a polished digital newsroom atmosphere'
|
||||
}
|
||||
|
||||
function resolveNewsPromptSupportingModules(data) {
|
||||
const type = String(data.type || '').toLowerCase()
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const combined = `${title} ${tags}`
|
||||
|
||||
if (type === 'release' || /release|launch|version/.test(combined)) return 'version badge, feature highlight cards, changelog strip, UI screenshots, product icon panels'
|
||||
if (type === 'tutorial') return 'step-by-step panels, UI callouts, workflow arrows, numbered feature blocks'
|
||||
if (type === 'event') return 'schedule panels, speaker cards, keynote countdown, location badge, feature preview cards'
|
||||
if (type === 'archive') return 'retro spec badges, vintage hardware panels, timeline strip, era-appropriate UI screenshots'
|
||||
if (/\bai\b|artificial intelligence|generative/.test(combined)) return 'AI feature cards, generative output previews, glowing interface panels, model capability badges'
|
||||
if (/hardware|chip|cpu|gpu/.test(combined)) return 'performance charts, spec comparison cards, hardware close-ups, benchmark badges'
|
||||
return 'feature cards, interface panels, product highlights, mini screenshots, icon blocks'
|
||||
}
|
||||
|
||||
function resolveNewsPromptMood(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_MOODS[type] || 'Modern Tech'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTypeAddon(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_ADDONS[type] || ''
|
||||
}
|
||||
|
||||
function resolveNewsPromptKeywordAddon(data) {
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const category = String(data.category || '').toLowerCase()
|
||||
const combined = `${title} ${tags} ${category}`
|
||||
const addons = []
|
||||
for (const pattern of NEWS_PROMPT_KEYWORD_PATTERNS) {
|
||||
if (pattern.keywords.some((kw) => combined.includes(kw))) {
|
||||
addons.push(pattern.addon)
|
||||
}
|
||||
}
|
||||
return [...new Set(addons)].join('\n')
|
||||
}
|
||||
|
||||
function buildNewsImagePrompt(data) {
|
||||
const headline = resolveNewsPromptHeadline(data)
|
||||
const subheadline = resolveNewsPromptSubheadline(data)
|
||||
const topic = resolveNewsPromptTopic(data)
|
||||
const newsType = resolveNewsPromptType(data)
|
||||
const heroSubject = resolveNewsPromptHeroSubject(data)
|
||||
const supportingModules = resolveNewsPromptSupportingModules(data)
|
||||
const mood = resolveNewsPromptMood(data)
|
||||
const typeAddon = resolveNewsPromptTypeAddon(data)
|
||||
const keywordAddon = resolveNewsPromptKeywordAddon(data)
|
||||
|
||||
const lines = [
|
||||
'Create a premium Skinbase news cover image in 16:9 aspect ratio.',
|
||||
'',
|
||||
'Design it as a professional editorial tech poster for a digital culture, software, hardware, AI, creative tools, desktop customization, or retro computing news article.',
|
||||
'',
|
||||
'ARTICLE DETAILS:',
|
||||
`Headline: "${headline}"`,
|
||||
`Subheadline: "${subheadline}"`,
|
||||
`Topic: ${topic}`,
|
||||
`News type: ${newsType}`,
|
||||
`Hero subject: ${heroSubject}`,
|
||||
`Supporting modules: ${supportingModules}`,
|
||||
`Mood: ${mood}`,
|
||||
'',
|
||||
'LAYOUT:',
|
||||
'Use a structured 16:9 news hero composition with:',
|
||||
'- Large bold headline in the upper-left or top-center',
|
||||
'- Smaller subtitle directly below the headline',
|
||||
'- One strong central hero visual',
|
||||
'- Supporting side panels, feature cards, icons, UI windows, diagrams, or mini screenshots',
|
||||
'- A bottom strip with 3 to 6 small highlight blocks or visual details',
|
||||
'- Clean spacing and a strong visual hierarchy',
|
||||
'',
|
||||
'VISUAL STYLE:',
|
||||
'Use a dark premium background with blue, cyan, violet, neon, or topic-matching accent colors. Add glossy highlights, subtle glow, cinematic depth, crisp lighting, and a polished high-tech editorial look.',
|
||||
'',
|
||||
'The image should feel like a professional magazine cover, software release poster, tech conference banner, or retro computing feature graphic. It should be visually rich, but still clean, readable, and organized.',
|
||||
'',
|
||||
'TEXT STYLE:',
|
||||
'Use bold clean sans-serif typography. Keep all visible text short and readable. Avoid long paragraphs inside the image. Use only short labels, feature names, or headline-style phrases.',
|
||||
'',
|
||||
'CONTENT DIRECTION:',
|
||||
'Represent the topic clearly through the central visual. Use relevant objects such as:',
|
||||
'- software windows',
|
||||
'- futuristic workstations',
|
||||
'- creative AI panels',
|
||||
'- computer chips',
|
||||
'- retro hardware',
|
||||
'- desktop customization elements',
|
||||
'- conference screens',
|
||||
'- app interface mockups',
|
||||
'- glowing diagrams',
|
||||
'- feature cards',
|
||||
'- product-style panels',
|
||||
'',
|
||||
'QUALITY RULES:',
|
||||
'Make it sharp, premium, polished, high detail, thumbnail-friendly, and suitable as a Skinbase news article cover image.',
|
||||
'',
|
||||
'Avoid clutter, random filler objects, unreadable microtext, messy typography, distorted UI, weak composition, watermarks, fake signatures, low-quality stock-photo style, and irrelevant logos.',
|
||||
]
|
||||
|
||||
if (typeAddon) {
|
||||
lines.push('', typeAddon)
|
||||
}
|
||||
if (keywordAddon) {
|
||||
lines.push('', keywordAddon)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function buildNewsExportPayloads(data, context = {}) {
|
||||
const normalized = buildSubmitPayload(data || {})
|
||||
const category = findNewsOptionById(context.categoryOptions, normalized.category_id)
|
||||
@@ -1058,12 +1330,14 @@ function buildNewsExportPayloads(data, context = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, articleData = {}, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||||
const backdropRef = useRef(null)
|
||||
const [activeImportTab, setActiveImportTab] = useState('input')
|
||||
const [copyFeedback, setCopyFeedback] = useState('')
|
||||
const [exportMode, setExportMode] = useState('full')
|
||||
const [markdownExportText, setMarkdownExportText] = useState(String(exportPayloads?.markdown || ''))
|
||||
const [promptText, setPromptText] = useState('')
|
||||
const [promptIsManual, setPromptIsManual] = useState(false)
|
||||
|
||||
const importTabs = [
|
||||
{ id: 'input', label: 'Input', description: 'Paste JSON and apply it to the editor.' },
|
||||
@@ -1071,6 +1345,7 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, expo
|
||||
{ id: 'docs', label: 'Documentation', description: 'Field notes and mapping rules.' },
|
||||
{ id: 'prompts', label: 'AI prompts', description: 'Prompt examples for generating structured news.' },
|
||||
{ id: 'export', label: 'Export', description: 'Copy the current article out as JSON, text, or Markdown.' },
|
||||
{ id: 'image_prompt', label: 'Image Prompt', description: 'Auto-generate a cover image prompt from article data.' },
|
||||
]
|
||||
|
||||
const structureExample = {
|
||||
@@ -1107,7 +1382,6 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, expo
|
||||
meta_keywords: 'sample news, structured import, editorial example',
|
||||
og_title: 'Sample News Title',
|
||||
og_description: 'This is a sample news OG description for the structured import example.',
|
||||
og_image: 'sample-news-cover.webp',
|
||||
}
|
||||
|
||||
const newsJsonSchemaSummary = `You are generating a Skinbase news article JSON object.
|
||||
@@ -1131,7 +1405,7 @@ Recommended fields:
|
||||
- is_featured: boolean
|
||||
- is_pinned: boolean
|
||||
- meta_title, meta_description, meta_keywords
|
||||
- og_title, og_description, og_image
|
||||
- og_title, og_description
|
||||
- tags: array of strings or objects with name/title/label/slug
|
||||
- tag_names: array of strings
|
||||
- tag_ids: array of ids if you already know them
|
||||
@@ -1156,7 +1430,7 @@ Transform the following article into a news payload for the editor.
|
||||
- Write content as HTML paragraphs.
|
||||
- Include 8 to 14 highly relevant tags.
|
||||
- Include category_id when possible, otherwise use category_slug or category to help matching.
|
||||
- Fill meta_title, meta_description, og_title, og_description, and og_image when available.
|
||||
- Fill meta_title, meta_description, og_title, and og_description when available.
|
||||
- Make comments_enabled true unless the source clearly says otherwise.
|
||||
|
||||
Input article text:
|
||||
@@ -1253,6 +1527,24 @@ Source article:
|
||||
}
|
||||
}, [activeImportTab, exportMode, exportPayloads, open])
|
||||
|
||||
// Auto-generate image prompt when the tab opens, or when article data changes
|
||||
// (unless the editor has manually modified the prompt text).
|
||||
useEffect(() => {
|
||||
if (!open || activeImportTab !== 'image_prompt') return
|
||||
if (promptIsManual) return
|
||||
setPromptText(buildNewsImagePrompt(articleData))
|
||||
}, [open, activeImportTab, articleData, promptIsManual])
|
||||
|
||||
const handleRegeneratePrompt = useCallback(() => {
|
||||
setPromptIsManual(false)
|
||||
setPromptText(buildNewsImagePrompt(articleData))
|
||||
}, [articleData])
|
||||
|
||||
const handleResetPrompt = useCallback(() => {
|
||||
setPromptIsManual(false)
|
||||
setPromptText(buildNewsImagePrompt(articleData))
|
||||
}, [articleData])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
@@ -1279,7 +1571,7 @@ Source article:
|
||||
</div>
|
||||
|
||||
<div className="border-b border-white/[0.06] px-4 py-4">
|
||||
<div className="grid gap-2 md:grid-cols-5">
|
||||
<div className="grid gap-2 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
{importTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -1317,7 +1609,7 @@ Source article:
|
||||
<p>`is_featured`, `is_pinned`, `comments_enabled`</p>
|
||||
<p>`tags`, `tag_names`, `tag_ids`, `relations`</p>
|
||||
<p>`new_tag_names` is capped at {newTagLimit} items per article.</p>
|
||||
<p>`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`, `og_image`</p>
|
||||
<p>`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1457,6 +1749,72 @@ Source article:
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeImportTab === 'image_prompt' ? (
|
||||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_320px]">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">
|
||||
Generated cover image prompt
|
||||
{promptIsManual ? <span className="ml-2 rounded-full border border-amber-300/20 bg-amber-400/10 px-2 py-0.5 text-[10px] text-amber-100">Manually edited</span> : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRegeneratePrompt}
|
||||
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
{promptIsManual ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetPrompt}
|
||||
className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1.5 text-xs font-semibold text-amber-100 transition hover:bg-amber-400/20"
|
||||
>
|
||||
Reset to auto
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(promptText, 'Image prompt')}
|
||||
className="rounded-full border border-sky-300/25 bg-sky-400/90 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:brightness-110"
|
||||
>
|
||||
Copy prompt
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={promptText}
|
||||
onChange={(event) => {
|
||||
setPromptText(event.target.value)
|
||||
setPromptIsManual(true)
|
||||
}}
|
||||
rows={22}
|
||||
spellCheck={false}
|
||||
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm leading-6 text-white outline-none placeholder:text-white/30"
|
||||
placeholder="Opening the tab will generate a prompt automatically…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">How it works</div>
|
||||
<div className="mt-3 space-y-3 leading-6 text-slate-400">
|
||||
<p>The prompt is built automatically from the current article fields: title, excerpt, type, category, and tags.</p>
|
||||
<p>You can edit the prompt freely. It will be marked as <span className="rounded border border-amber-300/20 bg-amber-400/10 px-1 text-amber-100">Manually edited</span> once you change it.</p>
|
||||
<p>Click <strong className="text-slate-200">Regenerate</strong> or <strong className="text-slate-200">Reset to auto</strong> to rebuild from the current article state.</p>
|
||||
<p>Copy the prompt and paste it into an AI image generator such as Midjourney, DALL-E, Stable Diffusion, or Flux.</p>
|
||||
</div>
|
||||
<div className="mt-5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Auto-filled from</div>
|
||||
<ul className="mt-2 space-y-1 text-xs leading-6 text-slate-400">
|
||||
<li><span className="text-slate-300">Headline</span> — title, meta_title</li>
|
||||
<li><span className="text-slate-300">Subheadline</span> — excerpt, meta_description, content</li>
|
||||
<li><span className="text-slate-300">Topic</span> — category, tags</li>
|
||||
<li><span className="text-slate-300">Type</span> — article type</li>
|
||||
<li><span className="text-slate-300">Hero / Mood</span> — inferred from title, tags, type</li>
|
||||
<li><span className="text-slate-300">Addons</span> — type-based and keyword-based style blocks</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{copyFeedback ? (
|
||||
@@ -1490,7 +1848,7 @@ Source article:
|
||||
Copy export
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
) : activeImportTab === 'image_prompt' ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply?.()}
|
||||
@@ -1529,6 +1887,7 @@ export default function StudioNewsEditor() {
|
||||
const normalizedInitialPayload = useMemo(() => JSON.stringify(buildSubmitPayload(initialFormData)), [initialFormData])
|
||||
const normalizedCurrentPayload = useMemo(() => JSON.stringify(buildSubmitPayload(form.data)), [form.data])
|
||||
const hasUnsavedChanges = normalizedCurrentPayload !== normalizedInitialPayload
|
||||
const frontendArticleUrl = String(article.canonical_url || '').trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSyncedArticleKeyRef.current === articleSyncKey) {
|
||||
@@ -1646,6 +2005,25 @@ export default function StudioNewsEditor() {
|
||||
author: selectedAuthor,
|
||||
}), [form.data, props.categoryOptions, props.tagOptions, selectedAuthor])
|
||||
|
||||
const imagePromptArticleData = useMemo(() => {
|
||||
const category = findNewsOptionById(props.categoryOptions, form.data.category_id)
|
||||
const existingTags = findNewsTagsByIds(props.tagOptions, form.data.tag_ids)
|
||||
return {
|
||||
title: form.data.title,
|
||||
excerpt: form.data.excerpt,
|
||||
content: form.data.content,
|
||||
type: form.data.type,
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
category_slug: category?.slug ?? '',
|
||||
tag_names: [
|
||||
...existingTags.map((t) => t.name),
|
||||
...(Array.isArray(form.data.new_tag_names) ? form.data.new_tag_names : []),
|
||||
],
|
||||
meta_title: form.data.meta_title,
|
||||
meta_description: form.data.meta_description,
|
||||
}
|
||||
}, [form.data, props.categoryOptions, props.tagOptions])
|
||||
|
||||
useEffect(() => {
|
||||
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
|
||||
if (firstErrorTab) {
|
||||
@@ -1684,6 +2062,7 @@ export default function StudioNewsEditor() {
|
||||
{
|
||||
entity_type: props.relationTypeOptions?.[0]?.value || 'group',
|
||||
entity_id: '',
|
||||
external_url: '',
|
||||
context_label: '',
|
||||
preview: null,
|
||||
query: '',
|
||||
@@ -1825,8 +2204,8 @@ export default function StudioNewsEditor() {
|
||||
<ToastStack toasts={toasts} onDismiss={dismissToast} />
|
||||
<div className="space-y-6 pb-24">
|
||||
<section className="overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-white/10 px-5 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="grid gap-5 border-b border-white/10 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_360px] lg:items-start">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{props.indexUrl ? <a href={props.indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to news list</a> : null}
|
||||
<span>{article.id ? `Article #${article.id}` : 'New article'}</span>
|
||||
@@ -1836,13 +2215,29 @@ export default function StudioNewsEditor() {
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">Keep the draft flow simple: write the story in one place, handle publishing in one place, and keep promotion metadata nearby instead of buried below the fold.</p>
|
||||
</div>
|
||||
|
||||
{coverPreviewUrl ? (
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_40px_rgba(2,6,23,0.35)]">
|
||||
<div className="relative aspect-[16/9] bg-black/30">
|
||||
<img src={coverPreviewUrl} alt={String(form.data.title || '').trim() || 'News cover preview'} className="h-full w-full object-cover" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#020611d9] via-[#02061144] to-transparent" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Header cover preview</div>
|
||||
<div className="mt-1 line-clamp-2 text-sm font-semibold text-white">{String(form.data.title || '').trim() || 'Cover image preview'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="sticky top-16 z-30 border-y border-white/10 bg-[linear-gradient(180deg,rgba(9,14,24,0.98),rgba(6,10,18,0.98))] px-4 py-3 backdrop-blur">
|
||||
<div className="flex justify-end gap-2 overflow-x-auto">
|
||||
{frontendArticleUrl ? <a href={frontendArticleUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-cyan-300/20 bg-cyan-400/10 px-4 py-2.5 text-sm font-semibold text-cyan-100 transition hover:bg-cyan-400/15">Frontend link</a> : null}
|
||||
{props.previewUrl ? <a href={props.previewUrl} target="_blank" rel="noreferrer" className="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-4 py-2.5 text-sm font-semibold text-indigo-100 transition hover:bg-indigo-400/15">Preview</a> : null}
|
||||
<button type="button" onClick={() => setJsonImportOpen(true)} className="rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Import JSON</button>
|
||||
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">{form.processing ? 'Saving…' : 'Save article'}</button>
|
||||
<button type="submit" form="studio-news-editor-form" disabled={form.processing} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2.5 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60">
|
||||
{hasUnsavedChanges && !form.processing ? <span className="h-2.5 w-2.5 rounded-full bg-rose-400 shadow-[0_0_10px_rgba(251,113,133,0.9)] animate-pulse" aria-hidden="true" /> : null}
|
||||
<span>{form.processing ? 'Saving…' : 'Save article'}</span>
|
||||
</button>
|
||||
{props.publishUrl ? <button type="button" onClick={() => router.post(props.publishUrl)} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2.5 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-400/15">Publish now</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1965,6 +2360,11 @@ export default function StudioNewsEditor() {
|
||||
autofocus={false}
|
||||
advancedNews
|
||||
searchEntities={searchEntities}
|
||||
mediaSupport={{
|
||||
uploadUrl: props.coverUploadUrl,
|
||||
deleteUrl: props.coverDeleteUrl,
|
||||
slot: 'body',
|
||||
}}
|
||||
/>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
|
||||
Story workflow suggestion: lead with the change, explain why it matters, add supporting detail, then end with a clear call to action or next step.
|
||||
@@ -2187,6 +2587,7 @@ export default function StudioNewsEditor() {
|
||||
value={jsonImportValue}
|
||||
error={jsonImportError}
|
||||
exportPayloads={jsonExportPayloads}
|
||||
articleData={imagePromptArticleData}
|
||||
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
|
||||
onChange={(nextValue) => {
|
||||
setJsonImportValue(nextValue)
|
||||
|
||||
@@ -30,7 +30,12 @@ export default function StudioStories() {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<StudioContentBrowser listing={props.listing} quickCreate={props.quickCreate} hideModuleFilter />
|
||||
<StudioContentBrowser
|
||||
listing={props.listing}
|
||||
quickCreate={props.quickCreate}
|
||||
hideModuleFilter
|
||||
sortStorageKey="studio-stories-sort"
|
||||
/>
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
312
resources/js/Pages/Studio/__tests__/newsImagePrompt.test.js
Normal file
312
resources/js/Pages/Studio/__tests__/newsImagePrompt.test.js
Normal file
@@ -0,0 +1,312 @@
|
||||
// Vitest uses globals (configured in vite.config.mjs: test.globals = true).
|
||||
// Pure logic tests for the news image prompt builder helpers.
|
||||
// These helpers live in StudioNewsEditor.jsx — this file duplicates them
|
||||
// so they can be tested in isolation without pulling in the full editor bundle.
|
||||
|
||||
// ── Minimal copies of the prompt-builder helpers ──────────────────────────
|
||||
|
||||
const NEWS_PROMPT_TYPE_MOODS = {
|
||||
announcement: 'Futuristic',
|
||||
release: 'Software Release',
|
||||
editorial: 'Editorial',
|
||||
opinion: 'Editorial',
|
||||
tutorial: 'Clean Instructional',
|
||||
platform_update: 'Modern Tech',
|
||||
event: 'Futuristic',
|
||||
challenge: 'Futuristic',
|
||||
interview: 'Editorial',
|
||||
spotlight: 'Editorial',
|
||||
archive: 'Retro Tech',
|
||||
industry_news: 'Modern Tech',
|
||||
review: 'Modern Tech',
|
||||
roundup: 'Modern Tech',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_TYPE_ADDONS = {
|
||||
release: 'Use a glossy software-release poster style with product UI panels, feature highlights, and a polished launch atmosphere.',
|
||||
announcement: 'Use a clean announcement-poster style with a strong headline, clear hero image, and supporting modules that communicate the main update quickly.',
|
||||
editorial: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
opinion: 'Use a refined editorial magazine-cover style with a strong visual concept, cleaner composition, and slightly more cinematic atmosphere.',
|
||||
event: 'Use a conference or event-poster style with keynote energy, glowing screens, stage-like lighting, and a premium event atmosphere.',
|
||||
tutorial: 'Use a clear structured instructional poster style with organized UI panels, workflow callouts, and helpful visual hierarchy.',
|
||||
platform_update: 'Use a modern platform-update style with system UI visuals, feature modules, and a polished ecosystem presentation.',
|
||||
archive: 'Use a retro-tech editorial style inspired by early 2000s computer magazines, with classic hardware, vintage UI influences, and modern polished lighting.',
|
||||
}
|
||||
|
||||
const NEWS_PROMPT_KEYWORD_PATTERNS = [
|
||||
{
|
||||
keywords: ['apple', 'wwdc', 'ios', 'macos', 'iphone', 'ipad', 'swift'],
|
||||
addon: 'Use a sleek developer-conference atmosphere with modern device screens, app ecosystem visuals, and a premium keynote mood.',
|
||||
},
|
||||
{
|
||||
keywords: ['google', 'gemini', 'google i/o', 'android', 'pixel', 'tensorflow'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, image and video creation tools, search elements, and generative media visuals.',
|
||||
},
|
||||
{
|
||||
keywords: ['intel', 'amd', 'processor', 'cpu', 'gpu', 'nvidia', 'radeon', 'chip'],
|
||||
addon: 'Use a retro computing hardware feature style with processor chips, technical callouts, old-school PC references, and magazine-cover energy.',
|
||||
},
|
||||
{
|
||||
keywords: ['skin', 'theme', 'desktop', 'customize', 'customization', 'rainmeter', 'widget'],
|
||||
addon: 'Use a desktop customization promo style with theme previews, icon panels, widget windows, and a glossy desktop software aesthetic.',
|
||||
},
|
||||
{
|
||||
keywords: ['ai', 'artificial intelligence', 'llm', 'chatgpt', 'openai', 'midjourney', 'stable diffusion', 'generative'],
|
||||
addon: 'Use a colorful futuristic creative AI studio style with glowing panels, generative media outputs, neural network visuals, and advanced AI tool interfaces.',
|
||||
},
|
||||
]
|
||||
|
||||
function resolveNewsPromptHeadline(data) {
|
||||
return String(data.title || data.meta_title || '').trim() || 'Skinbase News'
|
||||
}
|
||||
|
||||
function resolveNewsPromptSubheadline(data) {
|
||||
const raw = String(data.excerpt || data.meta_description || '').replace(/<[^>]*>/g, '').trim()
|
||||
if (raw) {
|
||||
const words = raw.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ') + (words.length > 18 ? '…' : '')
|
||||
}
|
||||
const plain = String(data.content || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
if (plain) {
|
||||
const sentence = plain.split(/[.!?]/)[0].trim()
|
||||
if (sentence.length > 10) {
|
||||
const words = sentence.split(/\s+/)
|
||||
return words.slice(0, 18).join(' ')
|
||||
}
|
||||
}
|
||||
return 'Latest technology and creative industry update'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTopic(data) {
|
||||
const parts = []
|
||||
const cat = String(data.category || '').trim()
|
||||
if (cat) parts.push(cat)
|
||||
const tagList = (Array.isArray(data.tag_names) ? data.tag_names : []).slice(0, 5).filter(Boolean)
|
||||
if (tagList.length) parts.push(tagList.join(', '))
|
||||
if (!parts.length) {
|
||||
const words = String(data.title || '').split(/\s+/).filter((w) => w.length > 3).slice(0, 4)
|
||||
if (words.length) parts.push(words.join(' '))
|
||||
}
|
||||
return parts.join(' · ') || 'Technology and digital culture news'
|
||||
}
|
||||
|
||||
function resolveNewsPromptType(data) {
|
||||
const raw = String(data.type || '').trim()
|
||||
if (!raw) return 'News'
|
||||
return raw.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
function resolveNewsPromptMood(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_MOODS[type] || 'Modern Tech'
|
||||
}
|
||||
|
||||
function resolveNewsPromptTypeAddon(data) {
|
||||
const type = String(data.type || '').toLowerCase().replace(/\s+/g, '_')
|
||||
return NEWS_PROMPT_TYPE_ADDONS[type] || ''
|
||||
}
|
||||
|
||||
function resolveNewsPromptKeywordAddon(data) {
|
||||
const title = String(data.title || '').toLowerCase()
|
||||
const tags = (Array.isArray(data.tag_names) ? data.tag_names : []).join(' ').toLowerCase()
|
||||
const category = String(data.category || '').toLowerCase()
|
||||
const combined = `${title} ${tags} ${category}`
|
||||
const addons = []
|
||||
for (const pattern of NEWS_PROMPT_KEYWORD_PATTERNS) {
|
||||
if (pattern.keywords.some((kw) => combined.includes(kw))) {
|
||||
addons.push(pattern.addon)
|
||||
}
|
||||
}
|
||||
return [...new Set(addons)].join('\n')
|
||||
}
|
||||
|
||||
function buildNewsImagePrompt(data) {
|
||||
const headline = resolveNewsPromptHeadline(data)
|
||||
const subheadline = resolveNewsPromptSubheadline(data)
|
||||
const topic = resolveNewsPromptTopic(data)
|
||||
const newsType = resolveNewsPromptType(data)
|
||||
const mood = resolveNewsPromptMood(data)
|
||||
const typeAddon = resolveNewsPromptTypeAddon(data)
|
||||
const keywordAddon = resolveNewsPromptKeywordAddon(data)
|
||||
|
||||
const lines = [
|
||||
'Create a premium Skinbase news cover image in 16:9 aspect ratio.',
|
||||
'',
|
||||
`Headline: "${headline}"`,
|
||||
`Subheadline: "${subheadline}"`,
|
||||
`Topic: ${topic}`,
|
||||
`News type: ${newsType}`,
|
||||
`Mood: ${mood}`,
|
||||
]
|
||||
if (typeAddon) lines.push('', typeAddon)
|
||||
if (keywordAddon) lines.push('', keywordAddon)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('resolveNewsPromptHeadline', () => {
|
||||
it('returns title when present', () => {
|
||||
expect(resolveNewsPromptHeadline({ title: 'My Title' })).toBe('My Title')
|
||||
})
|
||||
|
||||
it('falls back to meta_title', () => {
|
||||
expect(resolveNewsPromptHeadline({ title: '', meta_title: 'Meta Title' })).toBe('Meta Title')
|
||||
})
|
||||
|
||||
it('returns fallback when both are empty', () => {
|
||||
expect(resolveNewsPromptHeadline({})).toBe('Skinbase News')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptSubheadline', () => {
|
||||
it('returns excerpt truncated to 18 words', () => {
|
||||
const long = 'word '.repeat(25).trim()
|
||||
const result = resolveNewsPromptSubheadline({ excerpt: long })
|
||||
expect(result.endsWith('…')).toBe(true)
|
||||
expect(result.split(/\s+/).filter((w) => !w.startsWith('…') && w !== '…').length).toBeLessThanOrEqual(18)
|
||||
})
|
||||
|
||||
it('returns excerpt as-is when short', () => {
|
||||
expect(resolveNewsPromptSubheadline({ excerpt: 'Short excerpt.' })).toBe('Short excerpt.')
|
||||
})
|
||||
|
||||
it('strips HTML tags from excerpt', () => {
|
||||
expect(resolveNewsPromptSubheadline({ excerpt: '<p>Clean text</p>' })).toBe('Clean text')
|
||||
})
|
||||
|
||||
it('returns fallback when all fields are empty', () => {
|
||||
expect(resolveNewsPromptSubheadline({})).toBe('Latest technology and creative industry update')
|
||||
})
|
||||
|
||||
it('falls back to meta_description when excerpt is missing', () => {
|
||||
expect(resolveNewsPromptSubheadline({ meta_description: 'Meta desc' })).toBe('Meta desc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptTopic', () => {
|
||||
it('includes category and tags', () => {
|
||||
const result = resolveNewsPromptTopic({ category: 'Tech News', tag_names: ['AI', 'Google'] })
|
||||
expect(result).toContain('Tech News')
|
||||
expect(result).toContain('AI')
|
||||
expect(result).toContain('Google')
|
||||
})
|
||||
|
||||
it('falls back to title keywords when no category or tags', () => {
|
||||
const result = resolveNewsPromptTopic({ title: 'Some Long Title With Words' })
|
||||
expect(result.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('returns generic fallback when all are empty', () => {
|
||||
expect(resolveNewsPromptTopic({})).toBe('Technology and digital culture news')
|
||||
})
|
||||
|
||||
it('caps tags at 5', () => {
|
||||
const data = { tag_names: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] }
|
||||
const result = resolveNewsPromptTopic(data)
|
||||
expect(result.split(',').length).toBeLessThanOrEqual(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptType', () => {
|
||||
it('capitalizes and humanizes underscored types', () => {
|
||||
expect(resolveNewsPromptType({ type: 'platform_update' })).toBe('Platform Update')
|
||||
})
|
||||
|
||||
it('returns "News" when type is missing', () => {
|
||||
expect(resolveNewsPromptType({})).toBe('News')
|
||||
})
|
||||
|
||||
it('handles simple types correctly', () => {
|
||||
expect(resolveNewsPromptType({ type: 'editorial' })).toBe('Editorial')
|
||||
expect(resolveNewsPromptType({ type: 'release' })).toBe('Release')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptMood', () => {
|
||||
it('returns correct mood for known types', () => {
|
||||
expect(resolveNewsPromptMood({ type: 'archive' })).toBe('Retro Tech')
|
||||
expect(resolveNewsPromptMood({ type: 'tutorial' })).toBe('Clean Instructional')
|
||||
expect(resolveNewsPromptMood({ type: 'announcement' })).toBe('Futuristic')
|
||||
expect(resolveNewsPromptMood({ type: 'release' })).toBe('Software Release')
|
||||
})
|
||||
|
||||
it('returns Modern Tech fallback for unknown type', () => {
|
||||
expect(resolveNewsPromptMood({ type: 'unknown_type' })).toBe('Modern Tech')
|
||||
expect(resolveNewsPromptMood({})).toBe('Modern Tech')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptTypeAddon', () => {
|
||||
it('returns addon text for known types', () => {
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'release' })).toContain('software-release poster')
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'event' })).toContain('conference or event-poster')
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'archive' })).toContain('retro-tech editorial')
|
||||
})
|
||||
|
||||
it('returns empty string for unknown type', () => {
|
||||
expect(resolveNewsPromptTypeAddon({ type: 'unknown' })).toBe('')
|
||||
expect(resolveNewsPromptTypeAddon({})).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNewsPromptKeywordAddon', () => {
|
||||
it('detects apple/WWDC keyword in title', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'Apple WWDC 2026 Keynote', tag_names: [] })
|
||||
expect(result).toContain('developer-conference atmosphere')
|
||||
})
|
||||
|
||||
it('detects AI keyword in tags', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'New Update', tag_names: ['AI', 'Gemini'] })
|
||||
expect(result).toContain('creative AI studio')
|
||||
})
|
||||
|
||||
it('detects desktop customization keyword', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'Best desktop customization tools', tag_names: [] })
|
||||
expect(result).toContain('desktop customization promo')
|
||||
})
|
||||
|
||||
it('returns empty string when no keywords match', () => {
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'Random Article', tag_names: ['general'] })
|
||||
expect(result).toBe('')
|
||||
})
|
||||
|
||||
it('deduplicates addons when multiple patterns match the same text', () => {
|
||||
// "AI" matches both ai pattern; ensure no duplicates
|
||||
const result = resolveNewsPromptKeywordAddon({ title: 'ai ai ai', tag_names: ['ai', 'artificial intelligence'] })
|
||||
const lines = result.split('\n').filter(Boolean)
|
||||
expect(lines.length).toBe(new Set(lines).size)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNewsImagePrompt', () => {
|
||||
it('includes the article headline in the prompt', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'Google I/O 2026 Keynote', type: 'announcement' })
|
||||
expect(prompt).toContain('Google I/O 2026 Keynote')
|
||||
})
|
||||
|
||||
it('includes the type addon for release articles', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'App Launch', type: 'release' })
|
||||
expect(prompt).toContain('software-release poster')
|
||||
})
|
||||
|
||||
it('includes the keyword addon when tags match', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'New skin', tag_names: ['desktop', 'theme'], type: 'release' })
|
||||
expect(prompt).toContain('desktop customization promo')
|
||||
})
|
||||
|
||||
it('uses fallback headline when title is missing', () => {
|
||||
const prompt = buildNewsImagePrompt({})
|
||||
expect(prompt).toContain('Skinbase News')
|
||||
})
|
||||
|
||||
it('contains the 16:9 aspect ratio instruction', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'Test' })
|
||||
expect(prompt).toContain('16:9')
|
||||
})
|
||||
|
||||
it('contains the mood line', () => {
|
||||
const prompt = buildNewsImagePrompt({ title: 'Tutorial Article', type: 'tutorial' })
|
||||
expect(prompt).toContain('Clean Instructional')
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,10 @@ import { usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import TagInput from '../../components/tags/TagInput'
|
||||
import UploadWizard from '../../components/upload/UploadWizard'
|
||||
import UploadDescriptionEditor from '../../components/upload/UploadDescriptionEditor'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
|
||||
import { validateMarkdownLiteContent } from '../../utils/contentValidation'
|
||||
|
||||
const phases = {
|
||||
idle: 'idle',
|
||||
@@ -177,7 +179,7 @@ function getTypeKey(ct) {
|
||||
return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
|
||||
}
|
||||
|
||||
function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs, userId }) {
|
||||
function useUploadMachine({ draftId = null, filesCdnUrl = '', chunkSize, chunkRequestTimeoutMs, userId = null } = {}) {
|
||||
const [state, dispatch] = useReducer(reducer, { ...initialState, draftId })
|
||||
const pollRef = useRef(null)
|
||||
const adaptiveChunkSizeRef = useRef(Math.max(1, Number(chunkSize || 0)))
|
||||
@@ -548,6 +550,14 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeout
|
||||
return
|
||||
}
|
||||
|
||||
const descriptionErrors = validateMarkdownLiteContent(state.metadata.description)
|
||||
if (descriptionErrors.length > 0) {
|
||||
const message = descriptionErrors[0]
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
||||
pushNotice('error', message)
|
||||
return
|
||||
}
|
||||
|
||||
if (!state.metadata.licenseAccepted) {
|
||||
const message = 'You must confirm ownership of the artwork.'
|
||||
dispatch({ type: 'UPLOAD_ERROR', error: message })
|
||||
@@ -619,7 +629,7 @@ function useUploadMachine({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeout
|
||||
}
|
||||
}
|
||||
|
||||
export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkRequestTimeoutMs }) {
|
||||
export default function UploadPage({ draftId = null, filesCdnUrl = '', chunkSize, chunkRequestTimeoutMs } = {}) {
|
||||
const { props } = usePage()
|
||||
const pageTitle = 'Upload Artwork — Creator Studio'
|
||||
const pageDescription = 'Submit a new artwork, complete the required metadata, and publish it from Skinbase Creator Studio.'
|
||||
@@ -745,6 +755,7 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
|
||||
)
|
||||
const categoryOptions = useMemo(() => selectedType?.categories || [], [selectedType])
|
||||
const hasAtLeastOneTag = useMemo(() => parseUiTags(state.metadata.tags).length > 0, [state.metadata.tags])
|
||||
const descriptionErrors = useMemo(() => validateMarkdownLiteContent(state.metadata.description), [state.metadata.description])
|
||||
|
||||
useEffect(() => {
|
||||
// Prefer server-provided props, else try fetching from API endpoints
|
||||
@@ -1047,13 +1058,17 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
|
||||
|
||||
<label className="mt-4 block text-sm">
|
||||
<span className="text-white/80">Description</span>
|
||||
<textarea
|
||||
value={state.metadata.description}
|
||||
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { description: e.target.value } })}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
|
||||
rows={4}
|
||||
placeholder="Tell the story behind this artwork."
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<UploadDescriptionEditor
|
||||
id="legacy-upload-description"
|
||||
value={state.metadata.description}
|
||||
onChange={(value) => dispatch({ type: 'SET_METADATA', payload: { description: value } })}
|
||||
placeholder="Tell the story behind this artwork."
|
||||
error={descriptionErrors[0] || ''}
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
{descriptionErrors.length > 0 && <p className="mt-2 text-xs text-red-200">{descriptionErrors[0]}</p>}
|
||||
</label>
|
||||
|
||||
<div className="mt-4">
|
||||
@@ -1099,9 +1114,10 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize, chunkReque
|
||||
!state.metadata.category ||
|
||||
!hasAtLeastOneTag ||
|
||||
!state.metadata.description.trim() ||
|
||||
descriptionErrors.length > 0 ||
|
||||
!state.metadata.licenseAccepted
|
||||
}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
|
||||
className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || descriptionErrors.length > 0 || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
|
||||
>
|
||||
<i className="fa-solid fa-rocket" aria-hidden="true"></i>
|
||||
Start upload
|
||||
|
||||
@@ -32,6 +32,11 @@ const pages = {
|
||||
'!./Pages/Academy/**/__tests__/**',
|
||||
'!./Pages/Academy/**/*.test.jsx',
|
||||
]),
|
||||
...import.meta.glob([
|
||||
'./Pages/Enhance/**/*.jsx',
|
||||
'!./Pages/Enhance/**/__tests__/**',
|
||||
'!./Pages/Enhance/**/*.test.jsx',
|
||||
]),
|
||||
}
|
||||
|
||||
function resolvePage(name) {
|
||||
|
||||
@@ -389,6 +389,8 @@ export default function StudioContentBrowser({
|
||||
quickCreate = [],
|
||||
hideModuleFilter = false,
|
||||
hideBucketFilter = false,
|
||||
defaultSort = 'updated_desc',
|
||||
sortStorageKey = null,
|
||||
emptyTitle = 'Nothing here yet',
|
||||
emptyBody = 'Try adjusting filters or create something new.',
|
||||
}) {
|
||||
@@ -400,7 +402,7 @@ export default function StudioContentBrowser({
|
||||
const [pendingFilters, setPendingFilters] = useState({
|
||||
q: '',
|
||||
bucket: 'all',
|
||||
sort: 'updated_desc',
|
||||
sort: defaultSort,
|
||||
content_type: 'all',
|
||||
category: 'all',
|
||||
tag: '',
|
||||
@@ -466,12 +468,41 @@ export default function StudioContentBrowser({
|
||||
setPendingFilters({
|
||||
q: filters.q || '',
|
||||
bucket: filters.bucket || 'all',
|
||||
sort: filters.sort || 'updated_desc',
|
||||
sort: filters.sort || defaultSort,
|
||||
content_type: filters.content_type || 'all',
|
||||
category: filters.category || 'all',
|
||||
tag: filters.tag || '',
|
||||
})
|
||||
}, [filters.q, filters.bucket, filters.sort, filters.content_type, filters.category, filters.tag])
|
||||
}, [filters.q, filters.bucket, filters.sort, filters.content_type, filters.category, filters.tag, defaultSort])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortStorageKey) {
|
||||
return
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has('sort')) {
|
||||
return
|
||||
}
|
||||
|
||||
const storedSort = window.localStorage.getItem(sortStorageKey)
|
||||
const sortOptions = new Set((listing?.sort_options || []).map((option) => option.value))
|
||||
const activeSort = filters.sort || defaultSort
|
||||
|
||||
if (!storedSort || !sortOptions.has(storedSort) || storedSort === activeSort) {
|
||||
return
|
||||
}
|
||||
|
||||
router.get(window.location.pathname, {
|
||||
...filters,
|
||||
sort: storedSort,
|
||||
page: 1,
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}, [sortStorageKey, listing?.sort_options, filters, defaultSort])
|
||||
|
||||
const updateQuery = (patch) => {
|
||||
const next = {
|
||||
@@ -491,6 +522,10 @@ export default function StudioContentBrowser({
|
||||
},
|
||||
})
|
||||
|
||||
if (sortStorageKey && typeof next.sort === 'string' && next.sort !== '') {
|
||||
window.localStorage.setItem(sortStorageKey, next.sort)
|
||||
}
|
||||
|
||||
router.get(window.location.pathname, next, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
@@ -882,7 +917,7 @@ export default function StudioContentBrowser({
|
||||
id="studio-filter-sort"
|
||||
options={selectOptions(listing?.sort_options || [])}
|
||||
value={pendingFilters.sort}
|
||||
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
|
||||
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? defaultSort)}
|
||||
placeholder="Recently updated"
|
||||
searchable={false}
|
||||
/>
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function Topbar({ user = null }) {
|
||||
</a>
|
||||
<div className="border-t border-neutral-700" />
|
||||
<a href={user.uploadUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Upload</a>
|
||||
<a href="/enhance" className="block px-4 py-2 text-sm hover:bg-white/5">Enhance</a>
|
||||
<a href="/studio/artworks" className="block px-4 py-2 text-sm hover:bg-white/5">Studio</a>
|
||||
{user.moderationUrl ? <a href={user.moderationUrl} className="block px-4 py-2 text-sm hover:bg-white/5">Moderation</a> : null}
|
||||
<a href="/dashboard" className="block px-4 py-2 text-sm hover:bg-white/5">Dashboard</a>
|
||||
|
||||
@@ -51,6 +51,15 @@ function ChartIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function EnhanceIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.8} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3l1.9 4.6L18.5 9l-4.6 1.4L12 15l-1.9-4.6L5.5 9l4.6-1.4L12 3Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 15l.95 2.05L21 18l-2.05.95L18 21l-.95-2.05L15 18l2.05-.95L18 15Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/* ShareIcon removed — now provided by ArtworkShareButton */
|
||||
|
||||
function FlagIcon() {
|
||||
@@ -215,6 +224,9 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
|
||||
const analyticsUrl = artwork?.management?.analytics_url
|
||||
|| (artwork?.viewer?.is_owner ? `/studio/artworks/${artwork.id}/analytics` : null)
|
||||
const enhanceUrl = artwork?.viewer?.is_owner && artwork?.id
|
||||
? `/enhance/create?artwork=${encodeURIComponent(artwork.id)}`
|
||||
: null
|
||||
const csrfToken = typeof document !== 'undefined'
|
||||
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
: null
|
||||
@@ -374,6 +386,16 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{enhanceUrl ? (
|
||||
<a
|
||||
href={enhanceUrl}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-violet-300/25 bg-violet-400/12 px-5 py-2.5 text-sm font-medium text-violet-50 transition-all duration-200 hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
|
||||
>
|
||||
<EnhanceIcon />
|
||||
Enhance image
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Report pill */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -451,6 +473,18 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{enhanceUrl ? (
|
||||
<a
|
||||
href={enhanceUrl}
|
||||
aria-label="Enhance artwork image"
|
||||
title="Enhance image"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-violet-300/25 bg-violet-400/12 px-3.5 py-2 text-xs font-medium text-violet-50 transition-all hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
|
||||
>
|
||||
<EnhanceIcon />
|
||||
Enhance
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
{/* Report */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
53
resources/js/components/enhance/BeforeAfterSlider.jsx
Normal file
53
resources/js/components/enhance/BeforeAfterSlider.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function BeforeAfterSlider({ beforeUrl, afterUrl, beforeAlt = 'Original image', afterAlt = 'Enhanced image' }) {
|
||||
const [position, setPosition] = React.useState(50)
|
||||
|
||||
if (!beforeUrl || !afterUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Before / after</p>
|
||||
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">Compare the original with the enhanced result</h3>
|
||||
</div>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-slate-200">{position}%</span>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-5 overflow-hidden rounded-[24px] border border-white/10 bg-black/40">
|
||||
<img src={beforeUrl} alt={beforeAlt} className="block w-full object-cover" />
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 overflow-hidden border-r border-white/80" style={{ width: `${position}%` }}>
|
||||
<img src={afterUrl} alt={afterAlt} className="block h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0" style={{ left: `calc(${position}% - 1px)` }}>
|
||||
<div className="flex h-full items-center">
|
||||
<div className="flex h-10 w-10 -translate-x-1/2 items-center justify-center rounded-full border border-white/80 bg-black/60 text-white shadow-[0_0_30px_rgba(15,23,42,0.5)]">
|
||||
<i className="fa-solid fa-left-right text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute left-3 top-3 rounded-full border border-white/10 bg-[#08111dd8] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-100">Original</div>
|
||||
<div className="pointer-events-none absolute right-3 top-3 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Enhanced</div>
|
||||
</div>
|
||||
|
||||
<label className="mt-5 block">
|
||||
<span className="sr-only">Adjust before and after comparison slider</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="1"
|
||||
value={position}
|
||||
onChange={(event) => setPosition(Number(event.target.value || 50))}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-white/10 accent-sky-300"
|
||||
aria-label="Adjust before and after comparison slider"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
resources/js/components/enhance/EnhanceStatusBadge.jsx
Normal file
34
resources/js/components/enhance/EnhanceStatusBadge.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
|
||||
const TONES = {
|
||||
pending: 'border-white/10 bg-white/[0.05] text-slate-200',
|
||||
queued: 'border-sky-300/20 bg-sky-400/12 text-sky-100',
|
||||
processing: 'border-violet-300/20 bg-violet-400/12 text-violet-100',
|
||||
completed: 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100',
|
||||
failed: 'border-rose-300/20 bg-rose-400/12 text-rose-100',
|
||||
cancelled: 'border-amber-300/20 bg-amber-400/12 text-amber-100',
|
||||
expired: 'border-white/10 bg-white/[0.05] text-slate-300',
|
||||
unknown: 'border-white/10 bg-white/[0.04] text-slate-300',
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
processing: 'Processing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
cancelled: 'Cancelled',
|
||||
expired: 'Expired',
|
||||
}
|
||||
|
||||
export default function EnhanceStatusBadge({ status, className = '' }) {
|
||||
const key = String(status || '').toLowerCase()
|
||||
const tone = TONES[key] || TONES.unknown
|
||||
const label = LABELS[key] || 'Unknown'
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone} ${className}`.trim()}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
14
resources/js/components/enhance/EnhanceStubWarning.jsx
Normal file
14
resources/js/components/enhance/EnhanceStubWarning.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function EnhanceStubWarning({ config, moderation = false, className = '' }) {
|
||||
if (!config?.showStubWarning) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm text-amber-50 ${className}`.trim()}>
|
||||
<div>Skinbase Enhance is currently running in preview mode. The generated result is a workflow placeholder until the real upscaling worker is enabled.</div>
|
||||
{moderation ? <div className="mt-2 text-xs uppercase tracking-[0.14em] text-amber-100/80">Engine: {config.engine}. This is not a real AI upscale result.</div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
239
resources/js/components/upload/UploadDescriptionEditor.jsx
Normal file
239
resources/js/components/upload/UploadDescriptionEditor.jsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import EmojiPickerButton from '../comments/EmojiPickerButton'
|
||||
|
||||
function ToolbarButton({ title, onClick, children, className = '' }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onClick?.()
|
||||
}}
|
||||
className={[
|
||||
'inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-xs font-semibold text-white/60 transition',
|
||||
'hover:bg-white/10 hover:text-white',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UploadDescriptionEditor({ id, value, onChange, placeholder, error, rows = 8 }) {
|
||||
const [tab, setTab] = useState('write')
|
||||
const textareaRef = useRef(null)
|
||||
|
||||
const focusTextarea = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
}, [])
|
||||
|
||||
const wrapSelection = useCallback((before, after, placeholderText = 'text') => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = `${before}${selected || placeholderText}${after}`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
if (selected) {
|
||||
textarea.selectionStart = start + replacement.length
|
||||
textarea.selectionEnd = start + replacement.length
|
||||
} else {
|
||||
textarea.selectionStart = start + before.length
|
||||
textarea.selectionEnd = start + before.length + placeholderText.length
|
||||
}
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const prefixLines = useCallback((prefix) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const fallback = prefix.endsWith('. ') ? `${prefix}item` : `${prefix}item`
|
||||
const source = selected || fallback
|
||||
const nextBlock = source.split('\n').map((line) => `${prefix}${line}`).join('\n')
|
||||
const next = current.slice(0, start) + nextBlock + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start
|
||||
textarea.selectionEnd = start + nextBlock.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertLink = useCallback(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const selected = current.slice(start, end)
|
||||
const replacement = selected && /^https?:\/\//i.test(selected)
|
||||
? `[link](${selected})`
|
||||
: `[link](https://)`
|
||||
const next = current.slice(0, start) + replacement + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
if (selected && /^https?:\/\//i.test(selected)) {
|
||||
textarea.selectionStart = start + 1
|
||||
textarea.selectionEnd = start + 5
|
||||
} else {
|
||||
const urlStart = start + replacement.indexOf('https://')
|
||||
textarea.selectionStart = urlStart
|
||||
textarea.selectionEnd = urlStart + 'https://'.length
|
||||
}
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const insertAtCursor = useCallback((text) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) {
|
||||
onChange?.(`${String(value || '')}${text}`)
|
||||
return
|
||||
}
|
||||
|
||||
const current = String(value || '')
|
||||
const start = textarea.selectionStart ?? current.length
|
||||
const end = textarea.selectionEnd ?? current.length
|
||||
const next = current.slice(0, start) + text + current.slice(end)
|
||||
|
||||
onChange?.(next)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.selectionStart = start + text.length
|
||||
textarea.selectionEnd = start + text.length
|
||||
})
|
||||
}, [onChange, value])
|
||||
|
||||
const handleKeyDown = useCallback((event) => {
|
||||
const withModifier = event.ctrlKey || event.metaKey
|
||||
if (!withModifier) return
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'b':
|
||||
event.preventDefault()
|
||||
wrapSelection('**', '**')
|
||||
break
|
||||
case 'i':
|
||||
event.preventDefault()
|
||||
wrapSelection('*', '*')
|
||||
break
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
insertLink()
|
||||
break
|
||||
case 'e':
|
||||
event.preventDefault()
|
||||
wrapSelection('`', '`')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}, [insertLink, wrapSelection])
|
||||
|
||||
const previewValue = String(value || '').trim()
|
||||
|
||||
return (
|
||||
<div className={`overflow-hidden rounded-xl border bg-white/10 ${error ? 'border-red-300/60' : 'border-white/15'}`}>
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('write')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'write' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('preview')}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition ${tab === 'preview' ? 'bg-white/12 text-white' : 'text-white/55 hover:text-white/80'}`}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] uppercase tracking-[0.16em] text-white/40">
|
||||
Safe formatting only
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'write' && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-1 border-b border-white/10 px-2 py-1">
|
||||
<ToolbarButton title="Bold (Ctrl+B)" onClick={() => wrapSelection('**', '**')}>B</ToolbarButton>
|
||||
<ToolbarButton title="Italic (Ctrl+I)" onClick={() => wrapSelection('*', '*')}>I</ToolbarButton>
|
||||
<ToolbarButton title="Inline code (Ctrl+E)" onClick={() => wrapSelection('`', '`')}>{'</>'}</ToolbarButton>
|
||||
<ToolbarButton title="Link (Ctrl+K)" onClick={insertLink}>Link</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<ToolbarButton title="Bulleted list" onClick={() => prefixLines('- ')}>List</ToolbarButton>
|
||||
<ToolbarButton title="Numbered list" onClick={() => prefixLines('1. ')}>1.</ToolbarButton>
|
||||
<ToolbarButton title="Quote" onClick={() => prefixLines('> ')}>Quote</ToolbarButton>
|
||||
<span className="mx-1 h-4 w-px bg-white/10" aria-hidden="true" />
|
||||
<EmojiPickerButton onEmojiSelect={insertAtCursor} className="h-8 w-8 rounded-md text-white/60 hover:bg-white/10 hover:text-white" />
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={rows}
|
||||
className="w-full resize-y bg-transparent px-3 py-3 text-sm text-white placeholder-white/45 focus:outline-none"
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 pb-2 text-[11px] text-white/45">
|
||||
<span>Supports bold, italic, code, links, lists, quotes, and emoji.</span>
|
||||
<button type="button" onClick={focusTextarea} className="text-white/50 transition hover:text-white/80">Continue editing</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'preview' && (
|
||||
<div className="min-h-[188px] px-3 py-3">
|
||||
{previewValue ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none text-white/85 [&_a]:text-sky-300 [&_a]:no-underline hover:[&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-white/20 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-white/10 [&_code]:px-1 [&_code]:py-0.5 [&_ul]:pl-4 [&_ol]:pl-4">
|
||||
<ReactMarkdown
|
||||
allowedElements={['p', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'blockquote', 'br']}
|
||||
unwrapDisallowed
|
||||
components={{
|
||||
a: ({ href, children }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer nofollow">{children}</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{previewValue}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm italic text-white/35">Nothing to preview yet.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
import RichTextEditor from '../forum/RichTextEditor'
|
||||
import SchedulePublishPicker from './SchedulePublishPicker'
|
||||
import UploadDescriptionEditor from './UploadDescriptionEditor'
|
||||
|
||||
export default function UploadSidebar({
|
||||
title = 'Artwork details',
|
||||
@@ -53,15 +53,17 @@ export default function UploadSidebar({
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
|
||||
<div className="mt-2">
|
||||
<RichTextEditor
|
||||
content={metadata.description}
|
||||
onChange={onChangeDescription}
|
||||
placeholder="Describe your artwork, tools, inspiration…"
|
||||
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
|
||||
minHeight={12}
|
||||
autofocus={false}
|
||||
/>
|
||||
<UploadDescriptionEditor
|
||||
id="upload-sidebar-description"
|
||||
value={metadata.description}
|
||||
onChange={onChangeDescription}
|
||||
placeholder="Describe your artwork, tools, inspiration..."
|
||||
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
|
||||
rows={9}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-white/50">This upload editor only allows safe formatting and emoji. Images, embeds, and raw HTML are blocked.</p>
|
||||
{errors.description && <p className="mt-1 text-xs text-red-200">{Array.isArray(errors.description) ? errors.description[0] : errors.description}</p>}
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getContentTypeValue,
|
||||
getProcessingTransparencyLabel,
|
||||
} from '../../lib/uploadUtils'
|
||||
import { validateMarkdownLiteContent } from '../../utils/contentValidation'
|
||||
|
||||
// ─── Wizard step config ───────────────────────────────────────────────────────
|
||||
const wizardSteps = [
|
||||
@@ -335,6 +336,15 @@ export default function UploadWizard({
|
||||
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
|
||||
errors.category = 'Subcategory is required for the selected category.'
|
||||
}
|
||||
if (!Array.isArray(metadata.tags) || metadata.tags.length === 0) errors.tags = 'Add at least one tag.'
|
||||
if (!String(metadata.description || '').trim()) {
|
||||
errors.description = 'Description is required.'
|
||||
} else {
|
||||
const descriptionErrors = validateMarkdownLiteContent(metadata.description)
|
||||
if (descriptionErrors.length > 0) {
|
||||
errors.description = descriptionErrors[0]
|
||||
}
|
||||
}
|
||||
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
|
||||
return errors
|
||||
}, [metadata, requiresSubCategory])
|
||||
@@ -381,9 +391,11 @@ export default function UploadWizard({
|
||||
hasCompleteCategory &&
|
||||
hasTag &&
|
||||
hasRequiredScreenshot &&
|
||||
String(metadata.description || '').trim() &&
|
||||
!metadataErrors.description &&
|
||||
metadata.rightsAccepted &&
|
||||
machine.state !== machineStates.publishing
|
||||
), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.rightsAccepted, machine.state])
|
||||
), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.description, metadata.rightsAccepted, metadataErrors.description, machine.state])
|
||||
|
||||
const canScheduleSubmit = useMemo(() => {
|
||||
if (!canPublish) return false
|
||||
|
||||
@@ -199,6 +199,30 @@ function contentViewEventType(contentType) {
|
||||
return 'academy_content_view'
|
||||
}
|
||||
|
||||
function analyticsMetadata(analytics, extra = {}) {
|
||||
const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
|
||||
|
||||
return {
|
||||
page_name: analytics?.pageName,
|
||||
...metadata,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
function analyticsTrackingKey(analytics) {
|
||||
if (analytics?.trackingKey) {
|
||||
return String(analytics.trackingKey)
|
||||
}
|
||||
|
||||
const metadata = analytics?.metadata && typeof analytics.metadata === 'object' ? analytics.metadata : {}
|
||||
const pairs = Object.entries(metadata)
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${String(value)}`)
|
||||
|
||||
return pairs.join('|')
|
||||
}
|
||||
|
||||
export function trackUpgradeClick(analytics, metadata = {}) {
|
||||
if (!analytics?.eventUrl) {
|
||||
return
|
||||
@@ -217,20 +241,17 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}`
|
||||
const trackingKey = analyticsTrackingKey(analytics)
|
||||
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}:${trackingKey || 'default'}`
|
||||
|
||||
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:page-view`,
|
||||
})
|
||||
|
||||
if (analytics.contentType || analytics.contentId) {
|
||||
void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:content-view`,
|
||||
@@ -238,9 +259,7 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
}
|
||||
|
||||
if (analytics.isPremium && analytics.isLocked) {
|
||||
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:premium-preview`,
|
||||
@@ -248,10 +267,9 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
}
|
||||
|
||||
const engagedTimer = window.setTimeout(() => {
|
||||
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
|
||||
engaged_seconds: 15,
|
||||
}, {
|
||||
}), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:engaged`,
|
||||
@@ -275,10 +293,9 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
}
|
||||
|
||||
sentMilestones.add(milestone.threshold)
|
||||
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, analyticsMetadata(analytics, {
|
||||
scroll_percent: milestone.threshold,
|
||||
}, {
|
||||
}), {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:scroll-${milestone.threshold}`,
|
||||
@@ -292,5 +309,5 @@ export function useAcademyPageAnalytics(analytics) {
|
||||
window.clearTimeout(engagedTimer)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName])
|
||||
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName, analytics?.trackingKey, JSON.stringify(analytics?.metadata || {})])
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import React from 'react'
|
||||
import { cleanup, render, waitFor } from '@testing-library/react'
|
||||
|
||||
function prepareEnvironment() {
|
||||
document.head.innerHTML = '<meta name="csrf-token" content="csrf-token" />'
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('visitor-123')
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
|
||||
const storage = new Map([['academy.analytics.visitor-id', 'visitor-123']])
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => storage.get(String(key)) ?? null)
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key, value) => {
|
||||
storage.set(String(key), String(value))
|
||||
})
|
||||
globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true, headers: { get: () => 'application/json' }, json: () => Promise.resolve({ ok: true }) }))
|
||||
}
|
||||
|
||||
function cleanupEnvironment() {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
document.head.innerHTML = ''
|
||||
}
|
||||
@@ -66,5 +73,73 @@ test('academy search click attribution falls back to keepalive fetch when sendBe
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(globalThis.fetch.mock.calls[0][1].keepalive).toBe(true)
|
||||
|
||||
cleanupEnvironment()
|
||||
})
|
||||
|
||||
test('academy page analytics includes custom metadata and varies page-view once keys by tracking context', async () => {
|
||||
prepareEnvironment()
|
||||
|
||||
const { useAcademyPageAnalytics } = await import('./academyAnalytics.js')
|
||||
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
})
|
||||
|
||||
function TestPage({ analytics }) {
|
||||
useAcademyPageAnalytics(analytics)
|
||||
return React.createElement('div', null, 'Academy page')
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
React.createElement(TestPage, {
|
||||
analytics: {
|
||||
enabled: true,
|
||||
eventUrl: '/academy/analytics/events',
|
||||
pageName: 'academy_prompts_popular',
|
||||
contentType: 'academy_prompt_popular',
|
||||
contentId: null,
|
||||
trackingKey: 'period:30d',
|
||||
metadata: { period: '30d', period_days: 30 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
const firstPageView = globalThis.fetch.mock.calls
|
||||
.map((call) => JSON.parse(call[1].body))
|
||||
.find((payload) => payload.event_type === 'academy_page_view')
|
||||
|
||||
expect(firstPageView.metadata.period).toBe('30d')
|
||||
expect(firstPageView.metadata.period_days).toBe(30)
|
||||
|
||||
rerender(
|
||||
React.createElement(TestPage, {
|
||||
analytics: {
|
||||
enabled: true,
|
||||
eventUrl: '/academy/analytics/events',
|
||||
pageName: 'academy_prompts_popular',
|
||||
contentType: 'academy_prompt_popular',
|
||||
contentId: null,
|
||||
trackingKey: 'period:7d',
|
||||
metadata: { period: '7d', period_days: 7 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
const pageViews = globalThis.fetch.mock.calls
|
||||
.map((call) => JSON.parse(call[1].body))
|
||||
.filter((payload) => payload.event_type === 'academy_page_view')
|
||||
|
||||
expect(pageViews).toHaveLength(2)
|
||||
expect(pageViews[1].metadata.period).toBe('7d')
|
||||
|
||||
cleanupEnvironment()
|
||||
})
|
||||
@@ -23,6 +23,17 @@ function normalizeType(value, fallback = 'error') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function firstValidationError(errors) {
|
||||
if (!errors || typeof errors !== 'object') return ''
|
||||
|
||||
for (const value of Object.values(errors)) {
|
||||
if (Array.isArray(value) && value[0]) return String(value[0]).trim()
|
||||
if (typeof value === 'string' && value.trim()) return value.trim()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
||||
const status = Number(error?.response?.status || 0)
|
||||
const payload = error?.response?.data || {}
|
||||
@@ -30,6 +41,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
||||
const mapped = REASON_MAP[reason]
|
||||
const errorCode = String(error?.code || '').toUpperCase()
|
||||
const rawMessage = typeof error?.message === 'string' ? error.message.trim() : ''
|
||||
const validationMessage = firstValidationError(payload?.errors)
|
||||
const timedOut = errorCode === 'ECONNABORTED' || /timeout/i.test(rawMessage)
|
||||
const requestTooLarge = status === 413
|
||||
|
||||
@@ -41,6 +53,7 @@ export function mapUploadErrorNotice(error, fallback = 'Upload failed.') {
|
||||
(requestTooLarge ? 'Server rejected this upload chunk as too large. Retrying with smaller chunks may help, or increase Nginx/PHP upload limits.' : '') ||
|
||||
(timedOut ? 'Upload request timed out before the server responded. Check Nginx/PHP-FPM body handling and try again.' : '') ||
|
||||
mapped?.message ||
|
||||
validationMessage ||
|
||||
(typeof payload?.message === 'string' && payload.message.trim()) ||
|
||||
rawMessage ||
|
||||
fallback
|
||||
|
||||
@@ -3,12 +3,31 @@ import React from 'react'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
import UploadPage from './Pages/Upload/Index'
|
||||
|
||||
const pages = {
|
||||
const staticPages = {
|
||||
'Upload/Index': UploadPage,
|
||||
}
|
||||
|
||||
const dynamicPages = Object.fromEntries(
|
||||
Object.entries(import.meta.glob('./Pages/Enhance/**/*.jsx')).map(([path, resolver]) => [
|
||||
path.replace('./Pages/', '').replace('.jsx', ''),
|
||||
resolver,
|
||||
])
|
||||
)
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => pages[name],
|
||||
resolve: (name) => {
|
||||
if (staticPages[name]) {
|
||||
return staticPages[name]
|
||||
}
|
||||
|
||||
const page = dynamicPages[name]
|
||||
|
||||
if (!page) {
|
||||
throw new Error(`Unknown upload page: ${name}`)
|
||||
}
|
||||
|
||||
return page().then((module) => module.default)
|
||||
},
|
||||
setup({ el, App, props }) {
|
||||
mountInertiaRoot(el, App, props)
|
||||
},
|
||||
|
||||
93
resources/js/utils/contentValidation.js
Normal file
93
resources/js/utils/contentValidation.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { countEmoji, FLOOD_DENSITY_THRESHOLD, FLOOD_COUNT_THRESHOLD } from './emojiFlood'
|
||||
|
||||
const HTML_TAG_RE = /<[a-z][^>]*>/i
|
||||
const MAX_CONTENT_LENGTH = 10000
|
||||
|
||||
function decodeHtmlEntities(value) {
|
||||
const decoded = String(value || '')
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return decoded
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/&/gi, '&')
|
||||
.replace(/</gi, '<')
|
||||
.replace(/>/gi, '>')
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/gi, "'")
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.innerHTML = decoded
|
||||
return textarea.value
|
||||
}
|
||||
|
||||
function stripResidualTags(value) {
|
||||
return String(value || '').replace(/<[^>]+>/g, '')
|
||||
}
|
||||
|
||||
export function normalizeMarkdownLiteContent(value) {
|
||||
const raw = String(value || '')
|
||||
const trimmed = raw.trim()
|
||||
|
||||
if (!trimmed || !HTML_TAG_RE.test(trimmed)) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const normalized = raw
|
||||
.replace(/<\s*a[^>]*href=(['"])(.*?)\1[^>]*>([\s\S]*?)<\s*\/a\s*>/gi, (_, __, href, label) => {
|
||||
const text = stripResidualTags(label).trim() || href
|
||||
return `[${text}](${href})`
|
||||
})
|
||||
.replace(/<\s*(strong|b)(?:\s+[^>]*)?>([\s\S]*?)<\s*\/\s*\1\s*>/gi, (_, __, text) => `**${stripResidualTags(text)}**`)
|
||||
.replace(/<\s*(em|i)(?:\s+[^>]*)?>([\s\S]*?)<\s*\/\s*\1\s*>/gi, (_, __, text) => `*${stripResidualTags(text)}*`)
|
||||
.replace(/<\s*code(?:\s+[^>]*)?>([\s\S]*?)<\s*\/code\s*>/gi, (_, text) => `\`${stripResidualTags(text)}\``)
|
||||
.replace(/<\s*br\s*\/?>/gi, '\n')
|
||||
.replace(/<\s*\/p\s*>/gi, '\n\n')
|
||||
.replace(/<\s*p(?:\s+[^>]*)?>/gi, '')
|
||||
.replace(/<\s*li(?:\s+[^>]*)?>([\s\S]*?)<\s*\/li\s*>/gi, (_, text) => `- ${stripResidualTags(text).trim()}\n`)
|
||||
.replace(/<\s*\/ul\s*>|<\s*\/ol\s*>/gi, '\n')
|
||||
.replace(/<\s*(ul|ol)(?:\s+[^>]*)?>/gi, '')
|
||||
.replace(/<\s*blockquote(?:\s+[^>]*)?>([\s\S]*?)<\s*\/blockquote\s*>/gi, (_, text) => {
|
||||
const lines = stripResidualTags(text)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `> ${line}`)
|
||||
return `${lines.join('\n')}\n\n`
|
||||
})
|
||||
.replace(/<[^>]+>/g, '')
|
||||
|
||||
return decodeHtmlEntities(normalized)
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[\t ]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function validateMarkdownLiteContent(value) {
|
||||
const raw = String(value || '')
|
||||
const trimmed = raw.trim()
|
||||
|
||||
if (!trimmed) return []
|
||||
|
||||
const errors = []
|
||||
|
||||
if (trimmed.length > MAX_CONTENT_LENGTH) {
|
||||
errors.push('Content exceeds maximum length of 10,000 characters.')
|
||||
}
|
||||
|
||||
if (HTML_TAG_RE.test(trimmed)) {
|
||||
errors.push('HTML tags are not allowed. Use Markdown formatting instead.')
|
||||
}
|
||||
|
||||
const emojiCount = countEmoji(trimmed)
|
||||
if (emojiCount > FLOOD_COUNT_THRESHOLD) {
|
||||
errors.push('Too many emoji. Please limit emoji usage.')
|
||||
}
|
||||
|
||||
if (emojiCount > 5 && trimmed.length > 0 && (emojiCount / trimmed.length) > FLOOD_DENSITY_THRESHOLD) {
|
||||
errors.push('Content is mostly emoji. Please add some text.')
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
33
resources/js/utils/enhanceFormatting.js
Normal file
33
resources/js/utils/enhanceFormatting.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const dateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat('en-US')
|
||||
|
||||
export function formatEnhanceDate(value) {
|
||||
if (!value) return '—'
|
||||
|
||||
const parsed = new Date(value)
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return `${dateFormatter.format(parsed)} UTC`
|
||||
}
|
||||
|
||||
export function formatEnhanceInteger(value) {
|
||||
const parsed = Number(value)
|
||||
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
return numberFormatter.format(parsed)
|
||||
}
|
||||
Reference in New Issue
Block a user