Optimize academy

This commit is contained in:
2026-06-09 13:16:01 +02:00
parent f89ee937c0
commit 5af95f6533
109 changed files with 6862 additions and 719 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import React, { useState, useRef, useEffect } from 'react'
import { Head, Link, useForm, usePage } from '@inertiajs/react'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
function formatDate(iso) {
@@ -12,6 +12,72 @@ function formatDate(iso) {
}
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
const { flash, auth } = usePage().props
const { data, setData, post, processing } = useForm({
issue_type: 'billing',
contact_email: auth?.user?.email || '',
message: '',
session_id: null,
})
function IssueTypeDropdown({ value, onChange }) {
const options = [
{ value: 'billing', label: 'Billing question' },
{ value: 'payment', label: 'Payment problem' },
{ value: 'upgrade', label: 'Upgrade problem' },
{ value: 'downgrade', label: 'Downgrade problem' },
{ value: 'cancel', label: 'Cancellation problem' },
{ value: 'access', label: 'Access not updated' },
{ value: 'other', label: 'Other' },
]
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
function onDoc(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const current = options.find((o) => o.value === value) || options[0]
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((s) => !s)}
className="w-full text-left rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 flex items-center justify-between"
>
<span>{current.label}</span>
<svg className="ml-2 h-4 w-4 text-amber-100/70" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
{open ? (
<div className="absolute left-0 top-full mt-2 w-full rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`w-full text-left px-4 py-3 text-sm ${opt.value === value ? 'bg-white/[0.03] text-white' : 'text-slate-300 hover:bg-white/[0.02]'}`}
>
{opt.label}
</button>
))}
</div>
) : null}
</div>
)
}
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
const endsAt = formatDate(subscription?.endsAt)
const onGracePeriod = subscription?.onGracePeriod === true
const subscriptionActive = subscription?.active === true
@@ -21,6 +87,16 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<Head title="Academy Subscription" />
<div className="mx-auto max-w-[1280px] space-y-8">
{flash?.error ? (
<section className="rounded-[20px] border border-rose-300/20 bg-rose-300/8 p-4">
<p className="font-semibold text-rose-100">{flash.error}</p>
</section>
) : null}
{flash?.success ? (
<section className="rounded-[20px] border border-emerald-300/20 bg-emerald-300/8 p-4">
<p className="font-semibold text-emerald-100">{flash.success}</p>
</section>
) : null}
{/* Header */}
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(15,23,42,0.96))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
<div className="flex flex-wrap items-center gap-3">
@@ -42,12 +118,12 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<section className="rounded-[30px] border border-amber-300/25 bg-amber-300/[0.06] px-6 py-5">
<p className="font-semibold text-amber-100">Your subscription was cancelled and will end on {endsAt}.</p>
<p className="mt-2 text-sm leading-6 text-amber-100/75">You still have full access until that date. Open the subscription portal to resume your plan if you change your mind.</p>
<Link
<a
href={links.portal}
className="mt-4 inline-flex items-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/20"
>
Resume subscription
</Link>
</a>
</section>
) : null}
@@ -123,20 +199,75 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<aside className="space-y-3 rounded-[32px] border border-white/10 bg-black/20 p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Manage</p>
<p className="text-xs leading-6 text-slate-400">
Use the subscription portal to upgrade, downgrade, or cancel. Changes take effect at your next billing date.
Use the subscription portal to cancel or manage billing details. Plan upgrades are handled here on Skinbase.
</p>
<Link
href={links.portal}
className="mt-2 inline-flex w-full items-center justify-center 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"
>
Upgrade, downgrade or cancel
</Link>
{/* Use a plain anchor to perform a full navigation to Stripe (avoid Inertia XHR/CORS) */}
<a
href={links.portal}
className="mt-2 inline-flex w-full items-center justify-center 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"
>
Open billing portal
</a>
<Link
href={links.pricing || '/academy/pricing'}
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
>
Compare plans
</Link>
{/* Quick upgrade form: allow Creator -> Pro upgrade in one click (full POST, not Inertia) */}
{activePlan?.tier === 'creator' ? (
<form action={links.checkout} method="POST" data-no-inertia className="mt-2">
<input type="hidden" name="_token" value={getCsrfToken()} />
<input type="hidden" name="plan" value="pro_monthly" />
<button type="submit" className="inline-flex w-full items-center justify-center rounded-full border border-emerald-300/25 bg-emerald-300/10 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18">Upgrade to Pro now</button>
</form>
) : null}
{links.reportIssue ? (
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-300/8 p-4">
<p className="text-sm font-semibold text-amber-100">Need help with billing or access?</p>
<p className="mt-1 text-xs leading-5 text-amber-100/80">
Send a quick report here if payment, access, or subscription changes do not behave as expected.
</p>
<form
onSubmit={(event) => {
event.preventDefault()
post(links.reportIssue, { preserveScroll: true })
}}
className="mt-3 space-y-3"
>
<div className="grid gap-3">
<label className="space-y-1 relative">
<span className="text-xs font-medium text-amber-100/80">Issue type</span>
{/* Custom dropdown to avoid native browser option styling */}
<IssueTypeDropdown value={data.issue_type} onChange={(v) => setData('issue_type', v)} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-amber-100/80">Reply email</span>
<input
type="email"
value={data.contact_email}
onChange={(event) => setData('contact_email', event.target.value)}
placeholder="you@example.com"
className="w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
</label>
</div>
<textarea
value={data.message}
onChange={(event) => setData('message', event.target.value)}
placeholder="Describe the issue you hit, what you expected, and anything already charged or missing"
className="min-h-[96px] w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
<button
type="submit"
disabled={processing}
className="inline-flex w-full items-center justify-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18 disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? 'Sending report...' : 'Send support report'}
</button>
</form>
</div>
) : null}
<Link
href={links.academy || '/academy'}
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"

View File

@@ -83,7 +83,7 @@ function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice
)
}
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) {
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics, missingRemote = [] }) {
const { auth, errors, flash } = usePage().props
useAcademyPageAnalytics(analytics)
@@ -151,6 +151,12 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
{errors?.plan ? <p className="mt-4 text-sm font-medium text-rose-200">{errors.plan}</p> : null}
{flash?.error ? <p className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm font-medium text-rose-100">{flash.error}</p> : null}
{flash?.success ? <p className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm font-medium text-emerald-100">{flash.success}</p> : null}
{Array.isArray(missingRemote) && missingRemote.length > 0 ? (
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm font-medium text-amber-50">
<p className="font-semibold">Purchases temporarily disabled:</p>
<p className="mt-1 text-xs">The following plans could not be verified in Stripe: {missingRemote.join(', ')}</p>
</div>
) : null}
</div>
<SidePanel
@@ -217,4 +223,4 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
</div>
</main>
)
}
}

View File

@@ -1,8 +1,12 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import { Head, Link, usePage, useForm } from '@inertiajs/react'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) {
const { auth } = usePage().props
const sessionId = usePage().props.sessionId || null
const userEmail = auth?.user?.email ?? null
const { data, setData, post, processing } = useForm({ message: '', session_id: sessionId })
return (
<main className="flex min-h-screen items-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.14),_transparent_24%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
<Head title="Subscription Confirmed" />
@@ -21,6 +25,25 @@ export default function AcademyBillingSuccess({ currentTier, isSubscribed, links
? 'Your subscription is active and all premium content for your plan is now unlocked. Head to Academy and start exploring.'
: "Your payment was confirmed and your subscription is activating now. This usually takes just a moment. If you don't see your access right away, refresh the Academy page in a few seconds."}
</p>
{!isSubscribed ? (
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm text-amber-50">
<p className="font-semibold">If your access isn't updated automatically</p>
<p className="mt-1">If your Academy access doesn't appear within a few minutes, email <strong>academy@skinbase.org</strong> or click the button below to open a prefilled message. Include your account email{userEmail ? ` (${userEmail})` : ''} and the checkout session id{sessionId ? `: ${sessionId}` : '.'}</p>
<div className="mt-3 flex flex-wrap items-start gap-4">
<form onSubmit={(e) => { e.preventDefault(); post(links.reportIssue, { preserveScroll: true }) }} className="flex w-full max-w-lg items-start gap-2">
<textarea value={data.message} onChange={(e) => setData('message', e.target.value)} placeholder="Optional: Tell us what you expected to see or any useful details" className="flex-1 rounded-md bg-black/20 border border-amber-300/20 p-2 text-sm text-amber-50" rows={3} />
<button type="submit" disabled={processing} className="rounded-full border border-amber-300/30 bg-amber-300/12 px-4 py-2 text-sm font-semibold text-amber-900 hover:bg-amber-300/16">Send report</button>
</form>
<div className="text-xs text-amber-100">
<div>- Wait 23 minutes and refresh the Academy page.</div>
<div>- If you still lack access, use the form above or email academy@skinbase.org.</div>
</div>
</div>
</div>
) : null}
</section>
<div className="flex flex-wrap gap-3">

View File

@@ -595,6 +595,192 @@ function PromptPlaceholderCard({ placeholder }) {
)
}
function PromptFilledExampleCard({ example, analytics, contentId, index }) {
const placeholderEntries = Object.entries(example?.placeholder_values || {}).filter(([key, value]) => String(key || '').trim() && value != null && value !== '' && value !== false)
return (
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.16)]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-200/75">Filled example {index + 1}</p>
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{example?.title || `Example ${index + 1}`}</h3>
{example?.description ? <p className="mt-3 text-sm leading-7 text-slate-300">{example.description}</p> : null}
</div>
{example?.prompt ? (
<PromptCopyButton
prompt={example.prompt}
label="Copy example"
analytics={analytics}
contentId={contentId}
eventType="academy_prompt_filled_example_copy"
metadata={{ copy_type: 'filled_example', filled_example_index: index, source: 'prompt_filled_examples' }}
/>
) : null}
</div>
{placeholderEntries.length ? (
<div className="mt-5 flex flex-wrap gap-2">
{placeholderEntries.map(([key, value]) => (
<span key={key} className="rounded-full border border-violet-300/20 bg-violet-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-violet-100">
{key}: <span className="normal-case tracking-normal text-white">{String(value)}</span>
</span>
))}
</div>
) : null}
{example?.prompt ? <pre className="mt-5 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100">{example.prompt}</pre> : null}
{example?.negative_prompt ? (
<div className="mt-4 rounded-[24px] border border-white/10 bg-slate-950/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
<PromptCopyButton
prompt={example.negative_prompt}
label="Copy negative"
analytics={analytics}
contentId={contentId}
eventType="academy_prompt_filled_example_negative_copy"
metadata={{ copy_type: 'filled_example_negative', filled_example_index: index, source: 'prompt_filled_examples' }}
/>
</div>
<pre className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{example.negative_prompt}</pre>
</div>
) : null}
</article>
)
}
function PromptFilledExamplesSection({ examples, analytics, contentId }) {
const visibleExamples = Array.isArray(examples) ? examples.filter((example) => example && typeof example === 'object') : []
const [activeExampleIndex, setActiveExampleIndex] = useState(0)
const examplesScrollRef = useRef(null)
const [canScrollExamplesLeft, setCanScrollExamplesLeft] = useState(false)
const [canScrollExamplesRight, setCanScrollExamplesRight] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') {
return undefined
}
const updateExampleScrollState = () => {
const element = examplesScrollRef.current
if (!element) {
setCanScrollExamplesLeft(false)
setCanScrollExamplesRight(false)
return
}
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
setCanScrollExamplesLeft(element.scrollLeft > 6)
setCanScrollExamplesRight(element.scrollLeft < maxScrollLeft - 6)
}
updateExampleScrollState()
const element = examplesScrollRef.current
if (!element) {
return undefined
}
element.addEventListener('scroll', updateExampleScrollState, { passive: true })
window.addEventListener('resize', updateExampleScrollState, { passive: true })
return () => {
element.removeEventListener('scroll', updateExampleScrollState)
window.removeEventListener('resize', updateExampleScrollState)
}
}, [visibleExamples.length])
useEffect(() => {
if (!visibleExamples.length) {
setActiveExampleIndex(0)
return
}
setActiveExampleIndex((current) => Math.max(0, Math.min(current, visibleExamples.length - 1)))
}, [visibleExamples.length])
if (!visibleExamples.length) return null
const activeExample = visibleExamples[activeExampleIndex] || visibleExamples[0]
const activeExampleLabel = String(activeExample?.title || '').trim() || `Example ${activeExampleIndex + 1}`
const activeExampleDescription = String(activeExample?.description || '').trim()
const scrollExamples = (direction) => {
const element = examplesScrollRef.current
if (!element) return
const amount = Math.max(220, Math.floor(element.clientWidth * 0.65))
element.scrollBy({
left: direction === 'left' ? -amount : amount,
behavior: 'smooth',
})
}
return (
<div className="mt-6 space-y-5">
<div className="rounded-[24px] border border-violet-300/15 bg-violet-300/10 p-5 md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Selected example</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2rem]">{activeExampleLabel}</h3>
{activeExampleDescription ? <p className="mt-3 text-sm leading-7 text-slate-200 md:text-base">{activeExampleDescription}</p> : null}
</div>
<div className="relative">
<div className={`pointer-events-none absolute inset-y-0 left-0 z-10 w-14 bg-gradient-to-r from-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesLeft ? '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-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesRight ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
<button
type="button"
aria-label="Scroll filled examples left"
onClick={() => scrollExamples('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 ${canScrollExamplesLeft ? '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 filled examples right"
onClick={() => scrollExamples('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 ${canScrollExamplesRight ? '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={examplesScrollRef} className="overflow-x-auto pb-1 scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="inline-flex min-w-full gap-2.5 px-1 py-1">
{visibleExamples.map((example, index) => {
const isActive = index === activeExampleIndex
const exampleLabel = String(example?.title || '').trim() || `Example ${index + 1}`
return (
<button
key={`${example.title || 'filled-example-tab'}-${index}`}
type="button"
onClick={() => setActiveExampleIndex(index)}
aria-pressed={isActive}
title={exampleLabel}
className={`max-w-full whitespace-nowrap rounded-full border px-4 py-2.5 text-sm font-semibold uppercase tracking-[0.18em] transition ${isActive ? 'border-violet-300/30 bg-violet-300/18 text-white shadow-[0_12px_30px_rgba(76,29,149,0.24)]' : 'border-white/10 bg-white/[0.04] text-violet-100/80 hover:border-violet-300/20 hover:bg-violet-300/10 hover:text-white'}`}
>
<span className="block max-w-[240px] truncate">{exampleLabel}</span>
</button>
)
})}
</div>
</div>
</div>
<PromptFilledExampleCard
key={`${activeExample.title || 'filled-example-active'}-${activeExampleIndex}`}
example={activeExample}
analytics={analytics}
contentId={contentId}
index={activeExampleIndex}
/>
</div>
)
}
function PromptHelperPromptCard({ helperPrompt, analytics, contentId }) {
if (!helperPrompt || typeof helperPrompt !== 'object') return null
@@ -1088,11 +1274,25 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|| promptDocumentation.data_accuracy_notes.length,
)
const hasPromptPlaceholders = Boolean(item?.has_placeholder_inputs) && promptPlaceholders.length > 0
const promptFilledExamples = Array.isArray(item?.filled_examples)
? item.filled_examples.filter((example) => example && typeof example === 'object' && [
example.title,
example.description,
example.prompt,
example.negative_prompt,
...(example.placeholder_values && typeof example.placeholder_values === 'object' ? Object.values(example.placeholder_values) : []),
].some((value) => value != null && value !== '' && value !== false))
: []
const hasPromptFilledExamples = promptFilledExamples.length > 0
const promptFilledExamplesTotal = Number(item?.filled_examples_total || promptFilledExamples.length || 0)
const promptHasMoreFilledExamples = Boolean(item?.has_more_filled_examples) || promptFilledExamplesTotal > promptFilledExamples.length
const promptHasFullFilledExamplesAccess = Boolean(item?.has_full_filled_examples_access)
const promptHasLockedFilledExamples = Boolean(item?.has_filled_examples) && (!Boolean(item?.can_access_filled_examples) || promptHasMoreFilledExamples)
const promptHasLockedHelperPrompts = Boolean(item?.has_helper_prompts) && !promptHasFullAccess
const promptHasLockedVariants = Boolean(item?.has_prompt_variants) && !promptHasFullAccess
const hasPromptHelperPrompts = promptHelperPrompts.length > 0
const hasPromptVariants = promptVariants.length > 0
const showPromptHelperPrompts = false
const showPromptHelperPrompts = true
const promptAccessRequirement = item?.access_requirement || promptRequirementText(item?.access_level)
const promptUnlockTitle = item?.unlock_heading || promptUnlockHeading(item?.access_level)
const promptUnlockDetails = item?.unlock_description || promptUnlockDescription(item?.access_level)
@@ -2103,6 +2303,45 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
</section>
) : null}
{hasPromptFilledExamples ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
{promptFilledExamplesTotal > 0 ? `${promptFilledExamplesTotal} ready-made prompt runs for real user inputs` : 'Ready-made prompt runs for real user inputs'}
</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
{promptHasMoreFilledExamples
? `You can view ${promptFilledExamples.length} example${promptFilledExamples.length === 1 ? '' : 's'} right now. Upgrade to Pro to unlock all ${promptFilledExamplesTotal} filled prompt runs and copy a closer starting point instead of filling everything from scratch.`
: 'These examples show how the prompt looks after swapping real placeholder values, so you can copy a closer starting point instead of filling everything from scratch.'}
</p>
</div>
<PromptFilledExamplesSection examples={promptFilledExamples} analytics={analytics} contentId={item.id} />
</section>
) : null}
{promptHasLockedFilledExamples ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
{promptHasMoreFilledExamples && hasPromptFilledExamples
? `${Math.max(promptFilledExamplesTotal - promptFilledExamples.length, 0)} more filled prompt example${promptFilledExamplesTotal - promptFilledExamples.length === 1 ? '' : 's'} are available`
: `${promptFilledExamplesTotal || 5} filled prompt examples are included`}
</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
{promptHasMoreFilledExamples && hasPromptFilledExamples
? 'Creator access includes a smaller set here. Upgrade to Academy Pro to unlock the remaining filled prompt runs.'
: 'This prompt ships with ready-made filled examples for different user inputs, but they unlock only for Academy Pro members.'}
</p>
</div>
<div className="mt-6">
<LockedPanel pricingUrl={pricingUrl} label="prompt" accessLevel="pro" onUpgrade={() => trackUpgradeClick(analytics, { source: 'prompt_filled_examples_locked_panel' })} />
</div>
</section>
) : null}
{showPromptHelperPrompts && hasPromptHelperPrompts ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
@@ -2230,4 +2469,4 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
<ImageLightbox gallery={lightboxGallery} onClose={() => setLightboxGallery(null)} onNavigate={navigateLightboxGallery} />
</main>
)
}
}

View File

@@ -52,6 +52,181 @@ function serializeStructuredJson(value) {
}
}
function parseStructuredJson(value) {
if (value == null || value === '') return null
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) {
return null
}
return JSON.parse(trimmed)
}
return value
}
function toDisplayText(value) {
if (value == null) return ''
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
if (Array.isArray(value)) return value.map((item) => toDisplayText(item)).filter(Boolean).join(', ')
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function humanizePlaceholderKey(value) {
const normalized = String(value || '')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) {
return 'Placeholder'
}
return normalized
.split(' ')
.map((part) => part ? `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}` : '')
.join(' ')
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function buildPlaceholderSeedValues(placeholder, limit = 5) {
const readableLabel = humanizePlaceholderKey(placeholder?.label || placeholder?.key || 'Placeholder')
const seeded = [
placeholder?.example,
placeholder?.default,
...(Array.isArray(placeholder?.examples) ? placeholder.examples : []),
...(Array.isArray(placeholder?.options) ? placeholder.options : []),
...(Array.isArray(placeholder?.choices) ? placeholder.choices : []),
...(Array.isArray(placeholder?.values) ? placeholder.values : []),
]
.map((entry) => toDisplayText(entry))
.map((entry) => entry.trim())
.filter(Boolean)
const unique = Array.from(new Set(seeded))
while (unique.length < limit) {
unique.push(`${readableLabel} ${unique.length + 1}`)
}
return unique.slice(0, limit)
}
function normalizePromptPlaceholders(value) {
if (!Array.isArray(value)) return []
return value
.map((placeholder) => {
if (!placeholder || typeof placeholder !== 'object') return null
const key = String(placeholder.key || '').trim()
const label = String(placeholder.label || '').trim()
if (!key && !label) {
return null
}
return {
...placeholder,
key,
label,
}
})
.filter(Boolean)
}
function applyPlaceholderValuesToPrompt(template, placeholderValues, placeholders) {
let nextText = String(template || '')
let replacementCount = 0
placeholders.forEach((placeholder) => {
const key = String(placeholder?.key || '').trim()
if (!key) return
const replacement = toDisplayText(placeholderValues[key])
if (!replacement) return
const patterns = [
new RegExp(`\\[${escapeRegExp(key)}\\]`, 'g'),
new RegExp(`\\{\\{\\s*${escapeRegExp(key)}\\s*\\}\\}`, 'g'),
new RegExp(`\\{${escapeRegExp(key)}\\}`, 'g'),
new RegExp(`<${escapeRegExp(key)}>`, 'g'),
]
patterns.forEach((pattern) => {
nextText = nextText.replace(pattern, () => {
replacementCount += 1
return replacement
})
})
})
if (replacementCount === 0 && placeholders.length > 0) {
const placeholderSummary = placeholders
.map((placeholder) => {
const key = String(placeholder?.key || '').trim()
if (!key) return null
const readableLabel = humanizePlaceholderKey(placeholder.label || key)
const replacement = toDisplayText(placeholderValues[key])
return replacement ? `- ${readableLabel}: ${replacement}` : null
})
.filter(Boolean)
.join('\n')
if (placeholderSummary) {
nextText = `${nextText.trim()}\n\nPlaceholder values:\n${placeholderSummary}`.trim()
}
}
return nextText.trim()
}
function buildStarterFilledExamples({ title, excerpt, prompt, negativePrompt, placeholders }) {
const normalizedPlaceholders = normalizePromptPlaceholders(placeholders)
const exampleCount = Math.min(5, Math.max(1, normalizedPlaceholders.length ? 5 : 1))
const fallbackTitle = stripPlainText(title) || 'Prompt'
const fallbackDescription = stripPlainText(excerpt) || 'Starter example generated from the current placeholders. Review and refine before publishing.'
return Array.from({ length: exampleCount }, (_, index) => {
const placeholderValues = normalizedPlaceholders.reduce((accumulator, placeholder) => {
const key = String(placeholder?.key || '').trim()
if (!key) return accumulator
const seeds = buildPlaceholderSeedValues(placeholder, 5)
accumulator[key] = seeds[index % seeds.length]
return accumulator
}, {})
const titleParts = Object.values(placeholderValues)
.map((value) => stripPlainText(value))
.filter(Boolean)
.slice(0, 2)
return {
title: titleParts.length > 0
? `Example ${index + 1} · ${titleParts.join(' · ')}`.slice(0, 180)
: `Example ${index + 1} · ${fallbackTitle}`.slice(0, 180),
description: `${fallbackDescription} Starter ${index + 1} for editors.`.trim(),
placeholder_values: placeholderValues,
prompt: applyPlaceholderValuesToPrompt(prompt, placeholderValues, normalizedPlaceholders),
negative_prompt: negativePrompt ? applyPlaceholderValuesToPrompt(negativePrompt, placeholderValues, normalizedPlaceholders) : '',
}
})
}
function copyTextToClipboard(text) {
const source = String(text || '')
if (!source) return Promise.reject(new Error('Nothing to copy'))
@@ -229,6 +404,7 @@ const PROMPT_FIELD_TAB_MAP = {
placeholders: 'advanced',
helper_prompts: 'advanced',
prompt_variants: 'advanced',
filled_examples: 'advanced',
preview_image: 'media',
preview_image_file: 'media',
published_at: 'publish',
@@ -436,6 +612,7 @@ function parsePromptImport(rawText, categoryOptions) {
if (parsed.placeholders != null) apply('placeholders', serializeStructuredJson(parsed.placeholders))
if (parsed.helper_prompts != null) apply('helper_prompts', serializeStructuredJson(parsed.helper_prompts))
if (parsed.prompt_variants != null) apply('prompt_variants', serializeStructuredJson(parsed.prompt_variants))
if (parsed.filled_examples != null) apply('filled_examples', serializeStructuredJson(parsed.filled_examples))
if (parsed.preview_image != null) apply('preview_image', String(parsed.preview_image))
if (parsed.preview_image_url != null && parsed.preview_image == null) apply('preview_image', String(parsed.preview_image_url))
if (parsed.published_at != null) apply('published_at', String(parsed.published_at))
@@ -619,6 +796,30 @@ function PromptJsonImportDialog({ open, value, error, onChange, onClose, onApply
active: true,
},
],
filled_examples: [
{
title: 'Alpine sunrise travel poster',
description: 'A scenic poster version tuned for crisp mountain light and clean copy-safe composition.',
placeholder_values: {
LOCATION: 'Lake Bled, Slovenia',
SEASON: 'spring',
MOOD: 'calm sunrise',
},
prompt: 'Create a calm sunrise travel poster of Lake Bled in spring, with clear mountain reflections, light mist, soft golden light, and a clean editorial composition.',
negative_prompt: 'muddy light, cluttered foreground, oversharpening, distorted architecture',
},
{
title: 'Misty forest variant',
description: 'Leans into atmosphere and fog while keeping the same placeholder structure.',
placeholder_values: {
LOCATION: 'Triglav National Park',
SEASON: 'autumn',
MOOD: 'misty cinematic',
},
prompt: 'Create a cinematic autumn landscape in Triglav National Park with layered mist, warm foliage, soft directional light, and strong depth.',
negative_prompt: 'flat composition, weak fog, repetitive trees, blown highlights',
},
],
preview_image: 'https://files.skinbase.org/prompts/peaceful-fantasy-forest.webp',
featured: false,
prompt_of_week: false,
@@ -659,6 +860,7 @@ Recommended fields:
- placeholders: array of prompt variable objects
- helper_prompts: array of supporting prompts used before or after the main prompt
- prompt_variants: array of alternative prompt versions
- filled_examples: array of up to 5 filled prompt examples with placeholder_values and final prompts
- preview_image: path or URL
- featured: boolean
- prompt_of_week: boolean
@@ -698,10 +900,18 @@ prompt_variants object fields:
- risk_notes
- active boolean
filled_examples object fields:
- title
- description
- placeholder_values: object keyed by placeholder name
- prompt
- negative_prompt
Rules:
- Return one JSON object only.
- Keep excerpt concise and readable in cards.
- Keep tags relevant and production-usable.
- Include exactly 5 filled_examples whenever the prompt uses placeholders or has clear user-editable parameters.
- If you include tool_notes, keep them normalized and consistent.`
const aiPromptExamples = [
@@ -714,6 +924,7 @@ Create a Skinbase Academy prompt template JSON object from the following creativ
- Write a prompt that is immediately usable.
- Write an excerpt that works in cards and search results.
- Add 5 to 12 focused tags.
- Include 5 filled_examples with realistic placeholder_values and ready-to-copy final prompts.
- Include 2 to 4 tool_notes comparisons when the brief mentions multiple AI providers.
Creative brief:
@@ -727,6 +938,7 @@ Generate a prompt template JSON object for Skinbase Academy.
- Focus on the same core prompt being tested across multiple AI image providers.
- Include tool_notes entries for each provider.
- Each tool_notes item should explain settings, strengths, weaknesses, and best_for in plain production language.
- Include 5 filled_examples that show how users would swap placeholder values in real projects.
- Return JSON only.
Source notes:
@@ -740,6 +952,7 @@ Convert the following source prompt page into structured Skinbase Academy prompt
- Preserve the core instruction intent.
- Normalize tags and metadata.
- Convert provider reviews into tool_notes.
- Generate 5 filled_examples that demonstrate realistic filled-in prompt runs for end users.
- Use category/category_slug when category_id is unknown.
- Return JSON only.
@@ -832,6 +1045,7 @@ Source content:
<p>usage_notes, workflow_notes</p>
<p>documentation, placeholders</p>
<p>helper_prompts, prompt_variants</p>
<p>filled_examples</p>
<p>preview_image, preview_image_url</p>
<p>published_at, seo_title, seo_description</p>
<p>featured, prompt_of_week, active</p>
@@ -855,7 +1069,7 @@ Source content:
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Notes</div>
<div className="mt-3 space-y-3 leading-6 text-slate-400">
<p>`tool_notes` can be an array of comparison objects or a simpler array under `comparisons`.</p>
<p>`documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` can be nested JSON and are preserved during import.</p>
<p>`documentation`, `placeholders`, `helper_prompts`, `prompt_variants`, and `filled_examples` can be nested JSON and are preserved during import.</p>
<p>`tags` can be strings or objects with `name`, `label`, `title`, or `slug`.</p>
<p>`preview_image` accepts either a stored path or an external URL.</p>
</div>
@@ -876,6 +1090,7 @@ Source content:
<p><strong className="text-slate-200">placeholders</strong> - prompt variables such as `CITY_NAME` or `MONTHLY_WEATHER_DATA`.</p>
<p><strong className="text-slate-200">helper_prompts</strong> - supporting prompts for data collection, validation, or refinement.</p>
<p><strong className="text-slate-200">prompt_variants</strong> - alternative versions of the same prompt for safer or model-specific output.</p>
<p><strong className="text-slate-200">filled_examples</strong> - up to 5 ready-to-copy filled prompt runs that show real placeholder substitutions.</p>
<p><strong className="text-slate-200">tool_notes</strong> - structured comparison notes for provider/model variants.</p>
<p><strong className="text-slate-200">preview_image</strong> - existing asset URL or stored path. File upload still happens separately.</p>
<p><strong className="text-slate-200">category_id</strong> is preferred when known. `category` or `category_slug` are used for best-effort matching.</p>
@@ -889,7 +1104,7 @@ Source content:
<p>Use JSON booleans for featured, prompt_of_week, and active.</p>
<p>Use `YYYY-MM-DD HH:MM:SS` for `published_at` when scheduling is needed.</p>
<p>Use `documentation` for longer public guidance, and keep `usage_notes` short and practical.</p>
<p>Use `helper_prompts` for data collection or validation prompts, and `prompt_variants` for safer or model-specific alternatives.</p>
<p>Use `helper_prompts` for data collection or validation prompts, `prompt_variants` for safer or model-specific alternatives, and `filled_examples` for ready-made filled prompt runs.</p>
<p>Keep comparison rows normalized so provider/model names remain consistent in the frontend.</p>
</div>
</div>
@@ -915,7 +1130,7 @@ Source content:
<div className="mt-3 space-y-2 leading-6 text-slate-400">
<p>Tell the model to return JSON only, with no explanation text.</p>
<p>Ask for `tool_notes` when you want provider-by-provider comparison output.</p>
<p>Ask for `documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` only when the prompt needs advanced structure.</p>
<p>Ask for `documentation`, `placeholders`, `helper_prompts`, `prompt_variants`, and `filled_examples` when the prompt needs advanced structure and user-ready examples.</p>
<p>Tell the model to keep titles and tags production-ready, not overly verbose.</p>
</div>
</div>
@@ -1799,6 +2014,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
const placeholdersField = useMemo(() => getField(fields, 'placeholders'), [fields])
const helperPromptsField = useMemo(() => getField(fields, 'helper_prompts'), [fields])
const promptVariantsField = useMemo(() => getField(fields, 'prompt_variants'), [fields])
const filledExamplesField = useMemo(() => getField(fields, 'filled_examples'), [fields])
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
const [activeTab, setActiveTab] = useState('overview')
const [jsonImportOpen, setJsonImportOpen] = useState(false)
@@ -1876,6 +2092,60 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
}
}
const generateStarterFilledExamples = () => {
let parsedPlaceholders
try {
parsedPlaceholders = parseStructuredJson(form.data.placeholders)
} catch {
const message = `${placeholdersField?.label || 'Placeholders JSON'} must be valid JSON before generating filled examples.`
form.setError('placeholders', message)
setActiveTab('advanced')
showToast(message, 'error')
return
}
const normalizedPlaceholders = normalizePromptPlaceholders(parsedPlaceholders)
if (normalizedPlaceholders.length === 0) {
const message = 'Add at least one placeholder before generating starter filled examples.'
form.setError('placeholders', message)
setActiveTab('advanced')
showToast(message, 'error')
return
}
const promptText = String(form.data.prompt || '').trim()
if (!promptText) {
const message = 'Write the main prompt before generating starter filled examples.'
form.setError('prompt', message)
setActiveTab('prompt')
showToast(message, 'error')
return
}
const existingExamples = String(form.data.filled_examples || '').trim()
if (existingExamples && typeof window !== 'undefined' && !window.confirm('Replace the current filled examples with a new 5-example starter set?')) {
return
}
const generatedExamples = buildStarterFilledExamples({
title: form.data.title,
excerpt: form.data.excerpt,
prompt: promptText,
negativePrompt: form.data.negative_prompt,
placeholders: normalizedPlaceholders,
})
form.clearErrors('placeholders')
form.clearErrors('filled_examples')
form.setData('filled_examples', serializeStructuredJson(generatedExamples))
setActiveTab('advanced')
showToast('Generated 5 starter filled examples. Review them before saving.', 'success')
}
const submit = (event) => {
event.preventDefault()
@@ -1884,6 +2154,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
{ name: 'placeholders', label: placeholdersField?.label || 'Placeholders JSON' },
{ name: 'helper_prompts', label: helperPromptsField?.label || 'Helper Prompts JSON' },
{ name: 'prompt_variants', label: promptVariantsField?.label || 'Prompt Variants JSON' },
{ name: 'filled_examples', label: filledExamplesField?.label || 'Filled Examples JSON' },
]
const parsedJsonFields = {}
@@ -1943,6 +2214,12 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
form.post(submitUrl, submitOptions)
}
const hasRequiredCategory = useMemo(() => {
const existing = String(form.data.category_id || '').trim()
const named = String(form.data.new_category_name || '').trim()
return Boolean(existing || named)
}, [form.data.category_id, form.data.new_category_name])
return (
<AdminLayout title={title} subtitle={subtitle}>
<Head title={`Admin · ${title}`} />
@@ -1996,7 +2273,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
<i className="fa-solid fa-file-import text-xs" />
<span>Import JSON</span>
</button>
<button type="submit" disabled={form.processing} className="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-sky-300/25 bg-sky-300/18 px-4 py-2 text-sm font-semibold text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition hover:bg-sky-300/24">
<button type="submit" disabled={form.processing || !hasRequiredCategory} className="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-sky-300/25 bg-sky-300/18 px-4 py-2 text-sm font-semibold text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition hover:bg-sky-300/24">
<i className="fa-solid fa-floppy-disk text-xs" />
<span>{form.processing ? 'Saving...' : 'Save prompt'}</span>
</button>
@@ -2034,6 +2311,9 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
}
}} options={categoryOptions} searchable searchPlaceholder="Filter categories..." className="rounded-2xl bg-black/20" error={form.errors.category_id} /> : null}
<TextField label="Or enter new category" value={form.data.new_category_name || ''} onChange={(event) => form.setData('new_category_name', event.target.value)} error={form.errors.new_category_name} placeholder="New prompt category name" />
{!hasRequiredCategory ? (
<div className="mt-2 text-xs text-rose-300">Choose an existing category or enter a new category name before saving.</div>
) : null}
{difficultyField ? <NovaSelect label={difficultyField.label} value={form.data.difficulty ?? ''} onChange={(nextValue) => form.setData('difficulty', nextValue ?? '')} options={difficultyField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.difficulty} /> : null}
</div>
@@ -2085,6 +2365,16 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
<TextAreaField label={helperPromptsField?.label || 'Helper Prompts JSON'} value={form.data.helper_prompts || ''} onChange={(event) => form.setData('helper_prompts', event.target.value)} error={form.errors.helper_prompts} rows={12} hint="Array of supporting prompts used for data collection, preparation, validation, or refinement." />
</div>
<TextAreaField label={promptVariantsField?.label || 'Prompt Variants JSON'} value={form.data.prompt_variants || ''} onChange={(event) => form.setData('prompt_variants', event.target.value)} error={form.errors.prompt_variants} rows={12} hint="Array of alternative prompt versions with prompt, negative_prompt, recommended flags, and risk notes." />
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-3">
<div>
<p className="text-sm font-semibold text-white">Starter filled examples</p>
<p className="mt-1 text-xs leading-5 text-slate-400">Generate 5 editable examples from the current placeholders, prompt text, and negative prompt.</p>
</div>
<button type="button" onClick={generateStarterFilledExamples} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18">
Generate 5 starter examples
</button>
</div>
<TextAreaField label={filledExamplesField?.label || 'Filled Examples JSON'} value={form.data.filled_examples || ''} onChange={(event) => form.setData('filled_examples', event.target.value)} error={form.errors.filled_examples} rows={12} hint="Array of up to 5 filled prompt examples with title, description, placeholder_values, prompt, and optional negative_prompt." />
</SectionCard>
<SectionCard eyebrow="Structured blocks" title="AI model comparisons" description="Add reusable same-prompt comparison notes without burying provider-specific behavior inside the main prompt body." className={sectionClassName('prompt-comparisons')}>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Head, Link, router, usePage } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
const PROMPT_VIEW_STORAGE_KEY = 'skinbase.admin.academy.prompts.view'
const COURSE_VIEW_STORAGE_KEY = 'skinbase.admin.academy.courses.view'
@@ -84,14 +85,34 @@ function courseSummary(items = [], summary = null) {
}), { total: 0, published: 0, featured: 0, drafts: 0, visibleOnPage: 0 })
}
function promptSummary(items = []) {
function promptSummary(items = [], summary = null) {
if (summary && typeof summary === 'object') {
return {
total: Number(summary.total || 0),
active: Number(summary.active || 0),
featured: Number(summary.featured || 0),
promptOfWeek: Number(summary.promptOfWeek || 0),
comparisons: Array.isArray(items) ? items.reduce((count, item) => count + Number(item.comparisons_count || 0), 0) : 0,
access: {
free: Number(summary.access?.free || 0),
creator: Number(summary.access?.creator || 0),
pro: Number(summary.access?.pro || 0),
},
}
}
return items.reduce((summary, item) => ({
total: summary.total + 1,
active: summary.active + (item.active ? 1 : 0),
featured: summary.featured + (item.featured ? 1 : 0),
promptOfWeek: summary.promptOfWeek + (item.prompt_of_week ? 1 : 0),
comparisons: summary.comparisons + Number(item.comparisons_count || 0),
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0 })
access: {
free: summary.access.free + (item.access_level === 'free' ? 1 : 0),
creator: summary.access.creator + (item.access_level === 'creator' ? 1 : 0),
pro: summary.access.pro + (item.access_level === 'pro' ? 1 : 0),
},
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0, access: { free: 0, creator: 0, pro: 0 } })
}
function PromptFlag({ children, tone = 'default' }) {
@@ -371,9 +392,9 @@ function PromptPreview({ item, compact = false }) {
function PromptMeta({ item }) {
return (
<div className="flex flex-wrap gap-2">
{item.access_level ? <AccessBadge tier={item.access_level} className="px-3 py-1" /> : null}
{item.category_name ? <PromptFlag tone="warm">{item.category_name}</PromptFlag> : null}
{item.difficulty ? <PromptFlag>{item.difficulty}</PromptFlag> : null}
{item.access_level ? <PromptFlag>{item.access_level}</PromptFlag> : null}
{item.aspect_ratio ? <PromptFlag>{item.aspect_ratio}</PromptFlag> : null}
{item.featured ? <PromptFlag tone="sky">Featured</PromptFlag> : null}
{item.prompt_of_week ? <PromptFlag tone="emerald">Prompt of week</PromptFlag> : null}
@@ -389,7 +410,9 @@ function PromptGalleryCard({ item }) {
<div className="relative min-h-[250px] overflow-hidden border-b border-white/10 xl:min-h-full xl:border-b-0 xl:border-r xl:border-white/10">
<PromptPreview item={item} />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.32))]" />
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
<AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" />
<PromptFlag>{Number(item.views_count || 0).toLocaleString()} views</PromptFlag>
<PromptFlag tone="warm">{item.comparisons_count || 0} comparisons</PromptFlag>
{item.slug ? <PromptFlag>{item.slug}</PromptFlag> : null}
</div>
@@ -418,7 +441,7 @@ function PromptGalleryCard({ item }) {
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Access</p>
<p className="mt-1 text-sm font-semibold text-white">{item.access_level || 'free'}</p>
<div className="mt-2"><AccessBadge tier={item.access_level || 'free'} /></div>
</div>
<div>
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</p>
@@ -440,6 +463,9 @@ function PromptGridCard({ item }) {
<div className="relative h-52 overflow-hidden border-b border-white/10">
<PromptPreview item={item} compact />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.34))]" />
<div className="absolute left-4 top-4">
<AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" />
</div>
</div>
<div className="p-5">
<PromptMeta item={item} />
@@ -447,7 +473,7 @@ function PromptGridCard({ item }) {
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || 'No excerpt added yet.'}</p>
<div className="mt-5 flex items-center justify-between gap-3 text-sm text-slate-400">
<span>{formatDateLabel(item.updated_at)}</span>
<span>{item.comparisons_count || 0} comparisons</span>
<span>{Number(item.views_count || 0).toLocaleString()} views</span>
</div>
<div className="mt-5">
<PromptActions item={item} />
@@ -468,6 +494,7 @@ function PromptTable({ items }) {
<th className="px-5 py-4">Category</th>
<th className="px-5 py-4">Access</th>
<th className="px-5 py-4">Signals</th>
<th className="px-5 py-4">Views</th>
<th className="px-5 py-4">Updated</th>
<th className="px-5 py-4 text-right">Actions</th>
</tr>
@@ -487,7 +514,7 @@ function PromptTable({ items }) {
</div>
</td>
<td className="px-5 py-4">{item.category_name || 'Uncategorized'}</td>
<td className="px-5 py-4">{item.access_level || 'free'}</td>
<td className="px-5 py-4"><AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" /></td>
<td className="px-5 py-4">
<div className="space-y-1">
<p>{item.comparisons_count || 0} comparisons</p>
@@ -495,6 +522,7 @@ function PromptTable({ items }) {
<p>{item.active ? 'Active' : 'Draft'}</p>
</div>
</td>
<td className="px-5 py-4">{Number(item.views_count || 0).toLocaleString()}</td>
<td className="px-5 py-4">{formatDateLabel(item.updated_at)}</td>
<td className="px-5 py-4">
<div className="flex justify-end gap-2">
@@ -511,6 +539,99 @@ function PromptTable({ items }) {
)
}
function PromptStatCard({ label, value, tone = 'default' }) {
const toneClass = tone === 'sky'
? 'border-sky-300/20 bg-sky-300/10 text-sky-100'
: tone === 'emerald'
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
: tone === 'warm'
? 'border-amber-300/20 bg-amber-300/10 text-amber-100'
: 'border-white/10 bg-black/20 text-slate-300'
return (
<div className={`rounded-[24px] border px-5 py-4 ${toneClass}`}>
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{value}</p>
</div>
)
}
function PromptSelect({ value, options = [], onChange }) {
return (
<select
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
>
{options.map((option) => (
<option key={`${option.value}-${option.label}`} value={option.value} className="bg-slate-950 text-white">
{option.label}
</option>
))}
</select>
)
}
function PromptSearchBar({ filters, onChange, onSubmit, onReset, viewMode, onViewModeChange, filterOptions = {} }) {
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
<form onSubmit={onSubmit} className="flex-1 space-y-4">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_repeat(3,minmax(0,0.8fr))]">
<div className="relative">
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
<input
name="search"
value={filters.search}
onChange={(event) => onChange('search', event.target.value)}
placeholder="Search title, slug, excerpt, prompt text, or category…"
className="w-full rounded-2xl border border-white/10 bg-black/20 py-3 pl-9 pr-4 text-sm text-white placeholder:text-slate-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
/>
</div>
<PromptSelect value={filters.category} onChange={(value) => onChange('category', value)} options={filterOptions.categories} />
<PromptSelect value={filters.access_level} onChange={(value) => onChange('access_level', value)} options={filterOptions.access} />
<PromptSelect value={filters.order} onChange={(value) => onChange('order', value)} options={filterOptions.order} />
</div>
<div className="grid gap-4 xl:grid-cols-4">
<PromptSelect value={filters.difficulty} onChange={(value) => onChange('difficulty', value)} options={filterOptions.difficulty} />
<PromptSelect value={filters.featured} onChange={(value) => onChange('featured', value)} options={filterOptions.featured} />
<PromptSelect value={filters.prompt_of_week} onChange={(value) => onChange('prompt_of_week', value)} options={filterOptions.promptOfWeek} />
<PromptSelect value={filters.active} onChange={(value) => onChange('active', value)} options={filterOptions.active} />
</div>
<div className="flex flex-wrap gap-3">
<button type="submit" className="rounded-2xl bg-sky-300/12 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/16">
Apply filters
</button>
<button type="button" onClick={onReset} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white/80 transition hover:bg-white/[0.08]">
Reset
</button>
</div>
</form>
<div className="flex flex-wrap items-center gap-2">
{PROMPT_VIEW_OPTIONS.map((option) => {
const active = option.value === viewMode
return (
<button
key={option.value}
type="button"
onClick={() => onViewModeChange(option.value)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
>
<i className={`fa-solid ${option.icon} text-xs`} />
<span>{option.label}</span>
</button>
)
})}
</div>
</div>
</div>
)
}
function PromptHeroCollage({ items = [] }) {
const images = items
.map((item) => item?.preview_image_url)
@@ -734,10 +855,21 @@ function renderCrudCell(column, item) {
return <p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
}
function PromptIndexContent({ title, subtitle, items, createUrl }) {
function PromptIndexContent({ title, subtitle, items, createUrl, filters = {}, summary = {}, filterOptions = {} }) {
const { url } = usePage()
const promptItems = items?.data || []
const summary = promptSummary(promptItems)
const stats = useMemo(() => promptSummary(promptItems, summary), [promptItems, summary])
const [viewMode, setViewMode] = useState('gallery')
const [query, setQuery] = useState({
search: filters.search || '',
category: filters.category || 'all',
featured: filters.featured || 'all',
prompt_of_week: filters.prompt_of_week || 'all',
active: filters.active || 'all',
access_level: filters.access_level || 'all',
difficulty: filters.difficulty || 'all',
order: filters.order || 'updated_desc',
})
useEffect(() => {
if (typeof window === 'undefined') return
@@ -753,6 +885,72 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
window.localStorage.setItem(PROMPT_VIEW_STORAGE_KEY, viewMode)
}, [viewMode])
useEffect(() => {
setQuery({
search: filters.search || '',
category: filters.category || 'all',
featured: filters.featured || 'all',
prompt_of_week: filters.prompt_of_week || 'all',
active: filters.active || 'all',
access_level: filters.access_level || 'all',
difficulty: filters.difficulty || 'all',
order: filters.order || 'updated_desc',
})
}, [filters])
const currentPath = url.split('?')[0]
const meta = items?.meta || {}
const hasFilters = Boolean(
(query.search || '').trim()
|| query.category !== 'all'
|| query.featured !== 'all'
|| query.prompt_of_week !== 'all'
|| query.active !== 'all'
|| query.access_level !== 'all'
|| query.difficulty !== 'all'
|| query.order !== 'updated_desc'
)
const applyQuery = (nextQuery) => {
const payload = {}
if ((nextQuery.search || '').trim()) payload.search = nextQuery.search.trim()
if (nextQuery.category && nextQuery.category !== 'all') payload.category = nextQuery.category
if (nextQuery.featured && nextQuery.featured !== 'all') payload.featured = nextQuery.featured
if (nextQuery.prompt_of_week && nextQuery.prompt_of_week !== 'all') payload.prompt_of_week = nextQuery.prompt_of_week
if (nextQuery.active && nextQuery.active !== 'all') payload.active = nextQuery.active
if (nextQuery.access_level && nextQuery.access_level !== 'all') payload.access_level = nextQuery.access_level
if (nextQuery.difficulty && nextQuery.difficulty !== 'all') payload.difficulty = nextQuery.difficulty
if (nextQuery.order && nextQuery.order !== 'updated_desc') payload.order = nextQuery.order
router.get(currentPath, payload, { preserveScroll: true, preserveState: true, replace: true })
}
const handleFilterChange = (key, value) => {
setQuery((current) => ({ ...current, [key]: value }))
}
const handleSubmit = (event) => {
event.preventDefault()
applyQuery(query)
}
const handleReset = () => {
const nextQuery = {
search: '',
category: 'all',
featured: 'all',
prompt_of_week: 'all',
active: 'all',
access_level: 'all',
difficulty: 'all',
order: 'updated_desc',
}
setQuery(nextQuery)
router.get(currentPath, {}, { preserveScroll: true, preserveState: true, replace: true })
}
return (
<div className="space-y-6">
<section className="overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]">
@@ -781,46 +979,40 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
{PROMPT_VIEW_OPTIONS.map((option) => {
const active = option.value === viewMode
return (
<button
key={option.value}
type="button"
onClick={() => setViewMode(option.value)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
>
<i className={`fa-solid ${option.icon}`} />
<span>{option.label} view</span>
</button>
)
})}
</div>
<div className="mt-7 flex flex-nowrap gap-3 overflow-x-auto pb-1">
<Link href={createUrl} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
<Link href="/academy/prompts" className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />Open public library</Link>
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{summary.total} prompts in view</span>
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{stats.total} prompts in view</span>
</div>
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<PromptStatCard label="Active" value={stats.active} tone="emerald" />
<PromptStatCard label="Featured" value={stats.featured} tone="sky" />
<PromptStatCard label="Prompt of week" value={stats.promptOfWeek} tone="warm" />
<PromptStatCard label="Views on page" value={promptItems.reduce((count, item) => count + Number(item.views_count || 0), 0).toLocaleString()} />
</div>
<div className="mt-3 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">Active</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.active}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Free access</p>
<AccessBadge tier="free" />
</div>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.free}</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">Featured</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.featured}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator access</p>
<AccessBadge tier="creator" />
</div>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.creator}</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">Prompt of week</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.promptOfWeek}</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">Comparisons</p>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.comparisons}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Pro access</p>
<AccessBadge tier="pro" />
</div>
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.pro}</p>
</div>
</div>
</div>
@@ -831,8 +1023,27 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
</div>
</section>
<PromptSearchBar
filters={query}
onChange={handleFilterChange}
onSubmit={handleSubmit}
onReset={handleReset}
viewMode={viewMode}
onViewModeChange={setViewMode}
filterOptions={filterOptions}
/>
<div className="flex flex-wrap items-center justify-between gap-4">
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
<p className="text-sm text-slate-400">
{meta.total ? (
<>
Showing {meta.from || 0}-{meta.to || 0} of {meta.total} prompts
{hasFilters ? <span className="ml-2 text-sky-200">with active search or filters</span> : null}
</>
) : (
'Manage Academy content below. Changes clear Academy cache automatically.'
)}
</p>
<div className="flex flex-wrap gap-3">
<Link href="/academy/prompts" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />View public library</Link>
<Link href={createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
@@ -840,7 +1051,16 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
</div>
{promptItems.length === 0 ? (
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No prompt templates exist yet.</div>
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">
{hasFilters ? (
<div className="space-y-3">
<p className="text-lg font-semibold text-white">No prompt templates matched these filters.</p>
<button type="button" onClick={handleReset} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Reset filters</button>
</div>
) : (
'No prompt templates exist yet.'
)}
</div>
) : viewMode === 'table' ? (
<PromptTable items={promptItems} />
) : viewMode === 'grid' ? (
@@ -863,6 +1083,7 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
const resource = usePage().props.resource
const filters = usePage().props.filters || {}
const summary = usePage().props.summary || {}
const filterOptions = usePage().props.filterOptions || {}
return (
<AdminLayout title={title} subtitle={subtitle}>
@@ -873,7 +1094,7 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
{resource === 'courses' ? (
<CourseIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} filters={filters} summary={summary} />
) : resource === 'prompts' ? (
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} />
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} filters={filters} summary={summary} filterOptions={filterOptions} />
) : (
<>
<div className="mb-6 flex items-center justify-between gap-4">
@@ -911,4 +1132,4 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
)}
</AdminLayout>
)
}
}

View File

@@ -52,7 +52,6 @@ export default function Dashboard({ stats }) {
{ 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) => (
<a
key={item.href}

View File

@@ -316,7 +316,7 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
const hasMorePages = meta.current_page < meta.last_page
return (
<div className="pb-24 text-white">
<div className="categories-page pb-24 text-white">
<section className="relative overflow-hidden">
<div className="absolute inset-x-0 top-0 h-[28rem] bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_38%),radial-gradient(circle_at_top_right,rgba(249,115,22,0.14),transparent_34%)]" />
<div className="relative w-full px-6 pb-8 pt-14 sm:px-8 sm:pt-20 xl:px-10 2xl:px-14 lg:pt-24">

View File

@@ -16,10 +16,10 @@ export default function GroupIndex() {
const leaderboardItems = Array.isArray(props.leaderboard?.items) ? props.leaderboard.items : []
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<main className="groups-directory-page min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
<SeoHead title="Groups - Skinbase" description={props.description} />
<div className="mx-auto max-w-6xl">
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
<section className="groups-directory-page__hero rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Groups</p>
<h1 className="mt-2 text-4xl font-semibold text-white">Collective publishing identities</h1>
<p className="mt-4 max-w-3xl text-sm leading-6 text-slate-300">Discover collaborative studios, follow shared creative brands, and browse the artworks, releases, and collections published under each group identity.</p>
@@ -76,4 +76,4 @@ export default function GroupIndex() {
</div>
</main>
)
}
}

View File

@@ -77,9 +77,9 @@ export default function LeaderboardPage() {
<>
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, groups, artworks, stories, and Worlds on Skinbase.'} />
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
<div className="leaderboard-page min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<header className="rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
<header className="leaderboard-page__hero rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-300">Skinbase Competition Board</p>
<h1 className="mt-4 max-w-3xl text-4xl font-black tracking-tight text-white sm:text-5xl">
Top creators, groups, standout artworks, stories, and Worlds with momentum.

View File

@@ -0,0 +1,11 @@
import React from 'react'
import AdminLayout from '../../Layouts/AdminLayout'
import FeaturedArtworksAdmin from '../Collection/FeaturedArtworksAdmin'
export default function ModerationFeaturedArtworks() {
return (
<AdminLayout>
<FeaturedArtworksAdmin />
</AdminLayout>
)
}

View File

@@ -0,0 +1,148 @@
import React from 'react'
import { Head, router, Link } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function StatCard({ label, value }) {
return (
<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">{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
export default function StaffApplicationsIndex({ title, items, stats, filters, topics, endpoints }) {
const [state, setState] = React.useState(filters || { q: '', topic: 'all' })
React.useEffect(() => {
setState(filters || { q: '', topic: 'all' })
}, [filters])
function update(key, value) {
setState((current) => ({ ...current, [key]: value }))
}
function applyFilters(event) {
event.preventDefault()
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
}
const rows = items?.data || []
return (
<AdminLayout title={title || 'Staff Applications'} subtitle="Review staff and contact submissions without leaving moderation.">
<Head title="Moderation · Staff Applications" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),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">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Staff Applications</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review staff and contact submissions in the same moderation workspace as the rest of Skinbase.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {items?.current_page || 1} / {items?.last_page || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(items?.total || 0).toLocaleString()} submissions</span>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<StatCard label="Total" value={stats?.total} />
<StatCard label="Applications" value={stats?.applications} />
<StatCard label="Bug reports" value={stats?.bug} />
<StatCard label="Contact" value={stats?.contact} />
<StatCard label="Other" value={stats?.other} />
</div>
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
<input
value={state.q || ''}
onChange={(event) => update('q', event.target.value)}
placeholder="Search name, email, role, or message"
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<select
value={state.topic || 'all'}
onChange={(event) => update('topic', event.target.value)}
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
>
<option value="all">All topics</option>
{(topics || []).map((topic) => (
<option key={topic} value={topic}>{String(topic).replaceAll('_', ' ')}</option>
))}
</select>
<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]">Apply</button>
</form>
</section>
<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="min-w-full text-sm">
<thead className="bg-white/[0.03] text-left text-xs uppercase tracking-[0.18em] text-slate-400">
<tr>
<th className="px-5 py-4 font-medium">Received</th>
<th className="px-5 py-4 font-medium">Topic</th>
<th className="px-5 py-4 font-medium">Name</th>
<th className="px-5 py-4 font-medium">Email</th>
<th className="px-5 py-4 font-medium">Role</th>
<th className="px-5 py-4 font-medium">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5 text-slate-200">
{rows.length === 0 ? (
<tr>
<td colSpan={6} className="px-5 py-14 text-center text-slate-400">No staff applications matched the current filters.</td>
</tr>
) : rows.map((item) => (
<tr key={item.id} className="hover:bg-white/[0.02]">
<td className="px-5 py-4 text-slate-400">{formatDateTime(item.created_at)}</td>
<td className="px-5 py-4">
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
{String(item.topic || 'contact').replaceAll('_', ' ')}
</span>
</td>
<td className="px-5 py-4 font-medium text-white">{item.name}</td>
<td className="px-5 py-4 text-slate-300">{item.email}</td>
<td className="px-5 py-4 text-slate-300">{item.role || '—'}</td>
<td className="px-5 py-4">
<Link href={item.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]">
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{(items?.prev_page_url || items?.next_page_url) ? (
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
Showing page {items?.current_page || 1} of {items?.last_page || 1}
</div>
<div className="flex gap-2">
{items?.prev_page_url ? (
<button type="button" onClick={() => router.get(items.prev_page_url, {}, { preserveScroll: true })} 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]">
Previous
</button>
) : null}
{items?.next_page_url ? (
<button type="button" onClick={() => router.get(items.next_page_url, {}, { preserveScroll: true })} 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]">
Next
</button>
) : null}
</div>
</div>
) : null}
</AdminLayout>
)
}

View File

@@ -0,0 +1,87 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function Field({ label, children }) {
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
<div className="mt-2 text-sm leading-7 text-slate-100">{children}</div>
</div>
)
}
export default function StaffApplicationShow({ title, item, backUrl }) {
const payload = item?.payload || {}
return (
<AdminLayout title={title || 'Staff Application'} subtitle="Read the full submission in a moderation-friendly layout.">
<Head title="Moderation · Staff Application" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_34%),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">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{item?.name || 'Staff application'}</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Topic: {String(item?.topic || 'contact').replaceAll('_', ' ')} Received {formatDateTime(item?.created_at)}</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={backUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
Back
</Link>
{item?.email ? (
<a href={`mailto:${item.email}`} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18">
Email
</a>
) : null}
</div>
</div>
</section>
<div className="mt-8 grid gap-6 xl:grid-cols-[1.35fr_0.85fr]">
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Field label="Name">{item?.name || '—'}</Field>
<Field label="Email">{item?.email || '—'}</Field>
<Field label="Role">{item?.role || '—'}</Field>
<Field label="Portfolio">{item?.portfolio ? <a href={item.portfolio} className="text-sky-300 hover:text-sky-200" target="_blank" rel="noreferrer">{item.portfolio}</a> : '—'}</Field>
</div>
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Message</div>
<div className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-4 text-sm leading-7 text-slate-100">
{item?.message || 'No message included.'}
</div>
</div>
</div>
<aside className="space-y-4">
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Metadata</div>
<div className="mt-4 space-y-3 text-sm text-slate-200">
<div><span className="font-semibold text-white">Received:</span> {formatDateTime(item?.created_at)}</div>
<div><span className="font-semibold text-white">IP:</span> {item?.ip || '—'}</div>
<div><span className="font-semibold text-white">User agent:</span> {item?.user_agent || '—'}</div>
<div><span className="font-semibold text-white">ID:</span> {item?.id || '—'}</div>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Payload</div>
<pre className="mt-3 max-h-[420px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-4 text-xs leading-6 text-slate-200">
{JSON.stringify(payload, null, 2)}
</pre>
</div>
</aside>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,168 @@
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
function badgeTone(status) {
if (status === 'published') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
if (status === 'scheduled') return 'border-sky-300/20 bg-sky-400/12 text-sky-100'
if (status === 'pending_review') return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
if (status === 'archived' || status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
return 'border-white/10 bg-white/[0.06] text-slate-200'
}
function StatCard({ label, value }) {
return (
<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">{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
export default function Stories({ title, stories, filters, stats, endpoints }) {
const [state, setState] = React.useState(filters || { q: '', status: 'all' })
React.useEffect(() => {
setState(filters || { q: '', status: 'all' })
}, [filters])
function update(key, value) {
setState((current) => ({ ...current, [key]: value }))
}
function applyFilters(event) {
event.preventDefault()
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
}
const items = stories?.data || []
return (
<AdminLayout title={title || 'Stories'} subtitle="Review creator stories from the moderation surface, without jumping back to the old CP layout.">
<Head title="Moderation · Stories" />
<section className="rounded-[32px] 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.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">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Stories</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Browse creator stories, filter by status, and jump straight to the public view when it exists.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {stories?.current_page || 1} / {stories?.last_page || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(stories?.total || 0).toLocaleString()} stories</span>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-6">
<StatCard label="Total" value={stats?.total} />
<StatCard label="Published" value={stats?.published} />
<StatCard label="Draft" value={stats?.draft} />
<StatCard label="Scheduled" value={stats?.scheduled} />
<StatCard label="Pending review" value={stats?.pending_review} />
<StatCard label="Archived" value={stats?.archived} />
</div>
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
<input
value={state.q || ''}
onChange={(event) => update('q', event.target.value)}
placeholder="Search title, slug, or creator"
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<select
value={state.status || 'all'}
onChange={(event) => update('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"
>
<option value="all">All statuses</option>
<option value="draft">Draft</option>
<option value="pending_review">Pending review</option>
<option value="scheduled">Scheduled</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
<option value="rejected">Rejected</option>
</select>
<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]">Apply</button>
</form>
</section>
<div className="mt-8 grid gap-4 xl:grid-cols-2">
{items.length === 0 ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300 xl:col-span-2">
No stories matched the current filters.
</div>
) : items.map((story) => (
<article key={story.id} 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-[180px_1fr]">
<div className="aspect-[3/4] bg-black/30">
{story.cover_url ? (
<img src={story.cover_url} alt={story.title} className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center text-white/20">
<i className="fa-solid fa-feather-pointed text-4xl" />
</div>
)}
</div>
<div className="p-5">
<div className="flex flex-wrap items-center gap-2">
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(story.status)}`}>
{String(story.status || 'draft').replaceAll('_', ' ')}
</span>
{story.creator ? (
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
@{story.creator.username}
</span>
) : null}
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{story.title}</h2>
<p className="mt-2 text-sm text-slate-300">/{story.slug}{story.creator ? `${story.creator.name}` : ''}</p>
{story.excerpt ? <p className="mt-3 text-sm leading-6 text-slate-300">{story.excerpt}</p> : null}
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
<span>{story.published_at ? new Date(story.published_at).toLocaleDateString() : 'Unpublished'}</span>
<span>{story.created_at ? new Date(story.created_at).toLocaleDateString() : '—'}</span>
</div>
<div className="mt-5 flex flex-wrap gap-2">
{story.open_url ? (
<a href={story.open_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
</a>
) : (
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">
No public view
</span>
)}
</div>
</div>
</div>
</article>
))}
</div>
{stories?.prev_page_url || stories?.next_page_url ? (
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
Showing page {stories?.current_page || 1} of {stories?.last_page || 1}
</div>
<div className="flex gap-2">
{stories?.prev_page_url ? (
<button type="button" onClick={() => router.get(stories.prev_page_url, {}, { preserveScroll: true })} 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]">
Previous
</button>
) : null}
{stories?.next_page_url ? (
<button type="button" onClick={() => router.get(stories.next_page_url, {}, { preserveScroll: true })} 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]">
Next
</button>
) : null}
</div>
</div>
) : null}
</AdminLayout>
)
}

View File

@@ -0,0 +1,233 @@
import React from 'react'
import { Head, router } from '@inertiajs/react'
import AdminLayout from '../../Layouts/AdminLayout'
function formatDateTime(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function StatCard({ label, value, tone = 'sky' }) {
const tones = {
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
}
return (
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.sky}`}>{label}</div>
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
</div>
)
}
function badgeTone(status) {
if (status === 'approved') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
if (status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
}
async function requestJson(url, body) {
const response = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify(body || {}),
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || 'Request failed.')
}
return payload
}
export default function UsernameQueue({ title, requests, stats, filters, options, endpoints }) {
const [state, setState] = React.useState(filters || { q: '', status: 'pending' })
const [notes, setNotes] = React.useState({})
const [busy, setBusy] = React.useState('')
const [notice, setNotice] = React.useState('')
const [error, setError] = React.useState('')
React.useEffect(() => {
setState(filters || { q: '', status: 'pending' })
}, [filters])
function update(key, value) {
setState((current) => ({ ...current, [key]: value }))
}
function applyFilters(event) {
event.preventDefault()
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
}
async function moderate(item, action) {
const actionKey = `${action}-${item.id}`
setBusy(actionKey)
setError('')
setNotice('')
try {
const payload = await requestJson(action === 'approve' ? item.approve_url : item.reject_url, {
note: String(notes[item.id] || ''),
})
setNotice(payload.message || `Request ${action}d.`)
router.reload({ only: ['requests', 'stats'], preserveScroll: true })
} catch (requestError) {
setError(requestError.message || 'Request failed.')
} finally {
setBusy('')
}
}
const items = requests?.data || []
return (
<AdminLayout title={title || 'Username Queue'} subtitle="Review username changes in the same moderation surface as the rest of Skinbase.">
<Head title="Moderation · Username Queue" />
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.12),transparent_34%),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-rose-200/80">Moderation surface</p>
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Username Queue</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review pending username requests before they are applied to the account or history trail.</p>
</div>
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {requests?.current_page || 1} / {requests?.last_page || 1}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(requests?.total || 0).toLocaleString()} requests</span>
</div>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard label="Total" value={stats?.total} />
<StatCard label="Pending" value={stats?.pending} tone="amber" />
<StatCard label="Approved" value={stats?.approved} tone="emerald" />
<StatCard label="Rejected" value={stats?.rejected} tone="rose" />
</div>
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
<input
value={state.q || ''}
onChange={(event) => update('q', event.target.value)}
placeholder="Search requested or current username"
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<select
value={state.status || 'pending'}
onChange={(event) => update('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"
>
{(options?.statuses || []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<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]">Apply</button>
</form>
</section>
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
{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">{error}</div> : null}
<div className="mt-8 space-y-4">
{items.length === 0 ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No username requests matched the current filters.</div>
) : items.map((item) => (
<article key={item.id} className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
<div>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(item.status)}`}>
{String(item.status || 'pending').replaceAll('_', ' ')}
</span>
{item.context ? (
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
{item.context.replaceAll('_', ' ')}
</span>
) : null}
{item.similar_to ? (
<span className="inline-flex rounded-full border border-amber-300/20 bg-amber-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">
Similar to {item.similar_to}
</span>
) : null}
</div>
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{item.requested_username}</h2>
<p className="mt-2 text-sm text-slate-300">
{item.current_username ? `Current: @${item.current_username}` : 'No current username'}
{item.current_name ? `${item.current_name}` : ''}
</p>
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-400">
Requested {formatDateTime(item.created_at)}
{item.reviewed_at ? ` • reviewed ${formatDateTime(item.reviewed_at)}` : ''}
</p>
</div>
<div className="w-full max-w-xl space-y-3">
<textarea
value={notes[item.id] || ''}
onChange={(event) => setNotes((current) => ({ ...current, [item.id]: event.target.value }))}
placeholder="Optional moderation note"
rows={3}
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
/>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => moderate(item, 'approve')}
disabled={busy === `approve-${item.id}`}
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18 disabled:opacity-60"
>
{busy === `approve-${item.id}` ? 'Saving…' : 'Approve'}
</button>
<button
type="button"
onClick={() => moderate(item, 'reject')}
disabled={busy === `reject-${item.id}`}
className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/18 disabled:opacity-60"
>
{busy === `reject-${item.id}` ? 'Saving…' : 'Reject'}
</button>
</div>
</div>
</div>
</article>
))}
</div>
{requests?.prev_page_url || requests?.next_page_url ? (
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
Showing page {requests?.current_page || 1} of {requests?.last_page || 1}
</div>
<div className="flex gap-2">
{requests?.prev_page_url ? (
<button type="button" onClick={() => router.get(requests.prev_page_url, {}, { preserveScroll: true })} 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]">
Previous
</button>
) : null}
{requests?.next_page_url ? (
<button type="button" onClick={() => router.get(requests.next_page_url, {}, { preserveScroll: true })} 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]">
Next
</button>
) : null}
</div>
</div>
) : null}
</AdminLayout>
)
}

View File

@@ -135,7 +135,7 @@ export default function ProfileShow() {
return (
<>
<SeoHead seo={seo} />
<div className="relative min-h-screen overflow-hidden pb-16">
<div className="profile-page relative min-h-screen overflow-hidden pb-16">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"

View File

@@ -32,7 +32,6 @@ const TABS = [
{ id: 'worlds', label: 'Worlds', icon: 'fa-solid fa-globe' },
{ id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' },
{ id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' },
{ id: 'ai', label: 'AI Assist', icon: 'fa-solid fa-wand-magic-sparkles' },
]
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -1233,9 +1232,6 @@ export default function StudioArtworkEdit() {
{tab.id === 'evolution' && evolutionTarget && (
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" />
)}
{tab.id === 'ai' && aiStatus !== 'not_analyzed' && (
<span className={`h-1.5 w-1.5 rounded-full ${aiStatus === 'ready' ? 'bg-emerald-400' : aiStatus === 'failed' ? 'bg-red-400' : 'bg-sky-400'}`} />
)}
</button>
))}
</div>