Optimize academy
This commit is contained in:
@@ -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]"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 2–3 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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
11
resources/js/Pages/Moderation/FeaturedArtworks.jsx
Normal file
11
resources/js/Pages/Moderation/FeaturedArtworks.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal file
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal file
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
168
resources/js/Pages/Moderation/Stories.jsx
Normal file
168
resources/js/Pages/Moderation/Stories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal file
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user