Optimize academy
This commit is contained in:
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
|
||||
function formatDate(iso) {
|
||||
@@ -12,6 +12,72 @@ function formatDate(iso) {
|
||||
}
|
||||
|
||||
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
|
||||
const { flash, auth } = usePage().props
|
||||
const { data, setData, post, processing } = useForm({
|
||||
issue_type: 'billing',
|
||||
contact_email: auth?.user?.email || '',
|
||||
message: '',
|
||||
session_id: null,
|
||||
})
|
||||
|
||||
function IssueTypeDropdown({ value, onChange }) {
|
||||
const options = [
|
||||
{ value: 'billing', label: 'Billing question' },
|
||||
{ value: 'payment', label: 'Payment problem' },
|
||||
{ value: 'upgrade', label: 'Upgrade problem' },
|
||||
{ value: 'downgrade', label: 'Downgrade problem' },
|
||||
{ value: 'cancel', label: 'Cancellation problem' },
|
||||
{ value: 'access', label: 'Access not updated' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
function onDoc(e) {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
|
||||
const current = options.find((o) => o.value === value) || options[0]
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((s) => !s)}
|
||||
className="w-full text-left rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 flex items-center justify-between"
|
||||
>
|
||||
<span>{current.label}</span>
|
||||
<svg className="ml-2 h-4 w-4 text-amber-100/70" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="absolute left-0 top-full mt-2 w-full rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full text-left px-4 py-3 text-sm ${opt.value === value ? 'bg-white/[0.03] text-white' : 'text-slate-300 hover:bg-white/[0.02]'}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
const endsAt = formatDate(subscription?.endsAt)
|
||||
const onGracePeriod = subscription?.onGracePeriod === true
|
||||
const subscriptionActive = subscription?.active === true
|
||||
@@ -21,6 +87,16 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
|
||||
<Head title="Academy Subscription" />
|
||||
|
||||
<div className="mx-auto max-w-[1280px] space-y-8">
|
||||
{flash?.error ? (
|
||||
<section className="rounded-[20px] border border-rose-300/20 bg-rose-300/8 p-4">
|
||||
<p className="font-semibold text-rose-100">{flash.error}</p>
|
||||
</section>
|
||||
) : null}
|
||||
{flash?.success ? (
|
||||
<section className="rounded-[20px] border border-emerald-300/20 bg-emerald-300/8 p-4">
|
||||
<p className="font-semibold text-emerald-100">{flash.success}</p>
|
||||
</section>
|
||||
) : null}
|
||||
{/* Header */}
|
||||
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(15,23,42,0.96))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -42,12 +118,12 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
|
||||
<section className="rounded-[30px] border border-amber-300/25 bg-amber-300/[0.06] px-6 py-5">
|
||||
<p className="font-semibold text-amber-100">Your subscription was cancelled and will end on {endsAt}.</p>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-100/75">You still have full access until that date. Open the subscription portal to resume your plan if you change your mind.</p>
|
||||
<Link
|
||||
<a
|
||||
href={links.portal}
|
||||
className="mt-4 inline-flex items-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/20"
|
||||
>
|
||||
Resume subscription
|
||||
</Link>
|
||||
</a>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -123,20 +199,75 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
|
||||
<aside className="space-y-3 rounded-[32px] border border-white/10 bg-black/20 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Manage</p>
|
||||
<p className="text-xs leading-6 text-slate-400">
|
||||
Use the subscription portal to upgrade, downgrade, or cancel. Changes take effect at your next billing date.
|
||||
Use the subscription portal to cancel or manage billing details. Plan upgrades are handled here on Skinbase.
|
||||
</p>
|
||||
<Link
|
||||
href={links.portal}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18"
|
||||
>
|
||||
Upgrade, downgrade or cancel
|
||||
</Link>
|
||||
{/* Use a plain anchor to perform a full navigation to Stripe (avoid Inertia XHR/CORS) */}
|
||||
<a
|
||||
href={links.portal}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18"
|
||||
>
|
||||
Open billing portal
|
||||
</a>
|
||||
<Link
|
||||
href={links.pricing || '/academy/pricing'}
|
||||
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||
>
|
||||
Compare plans
|
||||
</Link>
|
||||
{/* Quick upgrade form: allow Creator -> Pro upgrade in one click (full POST, not Inertia) */}
|
||||
{activePlan?.tier === 'creator' ? (
|
||||
<form action={links.checkout} method="POST" data-no-inertia className="mt-2">
|
||||
<input type="hidden" name="_token" value={getCsrfToken()} />
|
||||
<input type="hidden" name="plan" value="pro_monthly" />
|
||||
<button type="submit" className="inline-flex w-full items-center justify-center rounded-full border border-emerald-300/25 bg-emerald-300/10 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18">Upgrade to Pro now</button>
|
||||
</form>
|
||||
) : null}
|
||||
{links.reportIssue ? (
|
||||
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-300/8 p-4">
|
||||
<p className="text-sm font-semibold text-amber-100">Need help with billing or access?</p>
|
||||
<p className="mt-1 text-xs leading-5 text-amber-100/80">
|
||||
Send a quick report here if payment, access, or subscription changes do not behave as expected.
|
||||
</p>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
post(links.reportIssue, { preserveScroll: true })
|
||||
}}
|
||||
className="mt-3 space-y-3"
|
||||
>
|
||||
<div className="grid gap-3">
|
||||
<label className="space-y-1 relative">
|
||||
<span className="text-xs font-medium text-amber-100/80">Issue type</span>
|
||||
{/* Custom dropdown to avoid native browser option styling */}
|
||||
<IssueTypeDropdown value={data.issue_type} onChange={(v) => setData('issue_type', v)} />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-amber-100/80">Reply email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={data.contact_email}
|
||||
onChange={(event) => setData('contact_email', event.target.value)}
|
||||
placeholder="you@example.com"
|
||||
className="w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<textarea
|
||||
value={data.message}
|
||||
onChange={(event) => setData('message', event.target.value)}
|
||||
placeholder="Describe the issue you hit, what you expected, and anything already charged or missing"
|
||||
className="min-h-[96px] w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="inline-flex w-full items-center justify-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{processing ? 'Sending report...' : 'Send support report'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
<Link
|
||||
href={links.academy || '/academy'}
|
||||
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||
|
||||
@@ -83,7 +83,7 @@ function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) {
|
||||
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics, missingRemote = [] }) {
|
||||
const { auth, errors, flash } = usePage().props
|
||||
|
||||
useAcademyPageAnalytics(analytics)
|
||||
@@ -151,6 +151,12 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
|
||||
{errors?.plan ? <p className="mt-4 text-sm font-medium text-rose-200">{errors.plan}</p> : null}
|
||||
{flash?.error ? <p className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm font-medium text-rose-100">{flash.error}</p> : null}
|
||||
{flash?.success ? <p className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm font-medium text-emerald-100">{flash.success}</p> : null}
|
||||
{Array.isArray(missingRemote) && missingRemote.length > 0 ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm font-medium text-amber-50">
|
||||
<p className="font-semibold">Purchases temporarily disabled:</p>
|
||||
<p className="mt-1 text-xs">The following plans could not be verified in Stripe: {missingRemote.join(', ')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<SidePanel
|
||||
@@ -217,4 +223,4 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import { Head, Link, usePage, useForm } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
|
||||
export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) {
|
||||
const { auth } = usePage().props
|
||||
const sessionId = usePage().props.sessionId || null
|
||||
const userEmail = auth?.user?.email ?? null
|
||||
const { data, setData, post, processing } = useForm({ message: '', session_id: sessionId })
|
||||
return (
|
||||
<main className="flex min-h-screen items-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.14),_transparent_24%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Subscription Confirmed" />
|
||||
@@ -21,6 +25,25 @@ export default function AcademyBillingSuccess({ currentTier, isSubscribed, links
|
||||
? 'Your subscription is active and all premium content for your plan is now unlocked. Head to Academy and start exploring.'
|
||||
: "Your payment was confirmed and your subscription is activating now. This usually takes just a moment. If you don't see your access right away, refresh the Academy page in a few seconds."}
|
||||
</p>
|
||||
|
||||
{!isSubscribed ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm text-amber-50">
|
||||
<p className="font-semibold">If your access isn't updated automatically</p>
|
||||
<p className="mt-1">If your Academy access doesn't appear within a few minutes, email <strong>academy@skinbase.org</strong> or click the button below to open a prefilled message. Include your account email{userEmail ? ` (${userEmail})` : ''} and the checkout session id{sessionId ? `: ${sessionId}` : '.'}</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-start gap-4">
|
||||
<form onSubmit={(e) => { e.preventDefault(); post(links.reportIssue, { preserveScroll: true }) }} className="flex w-full max-w-lg items-start gap-2">
|
||||
<textarea value={data.message} onChange={(e) => setData('message', e.target.value)} placeholder="Optional: Tell us what you expected to see or any useful details" className="flex-1 rounded-md bg-black/20 border border-amber-300/20 p-2 text-sm text-amber-50" rows={3} />
|
||||
<button type="submit" disabled={processing} className="rounded-full border border-amber-300/30 bg-amber-300/12 px-4 py-2 text-sm font-semibold text-amber-900 hover:bg-amber-300/16">Send report</button>
|
||||
</form>
|
||||
|
||||
<div className="text-xs text-amber-100">
|
||||
<div>- Wait 2–3 minutes and refresh the Academy page.</div>
|
||||
<div>- If you still lack access, use the form above or email academy@skinbase.org.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
@@ -595,6 +595,192 @@ function PromptPlaceholderCard({ placeholder }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PromptFilledExampleCard({ example, analytics, contentId, index }) {
|
||||
const placeholderEntries = Object.entries(example?.placeholder_values || {}).filter(([key, value]) => String(key || '').trim() && value != null && value !== '' && value !== false)
|
||||
|
||||
return (
|
||||
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.16)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-200/75">Filled example {index + 1}</p>
|
||||
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{example?.title || `Example ${index + 1}`}</h3>
|
||||
{example?.description ? <p className="mt-3 text-sm leading-7 text-slate-300">{example.description}</p> : null}
|
||||
</div>
|
||||
{example?.prompt ? (
|
||||
<PromptCopyButton
|
||||
prompt={example.prompt}
|
||||
label="Copy example"
|
||||
analytics={analytics}
|
||||
contentId={contentId}
|
||||
eventType="academy_prompt_filled_example_copy"
|
||||
metadata={{ copy_type: 'filled_example', filled_example_index: index, source: 'prompt_filled_examples' }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{placeholderEntries.length ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{placeholderEntries.map(([key, value]) => (
|
||||
<span key={key} className="rounded-full border border-violet-300/20 bg-violet-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-violet-100">
|
||||
{key}: <span className="normal-case tracking-normal text-white">{String(value)}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{example?.prompt ? <pre className="mt-5 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100">{example.prompt}</pre> : null}
|
||||
|
||||
{example?.negative_prompt ? (
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-slate-950/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
|
||||
<PromptCopyButton
|
||||
prompt={example.negative_prompt}
|
||||
label="Copy negative"
|
||||
analytics={analytics}
|
||||
contentId={contentId}
|
||||
eventType="academy_prompt_filled_example_negative_copy"
|
||||
metadata={{ copy_type: 'filled_example_negative', filled_example_index: index, source: 'prompt_filled_examples' }}
|
||||
/>
|
||||
</div>
|
||||
<pre className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{example.negative_prompt}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptFilledExamplesSection({ examples, analytics, contentId }) {
|
||||
const visibleExamples = Array.isArray(examples) ? examples.filter((example) => example && typeof example === 'object') : []
|
||||
const [activeExampleIndex, setActiveExampleIndex] = useState(0)
|
||||
const examplesScrollRef = useRef(null)
|
||||
const [canScrollExamplesLeft, setCanScrollExamplesLeft] = useState(false)
|
||||
const [canScrollExamplesRight, setCanScrollExamplesRight] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const updateExampleScrollState = () => {
|
||||
const element = examplesScrollRef.current
|
||||
if (!element) {
|
||||
setCanScrollExamplesLeft(false)
|
||||
setCanScrollExamplesRight(false)
|
||||
return
|
||||
}
|
||||
|
||||
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
|
||||
setCanScrollExamplesLeft(element.scrollLeft > 6)
|
||||
setCanScrollExamplesRight(element.scrollLeft < maxScrollLeft - 6)
|
||||
}
|
||||
|
||||
updateExampleScrollState()
|
||||
|
||||
const element = examplesScrollRef.current
|
||||
if (!element) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
element.addEventListener('scroll', updateExampleScrollState, { passive: true })
|
||||
window.addEventListener('resize', updateExampleScrollState, { passive: true })
|
||||
|
||||
return () => {
|
||||
element.removeEventListener('scroll', updateExampleScrollState)
|
||||
window.removeEventListener('resize', updateExampleScrollState)
|
||||
}
|
||||
}, [visibleExamples.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visibleExamples.length) {
|
||||
setActiveExampleIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
setActiveExampleIndex((current) => Math.max(0, Math.min(current, visibleExamples.length - 1)))
|
||||
}, [visibleExamples.length])
|
||||
|
||||
if (!visibleExamples.length) return null
|
||||
|
||||
const activeExample = visibleExamples[activeExampleIndex] || visibleExamples[0]
|
||||
const activeExampleLabel = String(activeExample?.title || '').trim() || `Example ${activeExampleIndex + 1}`
|
||||
const activeExampleDescription = String(activeExample?.description || '').trim()
|
||||
|
||||
const scrollExamples = (direction) => {
|
||||
const element = examplesScrollRef.current
|
||||
if (!element) return
|
||||
|
||||
const amount = Math.max(220, Math.floor(element.clientWidth * 0.65))
|
||||
element.scrollBy({
|
||||
left: direction === 'left' ? -amount : amount,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-5">
|
||||
<div className="rounded-[24px] border border-violet-300/15 bg-violet-300/10 p-5 md:p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Selected example</p>
|
||||
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2rem]">{activeExampleLabel}</h3>
|
||||
{activeExampleDescription ? <p className="mt-3 text-sm leading-7 text-slate-200 md:text-base">{activeExampleDescription}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className={`pointer-events-none absolute inset-y-0 left-0 z-10 w-14 bg-gradient-to-r from-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesLeft ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
|
||||
<div className={`pointer-events-none absolute inset-y-0 right-0 z-10 w-14 bg-gradient-to-l from-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesRight ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll filled examples left"
|
||||
onClick={() => scrollExamples('left')}
|
||||
className={`absolute left-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollExamplesLeft ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<i className="fa-solid fa-chevron-left text-sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Scroll filled examples right"
|
||||
onClick={() => scrollExamples('right')}
|
||||
className={`absolute right-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollExamplesRight ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<i className="fa-solid fa-chevron-right text-sm" />
|
||||
</button>
|
||||
|
||||
<div ref={examplesScrollRef} className="overflow-x-auto pb-1 scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="inline-flex min-w-full gap-2.5 px-1 py-1">
|
||||
{visibleExamples.map((example, index) => {
|
||||
const isActive = index === activeExampleIndex
|
||||
const exampleLabel = String(example?.title || '').trim() || `Example ${index + 1}`
|
||||
|
||||
return (
|
||||
<button
|
||||
key={`${example.title || 'filled-example-tab'}-${index}`}
|
||||
type="button"
|
||||
onClick={() => setActiveExampleIndex(index)}
|
||||
aria-pressed={isActive}
|
||||
title={exampleLabel}
|
||||
className={`max-w-full whitespace-nowrap rounded-full border px-4 py-2.5 text-sm font-semibold uppercase tracking-[0.18em] transition ${isActive ? 'border-violet-300/30 bg-violet-300/18 text-white shadow-[0_12px_30px_rgba(76,29,149,0.24)]' : 'border-white/10 bg-white/[0.04] text-violet-100/80 hover:border-violet-300/20 hover:bg-violet-300/10 hover:text-white'}`}
|
||||
>
|
||||
<span className="block max-w-[240px] truncate">{exampleLabel}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptFilledExampleCard
|
||||
key={`${activeExample.title || 'filled-example-active'}-${activeExampleIndex}`}
|
||||
example={activeExample}
|
||||
analytics={analytics}
|
||||
contentId={contentId}
|
||||
index={activeExampleIndex}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptHelperPromptCard({ helperPrompt, analytics, contentId }) {
|
||||
if (!helperPrompt || typeof helperPrompt !== 'object') return null
|
||||
|
||||
@@ -1088,11 +1274,25 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
|| promptDocumentation.data_accuracy_notes.length,
|
||||
)
|
||||
const hasPromptPlaceholders = Boolean(item?.has_placeholder_inputs) && promptPlaceholders.length > 0
|
||||
const promptFilledExamples = Array.isArray(item?.filled_examples)
|
||||
? item.filled_examples.filter((example) => example && typeof example === 'object' && [
|
||||
example.title,
|
||||
example.description,
|
||||
example.prompt,
|
||||
example.negative_prompt,
|
||||
...(example.placeholder_values && typeof example.placeholder_values === 'object' ? Object.values(example.placeholder_values) : []),
|
||||
].some((value) => value != null && value !== '' && value !== false))
|
||||
: []
|
||||
const hasPromptFilledExamples = promptFilledExamples.length > 0
|
||||
const promptFilledExamplesTotal = Number(item?.filled_examples_total || promptFilledExamples.length || 0)
|
||||
const promptHasMoreFilledExamples = Boolean(item?.has_more_filled_examples) || promptFilledExamplesTotal > promptFilledExamples.length
|
||||
const promptHasFullFilledExamplesAccess = Boolean(item?.has_full_filled_examples_access)
|
||||
const promptHasLockedFilledExamples = Boolean(item?.has_filled_examples) && (!Boolean(item?.can_access_filled_examples) || promptHasMoreFilledExamples)
|
||||
const promptHasLockedHelperPrompts = Boolean(item?.has_helper_prompts) && !promptHasFullAccess
|
||||
const promptHasLockedVariants = Boolean(item?.has_prompt_variants) && !promptHasFullAccess
|
||||
const hasPromptHelperPrompts = promptHelperPrompts.length > 0
|
||||
const hasPromptVariants = promptVariants.length > 0
|
||||
const showPromptHelperPrompts = false
|
||||
const showPromptHelperPrompts = true
|
||||
const promptAccessRequirement = item?.access_requirement || promptRequirementText(item?.access_level)
|
||||
const promptUnlockTitle = item?.unlock_heading || promptUnlockHeading(item?.access_level)
|
||||
const promptUnlockDetails = item?.unlock_description || promptUnlockDescription(item?.access_level)
|
||||
@@ -2103,6 +2303,45 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{hasPromptFilledExamples ? (
|
||||
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
|
||||
{promptFilledExamplesTotal > 0 ? `${promptFilledExamplesTotal} ready-made prompt runs for real user inputs` : 'Ready-made prompt runs for real user inputs'}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
|
||||
{promptHasMoreFilledExamples
|
||||
? `You can view ${promptFilledExamples.length} example${promptFilledExamples.length === 1 ? '' : 's'} right now. Upgrade to Pro to unlock all ${promptFilledExamplesTotal} filled prompt runs and copy a closer starting point instead of filling everything from scratch.`
|
||||
: 'These examples show how the prompt looks after swapping real placeholder values, so you can copy a closer starting point instead of filling everything from scratch.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PromptFilledExamplesSection examples={promptFilledExamples} analytics={analytics} contentId={item.id} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{promptHasLockedFilledExamples ? (
|
||||
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
|
||||
{promptHasMoreFilledExamples && hasPromptFilledExamples
|
||||
? `${Math.max(promptFilledExamplesTotal - promptFilledExamples.length, 0)} more filled prompt example${promptFilledExamplesTotal - promptFilledExamples.length === 1 ? '' : 's'} are available`
|
||||
: `${promptFilledExamplesTotal || 5} filled prompt examples are included`}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
|
||||
{promptHasMoreFilledExamples && hasPromptFilledExamples
|
||||
? 'Creator access includes a smaller set here. Upgrade to Academy Pro to unlock the remaining filled prompt runs.'
|
||||
: 'This prompt ships with ready-made filled examples for different user inputs, but they unlock only for Academy Pro members.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<LockedPanel pricingUrl={pricingUrl} label="prompt" accessLevel="pro" onUpgrade={() => trackUpgradeClick(analytics, { source: 'prompt_filled_examples_locked_panel' })} />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showPromptHelperPrompts && hasPromptHelperPrompts ? (
|
||||
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
|
||||
<div className="max-w-3xl">
|
||||
@@ -2230,4 +2469,4 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
<ImageLightbox gallery={lightboxGallery} onClose={() => setLightboxGallery(null)} onNavigate={navigateLightboxGallery} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,181 @@ function serializeStructuredJson(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseStructuredJson(value) {
|
||||
if (value == null || value === '') return null
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(trimmed)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function toDisplayText(value) {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'string') return value.trim()
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (Array.isArray(value)) return value.map((item) => toDisplayText(item)).filter(Boolean).join(', ')
|
||||
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function humanizePlaceholderKey(value) {
|
||||
const normalized = String(value || '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (!normalized) {
|
||||
return 'Placeholder'
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(' ')
|
||||
.map((part) => part ? `${part.charAt(0).toUpperCase()}${part.slice(1).toLowerCase()}` : '')
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function buildPlaceholderSeedValues(placeholder, limit = 5) {
|
||||
const readableLabel = humanizePlaceholderKey(placeholder?.label || placeholder?.key || 'Placeholder')
|
||||
const seeded = [
|
||||
placeholder?.example,
|
||||
placeholder?.default,
|
||||
...(Array.isArray(placeholder?.examples) ? placeholder.examples : []),
|
||||
...(Array.isArray(placeholder?.options) ? placeholder.options : []),
|
||||
...(Array.isArray(placeholder?.choices) ? placeholder.choices : []),
|
||||
...(Array.isArray(placeholder?.values) ? placeholder.values : []),
|
||||
]
|
||||
.map((entry) => toDisplayText(entry))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const unique = Array.from(new Set(seeded))
|
||||
|
||||
while (unique.length < limit) {
|
||||
unique.push(`${readableLabel} ${unique.length + 1}`)
|
||||
}
|
||||
|
||||
return unique.slice(0, limit)
|
||||
}
|
||||
|
||||
function normalizePromptPlaceholders(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
return value
|
||||
.map((placeholder) => {
|
||||
if (!placeholder || typeof placeholder !== 'object') return null
|
||||
|
||||
const key = String(placeholder.key || '').trim()
|
||||
const label = String(placeholder.label || '').trim()
|
||||
|
||||
if (!key && !label) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...placeholder,
|
||||
key,
|
||||
label,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function applyPlaceholderValuesToPrompt(template, placeholderValues, placeholders) {
|
||||
let nextText = String(template || '')
|
||||
let replacementCount = 0
|
||||
|
||||
placeholders.forEach((placeholder) => {
|
||||
const key = String(placeholder?.key || '').trim()
|
||||
if (!key) return
|
||||
|
||||
const replacement = toDisplayText(placeholderValues[key])
|
||||
if (!replacement) return
|
||||
|
||||
const patterns = [
|
||||
new RegExp(`\\[${escapeRegExp(key)}\\]`, 'g'),
|
||||
new RegExp(`\\{\\{\\s*${escapeRegExp(key)}\\s*\\}\\}`, 'g'),
|
||||
new RegExp(`\\{${escapeRegExp(key)}\\}`, 'g'),
|
||||
new RegExp(`<${escapeRegExp(key)}>`, 'g'),
|
||||
]
|
||||
|
||||
patterns.forEach((pattern) => {
|
||||
nextText = nextText.replace(pattern, () => {
|
||||
replacementCount += 1
|
||||
return replacement
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (replacementCount === 0 && placeholders.length > 0) {
|
||||
const placeholderSummary = placeholders
|
||||
.map((placeholder) => {
|
||||
const key = String(placeholder?.key || '').trim()
|
||||
if (!key) return null
|
||||
|
||||
const readableLabel = humanizePlaceholderKey(placeholder.label || key)
|
||||
const replacement = toDisplayText(placeholderValues[key])
|
||||
|
||||
return replacement ? `- ${readableLabel}: ${replacement}` : null
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
if (placeholderSummary) {
|
||||
nextText = `${nextText.trim()}\n\nPlaceholder values:\n${placeholderSummary}`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
return nextText.trim()
|
||||
}
|
||||
|
||||
function buildStarterFilledExamples({ title, excerpt, prompt, negativePrompt, placeholders }) {
|
||||
const normalizedPlaceholders = normalizePromptPlaceholders(placeholders)
|
||||
const exampleCount = Math.min(5, Math.max(1, normalizedPlaceholders.length ? 5 : 1))
|
||||
const fallbackTitle = stripPlainText(title) || 'Prompt'
|
||||
const fallbackDescription = stripPlainText(excerpt) || 'Starter example generated from the current placeholders. Review and refine before publishing.'
|
||||
|
||||
return Array.from({ length: exampleCount }, (_, index) => {
|
||||
const placeholderValues = normalizedPlaceholders.reduce((accumulator, placeholder) => {
|
||||
const key = String(placeholder?.key || '').trim()
|
||||
if (!key) return accumulator
|
||||
|
||||
const seeds = buildPlaceholderSeedValues(placeholder, 5)
|
||||
accumulator[key] = seeds[index % seeds.length]
|
||||
return accumulator
|
||||
}, {})
|
||||
|
||||
const titleParts = Object.values(placeholderValues)
|
||||
.map((value) => stripPlainText(value))
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
|
||||
return {
|
||||
title: titleParts.length > 0
|
||||
? `Example ${index + 1} · ${titleParts.join(' · ')}`.slice(0, 180)
|
||||
: `Example ${index + 1} · ${fallbackTitle}`.slice(0, 180),
|
||||
description: `${fallbackDescription} Starter ${index + 1} for editors.`.trim(),
|
||||
placeholder_values: placeholderValues,
|
||||
prompt: applyPlaceholderValuesToPrompt(prompt, placeholderValues, normalizedPlaceholders),
|
||||
negative_prompt: negativePrompt ? applyPlaceholderValuesToPrompt(negativePrompt, placeholderValues, normalizedPlaceholders) : '',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function copyTextToClipboard(text) {
|
||||
const source = String(text || '')
|
||||
if (!source) return Promise.reject(new Error('Nothing to copy'))
|
||||
@@ -229,6 +404,7 @@ const PROMPT_FIELD_TAB_MAP = {
|
||||
placeholders: 'advanced',
|
||||
helper_prompts: 'advanced',
|
||||
prompt_variants: 'advanced',
|
||||
filled_examples: 'advanced',
|
||||
preview_image: 'media',
|
||||
preview_image_file: 'media',
|
||||
published_at: 'publish',
|
||||
@@ -436,6 +612,7 @@ function parsePromptImport(rawText, categoryOptions) {
|
||||
if (parsed.placeholders != null) apply('placeholders', serializeStructuredJson(parsed.placeholders))
|
||||
if (parsed.helper_prompts != null) apply('helper_prompts', serializeStructuredJson(parsed.helper_prompts))
|
||||
if (parsed.prompt_variants != null) apply('prompt_variants', serializeStructuredJson(parsed.prompt_variants))
|
||||
if (parsed.filled_examples != null) apply('filled_examples', serializeStructuredJson(parsed.filled_examples))
|
||||
if (parsed.preview_image != null) apply('preview_image', String(parsed.preview_image))
|
||||
if (parsed.preview_image_url != null && parsed.preview_image == null) apply('preview_image', String(parsed.preview_image_url))
|
||||
if (parsed.published_at != null) apply('published_at', String(parsed.published_at))
|
||||
@@ -619,6 +796,30 @@ function PromptJsonImportDialog({ open, value, error, onChange, onClose, onApply
|
||||
active: true,
|
||||
},
|
||||
],
|
||||
filled_examples: [
|
||||
{
|
||||
title: 'Alpine sunrise travel poster',
|
||||
description: 'A scenic poster version tuned for crisp mountain light and clean copy-safe composition.',
|
||||
placeholder_values: {
|
||||
LOCATION: 'Lake Bled, Slovenia',
|
||||
SEASON: 'spring',
|
||||
MOOD: 'calm sunrise',
|
||||
},
|
||||
prompt: 'Create a calm sunrise travel poster of Lake Bled in spring, with clear mountain reflections, light mist, soft golden light, and a clean editorial composition.',
|
||||
negative_prompt: 'muddy light, cluttered foreground, oversharpening, distorted architecture',
|
||||
},
|
||||
{
|
||||
title: 'Misty forest variant',
|
||||
description: 'Leans into atmosphere and fog while keeping the same placeholder structure.',
|
||||
placeholder_values: {
|
||||
LOCATION: 'Triglav National Park',
|
||||
SEASON: 'autumn',
|
||||
MOOD: 'misty cinematic',
|
||||
},
|
||||
prompt: 'Create a cinematic autumn landscape in Triglav National Park with layered mist, warm foliage, soft directional light, and strong depth.',
|
||||
negative_prompt: 'flat composition, weak fog, repetitive trees, blown highlights',
|
||||
},
|
||||
],
|
||||
preview_image: 'https://files.skinbase.org/prompts/peaceful-fantasy-forest.webp',
|
||||
featured: false,
|
||||
prompt_of_week: false,
|
||||
@@ -659,6 +860,7 @@ Recommended fields:
|
||||
- placeholders: array of prompt variable objects
|
||||
- helper_prompts: array of supporting prompts used before or after the main prompt
|
||||
- prompt_variants: array of alternative prompt versions
|
||||
- filled_examples: array of up to 5 filled prompt examples with placeholder_values and final prompts
|
||||
- preview_image: path or URL
|
||||
- featured: boolean
|
||||
- prompt_of_week: boolean
|
||||
@@ -698,10 +900,18 @@ prompt_variants object fields:
|
||||
- risk_notes
|
||||
- active boolean
|
||||
|
||||
filled_examples object fields:
|
||||
- title
|
||||
- description
|
||||
- placeholder_values: object keyed by placeholder name
|
||||
- prompt
|
||||
- negative_prompt
|
||||
|
||||
Rules:
|
||||
- Return one JSON object only.
|
||||
- Keep excerpt concise and readable in cards.
|
||||
- Keep tags relevant and production-usable.
|
||||
- Include exactly 5 filled_examples whenever the prompt uses placeholders or has clear user-editable parameters.
|
||||
- If you include tool_notes, keep them normalized and consistent.`
|
||||
|
||||
const aiPromptExamples = [
|
||||
@@ -714,6 +924,7 @@ Create a Skinbase Academy prompt template JSON object from the following creativ
|
||||
- Write a prompt that is immediately usable.
|
||||
- Write an excerpt that works in cards and search results.
|
||||
- Add 5 to 12 focused tags.
|
||||
- Include 5 filled_examples with realistic placeholder_values and ready-to-copy final prompts.
|
||||
- Include 2 to 4 tool_notes comparisons when the brief mentions multiple AI providers.
|
||||
|
||||
Creative brief:
|
||||
@@ -727,6 +938,7 @@ Generate a prompt template JSON object for Skinbase Academy.
|
||||
- Focus on the same core prompt being tested across multiple AI image providers.
|
||||
- Include tool_notes entries for each provider.
|
||||
- Each tool_notes item should explain settings, strengths, weaknesses, and best_for in plain production language.
|
||||
- Include 5 filled_examples that show how users would swap placeholder values in real projects.
|
||||
- Return JSON only.
|
||||
|
||||
Source notes:
|
||||
@@ -740,6 +952,7 @@ Convert the following source prompt page into structured Skinbase Academy prompt
|
||||
- Preserve the core instruction intent.
|
||||
- Normalize tags and metadata.
|
||||
- Convert provider reviews into tool_notes.
|
||||
- Generate 5 filled_examples that demonstrate realistic filled-in prompt runs for end users.
|
||||
- Use category/category_slug when category_id is unknown.
|
||||
- Return JSON only.
|
||||
|
||||
@@ -832,6 +1045,7 @@ Source content:
|
||||
<p>usage_notes, workflow_notes</p>
|
||||
<p>documentation, placeholders</p>
|
||||
<p>helper_prompts, prompt_variants</p>
|
||||
<p>filled_examples</p>
|
||||
<p>preview_image, preview_image_url</p>
|
||||
<p>published_at, seo_title, seo_description</p>
|
||||
<p>featured, prompt_of_week, active</p>
|
||||
@@ -855,7 +1069,7 @@ Source content:
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Notes</div>
|
||||
<div className="mt-3 space-y-3 leading-6 text-slate-400">
|
||||
<p>`tool_notes` can be an array of comparison objects or a simpler array under `comparisons`.</p>
|
||||
<p>`documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` can be nested JSON and are preserved during import.</p>
|
||||
<p>`documentation`, `placeholders`, `helper_prompts`, `prompt_variants`, and `filled_examples` can be nested JSON and are preserved during import.</p>
|
||||
<p>`tags` can be strings or objects with `name`, `label`, `title`, or `slug`.</p>
|
||||
<p>`preview_image` accepts either a stored path or an external URL.</p>
|
||||
</div>
|
||||
@@ -876,6 +1090,7 @@ Source content:
|
||||
<p><strong className="text-slate-200">placeholders</strong> - prompt variables such as `CITY_NAME` or `MONTHLY_WEATHER_DATA`.</p>
|
||||
<p><strong className="text-slate-200">helper_prompts</strong> - supporting prompts for data collection, validation, or refinement.</p>
|
||||
<p><strong className="text-slate-200">prompt_variants</strong> - alternative versions of the same prompt for safer or model-specific output.</p>
|
||||
<p><strong className="text-slate-200">filled_examples</strong> - up to 5 ready-to-copy filled prompt runs that show real placeholder substitutions.</p>
|
||||
<p><strong className="text-slate-200">tool_notes</strong> - structured comparison notes for provider/model variants.</p>
|
||||
<p><strong className="text-slate-200">preview_image</strong> - existing asset URL or stored path. File upload still happens separately.</p>
|
||||
<p><strong className="text-slate-200">category_id</strong> is preferred when known. `category` or `category_slug` are used for best-effort matching.</p>
|
||||
@@ -889,7 +1104,7 @@ Source content:
|
||||
<p>Use JSON booleans for featured, prompt_of_week, and active.</p>
|
||||
<p>Use `YYYY-MM-DD HH:MM:SS` for `published_at` when scheduling is needed.</p>
|
||||
<p>Use `documentation` for longer public guidance, and keep `usage_notes` short and practical.</p>
|
||||
<p>Use `helper_prompts` for data collection or validation prompts, and `prompt_variants` for safer or model-specific alternatives.</p>
|
||||
<p>Use `helper_prompts` for data collection or validation prompts, `prompt_variants` for safer or model-specific alternatives, and `filled_examples` for ready-made filled prompt runs.</p>
|
||||
<p>Keep comparison rows normalized so provider/model names remain consistent in the frontend.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -915,7 +1130,7 @@ Source content:
|
||||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||||
<p>Tell the model to return JSON only, with no explanation text.</p>
|
||||
<p>Ask for `tool_notes` when you want provider-by-provider comparison output.</p>
|
||||
<p>Ask for `documentation`, `placeholders`, `helper_prompts`, and `prompt_variants` only when the prompt needs advanced structure.</p>
|
||||
<p>Ask for `documentation`, `placeholders`, `helper_prompts`, `prompt_variants`, and `filled_examples` when the prompt needs advanced structure and user-ready examples.</p>
|
||||
<p>Tell the model to keep titles and tags production-ready, not overly verbose.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1799,6 +2014,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
|
||||
const placeholdersField = useMemo(() => getField(fields, 'placeholders'), [fields])
|
||||
const helperPromptsField = useMemo(() => getField(fields, 'helper_prompts'), [fields])
|
||||
const promptVariantsField = useMemo(() => getField(fields, 'prompt_variants'), [fields])
|
||||
const filledExamplesField = useMemo(() => getField(fields, 'filled_examples'), [fields])
|
||||
const slugTouchedRef = useRef(Boolean(String(record.slug || '').trim()))
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [jsonImportOpen, setJsonImportOpen] = useState(false)
|
||||
@@ -1876,6 +2092,60 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
|
||||
}
|
||||
}
|
||||
|
||||
const generateStarterFilledExamples = () => {
|
||||
let parsedPlaceholders
|
||||
|
||||
try {
|
||||
parsedPlaceholders = parseStructuredJson(form.data.placeholders)
|
||||
} catch {
|
||||
const message = `${placeholdersField?.label || 'Placeholders JSON'} must be valid JSON before generating filled examples.`
|
||||
form.setError('placeholders', message)
|
||||
setActiveTab('advanced')
|
||||
showToast(message, 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const normalizedPlaceholders = normalizePromptPlaceholders(parsedPlaceholders)
|
||||
|
||||
if (normalizedPlaceholders.length === 0) {
|
||||
const message = 'Add at least one placeholder before generating starter filled examples.'
|
||||
form.setError('placeholders', message)
|
||||
setActiveTab('advanced')
|
||||
showToast(message, 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const promptText = String(form.data.prompt || '').trim()
|
||||
|
||||
if (!promptText) {
|
||||
const message = 'Write the main prompt before generating starter filled examples.'
|
||||
form.setError('prompt', message)
|
||||
setActiveTab('prompt')
|
||||
showToast(message, 'error')
|
||||
return
|
||||
}
|
||||
|
||||
const existingExamples = String(form.data.filled_examples || '').trim()
|
||||
|
||||
if (existingExamples && typeof window !== 'undefined' && !window.confirm('Replace the current filled examples with a new 5-example starter set?')) {
|
||||
return
|
||||
}
|
||||
|
||||
const generatedExamples = buildStarterFilledExamples({
|
||||
title: form.data.title,
|
||||
excerpt: form.data.excerpt,
|
||||
prompt: promptText,
|
||||
negativePrompt: form.data.negative_prompt,
|
||||
placeholders: normalizedPlaceholders,
|
||||
})
|
||||
|
||||
form.clearErrors('placeholders')
|
||||
form.clearErrors('filled_examples')
|
||||
form.setData('filled_examples', serializeStructuredJson(generatedExamples))
|
||||
setActiveTab('advanced')
|
||||
showToast('Generated 5 starter filled examples. Review them before saving.', 'success')
|
||||
}
|
||||
|
||||
const submit = (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -1884,6 +2154,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
|
||||
{ name: 'placeholders', label: placeholdersField?.label || 'Placeholders JSON' },
|
||||
{ name: 'helper_prompts', label: helperPromptsField?.label || 'Helper Prompts JSON' },
|
||||
{ name: 'prompt_variants', label: promptVariantsField?.label || 'Prompt Variants JSON' },
|
||||
{ name: 'filled_examples', label: filledExamplesField?.label || 'Filled Examples JSON' },
|
||||
]
|
||||
const parsedJsonFields = {}
|
||||
|
||||
@@ -1943,6 +2214,12 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
|
||||
form.post(submitUrl, submitOptions)
|
||||
}
|
||||
|
||||
const hasRequiredCategory = useMemo(() => {
|
||||
const existing = String(form.data.category_id || '').trim()
|
||||
const named = String(form.data.new_category_name || '').trim()
|
||||
return Boolean(existing || named)
|
||||
}, [form.data.category_id, form.data.new_category_name])
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
@@ -1996,7 +2273,7 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
|
||||
<i className="fa-solid fa-file-import text-xs" />
|
||||
<span>Import JSON</span>
|
||||
</button>
|
||||
<button type="submit" disabled={form.processing} className="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-sky-300/25 bg-sky-300/18 px-4 py-2 text-sm font-semibold text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition hover:bg-sky-300/24">
|
||||
<button type="submit" disabled={form.processing || !hasRequiredCategory} className="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-sky-300/25 bg-sky-300/18 px-4 py-2 text-sm font-semibold text-sky-100 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)] transition hover:bg-sky-300/24">
|
||||
<i className="fa-solid fa-floppy-disk text-xs" />
|
||||
<span>{form.processing ? 'Saving...' : 'Save prompt'}</span>
|
||||
</button>
|
||||
@@ -2034,6 +2311,9 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
|
||||
}
|
||||
}} options={categoryOptions} searchable searchPlaceholder="Filter categories..." className="rounded-2xl bg-black/20" error={form.errors.category_id} /> : null}
|
||||
<TextField label="Or enter new category" value={form.data.new_category_name || ''} onChange={(event) => form.setData('new_category_name', event.target.value)} error={form.errors.new_category_name} placeholder="New prompt category name" />
|
||||
{!hasRequiredCategory ? (
|
||||
<div className="mt-2 text-xs text-rose-300">Choose an existing category or enter a new category name before saving.</div>
|
||||
) : null}
|
||||
{difficultyField ? <NovaSelect label={difficultyField.label} value={form.data.difficulty ?? ''} onChange={(nextValue) => form.setData('difficulty', nextValue ?? '')} options={difficultyField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.difficulty} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -2085,6 +2365,16 @@ function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, de
|
||||
<TextAreaField label={helperPromptsField?.label || 'Helper Prompts JSON'} value={form.data.helper_prompts || ''} onChange={(event) => form.setData('helper_prompts', event.target.value)} error={form.errors.helper_prompts} rows={12} hint="Array of supporting prompts used for data collection, preparation, validation, or refinement." />
|
||||
</div>
|
||||
<TextAreaField label={promptVariantsField?.label || 'Prompt Variants JSON'} value={form.data.prompt_variants || ''} onChange={(event) => form.setData('prompt_variants', event.target.value)} error={form.errors.prompt_variants} rows={12} hint="Array of alternative prompt versions with prompt, negative_prompt, recommended flags, and risk notes." />
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">Starter filled examples</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-400">Generate 5 editable examples from the current placeholders, prompt text, and negative prompt.</p>
|
||||
</div>
|
||||
<button type="button" onClick={generateStarterFilledExamples} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-4 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18">
|
||||
Generate 5 starter examples
|
||||
</button>
|
||||
</div>
|
||||
<TextAreaField label={filledExamplesField?.label || 'Filled Examples JSON'} value={form.data.filled_examples || ''} onChange={(event) => form.setData('filled_examples', event.target.value)} error={form.errors.filled_examples} rows={12} hint="Array of up to 5 filled prompt examples with title, description, placeholder_values, prompt, and optional negative_prompt." />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard eyebrow="Structured blocks" title="AI model comparisons" description="Add reusable same-prompt comparison notes without burying provider-specific behavior inside the main prompt body." className={sectionClassName('prompt-comparisons')}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
|
||||
const PROMPT_VIEW_STORAGE_KEY = 'skinbase.admin.academy.prompts.view'
|
||||
const COURSE_VIEW_STORAGE_KEY = 'skinbase.admin.academy.courses.view'
|
||||
@@ -84,14 +85,34 @@ function courseSummary(items = [], summary = null) {
|
||||
}), { total: 0, published: 0, featured: 0, drafts: 0, visibleOnPage: 0 })
|
||||
}
|
||||
|
||||
function promptSummary(items = []) {
|
||||
function promptSummary(items = [], summary = null) {
|
||||
if (summary && typeof summary === 'object') {
|
||||
return {
|
||||
total: Number(summary.total || 0),
|
||||
active: Number(summary.active || 0),
|
||||
featured: Number(summary.featured || 0),
|
||||
promptOfWeek: Number(summary.promptOfWeek || 0),
|
||||
comparisons: Array.isArray(items) ? items.reduce((count, item) => count + Number(item.comparisons_count || 0), 0) : 0,
|
||||
access: {
|
||||
free: Number(summary.access?.free || 0),
|
||||
creator: Number(summary.access?.creator || 0),
|
||||
pro: Number(summary.access?.pro || 0),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return items.reduce((summary, item) => ({
|
||||
total: summary.total + 1,
|
||||
active: summary.active + (item.active ? 1 : 0),
|
||||
featured: summary.featured + (item.featured ? 1 : 0),
|
||||
promptOfWeek: summary.promptOfWeek + (item.prompt_of_week ? 1 : 0),
|
||||
comparisons: summary.comparisons + Number(item.comparisons_count || 0),
|
||||
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0 })
|
||||
access: {
|
||||
free: summary.access.free + (item.access_level === 'free' ? 1 : 0),
|
||||
creator: summary.access.creator + (item.access_level === 'creator' ? 1 : 0),
|
||||
pro: summary.access.pro + (item.access_level === 'pro' ? 1 : 0),
|
||||
},
|
||||
}), { total: 0, active: 0, featured: 0, promptOfWeek: 0, comparisons: 0, access: { free: 0, creator: 0, pro: 0 } })
|
||||
}
|
||||
|
||||
function PromptFlag({ children, tone = 'default' }) {
|
||||
@@ -371,9 +392,9 @@ function PromptPreview({ item, compact = false }) {
|
||||
function PromptMeta({ item }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{item.access_level ? <AccessBadge tier={item.access_level} className="px-3 py-1" /> : null}
|
||||
{item.category_name ? <PromptFlag tone="warm">{item.category_name}</PromptFlag> : null}
|
||||
{item.difficulty ? <PromptFlag>{item.difficulty}</PromptFlag> : null}
|
||||
{item.access_level ? <PromptFlag>{item.access_level}</PromptFlag> : null}
|
||||
{item.aspect_ratio ? <PromptFlag>{item.aspect_ratio}</PromptFlag> : null}
|
||||
{item.featured ? <PromptFlag tone="sky">Featured</PromptFlag> : null}
|
||||
{item.prompt_of_week ? <PromptFlag tone="emerald">Prompt of week</PromptFlag> : null}
|
||||
@@ -389,7 +410,9 @@ function PromptGalleryCard({ item }) {
|
||||
<div className="relative min-h-[250px] overflow-hidden border-b border-white/10 xl:min-h-full xl:border-b-0 xl:border-r xl:border-white/10">
|
||||
<PromptPreview item={item} />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.32))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" />
|
||||
<PromptFlag>{Number(item.views_count || 0).toLocaleString()} views</PromptFlag>
|
||||
<PromptFlag tone="warm">{item.comparisons_count || 0} comparisons</PromptFlag>
|
||||
{item.slug ? <PromptFlag>{item.slug}</PromptFlag> : null}
|
||||
</div>
|
||||
@@ -418,7 +441,7 @@ function PromptGalleryCard({ item }) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Access</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{item.access_level || 'free'}</p>
|
||||
<div className="mt-2"><AccessBadge tier={item.access_level || 'free'} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</p>
|
||||
@@ -440,6 +463,9 @@ function PromptGridCard({ item }) {
|
||||
<div className="relative h-52 overflow-hidden border-b border-white/10">
|
||||
<PromptPreview item={item} compact />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.34))]" />
|
||||
<div className="absolute left-4 top-4">
|
||||
<AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<PromptMeta item={item} />
|
||||
@@ -447,7 +473,7 @@ function PromptGridCard({ item }) {
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || 'No excerpt added yet.'}</p>
|
||||
<div className="mt-5 flex items-center justify-between gap-3 text-sm text-slate-400">
|
||||
<span>{formatDateLabel(item.updated_at)}</span>
|
||||
<span>{item.comparisons_count || 0} comparisons</span>
|
||||
<span>{Number(item.views_count || 0).toLocaleString()} views</span>
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<PromptActions item={item} />
|
||||
@@ -468,6 +494,7 @@ function PromptTable({ items }) {
|
||||
<th className="px-5 py-4">Category</th>
|
||||
<th className="px-5 py-4">Access</th>
|
||||
<th className="px-5 py-4">Signals</th>
|
||||
<th className="px-5 py-4">Views</th>
|
||||
<th className="px-5 py-4">Updated</th>
|
||||
<th className="px-5 py-4 text-right">Actions</th>
|
||||
</tr>
|
||||
@@ -487,7 +514,7 @@ function PromptTable({ items }) {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">{item.category_name || 'Uncategorized'}</td>
|
||||
<td className="px-5 py-4">{item.access_level || 'free'}</td>
|
||||
<td className="px-5 py-4"><AccessBadge tier={item.access_level || 'free'} className="px-3 py-1.5 text-[12px]" /></td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<p>{item.comparisons_count || 0} comparisons</p>
|
||||
@@ -495,6 +522,7 @@ function PromptTable({ items }) {
|
||||
<p>{item.active ? 'Active' : 'Draft'}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">{Number(item.views_count || 0).toLocaleString()}</td>
|
||||
<td className="px-5 py-4">{formatDateLabel(item.updated_at)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -511,6 +539,99 @@ function PromptTable({ items }) {
|
||||
)
|
||||
}
|
||||
|
||||
function PromptStatCard({ label, value, tone = 'default' }) {
|
||||
const toneClass = tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-300/10 text-sky-100'
|
||||
: tone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
|
||||
: tone === 'warm'
|
||||
? 'border-amber-300/20 bg-amber-300/10 text-amber-100'
|
||||
: 'border-white/10 bg-black/20 text-slate-300'
|
||||
|
||||
return (
|
||||
<div className={`rounded-[24px] border px-5 py-4 ${toneClass}`}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSelect({ value, options = [], onChange }) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={`${option.value}-${option.label}`} value={option.value} className="bg-slate-950 text-white">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSearchBar({ filters, onChange, onSubmit, onReset, viewMode, onViewModeChange, filterOptions = {} }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
|
||||
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
|
||||
<form onSubmit={onSubmit} className="flex-1 space-y-4">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_repeat(3,minmax(0,0.8fr))]">
|
||||
<div className="relative">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
|
||||
<input
|
||||
name="search"
|
||||
value={filters.search}
|
||||
onChange={(event) => onChange('search', event.target.value)}
|
||||
placeholder="Search title, slug, excerpt, prompt text, or category…"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 py-3 pl-9 pr-4 text-sm text-white placeholder:text-slate-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
||||
/>
|
||||
</div>
|
||||
<PromptSelect value={filters.category} onChange={(value) => onChange('category', value)} options={filterOptions.categories} />
|
||||
<PromptSelect value={filters.access_level} onChange={(value) => onChange('access_level', value)} options={filterOptions.access} />
|
||||
<PromptSelect value={filters.order} onChange={(value) => onChange('order', value)} options={filterOptions.order} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-4">
|
||||
<PromptSelect value={filters.difficulty} onChange={(value) => onChange('difficulty', value)} options={filterOptions.difficulty} />
|
||||
<PromptSelect value={filters.featured} onChange={(value) => onChange('featured', value)} options={filterOptions.featured} />
|
||||
<PromptSelect value={filters.prompt_of_week} onChange={(value) => onChange('prompt_of_week', value)} options={filterOptions.promptOfWeek} />
|
||||
<PromptSelect value={filters.active} onChange={(value) => onChange('active', value)} options={filterOptions.active} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button type="submit" className="rounded-2xl bg-sky-300/12 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/16">
|
||||
Apply filters
|
||||
</button>
|
||||
<button type="button" onClick={onReset} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white/80 transition hover:bg-white/[0.08]">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PROMPT_VIEW_OPTIONS.map((option) => {
|
||||
const active = option.value === viewMode
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onViewModeChange(option.value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
<i className={`fa-solid ${option.icon} text-xs`} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptHeroCollage({ items = [] }) {
|
||||
const images = items
|
||||
.map((item) => item?.preview_image_url)
|
||||
@@ -734,10 +855,21 @@ function renderCrudCell(column, item) {
|
||||
return <p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
|
||||
}
|
||||
|
||||
function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
function PromptIndexContent({ title, subtitle, items, createUrl, filters = {}, summary = {}, filterOptions = {} }) {
|
||||
const { url } = usePage()
|
||||
const promptItems = items?.data || []
|
||||
const summary = promptSummary(promptItems)
|
||||
const stats = useMemo(() => promptSummary(promptItems, summary), [promptItems, summary])
|
||||
const [viewMode, setViewMode] = useState('gallery')
|
||||
const [query, setQuery] = useState({
|
||||
search: filters.search || '',
|
||||
category: filters.category || 'all',
|
||||
featured: filters.featured || 'all',
|
||||
prompt_of_week: filters.prompt_of_week || 'all',
|
||||
active: filters.active || 'all',
|
||||
access_level: filters.access_level || 'all',
|
||||
difficulty: filters.difficulty || 'all',
|
||||
order: filters.order || 'updated_desc',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
@@ -753,6 +885,72 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
window.localStorage.setItem(PROMPT_VIEW_STORAGE_KEY, viewMode)
|
||||
}, [viewMode])
|
||||
|
||||
useEffect(() => {
|
||||
setQuery({
|
||||
search: filters.search || '',
|
||||
category: filters.category || 'all',
|
||||
featured: filters.featured || 'all',
|
||||
prompt_of_week: filters.prompt_of_week || 'all',
|
||||
active: filters.active || 'all',
|
||||
access_level: filters.access_level || 'all',
|
||||
difficulty: filters.difficulty || 'all',
|
||||
order: filters.order || 'updated_desc',
|
||||
})
|
||||
}, [filters])
|
||||
|
||||
const currentPath = url.split('?')[0]
|
||||
const meta = items?.meta || {}
|
||||
const hasFilters = Boolean(
|
||||
(query.search || '').trim()
|
||||
|| query.category !== 'all'
|
||||
|| query.featured !== 'all'
|
||||
|| query.prompt_of_week !== 'all'
|
||||
|| query.active !== 'all'
|
||||
|| query.access_level !== 'all'
|
||||
|| query.difficulty !== 'all'
|
||||
|| query.order !== 'updated_desc'
|
||||
)
|
||||
|
||||
const applyQuery = (nextQuery) => {
|
||||
const payload = {}
|
||||
|
||||
if ((nextQuery.search || '').trim()) payload.search = nextQuery.search.trim()
|
||||
if (nextQuery.category && nextQuery.category !== 'all') payload.category = nextQuery.category
|
||||
if (nextQuery.featured && nextQuery.featured !== 'all') payload.featured = nextQuery.featured
|
||||
if (nextQuery.prompt_of_week && nextQuery.prompt_of_week !== 'all') payload.prompt_of_week = nextQuery.prompt_of_week
|
||||
if (nextQuery.active && nextQuery.active !== 'all') payload.active = nextQuery.active
|
||||
if (nextQuery.access_level && nextQuery.access_level !== 'all') payload.access_level = nextQuery.access_level
|
||||
if (nextQuery.difficulty && nextQuery.difficulty !== 'all') payload.difficulty = nextQuery.difficulty
|
||||
if (nextQuery.order && nextQuery.order !== 'updated_desc') payload.order = nextQuery.order
|
||||
|
||||
router.get(currentPath, payload, { preserveScroll: true, preserveState: true, replace: true })
|
||||
}
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setQuery((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault()
|
||||
applyQuery(query)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
const nextQuery = {
|
||||
search: '',
|
||||
category: 'all',
|
||||
featured: 'all',
|
||||
prompt_of_week: 'all',
|
||||
active: 'all',
|
||||
access_level: 'all',
|
||||
difficulty: 'all',
|
||||
order: 'updated_desc',
|
||||
}
|
||||
|
||||
setQuery(nextQuery)
|
||||
router.get(currentPath, {}, { preserveScroll: true, preserveState: true, replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]">
|
||||
@@ -781,46 +979,40 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{PROMPT_VIEW_OPTIONS.map((option) => {
|
||||
const active = option.value === viewMode
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setViewMode(option.value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
<i className={`fa-solid ${option.icon}`} />
|
||||
<span>{option.label} view</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
<Link href={createUrl} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
|
||||
<Link href="/academy/prompts" className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />Open public library</Link>
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{summary.total} prompts in view</span>
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{stats.total} prompts in view</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<PromptStatCard label="Active" value={stats.active} tone="emerald" />
|
||||
<PromptStatCard label="Featured" value={stats.featured} tone="sky" />
|
||||
<PromptStatCard label="Prompt of week" value={stats.promptOfWeek} tone="warm" />
|
||||
<PromptStatCard label="Views on page" value={promptItems.reduce((count, item) => count + Number(item.views_count || 0), 0).toLocaleString()} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Active</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.active}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Free access</p>
|
||||
<AccessBadge tier="free" />
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.free}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Featured</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.featured}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Creator access</p>
|
||||
<AccessBadge tier="creator" />
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.creator}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Prompt of week</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.promptOfWeek}</p>
|
||||
</div>
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Comparisons</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{summary.comparisons}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Pro access</p>
|
||||
<AccessBadge tier="pro" />
|
||||
</div>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{stats.access.pro}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -831,8 +1023,27 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PromptSearchBar
|
||||
filters={query}
|
||||
onChange={handleFilterChange}
|
||||
onSubmit={handleSubmit}
|
||||
onReset={handleReset}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
filterOptions={filterOptions}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{meta.total ? (
|
||||
<>
|
||||
Showing {meta.from || 0}-{meta.to || 0} of {meta.total} prompts
|
||||
{hasFilters ? <span className="ml-2 text-sky-200">with active search or filters</span> : null}
|
||||
</>
|
||||
) : (
|
||||
'Manage Academy content below. Changes clear Academy cache automatically.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/academy/prompts" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />View public library</Link>
|
||||
<Link href={createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
|
||||
@@ -840,7 +1051,16 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
</div>
|
||||
|
||||
{promptItems.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">No prompt templates exist yet.</div>
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">
|
||||
{hasFilters ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-lg font-semibold text-white">No prompt templates matched these filters.</p>
|
||||
<button type="button" onClick={handleReset} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Reset filters</button>
|
||||
</div>
|
||||
) : (
|
||||
'No prompt templates exist yet.'
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<PromptTable items={promptItems} />
|
||||
) : viewMode === 'grid' ? (
|
||||
@@ -863,6 +1083,7 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
||||
const resource = usePage().props.resource
|
||||
const filters = usePage().props.filters || {}
|
||||
const summary = usePage().props.summary || {}
|
||||
const filterOptions = usePage().props.filterOptions || {}
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
@@ -873,7 +1094,7 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
||||
{resource === 'courses' ? (
|
||||
<CourseIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} filters={filters} summary={summary} />
|
||||
) : resource === 'prompts' ? (
|
||||
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} />
|
||||
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} filters={filters} summary={summary} filterOptions={filterOptions} />
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between gap-4">
|
||||
@@ -911,4 +1132,4 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
||||
)}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ export default function Dashboard({ stats }) {
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images', desc: 'Browse all uploaded artworks' },
|
||||
{ label: 'Enhance Jobs', href: '/moderation/enhance', icon: 'fa-solid fa-up-right-and-down-left-from-center', desc: 'Inspect queued, failed, and completed image enhance jobs' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star', desc: 'Curate the homepage featured artwork lineup' },
|
||||
{ label: 'AI Biography', href: '/moderation/ai-biography', icon: 'fa-solid fa-wand-magic-sparkles', desc: 'Review generated creator biographies and moderation flags' },
|
||||
].map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
|
||||
@@ -316,7 +316,7 @@ function CategoriesPage({ apiUrl = '/api/categories', pageTitle = 'Categories',
|
||||
const hasMorePages = meta.current_page < meta.last_page
|
||||
|
||||
return (
|
||||
<div className="pb-24 text-white">
|
||||
<div className="categories-page pb-24 text-white">
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="absolute inset-x-0 top-0 h-[28rem] bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_38%),radial-gradient(circle_at_top_right,rgba(249,115,22,0.14),transparent_34%)]" />
|
||||
<div className="relative w-full px-6 pb-8 pt-14 sm:px-8 sm:pt-20 xl:px-10 2xl:px-14 lg:pt-24">
|
||||
|
||||
@@ -16,10 +16,10 @@ export default function GroupIndex() {
|
||||
const leaderboardItems = Array.isArray(props.leaderboard?.items) ? props.leaderboard.items : []
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<main className="groups-directory-page min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_28%),linear-gradient(180deg,_#020617_0%,_#02040a_100%)] px-4 py-10 sm:px-6 lg:px-8">
|
||||
<SeoHead title="Groups - Skinbase" description={props.description} />
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<section className="rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<section className="groups-directory-page__hero rounded-[32px] border border-white/10 bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Groups</p>
|
||||
<h1 className="mt-2 text-4xl font-semibold text-white">Collective publishing identities</h1>
|
||||
<p className="mt-4 max-w-3xl text-sm leading-6 text-slate-300">Discover collaborative studios, follow shared creative brands, and browse the artworks, releases, and collections published under each group identity.</p>
|
||||
@@ -76,4 +76,4 @@ export default function GroupIndex() {
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,9 +77,9 @@ export default function LeaderboardPage() {
|
||||
<>
|
||||
<SeoHead seo={seo} title={seo?.title || 'Leaderboard — Skinbase'} description={seo?.description || 'Top creators, groups, artworks, stories, and Worlds on Skinbase.'} />
|
||||
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
|
||||
<div className="leaderboard-page min-h-screen bg-[radial-gradient(circle_at_top,rgba(14,165,233,0.14),transparent_34%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] pb-16 text-slate-100">
|
||||
<div className="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<header className="rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
|
||||
<header className="leaderboard-page__hero rounded-[2rem] border border-white/10 bg-slate-950/70 px-6 py-8 shadow-[0_35px_120px_rgba(2,6,23,0.75)] backdrop-blur">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-300">Skinbase Competition Board</p>
|
||||
<h1 className="mt-4 max-w-3xl text-4xl font-black tracking-tight text-white sm:text-5xl">
|
||||
Top creators, groups, standout artworks, stories, and Worlds with momentum.
|
||||
|
||||
11
resources/js/Pages/Moderation/FeaturedArtworks.jsx
Normal file
11
resources/js/Pages/Moderation/FeaturedArtworks.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import AdminLayout from '../../Layouts/AdminLayout'
|
||||
import FeaturedArtworksAdmin from '../Collection/FeaturedArtworksAdmin'
|
||||
|
||||
export default function ModerationFeaturedArtworks() {
|
||||
return (
|
||||
<AdminLayout>
|
||||
<FeaturedArtworksAdmin />
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal file
148
resources/js/Pages/Moderation/StaffApplications/Index.jsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React from 'react'
|
||||
import { Head, router, Link } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StaffApplicationsIndex({ title, items, stats, filters, topics, endpoints }) {
|
||||
const [state, setState] = React.useState(filters || { q: '', topic: 'all' })
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(filters || { q: '', topic: 'all' })
|
||||
}, [filters])
|
||||
|
||||
function update(key, value) {
|
||||
setState((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
const rows = items?.data || []
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Staff Applications'} subtitle="Review staff and contact submissions without leaving moderation.">
|
||||
<Head title="Moderation · Staff Applications" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Staff Applications</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review staff and contact submissions in the same moderation workspace as the rest of Skinbase.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {items?.current_page || 1} / {items?.last_page || 1}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(items?.total || 0).toLocaleString()} submissions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<StatCard label="Total" value={stats?.total} />
|
||||
<StatCard label="Applications" value={stats?.applications} />
|
||||
<StatCard label="Bug reports" value={stats?.bug} />
|
||||
<StatCard label="Contact" value={stats?.contact} />
|
||||
<StatCard label="Other" value={stats?.other} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
||||
<input
|
||||
value={state.q || ''}
|
||||
onChange={(event) => update('q', event.target.value)}
|
||||
placeholder="Search name, email, role, or message"
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<select
|
||||
value={state.topic || 'all'}
|
||||
onChange={(event) => update('topic', event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="all">All topics</option>
|
||||
{(topics || []).map((topic) => (
|
||||
<option key={topic} value={topic}>{String(topic).replaceAll('_', ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-white/[0.03] text-left text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
<tr>
|
||||
<th className="px-5 py-4 font-medium">Received</th>
|
||||
<th className="px-5 py-4 font-medium">Topic</th>
|
||||
<th className="px-5 py-4 font-medium">Name</th>
|
||||
<th className="px-5 py-4 font-medium">Email</th>
|
||||
<th className="px-5 py-4 font-medium">Role</th>
|
||||
<th className="px-5 py-4 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5 text-slate-200">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-5 py-14 text-center text-slate-400">No staff applications matched the current filters.</td>
|
||||
</tr>
|
||||
) : rows.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-white/[0.02]">
|
||||
<td className="px-5 py-4 text-slate-400">{formatDateTime(item.created_at)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
{String(item.topic || 'contact').replaceAll('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-medium text-white">{item.name}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{item.email}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{item.role || '—'}</td>
|
||||
<td className="px-5 py-4">
|
||||
<Link href={item.show_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(items?.prev_page_url || items?.next_page_url) ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Showing page {items?.current_page || 1} of {items?.last_page || 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{items?.prev_page_url ? (
|
||||
<button type="button" onClick={() => router.get(items.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Previous
|
||||
</button>
|
||||
) : null}
|
||||
{items?.next_page_url ? (
|
||||
<button type="button" onClick={() => router.get(items.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Next
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal file
87
resources/js/Pages/Moderation/StaffApplications/Show.jsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-2 text-sm leading-7 text-slate-100">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function StaffApplicationShow({ title, item, backUrl }) {
|
||||
const payload = item?.payload || {}
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Staff Application'} subtitle="Read the full submission in a moderation-friendly layout.">
|
||||
<Head title="Moderation · Staff Application" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{item?.name || 'Staff application'}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Topic: {String(item?.topic || 'contact').replaceAll('_', ' ')} • Received {formatDateTime(item?.created_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={backUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Back
|
||||
</Link>
|
||||
{item?.email ? (
|
||||
<a href={`mailto:${item.email}`} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/18">
|
||||
Email
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[1.35fr_0.85fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Name">{item?.name || '—'}</Field>
|
||||
<Field label="Email">{item?.email || '—'}</Field>
|
||||
<Field label="Role">{item?.role || '—'}</Field>
|
||||
<Field label="Portfolio">{item?.portfolio ? <a href={item.portfolio} className="text-sky-300 hover:text-sky-200" target="_blank" rel="noreferrer">{item.portfolio}</a> : '—'}</Field>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Message</div>
|
||||
<div className="mt-3 whitespace-pre-wrap rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-4 text-sm leading-7 text-slate-100">
|
||||
{item?.message || 'No message included.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-4">
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Metadata</div>
|
||||
<div className="mt-4 space-y-3 text-sm text-slate-200">
|
||||
<div><span className="font-semibold text-white">Received:</span> {formatDateTime(item?.created_at)}</div>
|
||||
<div><span className="font-semibold text-white">IP:</span> {item?.ip || '—'}</div>
|
||||
<div><span className="font-semibold text-white">User agent:</span> {item?.user_agent || '—'}</div>
|
||||
<div><span className="font-semibold text-white">ID:</span> {item?.id || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Payload</div>
|
||||
<pre className="mt-3 max-h-[420px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-4 text-xs leading-6 text-slate-200">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
168
resources/js/Pages/Moderation/Stories.jsx
Normal file
168
resources/js/Pages/Moderation/Stories.jsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from 'react'
|
||||
import { Head, router } from '@inertiajs/react'
|
||||
import AdminLayout from '../../Layouts/AdminLayout'
|
||||
|
||||
function badgeTone(status) {
|
||||
if (status === 'published') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
|
||||
if (status === 'scheduled') return 'border-sky-300/20 bg-sky-400/12 text-sky-100'
|
||||
if (status === 'pending_review') return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
|
||||
if (status === 'archived' || status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
|
||||
return 'border-white/10 bg-white/[0.06] text-slate-200'
|
||||
}
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Stories({ title, stories, filters, stats, endpoints }) {
|
||||
const [state, setState] = React.useState(filters || { q: '', status: 'all' })
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(filters || { q: '', status: 'all' })
|
||||
}, [filters])
|
||||
|
||||
function update(key, value) {
|
||||
setState((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
const items = stories?.data || []
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Stories'} subtitle="Review creator stories from the moderation surface, without jumping back to the old CP layout.">
|
||||
<Head title="Moderation · Stories" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Stories</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Browse creator stories, filter by status, and jump straight to the public view when it exists.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {stories?.current_page || 1} / {stories?.last_page || 1}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(stories?.total || 0).toLocaleString()} stories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||
<StatCard label="Total" value={stats?.total} />
|
||||
<StatCard label="Published" value={stats?.published} />
|
||||
<StatCard label="Draft" value={stats?.draft} />
|
||||
<StatCard label="Scheduled" value={stats?.scheduled} />
|
||||
<StatCard label="Pending review" value={stats?.pending_review} />
|
||||
<StatCard label="Archived" value={stats?.archived} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
||||
<input
|
||||
value={state.q || ''}
|
||||
onChange={(event) => update('q', event.target.value)}
|
||||
placeholder="Search title, slug, or creator"
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<select
|
||||
value={state.status || 'all'}
|
||||
onChange={(event) => update('status', event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="pending_review">Pending review</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-4 xl:grid-cols-2">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300 xl:col-span-2">
|
||||
No stories matched the current filters.
|
||||
</div>
|
||||
) : items.map((story) => (
|
||||
<article key={story.id} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-4 md:grid-cols-[180px_1fr]">
|
||||
<div className="aspect-[3/4] bg-black/30">
|
||||
{story.cover_url ? (
|
||||
<img src={story.cover_url} alt={story.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-white/20">
|
||||
<i className="fa-solid fa-feather-pointed text-4xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(story.status)}`}>
|
||||
{String(story.status || 'draft').replaceAll('_', ' ')}
|
||||
</span>
|
||||
{story.creator ? (
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
@{story.creator.username}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{story.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">/{story.slug}{story.creator ? ` • ${story.creator.name}` : ''}</p>
|
||||
{story.excerpt ? <p className="mt-3 text-sm leading-6 text-slate-300">{story.excerpt}</p> : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
<span>{story.published_at ? new Date(story.published_at).toLocaleDateString() : 'Unpublished'}</span>
|
||||
<span>{story.created_at ? new Date(story.created_at).toLocaleDateString() : '—'}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{story.open_url ? (
|
||||
<a href={story.open_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
No public view
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stories?.prev_page_url || stories?.next_page_url ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Showing page {stories?.current_page || 1} of {stories?.last_page || 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{stories?.prev_page_url ? (
|
||||
<button type="button" onClick={() => router.get(stories.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Previous
|
||||
</button>
|
||||
) : null}
|
||||
{stories?.next_page_url ? (
|
||||
<button type="button" onClick={() => router.get(stories.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Next
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal file
233
resources/js/Pages/Moderation/UsernameQueue.jsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React from 'react'
|
||||
import { Head, router } from '@inertiajs/react'
|
||||
import AdminLayout from '../../Layouts/AdminLayout'
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function StatCard({ label, value, tone = 'sky' }) {
|
||||
const tones = {
|
||||
sky: 'border-sky-300/15 bg-sky-400/10 text-sky-100',
|
||||
amber: 'border-amber-300/15 bg-amber-400/10 text-amber-100',
|
||||
emerald: 'border-emerald-300/15 bg-emerald-400/10 text-emerald-100',
|
||||
rose: 'border-rose-300/15 bg-rose-400/10 text-rose-100',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5 backdrop-blur-sm">
|
||||
<div className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.sky}`}>{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value || 0).toLocaleString()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function badgeTone(status) {
|
||||
if (status === 'approved') return 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
|
||||
if (status === 'rejected') return 'border-rose-300/20 bg-rose-400/12 text-rose-100'
|
||||
return 'border-amber-300/20 bg-amber-400/12 text-amber-100'
|
||||
}
|
||||
|
||||
async function requestJson(url, body) {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
export default function UsernameQueue({ title, requests, stats, filters, options, endpoints }) {
|
||||
const [state, setState] = React.useState(filters || { q: '', status: 'pending' })
|
||||
const [notes, setNotes] = React.useState({})
|
||||
const [busy, setBusy] = React.useState('')
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
setState(filters || { q: '', status: 'pending' })
|
||||
}, [filters])
|
||||
|
||||
function update(key, value) {
|
||||
setState((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(endpoints.index, state, { preserveState: true, replace: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
async function moderate(item, action) {
|
||||
const actionKey = `${action}-${item.id}`
|
||||
setBusy(actionKey)
|
||||
setError('')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(action === 'approve' ? item.approve_url : item.reject_url, {
|
||||
note: String(notes[item.id] || ''),
|
||||
})
|
||||
|
||||
setNotice(payload.message || `Request ${action}d.`)
|
||||
router.reload({ only: ['requests', 'stats'], preserveScroll: true })
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Request failed.')
|
||||
} finally {
|
||||
setBusy('')
|
||||
}
|
||||
}
|
||||
|
||||
const items = requests?.data || []
|
||||
|
||||
return (
|
||||
<AdminLayout title={title || 'Username Queue'} subtitle="Review username changes in the same moderation surface as the rest of Skinbase.">
|
||||
<Head title="Moderation · Username Queue" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(244,114,182,0.12),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-rose-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">Username Queue</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Review pending username requests before they are applied to the account or history trail.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-xs uppercase tracking-[0.16em] text-slate-300">
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">Page {requests?.current_page || 1} / {requests?.last_page || 1}</span>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2">{Number(requests?.total || 0).toLocaleString()} requests</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Total" value={stats?.total} />
|
||||
<StatCard label="Pending" value={stats?.pending} tone="amber" />
|
||||
<StatCard label="Approved" value={stats?.approved} tone="emerald" />
|
||||
<StatCard label="Rejected" value={stats?.rejected} tone="rose" />
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
||||
<input
|
||||
value={state.q || ''}
|
||||
onChange={(event) => update('q', event.target.value)}
|
||||
placeholder="Search requested or current username"
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<select
|
||||
value={state.status || 'pending'}
|
||||
onChange={(event) => update('status', event.target.value)}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
{(options?.statuses || []).map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
|
||||
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-300">No username requests matched the current filters.</div>
|
||||
) : items.map((item) => (
|
||||
<article key={item.id} className="rounded-[28px] border border-white/10 bg-[#08111d] p-5 shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${badgeTone(item.status)}`}>
|
||||
{String(item.status || 'pending').replaceAll('_', ' ')}
|
||||
</span>
|
||||
{item.context ? (
|
||||
<span className="inline-flex rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">
|
||||
{item.context.replaceAll('_', ' ')}
|
||||
</span>
|
||||
) : null}
|
||||
{item.similar_to ? (
|
||||
<span className="inline-flex rounded-full border border-amber-300/20 bg-amber-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">
|
||||
Similar to {item.similar_to}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{item.requested_username}</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
{item.current_username ? `Current: @${item.current_username}` : 'No current username'}
|
||||
{item.current_name ? ` • ${item.current_name}` : ''}
|
||||
</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Requested {formatDateTime(item.created_at)}
|
||||
{item.reviewed_at ? ` • reviewed ${formatDateTime(item.reviewed_at)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-xl space-y-3">
|
||||
<textarea
|
||||
value={notes[item.id] || ''}
|
||||
onChange={(event) => setNotes((current) => ({ ...current, [item.id]: event.target.value }))}
|
||||
placeholder="Optional moderation note"
|
||||
rows={3}
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moderate(item, 'approve')}
|
||||
disabled={busy === `approve-${item.id}`}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18 disabled:opacity-60"
|
||||
>
|
||||
{busy === `approve-${item.id}` ? 'Saving…' : 'Approve'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moderate(item, 'reject')}
|
||||
disabled={busy === `reject-${item.id}`}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/18 disabled:opacity-60"
|
||||
>
|
||||
{busy === `reject-${item.id}` ? 'Saving…' : 'Reject'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{requests?.prev_page_url || requests?.next_page_url ? (
|
||||
<div className="mt-8 flex items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
Showing page {requests?.current_page || 1} of {requests?.last_page || 1}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{requests?.prev_page_url ? (
|
||||
<button type="button" onClick={() => router.get(requests.prev_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Previous
|
||||
</button>
|
||||
) : null}
|
||||
{requests?.next_page_url ? (
|
||||
<button type="button" onClick={() => router.get(requests.next_page_url, {}, { preserveScroll: true })} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Next
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export default function ProfileShow() {
|
||||
return (
|
||||
<>
|
||||
<SeoHead seo={seo} />
|
||||
<div className="relative min-h-screen overflow-hidden pb-16">
|
||||
<div className="profile-page relative min-h-screen overflow-hidden pb-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-90"
|
||||
|
||||
@@ -32,7 +32,6 @@ const TABS = [
|
||||
{ id: 'worlds', label: 'Worlds', icon: 'fa-solid fa-globe' },
|
||||
{ id: 'taxonomy', label: 'Category', icon: 'fa-solid fa-palette' },
|
||||
{ id: 'visibility', label: 'Visibility', icon: 'fa-solid fa-eye' },
|
||||
{ id: 'ai', label: 'AI Assist', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
]
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
@@ -1233,9 +1232,6 @@ export default function StudioArtworkEdit() {
|
||||
{tab.id === 'evolution' && evolutionTarget && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" />
|
||||
)}
|
||||
{tab.id === 'ai' && aiStatus !== 'not_analyzed' && (
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${aiStatus === 'ready' ? 'bg-emerald-400' : aiStatus === 'failed' ? 'bg-red-400' : 'bg-sky-400'}`} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
27
resources/js/academy.jsx
Normal file
27
resources/js/academy.jsx
Normal 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)
|
||||
},
|
||||
})
|
||||
@@ -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
32
resources/js/bootstrap-lite.js
vendored
Normal 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)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mountInertiaRoot } from './bootstrap'
|
||||
import { mountInertiaRoot } from './bootstrap-lite'
|
||||
import React from 'react'
|
||||
import { createInertiaApp } from '@inertiajs/react'
|
||||
const pages = {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
20
resources/views/academy.blade.php
Normal file
20
resources/views/academy.blade.php
Normal 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
|
||||
@@ -21,7 +21,7 @@
|
||||
this.open = false
|
||||
},
|
||||
}"
|
||||
class="relative"
|
||||
class="dashboard-filter-select relative"
|
||||
@click.outside="open = false"
|
||||
@keydown.escape.window="open = false"
|
||||
>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
21
resources/views/emails/academy_access_issue.blade.php
Normal file
21
resources/views/emails/academy_access_issue.blade.php
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? '' }}">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user