Optimize academy

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

View File

@@ -16,6 +16,7 @@ const buildAdminNavGroups = (isAdmin) => [
{ label: 'All Users', href: '/moderation/users', icon: 'fa-solid fa-users' },
{ label: 'Staff', href: '/moderation/users?role=admin', icon: 'fa-solid fa-shield-halved' },
{ label: 'Moderators', href: '/moderation/users?role=moderator', icon: 'fa-solid fa-user-shield' },
{ label: 'Staff Applications', href: '/moderation/staff-applications', icon: 'fa-solid fa-user-check' },
],
},
{
@@ -29,7 +30,6 @@ const buildAdminNavGroups = (isAdmin) => [
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles' },
],
},
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -52,7 +52,6 @@ export default function Dashboard({ stats }) {
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles', desc: 'Review generated creator biographies and moderation flags' },
].map((item) => (
<a
key={item.href}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

27
resources/js/academy.jsx Normal file
View File

@@ -0,0 +1,27 @@
import { mountInertiaRoot } from './bootstrap-lite'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob([
'./Pages/Academy/**/*.jsx',
'!./Pages/Academy/**/__tests__/**',
'!./Pages/Academy/**/*.test.jsx',
])
function resolvePage(name) {
const path = `./Pages/${name}.jsx`
const page = pages[path]
if (!page) {
throw new Error(`Unknown academy page: ${path}`)
}
return page().then((module) => module.default)
}
createInertiaApp({
resolve: resolvePage,
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
})

View File

@@ -4,8 +4,11 @@ import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob([
'./Pages/Admin/**/*.jsx',
'./Pages/Moderation/**/*.jsx',
'!./Pages/Admin/**/__tests__/**',
'!./Pages/Admin/**/*.test.jsx',
'!./Pages/Moderation/**/__tests__/**',
'!./Pages/Moderation/**/*.test.jsx',
])
function resolvePage(name) {

32
resources/js/bootstrap-lite.js vendored Normal file
View File

@@ -0,0 +1,32 @@
import axios from 'axios'
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
if (csrfToken) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrfToken
}
if (typeof window !== 'undefined') {
window.axios = axios
}
export function mountInertiaRoot(el, App, props) {
if (!el) {
return null
}
const node = React.createElement(App, props)
const hasServerMarkup = el.childNodes.length > 0 && el.innerHTML.trim() !== ''
if (hasServerMarkup) {
return hydrateRoot(el, node)
}
return createRoot(el).render(node)
}

View File

@@ -1,4 +1,4 @@
import { mountInertiaRoot } from './bootstrap'
import { mountInertiaRoot } from './bootstrap-lite'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = {

View File

@@ -25,7 +25,7 @@ export default function PlanCard({ product, selectedPlan, currentTier, isSubscri
const isActivePlan = selectedPlan?.key === activePlanKey
// Pro subscribers already have creator access — don't show a separate "switch" CTA for creator card
const isHigherTierCovered = activeTier === 'pro' && product.tier === 'creator'
const isPlanReady = Boolean(selectedPlan?.configured && selectedPlan?.price_id_valid)
const isPlanReady = Boolean(selectedPlan?.configured && selectedPlan?.price_id_valid && selectedPlan?.remote_price_exists !== false)
// User has a different active subscription (not this plan)
const isSubscribedElsewhere = isSubscribed && !isActivePlan
@@ -96,10 +96,10 @@ export default function PlanCard({ product, selectedPlan, currentTier, isSubscri
onClick={() => onCheckout(selectedPlan)}
tone="primary"
>
{!billingEnabled ? 'Coming soon' : isPlanReady ? `Get ${product.name}${selectedPlan?.price_display || ''}` : 'Not available yet'}
{!billingEnabled ? 'Coming soon' : isPlanReady ? `Get ${product.name}${selectedPlan?.price_display || ''}` : 'Not available yet'}
</ActionButton>
) : null}
</div>
</article>
)
}
}

View File

@@ -386,16 +386,6 @@ export default function ArtworkActionBar({ artwork, stats, canonicalUrl, onStats
</a>
) : null}
{enhanceUrl ? (
<a
href={enhanceUrl}
className="inline-flex items-center gap-2 rounded-full border border-violet-300/25 bg-violet-400/12 px-5 py-2.5 text-sm font-medium text-violet-50 transition-all duration-200 hover:border-violet-200/40 hover:bg-violet-400/18 hover:text-white"
>
<EnhanceIcon />
Enhance image
</a>
) : null}
{/* Report pill */}
<button
type="button"

View File

@@ -279,7 +279,7 @@ function ActionLink({ href, label, children, onClick }) {
href={href || '#'}
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
className="artwork-card__action-btn inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</a>
@@ -292,7 +292,7 @@ function ActionButton({ label, children, onClick }) {
type="button"
aria-label={label}
onClick={onClick}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
className="artwork-card__action-btn inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
>
{children}
</button>
@@ -832,7 +832,7 @@ export default function ArtworkCard({
style={articleStyle}
{...articleData}
>
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<div className={cx('artwork-card__frame relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
<a
href={href}
aria-label={`Open artwork: ${cardLabel}`}
@@ -842,8 +842,8 @@ export default function ArtworkCard({
<span className="sr-only">{cardLabel}</span>
</a>
<div className={cx('relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
<div className={cx('artwork-card__media relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
<div className="artwork-card__media-glow absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
<img
src={image}
@@ -861,7 +861,7 @@ export default function ArtworkCard({
}}
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
<div className="artwork-card__media-shade pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
{(resolvedMetricBadge?.label || relativePublishedAt) ? (
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 flex items-start justify-between gap-3 p-3">
@@ -888,7 +888,7 @@ export default function ArtworkCard({
{showActions && (
<div className={cx(
'absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
'artwork-card__actions absolute right-3 z-20 flex max-w-[14rem] flex-wrap justify-end translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100',
relativePublishedAt ? 'top-12' : 'top-3'
)}>
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
@@ -917,7 +917,7 @@ export default function ArtworkCard({
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
<div className="artwork-card__meta pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100">
{shouldBlurMature ? <div className="mb-2 inline-flex rounded-full border border-amber-300/20 bg-black/55 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-amber-100">Blurred by your content settings</div> : null}
<h3 className={cx('truncate font-semibold text-white', titleClass)}>
{title}

View File

@@ -162,7 +162,7 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
event.currentTarget.onerror = null
if (mainImageMode === 'primary') {
setMainImageMode('fallback')
setMainImageMode(hasRealArtworkImage ? 'hidden' : 'fallback')
setIsLoaded(false)
return
}

View File

@@ -11,7 +11,7 @@ export default function GroupDiscoveryCard({ group, className = '', compact = fa
<a
href={group.urls?.public || '/groups'}
className={cx(
'group block overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.34)] transition duration-200 hover:-translate-y-1 hover:border-white/20',
'group-discovery-card group block overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.34)] transition duration-200 hover:-translate-y-1 hover:border-white/20',
className,
)}
>

View File

@@ -7,7 +7,7 @@ export default function GroupLeaderboardCard({ item }) {
const entity = item.entity
return (
<article className="rounded-[26px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.3)]">
<article className="group-leaderboard-card rounded-[26px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.3)]">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-slate-950/70 text-lg font-black text-white">
#{item.rank}

View File

@@ -5,7 +5,7 @@ export default function GroupPromoCard({ group, eyebrow = 'Groups spotlight', ti
if (!group) return null
return (
<section className="overflow-hidden rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_80%_20%,rgba(16,185,129,0.12),transparent_26%),linear-gradient(180deg,rgba(7,16,29,0.98),rgba(2,6,23,0.94))] shadow-[0_30px_90px_rgba(2,6,23,0.45)]">
<section className="group-promo-card overflow-hidden rounded-[34px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_80%_20%,rgba(16,185,129,0.12),transparent_26%),linear-gradient(180deg,rgba(7,16,29,0.98),rgba(2,6,23,0.94))] shadow-[0_30px_90px_rgba(2,6,23,0.45)]">
<div className="grid gap-6 p-6 lg:grid-cols-[minmax(0,1.3fr)_320px] lg:p-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">{eyebrow}</p>
@@ -30,7 +30,7 @@ export default function GroupPromoCard({ group, eyebrow = 'Groups spotlight', ti
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-black/25 p-5 backdrop-blur-sm">
<div className="group-promo-card__summary rounded-[28px] border border-white/10 bg-black/25 p-5 backdrop-blur-sm">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]">
{group.avatar_url ? <img src={group.avatar_url} alt="" className="h-full w-full object-cover" loading="lazy" /> : <i className="fa-solid fa-people-group text-slate-300" />}

View File

@@ -24,9 +24,9 @@ export default function LeaderboardItem({ item, type, highlight = false }) {
const groupSignals = Array.isArray(entity.trust_signals) ? entity.trust_signals.slice(0, 2) : []
return (
<article className={cx('rounded-3xl border p-4 shadow-lg transition', tone)}>
<article className={cx('leaderboard-item rounded-3xl border p-4 shadow-lg transition', tone)}>
<div className="flex items-start gap-4">
<div className={cx('flex shrink-0 items-center justify-center rounded-2xl border font-black', highlight ? 'h-14 w-14 text-xl' : 'h-11 w-11 text-base', 'border-white/10 bg-slate-950/70 text-white')}>
<div className={cx('leaderboard-item__rank flex shrink-0 items-center justify-center rounded-2xl border font-black', highlight ? 'h-14 w-14 text-xl' : 'h-11 w-11 text-base', 'border-white/10 bg-slate-950/70 text-white')}>
#{rank}
</div>

View File

@@ -6,7 +6,7 @@ function cx(...parts) {
export default function LeaderboardTabs({ items, active, onChange, sticky = false, label }) {
return (
<div className={cx(sticky ? 'sticky top-16 z-20' : '', 'rounded-2xl border border-white/10 bg-slate-950/85 p-2 backdrop-blur') }>
<div className={cx(sticky ? 'sticky top-16 z-20' : '', 'leaderboard-tabs rounded-2xl border border-white/10 bg-slate-950/85 p-2 backdrop-blur') }>
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label={label || 'Leaderboard tabs'}>
{items.map((item) => {
const isActive = item.value === active
@@ -19,7 +19,7 @@ export default function LeaderboardTabs({ items, active, onChange, sticky = fals
aria-selected={isActive}
onClick={() => onChange(item.value)}
className={cx(
'rounded-full px-4 py-2 text-sm font-semibold transition',
'leaderboard-tabs__tab rounded-full px-4 py-2 text-sm font-semibold transition',
isActive
? 'bg-sky-400 text-slate-950 shadow-[0_12px_30px_rgba(56,189,248,0.28)]'
: 'bg-white/5 text-slate-300 hover:bg-white/10 hover:text-white',

View File

@@ -35,7 +35,7 @@ export default function ProfileHero({ user, profile, isOwner, viewerIsFollowing,
return (
<>
<div className="relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
<div className="profile-hero relative mx-auto max-w-7xl px-4 pt-4 md:pt-6">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-10 top-8 -z-10 h-44 rounded-full blur-3xl"

View File

@@ -31,7 +31,7 @@ export default function ProfileTabs({ activeTab, onTabChange }) {
}, [activeTab])
return (
<div className="sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
<div className="profile-tabs-shell sticky top-0 z-30 border-b border-white/10 bg-[#08111f]/80 backdrop-blur-2xl">
<nav
ref={navRef}
className="profile-tabs-sticky overflow-x-auto scrollbar-hide"

View File

@@ -1148,12 +1148,12 @@ export default function DashboardPage({ username, isCreator, level, rank, receiv
: null
return (
<div className="min-h-screen bg-[#050c14] text-slate-100">
<div className="dashboard-home-page min-h-screen bg-[#050c14] text-slate-100">
<ShortcutSaveToast notice={shortcutNotice} />
<div className="relative isolate overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[520px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.24),_transparent_36%),radial-gradient(circle_at_top_right,_rgba(245,158,11,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(5,12,20,1))]" />
<div className="relative z-10 mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8 lg:py-10">
<header className="relative overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 p-6 shadow-2xl shadow-black/30 sm:p-8">
<header className="dashboard-home-page__hero relative overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 p-6 shadow-2xl shadow-black/30 sm:p-8">
<div className="absolute inset-0 bg-[linear-gradient(135deg,_rgba(56,189,248,0.12),_transparent_40%,_rgba(245,158,11,0.10)_100%)]" />
<div className="relative grid gap-8 xl:grid-cols-[1.35fr_0.95fr] xl:items-start">
<div>

View File

@@ -2,7 +2,14 @@ import { mountInertiaRoot } from './bootstrap'
import React from 'react'
import { createInertiaApp } from '@inertiajs/react'
const pages = import.meta.glob('./Pages/Moderation/**/*.jsx')
const pages = import.meta.glob([
'./Pages/Moderation/**/*.jsx',
'./Pages/Admin/**/*.jsx',
'!./Pages/Moderation/**/__tests__/**',
'!./Pages/Moderation/**/*.test.jsx',
'!./Pages/Admin/**/__tests__/**',
'!./Pages/Admin/**/*.test.jsx',
])
createInertiaApp({
resolve: (name) => {
@@ -17,4 +24,4 @@ createInertiaApp({
setup({ el, App, props }) {
mountInertiaRoot(el, App, props)
},
})
})

View File

@@ -14,6 +14,69 @@ if (!window.Alpine) {
import './lib/nav-context.js';
import { sendTagInteractionEvent } from './lib/tagAnalytics';
function initSkinbaseThemeToggle() {
var config = window.SKINBASE_THEME || {};
var storageKey = config.storageKey || 'skinbase.theme';
var allowedThemes = Array.isArray(config.themes) ? config.themes : ['default', 'light'];
var root = document.documentElement;
var toggles = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
function normalizeTheme(theme) {
return allowedThemes.indexOf(theme) >= 0 ? theme : 'default';
}
function readTheme() {
try {
return normalizeTheme(window.localStorage.getItem(storageKey));
} catch (_error) {
return normalizeTheme(root.dataset.skinbaseTheme);
}
}
function writeTheme(theme) {
try {
window.localStorage.setItem(storageKey, theme);
} catch (_error) {
// Keep the in-page theme even when storage is unavailable.
}
}
function applyTheme(theme) {
var normalized = normalizeTheme(theme);
var isLight = normalized === 'light';
root.dataset.skinbaseTheme = normalized;
toggles.forEach(function (toggle) {
var label = toggle.querySelector('[data-theme-toggle-label]');
toggle.setAttribute('aria-pressed', isLight ? 'true' : 'false');
toggle.setAttribute('aria-label', isLight ? 'Switch to default theme' : 'Switch to light theme');
toggle.setAttribute('title', isLight ? 'Switch to default theme' : 'Switch to light theme');
if (label) {
label.textContent = isLight ? 'Light' : 'Dark';
}
});
}
applyTheme(readTheme());
toggles.forEach(function (toggle) {
toggle.addEventListener('click', function () {
var nextTheme = root.dataset.skinbaseTheme === 'light' ? 'default' : 'light';
applyTheme(nextTheme);
writeTheme(nextTheme);
});
});
window.addEventListener('storage', function (event) {
if (event.key === storageKey) {
applyTheme(event.newValue);
}
});
}
initSkinbaseThemeToggle();
function safeParseJson(value, fallback) {
try {
return JSON.parse(value || 'null') ?? fallback;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
@extends('layouts.nova')
@push('head')
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
<meta name="csrf-token" content="{{ csrf_token() }}" />
@endif
@vite(['resources/js/academy.jsx'])
<style>
body.page-academy main { padding-top: 4rem; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-academy')
})
</script>
@endpush
@section('content')
@inertia
@endsection

View File

@@ -21,7 +21,7 @@
this.open = false
},
}"
class="relative"
class="dashboard-filter-select relative"
@click.outside="open = false"
@keydown.escape.window="open = false"
>

View File

@@ -1,7 +1,7 @@
@props(['story'])
<a href="{{ route('stories.show', $story->slug) }}"
class="group block overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition-transform duration-200 hover:scale-[1.02] hover:border-sky-500/40">
class="story-card group block overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg transition-transform duration-200 hover:scale-[1.02] hover:border-sky-500/40">
@if($story->cover_url)
<div class="aspect-video overflow-hidden bg-gray-900">
<img src="{{ $story->cover_url }}" alt="{{ $story->title }}" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" loading="lazy" />

View File

@@ -33,7 +33,7 @@
</x-slot>
</x-nova-page-header>
<section class="px-6 pb-16 pt-8 md:px-10">
<section class="following-dashboard-page px-6 pb-16 pt-8 md:px-10">
@php
$firstFollow = $following->getCollection()->first();
$latestFollowedAt = $firstFollow && !empty($firstFollow->followed_at)
@@ -43,29 +43,29 @@
@endphp
<div class="mb-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<div class="following-dashboard-card rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<p class="text-xs uppercase tracking-widest text-white/35">Following</p>
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['total_following']) }}</p>
<p class="mt-2 text-xs text-white/40">People you currently follow</p>
</div>
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<div class="following-dashboard-card rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<p class="text-xs uppercase tracking-widest text-white/35">Mutual follows</p>
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['mutual']) }}</p>
<p class="mt-2 text-xs text-white/40">People who follow you back</p>
</div>
<div class="rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<div class="following-dashboard-card rounded-2xl border border-white/[0.08] bg-white/[0.03] p-5 shadow-[0_16px_60px_rgba(0,0,0,0.16)]">
<p class="text-xs uppercase tracking-widest text-white/35">One-way follows</p>
<p class="mt-2 text-3xl font-semibold text-white">{{ number_format($summary['one_way']) }}</p>
<p class="mt-2 text-xs text-white/40">People you follow who do not follow back</p>
</div>
<div class="rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
<div class="following-dashboard-card rounded-2xl border border-sky-400/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.12),rgba(255,255,255,0.03))] p-5 shadow-[0_16px_60px_rgba(14,165,233,0.08)]">
<p class="text-xs uppercase tracking-widest text-sky-100/60">Latest followed</p>
<p class="mt-2 truncate text-xl font-semibold text-white">{{ $latestFollowedName ?? '—' }}</p>
<p class="mt-2 text-xs text-sky-50/60">{{ $latestFollowedAt ?? 'No recent follow activity' }}</p>
</div>
</div>
<div class="mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
<div class="following-dashboard-panel mb-6 rounded-2xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-[0_16px_60px_rgba(0,0,0,0.12)]">
@php
$sortOptions = [
['value' => 'recent', 'label' => 'Most recent'],
@@ -196,7 +196,7 @@
}
}"
:class="following ? 'opacity-100' : 'opacity-50'"
class="group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
class="following-dashboard-panel group overflow-hidden rounded-2xl border border-white/[0.06] bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.02))] shadow-[0_18px_70px_rgba(0,0,0,0.14)] transition-all hover:-translate-y-0.5 hover:border-white/[0.10] hover:shadow-[0_24px_90px_rgba(0,0,0,0.20)]">
<div class="flex items-start justify-between gap-4 border-b border-white/[0.05] px-5 py-5">
<a href="{{ $f->profile_url }}" class="min-w-0 flex-1">
<div class="flex items-center gap-4 min-w-0">

View File

@@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Academy access activation request</title>
</head>
<body>
<h2>Academy support request</h2>
<p><strong>User:</strong> {{ $user->id }} {{ $user->email }}</p>
<p><strong>Issue type:</strong> {{ $issueType ?? 'n/a' }}</p>
<p><strong>Reply-to email:</strong> {{ $contactEmail ?? $user->email }}</p>
<p><strong>Checkout session id:</strong> {{ $sessionId ?? 'n/a' }}</p>
<h3>Message</h3>
<p>{!! nl2br(e($message ?? 'No message provided.')) !!}</p>
<hr>
<p>Sent from Skinbase application.</p>
</body>
</html>

View File

@@ -41,6 +41,11 @@
<div><strong>Email:</strong> <a href="mailto:{{ $application->email }}">{{ $application->email }}</a></div>
@if($application->role)<div><strong>Role:</strong> {{ $application->role }}</div>@endif
@if($application->portfolio)<div><strong>Portfolio:</strong> <a href="{{ $application->portfolio }}">{{ $application->portfolio }}</a></div>@endif
@if($application->payload['data']['source'] ?? false)<div><strong>Source:</strong> {{ $application->payload['data']['source'] }}</div>@endif
@if($application->payload['data']['issue_type'] ?? false)<div><strong>Issue type:</strong> {{ $application->payload['data']['issue_type'] }}</div>@endif
@if($application->payload['data']['session_id'] ?? false)<div><strong>Session ID:</strong> {{ $application->payload['data']['session_id'] }}</div>@endif
@if($application->payload['data']['account_email'] ?? false)<div><strong>Account email:</strong> <a href="mailto:{{ $application->payload['data']['account_email'] }}">{{ $application->payload['data']['account_email'] }}</a></div>@endif
@if($application->payload['data']['user_id'] ?? false)<div><strong>User ID:</strong> {{ $application->payload['data']['user_id'] }}</div>@endif
</div>
</div>

View File

@@ -49,7 +49,7 @@
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="gallery-page container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
@php

View File

@@ -2,9 +2,9 @@
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
$skinbaseSessionSkipped = request()->attributes->get('skinbase.session_skipped') === true;
$skinbaseCanUseSession = request()->hasSession() && ! $skinbaseSessionSkipped;
$deferToolbarSearch = request()->routeIs('index');
$deferToolbarSearch = request()->routeIs('index', 'academy.*');
$deferFontAwesome = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index');
$deferWebManifest = request()->routeIs('index', 'academy.*');
$isInertiaPage = isset($page) && is_array($page);
$isAuthSeoRoute = request()->routeIs([
'login',
@@ -35,13 +35,27 @@
}
@endphp
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}" data-grid-version="{{ $gridVersion }}">
<html lang="{{ app()->getLocale() }}" data-grid-version="{{ $gridVersion }}" data-skinbase-theme="default">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@if($skinbaseCanUseSession)
<meta name="csrf-token" content="{{ csrf_token() }}">
@endif
<script>
(() => {
const storageKey = 'skinbase.theme';
const allowedThemes = new Set(['default', 'light']);
try {
const savedTheme = localStorage.getItem(storageKey);
const theme = allowedThemes.has(savedTheme) ? savedTheme : 'default';
document.documentElement.dataset.skinbaseTheme = theme;
} catch (_error) {
document.documentElement.dataset.skinbaseTheme = 'default';
}
})();
</script>
<meta name="msvalidate.01" content="E81C84AA9CE4A9CDF1B0039010228C41">
<meta name="verify-v1" content="HNZJnSy5ZbqcrmXUXUwUMtPZzXsKQ+esjxPgXIXDQdk=">
<meta name="google-site-verification" content="D5L-4F-ZP1HFLzLsau6ge7LNGEGb9Sfio4RINkleQto">
@@ -76,6 +90,11 @@
color-scheme: dark;
}
html[data-skinbase-theme="light"] {
background-color: rgb(244, 247, 251);
color-scheme: light;
}
body {
margin: 0;
min-height: 100vh;
@@ -84,6 +103,11 @@
background-color: rgb(14, 18, 27);
color: #fff;
}
html[data-skinbase-theme="light"] body {
background-color: rgb(244, 247, 251);
color: #172033;
}
</style>
@foreach($novaCssEntries as $novaCssEntry)
@php
@@ -100,6 +124,12 @@
}),
});
</script>
<script>
window.SKINBASE_THEME = {
storageKey: 'skinbase.theme',
themes: @json(array_values(array_filter(['default', config('theme.enabled') ? 'light' : null]))),
};
</script>
@stack('head')
@if($deferToolbarSearch)

View File

@@ -33,7 +33,7 @@
@endphp
@section('content')
<div class="container-fluid legacy-page">
<div class="explore-page container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp
<div class="pt-0">
@@ -100,7 +100,7 @@
$activeTab = $current_sort ?? 'trending';
@endphp
<div class="sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
<div class="explore-page__tabs sticky top-0 z-30 border-b border-white/10 bg-nova-900/90 backdrop-blur-md" id="gallery-ranking-tabs">
<div class="px-6 md:px-10">
<div class="flex items-center justify-between gap-4">
<nav class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist">

View File

@@ -261,6 +261,25 @@
</div>
</div>
@if(config('theme.show_toolbar_switch'))
<button
type="button"
class="theme-toggle shrink-0"
data-theme-toggle
aria-label="Switch to default theme"
aria-pressed="true"
title="Switch to default theme"
>
<span class="theme-toggle__track" aria-hidden="true">
<span class="theme-toggle__thumb">
<i class="theme-toggle__icon theme-toggle__icon--moon fa-solid fa-moon"></i>
<i class="theme-toggle__icon theme-toggle__icon--sun fa-solid fa-sun"></i>
</span>
</span>
<span class="hidden xl:inline text-xs font-semibold uppercase tracking-[0.14em]" data-theme-toggle-label>Light</span>
</button>
@endif
@if($skinbaseToolbarCanAuth)
<!-- Notification icons -->
<div class="hidden md:flex items-center gap-0.5 lg:gap-1 text-soft shrink-0">

View File

@@ -1,4 +1,4 @@
<article class="group overflow-hidden rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_18px_45px_rgba(0,0,0,0.22)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<article class="skinbase-dark-surface group overflow-hidden rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_18px_45px_rgba(0,0,0,0.22)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<a href="{{ route('news.show', $article->slug) }}" class="block">
<div class="relative aspect-[16/9] overflow-hidden bg-black/20">
@if($article->cover_url)

View File

@@ -7,14 +7,14 @@
@if($isPreview)
@if($article->commentsAreEnabled())
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<section id="comments" class="news-reading-panel mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="rounded-2xl border border-indigo-300/20 bg-indigo-400/10 px-5 py-4 text-sm text-indigo-100">
Comments are enabled for this article, but posting is disabled in preview mode.
</div>
</section>
@endif
@elseif($article->commentsAreEnabled())
<section id="comments" class="mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<section id="comments" class="news-reading-panel mt-8 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="flex flex-col gap-3 border-b border-white/[0.06] pb-6 sm:flex-row sm:items-end sm:justify-between">
<div>
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/70">Conversation</p>
@@ -118,4 +118,4 @@
</div>
@endif
</section>
@endif
@endif

View File

@@ -1,5 +1,5 @@
@if(!empty($categories) && $categories->isNotEmpty())
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<section class="news-sidebar-panel rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="text-sm font-semibold uppercase tracking-[0.18em] text-white/45">Categories</h2>
<span class="text-xs text-white/30">{{ $categories->count() }}</span>
@@ -16,7 +16,7 @@
@endif
@if(!empty($trending) && $trending->isNotEmpty())
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<section class="news-sidebar-panel rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<div class="mb-4 flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
<i class="fa-solid fa-fire text-[11px] text-rose-300"></i>
Trending
@@ -36,7 +36,7 @@
@endif
@if(!empty($tags) && $tags->isNotEmpty())
<section class="rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<section class="news-sidebar-panel rounded-[24px] border border-white/[0.06] bg-white/[0.025] p-5">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm font-semibold uppercase tracking-[0.18em] text-white/45">
<i class="fa-solid fa-tags text-[11px] text-sky-300"></i>
@@ -54,7 +54,7 @@
</section>
@endif
<section class="rounded-[24px] border border-amber-400/20 bg-amber-500/10 p-5 text-center">
<section class="news-sidebar-panel news-rss-panel rounded-[24px] border border-amber-400/20 bg-amber-500/10 p-5 text-center">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-amber-100/70">Stay updated</p>
<a href="{{ route('news.rss') }}" class="mt-3 inline-flex items-center gap-2 rounded-full border border-amber-300/25 bg-amber-500/10 px-4 py-2 text-sm font-medium text-amber-100 transition hover:bg-amber-500/20" target="_blank" rel="noopener noreferrer">
<i class="fa-solid fa-rss text-xs"></i>

View File

@@ -194,7 +194,7 @@
</div>
@endif
<div class="mt-6 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="news-reading-panel mt-6 rounded-[32px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] p-6 shadow-[0_18px_45px_rgba(0,0,0,0.2)] sm:p-8">
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-white/45">
<span>
@if($article->author?->username)

View File

@@ -97,6 +97,35 @@
width: 100%;
height: 100%;
}
@media (prefers-color-scheme: light) {
body {
background: #ffffff;
}
.story-text {
color: #0f172a;
text-shadow: none;
}
.story-kicker {
color: rgba(15,23,42,0.65);
opacity: .95;
}
.story-title {
color: #0f172a;
}
.story-body {
color: #0f172a;
}
.story-cta {
background: rgba(17,24,39,0.92);
color: #ffffff;
}
.overlay {
background: linear-gradient(to top, rgba(255,255,255,0.9), rgba(255,255,255,0.6), rgba(255,255,255,0.3));
}
.gradient-fill {
background: linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(14,165,233,0.08) 100%);
}
}
</style>
</head>
<body>

View File

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

View File

@@ -5,7 +5,7 @@
@endphp
@if (!$heroArtwork)
<section class="relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<section class="skinbase-dark-surface relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-nova-900 via-nova-900/60 to-transparent"></div>
<div class="relative z-10 w-full px-6 pb-7 sm:px-10 lg:px-16">
<p class="mb-1.5 text-xs font-semibold uppercase tracking-widest text-accent">
@@ -23,7 +23,7 @@
</div>
</section>
@else
<section class="group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<section class="skinbase-dark-surface group relative flex min-h-[62vh] max-h-[420px] w-full items-end overflow-hidden bg-nova-900 md:min-h-[38vh] md:max-h-[460px]">
<x-artwork.featured-picture
:image="$heroFeaturedImage ?? [
'alt' => $heroArtwork['title'] ?? 'Featured artwork',
@@ -72,4 +72,4 @@
</div>
</div>
</section>
@endif
@endif

View File

@@ -40,7 +40,7 @@
@endif
</div>
@if (!empty(data_get($collection, 'owner.username')))
<span class="shrink-0 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100/80">@{{ data_get($collection, 'owner.username') }}</span>
<span class="shrink-0 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100/80">{{ '@'.data_get($collection, 'owner.username') }}</span>
@endif
</div>

View File

@@ -15,7 +15,7 @@
<a
href="{{ $creator['url'] ?? '#' }}"
aria-label="View {{ $creator['name'] ?? 'Creator' }} profile"
class="group relative flex min-h-[16rem] flex-col items-center overflow-hidden rounded-xl bg-panel p-5 text-center shadow-sm transition hover:ring-1 hover:ring-nova-500"
class="{{ !empty($creator['bg_thumb']) ? 'skinbase-dark-surface ' : '' }}group relative flex min-h-[16rem] flex-col items-center overflow-hidden rounded-xl bg-panel p-5 text-center shadow-sm transition hover:ring-1 hover:ring-nova-500"
@if (!empty($creator['bg_thumb']))
style="background-image: linear-gradient(to top, rgba(13, 19, 28, 0.96), rgba(13, 19, 28, 0.7)), url('{{ $creator['bg_thumb'] }}'); background-size: cover; background-position: center;"
@endif
@@ -39,4 +39,4 @@
@endforeach
</div>
</section>
@endif
@endif

View File

@@ -25,7 +25,7 @@
['key' => 'followers', 'label' => 'followers', 'value' => (int) data_get($group, 'counts.followers', 0)],
])->filter(fn ($item) => $item['value'] > 0)->values();
@endphp
<article class="group relative flex flex-col overflow-hidden rounded-xl bg-panel p-5 shadow-sm transition hover:ring-1 hover:ring-nova-500">
<article class="{{ !empty($group['banner_url']) ? 'skinbase-dark-surface ' : '' }}group relative flex flex-col overflow-hidden rounded-xl bg-panel p-5 shadow-sm transition hover:ring-1 hover:ring-nova-500">
@if (!empty($group['banner_url']))
<img src="{{ $group['banner_url'] }}" alt="" aria-hidden="true" class="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-40 transition duration-500 group-hover:scale-105 group-hover:opacity-20" loading="lazy" decoding="async">
<div class="pointer-events-none absolute inset-0 bg-gradient-to-t from-panel via-panel/85 to-panel/70"></div>
@@ -71,4 +71,4 @@
@endforeach
</div>
</section>
@endif
@endif

View File

@@ -18,7 +18,7 @@
<div id="{{ $carouselId }}" class="news-carousel overflow-x-auto snap-x snap-proximity -mx-4 px-4 py-2">
<div class="flex gap-4">
@foreach ($newsItems as $item)
<article class="snap-start flex-shrink-0 w-[260px] group overflow-hidden rounded-[20px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_12px_30px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<article class="skinbase-dark-surface snap-start flex-shrink-0 w-[260px] group overflow-hidden rounded-[20px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.94),rgba(7,11,19,0.92))] shadow-[0_12px_30px_rgba(0,0,0,0.18)] transition hover:-translate-y-0.5 hover:border-white/[0.12]">
<a href="{{ $item['url'] ?? '#' }}" class="block">
<div class="relative aspect-[4/3] overflow-hidden bg-black/20">
@if (!empty($item['cover_url']))
@@ -131,4 +131,4 @@
display: none;
}
</style>
@endpush
@endpush

View File

@@ -22,7 +22,7 @@
<div class="mt-3 w-full text-center">
<a href="{{ $creator['url'] ?? '#' }}" class="block truncate text-sm font-semibold text-white transition hover:text-accent">{{ $creator['name'] ?? 'Creator' }}</a>
@if (!empty($creator['username']))
<p class="truncate text-xs text-nova-400">@{{ $creator['username'] }}</p>
<p class="truncate text-xs text-nova-400">{{ '@' . $creator['username'] }}</p>
@endif
<div class="mt-2 flex items-center justify-center gap-3 text-xs text-nova-500">
@if ((int) ($creator['followers_count'] ?? 0) > 0)
@@ -40,4 +40,4 @@
@endforeach
</div>
</section>
@endif
@endif

View File

@@ -43,7 +43,8 @@
</x-slot>
</x-nova-page-header>
<div class="border-b border-white/10 bg-nova-900/90 backdrop-blur-md">
<div class="stories-index-page">
<div class="stories-index-page__tabs border-b border-white/10 bg-nova-900/90 backdrop-blur-md">
<div class="px-6 md:px-10">
<nav data-stories-tabs class="flex items-center gap-0 -mb-px nb-scrollbar-none overflow-x-auto" role="tablist" aria-label="Stories sections">
@foreach($storyTabs as $index => $tab)
@@ -61,7 +62,7 @@
</div>
</div>
<div class="border-b border-white/10 bg-nova-900/70">
<div class="stories-index-page__categories border-b border-white/10 bg-nova-900/70">
<div class="px-6 md:px-10 py-6">
<div class="flex gap-3 overflow-x-auto nb-scrollbar-none pb-1">
<a href="{{ route('stories.index') }}" class="whitespace-nowrap rounded-full px-3 py-1.5 text-sm font-semibold transition-colors {{ $currentCategory === '' ? 'bg-orange-500 text-white' : 'border border-white/10 bg-white/[0.05] text-white/70 hover:bg-white/[0.1] hover:text-white' }}">All</a>
@@ -77,7 +78,7 @@
<div class="px-6 pb-16 md:px-10">
<div class="space-y-10">
@if($featured)
<section id="featured" class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
<section id="featured" class="stories-index-page__featured overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70 shadow-lg">
<a href="{{ route('stories.show', $featured->slug) }}" class="grid gap-0 lg:grid-cols-2">
<div class="aspect-video overflow-hidden bg-gray-900">
@if($featured->cover_url)
@@ -129,6 +130,7 @@
</section>
</div>
</div>
</div>
@endsection
@push('scripts')

View File

@@ -44,7 +44,24 @@
@endsection
@section('page-content')
<div class="mx-auto grid max-w-7xl gap-8 lg:grid-cols-12">
<div id="story-page" class="mx-auto grid max-w-7xl gap-8 lg:grid-cols-12">
<style>
@media (prefers-color-scheme: light) {
#story-page { color-scheme: light; }
#story-page .rounded-xl.border { background: #ffffff !important; border-color: #e6e6ef !important; }
#story-page img { background: transparent; }
#story-page h1.text-white { color: #0f172a !important; }
#story-page .text-white { color: #0f172a !important; }
#story-page .text-gray-300 { color: #6b7280 !important; }
#story-page .text-gray-400 { color: #9ca3af !important; }
#story-page .prose, #story-page .story-prose, #story-page .prose * { color: #0f172a !important; }
#story-page .prose a, #story-page a { color: #0f6fbf !important; }
#story-page .rounded-xl.border .p-6 { background: transparent !important; }
#story-page .rounded-xl.border.bg-gray-900\/50, #story-page .bg-gray-900\/50 { background: rgba(17,24,39,0.05) !important; }
#story-page textarea, #story-page button { color: inherit !important; }
#story-page button, #story-page .w-full.rounded-lg { background: #fef2f2 !important; color: #9f1239 !important; border-color: #fbcfe8 !important; }
}
</style>
<article class="lg:col-span-8">
<div class="overflow-hidden rounded-xl border border-gray-700 bg-gray-800/70">
@if($story->cover_url)