Current state with latest updates
This commit is contained in:
@@ -48,6 +48,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
label: 'System',
|
||||
items: [
|
||||
...(isAdmin ? [{ label: 'Auth Audit', href: '/moderation/auth-audit', icon: 'fa-solid fa-user-shield' }] : []),
|
||||
...(isAdmin ? [{ label: 'Security Report', href: '/moderation/system/security-report', icon: 'fa-solid fa-shield-virus' }] : []),
|
||||
{ label: 'Settings', href: '/moderation/settings', icon: 'fa-solid fa-gear' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Head, Link, useForm, usePage } from '@inertiajs/react'
|
||||
import { Link, useForm, usePage } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
import SeoHead from '../../../components/seo/SeoHead'
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return null
|
||||
@@ -11,7 +12,7 @@ function formatDate(iso) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
|
||||
export default function AcademyBillingAccount({ seo, currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
|
||||
const { flash, auth } = usePage().props
|
||||
const { data, setData, post, processing } = useForm({
|
||||
issue_type: 'billing',
|
||||
@@ -84,7 +85,7 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Academy Subscription" />
|
||||
<SeoHead seo={seo || {}} title="Academy Subscription" />
|
||||
|
||||
<div className="mx-auto max-w-[1280px] space-y-8">
|
||||
{flash?.error ? (
|
||||
@@ -280,4 +281,4 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import { Link } from '@inertiajs/react'
|
||||
import SeoHead from '../../../components/seo/SeoHead'
|
||||
|
||||
export default function AcademyBillingCancel({ message, links = {} }) {
|
||||
export default function AcademyBillingCancel({ seo, message, links = {} }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(148,163,184,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Academy Billing Canceled" />
|
||||
<SeoHead seo={seo || {}} title="Academy Billing Canceled" />
|
||||
|
||||
<div className="mx-auto max-w-[920px] space-y-8">
|
||||
<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(67,20,7,0.78))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
@@ -20,4 +21,4 @@ export default function AcademyBillingCancel({ message, links = {} }) {
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, usePage, useForm } from '@inertiajs/react'
|
||||
import { Link, usePage, useForm } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
import SeoHead from '../../../components/seo/SeoHead'
|
||||
|
||||
export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) {
|
||||
export default function AcademyBillingSuccess({ seo, 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" />
|
||||
<SeoHead seo={seo || {}} title="Academy Subscription Confirmed" />
|
||||
|
||||
<div className="mx-auto w-full max-w-[640px] space-y-6">
|
||||
<section className="rounded-[40px] border border-emerald-300/20 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.92),rgba(6,78,59,0.82))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
@@ -65,4 +66,4 @@ export default function AcademyBillingSuccess({ currentTier, isSubscribed, links
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Head, useForm } from '@inertiajs/react'
|
||||
import { useForm } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
|
||||
@@ -17,7 +17,6 @@ export default function AcademyChallengeSubmit({ seo, challenge, artworks, submi
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.15),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title={`Submit to ${challenge.title}`} />
|
||||
<SeoHead seo={seo || {}} title={`Submit to ${challenge.title}`} description={challenge.excerpt || challenge.description} />
|
||||
|
||||
<div className="mx-auto max-w-[960px] space-y-6">
|
||||
@@ -65,4 +64,4 @@ export default function AcademyChallengeSubmit({ seo, challenge, artworks, submi
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,13 +106,26 @@ function MetricCard({ label, value, accent }) {
|
||||
}
|
||||
|
||||
function FeaturedCourseCard({ course }) {
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
const cover = course?.cover_image_thumb_url || course?.cover_image_url || course?.teaser_image_thumb_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
const coverSrcSet = course?.cover_image_srcset || course?.teaser_image_srcset || ''
|
||||
|
||||
return (
|
||||
<Link href={course.public_url} className="group relative overflow-hidden rounded-[28px] border border-sky-200/12 bg-[linear-gradient(180deg,rgba(15,23,42,0.9),rgba(15,23,42,0.72))] transition hover:-translate-y-1 hover:border-sky-300/24 hover:shadow-[0_24px_72px_rgba(2,6,23,0.3)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.14),transparent_24%),linear-gradient(135deg,transparent_0%,transparent_48%,rgba(125,211,252,0.05)_48%,rgba(125,211,252,0.05)_52%,transparent_52%,transparent_100%)] opacity-80" />
|
||||
<div className="relative h-44 overflow-hidden bg-[linear-gradient(135deg,rgba(14,165,233,0.24),rgba(15,23,42,0.92))]">
|
||||
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover" /> : null}
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
srcSet={coverSrcSet || undefined}
|
||||
sizes="(min-width: 1280px) 31vw, (min-width: 768px) 50vw, 100vw"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
fetchPriority="low"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.82))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{course.difficulty}</span>
|
||||
@@ -552,4 +565,4 @@ export default function AcademyIndex({ seo, pricingUrl, academyAccess = null, li
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +628,7 @@ function PromptFilledExampleCard({ example, analytics, contentId, index }) {
|
||||
</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?.prompt ? <pre className="mt-5 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{example.prompt}</pre> : null}
|
||||
|
||||
{example?.negative_prompt ? (
|
||||
<div className="mt-4 rounded-[24px] border border-white/10 bg-slate-950/60 p-4">
|
||||
@@ -803,7 +803,7 @@ function PromptHelperPromptCard({ helperPrompt, analytics, contentId }) {
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Prompt text</p>
|
||||
<PromptCopyButton prompt={helperPrompt.prompt} label="Copy helper prompt" analytics={analytics} contentId={contentId} eventType="academy_prompt_helper_copy" metadata={{ copy_type: 'helper_prompt', helper_prompt_title: helperPrompt.title || '', source: 'prompt_helper' }} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{helperPrompt.prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{helperPrompt.prompt}</pre>
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
@@ -839,7 +839,7 @@ function PromptVariantCard({ variant, analytics, contentId }) {
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Variant prompt</p>
|
||||
<PromptCopyButton prompt={variant.prompt} label="Copy variant" analytics={analytics} contentId={contentId} eventType="academy_prompt_variant_copy" metadata={{ copy_type: 'prompt_variant', variant_title: variant.title || '', source: 'prompt_variant' }} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{variant.prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{variant.prompt}</pre>
|
||||
</div>
|
||||
|
||||
{variant.negative_prompt ? (
|
||||
@@ -848,7 +848,7 @@ function PromptVariantCard({ variant, analytics, contentId }) {
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
|
||||
<PromptCopyButton prompt={variant.negative_prompt} label="Copy negative" analytics={analytics} contentId={contentId} eventType="academy_prompt_variant_negative_copy" metadata={{ copy_type: 'prompt_variant_negative', variant_title: variant.title || '', source: 'prompt_variant' }} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200">{variant.negative_prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 [overflow-wrap:anywhere]">{variant.negative_prompt}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1082,7 +1082,7 @@ function AiComparisonSection({ block }) {
|
||||
</div>
|
||||
<PromptCopyButton prompt={payload.prompt} />
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100">{payload.prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[22px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere]">{payload.prompt}</pre>
|
||||
{hasNegativePrompt ? (
|
||||
<div className="mt-4 rounded-[22px] border border-white/10 bg-white/[0.03] p-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
|
||||
@@ -1735,7 +1735,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(59,130,246,0.14),_transparent_26%),linear-gradient(180deg,_#0b1220_0%,_#111827_46%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title={item?.title} description={item?.excerpt || item?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1320px] space-y-6">
|
||||
<div className="mx-auto min-w-0 max-w-[1320px] space-y-6">
|
||||
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||
|
||||
@@ -2017,13 +2017,13 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
</div>
|
||||
) : pageType === 'prompt' ? (
|
||||
<div className="space-y-8">
|
||||
<section className="relative overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="min-w-0 max-w-full space-y-8 overflow-x-clip">
|
||||
<section className="relative w-full min-w-0 max-w-full overflow-hidden rounded-[40px] border border-rose-200/12 bg-[linear-gradient(150deg,rgba(244,63,94,0.14),rgba(15,23,42,0.96)_36%,rgba(45,212,191,0.14))] shadow-[0_24px_90px_rgba(15,23,42,0.34)]">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(251,113,133,0.15),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
||||
<div className="absolute -left-8 top-10 h-36 w-36 rounded-full bg-rose-300/16 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-cyan-300/14 blur-3xl" />
|
||||
|
||||
<div className="relative grid gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:p-7">
|
||||
<div className="relative grid min-w-0 grid-cols-1 gap-6 p-5 md:p-6 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:p-7">
|
||||
<div className="min-w-0">
|
||||
{academyBreadcrumbs.length ? (
|
||||
<div className="mb-5">
|
||||
@@ -2092,7 +2092,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:pt-2">
|
||||
<div className="grid min-w-0 w-full gap-4 lg:pt-2">
|
||||
<div className="flex h-full flex-col rounded-[30px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-rose-100/80">Preview artwork</p>
|
||||
@@ -2102,7 +2102,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPromptPreviewImage}
|
||||
className="group mt-4 flex min-h-[420px] flex-1 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35 lg:min-h-[640px]"
|
||||
className="group mt-4 flex min-h-[420px] w-full min-w-0 flex-1 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-black/30 text-left shadow-[0_24px_80px_rgba(2,6,23,0.26)] transition hover:border-sky-300/25 focus:outline-none focus:ring-2 focus:ring-sky-300/35 lg:min-h-[640px]"
|
||||
disabled={!promptPreviewImage}
|
||||
aria-label={promptPreviewImage ? `Open preview image for ${item.title}` : 'Preview image unavailable'}
|
||||
>
|
||||
@@ -2135,8 +2135,8 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="space-y-8">
|
||||
<div className="grid min-w-0 grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="min-w-0 space-y-8">
|
||||
{!promptHasFullAccess && (promptPreviewImage || promptPublicExamples.length) ? (
|
||||
<section className="academy-public-examples rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.045),rgba(148,163,184,0.03))] p-6 text-slate-200 shadow-[0_24px_70px_rgba(2,6,23,0.2)] md:p-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
@@ -2228,7 +2228,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 md:p-5">{promptBody || 'Prompt text is not available yet.'}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100 [overflow-wrap:anywhere] md:p-5">{promptBody || 'Prompt text is not available yet.'}</pre>
|
||||
{!promptHasFullAccess ? (
|
||||
<div className="mt-4 rounded-[24px] border border-amber-300/20 bg-amber-300/10 p-4 text-amber-50">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-amber-100/80">{promptUnlockTitle || 'Unlock the full prompt'}</p>
|
||||
@@ -2251,7 +2251,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
metadata={{ copy_type: 'negative_prompt', source: 'prompt_body' }}
|
||||
/>
|
||||
</div>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 md:p-5">{item.negative_prompt}</pre>
|
||||
<pre className="mt-4 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-[24px] border border-white/10 bg-slate-950/70 p-4 text-sm leading-7 text-slate-200 [overflow-wrap:anywhere] md:p-5">{item.negative_prompt}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -2401,7 +2401,7 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<aside className="space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||
<aside className="min-w-0 space-y-6 lg:sticky lg:top-6 lg:self-start">
|
||||
{lessonTags.length ? (
|
||||
<section className="rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.84))] p-6 text-slate-200 shadow-[0_18px_50px_rgba(2,6,23,0.18)]">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400">Microtags</p>
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import React from 'react'
|
||||
import { Head, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) {
|
||||
return 'Not finished'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, tone }) {
|
||||
const tones = {
|
||||
red: 'border-rose-400/15 bg-rose-500/10 text-rose-100',
|
||||
orange: 'border-orange-400/15 bg-orange-500/10 text-orange-100',
|
||||
yellow: 'border-amber-400/15 bg-amber-500/10 text-amber-100',
|
||||
blue: 'border-sky-400/15 bg-sky-500/10 text-sky-100',
|
||||
slate: 'border-white/[0.07] bg-white/[0.02] text-white',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border p-5 ${tones[tone] ?? tones.slate}`}>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-tight">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RiskBadge({ label }) {
|
||||
const tone = label === 'Critical'
|
||||
? 'border-rose-400/20 bg-rose-500/10 text-rose-200'
|
||||
: label === 'High'
|
||||
? 'border-orange-400/20 bg-orange-500/10 text-orange-200'
|
||||
: label === 'Medium'
|
||||
? 'border-amber-400/20 bg-amber-500/10 text-amber-200'
|
||||
: label === 'Low'
|
||||
? 'border-sky-400/20 bg-sky-500/10 text-sky-200'
|
||||
: 'border-emerald-400/20 bg-emerald-500/10 text-emerald-200'
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${tone}`}>{label}</span>
|
||||
}
|
||||
|
||||
export default function SecurityReportIndex({ latest, reports, canRunScan }) {
|
||||
const flash = usePage().props.flash ?? {}
|
||||
const items = reports?.data ?? []
|
||||
|
||||
const runScan = () => {
|
||||
router.post('/moderation/system/security-report/run')
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Security Report" subtitle="Private Composer and npm vulnerability reporting for administrators.">
|
||||
<Head title="Admin · Security Report" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{flash.success ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{flash.success}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{latest ? (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<SummaryCard label="Critical" value={latest.total_critical} tone="red" />
|
||||
<SummaryCard label="High" value={latest.total_high} tone="orange" />
|
||||
<SummaryCard label="Medium" value={latest.total_medium} tone="yellow" />
|
||||
<SummaryCard label="Low" value={latest.total_low} tone="blue" />
|
||||
<SummaryCard label="Unknown" value={latest.total_unknown} tone="slate" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Latest scan</p>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<RiskBadge label={latest.risk_label} />
|
||||
<span className="text-sm text-slate-400">{latest.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
{canRunScan ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runScan}
|
||||
className="rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||||
>
|
||||
Run Scan
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Started</dt>
|
||||
<dd className="mt-2 text-sm text-white">{formatTimestamp(latest.started_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finished</dt>
|
||||
<dd className="mt-2 text-sm text-white">{formatTimestamp(latest.finished_at)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Triggered by</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.triggered_by || 'Unknown'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Composer outdated</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.composer_outdated_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">npm outdated</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.npm_outdated_count}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Requested by</dt>
|
||||
<dd className="mt-2 text-sm text-white">{latest.user ? (latest.user.username ? `@${latest.user.username}` : latest.user.name) : 'System'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6">
|
||||
<a href={latest.show_url} className="inline-flex rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-medium text-white transition hover:bg-white/[0.08]">
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-[28px] border border-amber-400/20 bg-amber-500/10 p-6 text-amber-100">
|
||||
<p className="text-sm font-semibold">No security report has been generated yet.</p>
|
||||
<p className="mt-2 text-sm text-amber-50/80">Run <code className="rounded bg-black/20 px-1.5 py-0.5">php artisan security:scan --notify</code> or use the manual scan button after the queue worker is running.</p>
|
||||
{canRunScan ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={runScan}
|
||||
className="mt-4 rounded-2xl bg-rose-500/80 px-5 py-3 text-sm font-semibold text-white transition hover:bg-rose-500"
|
||||
>
|
||||
Run Scan
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.02]">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="text-sm font-bold uppercase tracking-[0.18em] text-slate-400">Scan History</h2>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<th className="px-5 py-4">ID</th>
|
||||
<th className="px-5 py-4">Finished</th>
|
||||
<th className="px-5 py-4">Status</th>
|
||||
<th className="px-5 py-4">Risk</th>
|
||||
<th className="px-5 py-4">Critical</th>
|
||||
<th className="px-5 py-4">High</th>
|
||||
<th className="px-5 py-4">Medium</th>
|
||||
<th className="px-5 py-4">Low</th>
|
||||
<th className="px-5 py-4">Outdated</th>
|
||||
<th className="px-5 py-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.05]">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="px-5 py-12 text-center text-slate-500">No reports recorded yet.</td>
|
||||
</tr>
|
||||
) : items.map((report) => (
|
||||
<tr key={report.id} className="transition hover:bg-white/[0.025]">
|
||||
<td className="px-5 py-4 text-white">#{report.id}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{formatTimestamp(report.finished_at)}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.status}</td>
|
||||
<td className="px-5 py-4"><RiskBadge label={report.risk_label} /></td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_critical}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_high}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_medium}</td>
|
||||
<td className="px-5 py-4 text-slate-300">{report.total_low}</td>
|
||||
<td className="px-5 py-4 text-slate-300">Composer: {report.composer_outdated_count}, npm: {report.npm_outdated_count}</td>
|
||||
<td className="px-5 py-4">
|
||||
<a href={report.show_url} className="text-sky-200 transition hover:text-sky-100">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{reports?.last_page > 1 ? (
|
||||
<div className="flex items-center justify-between border-t border-white/[0.06] px-5 py-4">
|
||||
<p className="text-xs text-slate-500">Showing {reports.from}–{reports.to} of {reports.total} reports</p>
|
||||
<div className="flex gap-1">
|
||||
{reports.links.map((link, index) => (
|
||||
link.url ? (
|
||||
<button
|
||||
key={`${link.label}-${index}`}
|
||||
type="button"
|
||||
onClick={() => router.get(link.url, {}, { preserveScroll: true, preserveState: true })}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-rose-500/20 font-semibold text-rose-300' : 'text-slate-500 hover:bg-white/[0.06] hover:text-white'}`}
|
||||
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||
/>
|
||||
) : (
|
||||
<span key={`${link.label}-${index}`} className="rounded-lg px-3 py-1.5 text-xs text-slate-700" dangerouslySetInnerHTML={{ __html: link.label }} />
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) {
|
||||
return 'Not recorded'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
function JsonPanel({ title, value }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-5">
|
||||
<h2 className="text-sm font-bold uppercase tracking-[0.18em] text-slate-400">{title}</h2>
|
||||
<pre className="nova-scrollbar mt-4 max-h-[28rem] overflow-auto rounded-2xl bg-slate-950/80 p-4 text-[11px] leading-5 text-slate-200">
|
||||
{JSON.stringify(value ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FindingTable({ title, rows, columns }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/[0.07] bg-white/[0.02]">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h2 className="text-sm font-bold uppercase tracking-[0.18em] text-slate-400">{title}</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.07] text-left text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className="px-5 py-4">{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.05]">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-5 py-12 text-center text-slate-500">No entries in this section.</td>
|
||||
</tr>
|
||||
) : rows.map((row, index) => (
|
||||
<tr key={`${row.package}-${index}`} className="align-top transition hover:bg-white/[0.025]">
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-5 py-4 text-slate-300 break-words">
|
||||
{row[column.key] || '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SecurityReportShow({ report }) {
|
||||
return (
|
||||
<AdminLayout title={`Security Report #${report.id}`} subtitle="Private dependency audit details for administrators only.">
|
||||
<Head title={`Admin · Security Report #${report.id}`} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[28px] border border-white/[0.07] bg-white/[0.02] p-6">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p className="mt-2 text-sm text-white">{report.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Risk</p>
|
||||
<p className="mt-2 text-sm text-white">{report.risk_label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Started</p>
|
||||
<p className="mt-2 text-sm text-white">{formatTimestamp(report.started_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Finished</p>
|
||||
<p className="mt-2 text-sm text-white">{formatTimestamp(report.finished_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Triggered by</p>
|
||||
<p className="mt-2 text-sm text-white">{report.triggered_by || 'Unknown'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Requested by</p>
|
||||
<p className="mt-2 text-sm text-white">{report.user ? (report.user.username ? `@${report.user.username}` : report.user.name) : 'System'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Composer outdated</p>
|
||||
<p className="mt-2 text-sm text-white">{report.composer_outdated_count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">npm outdated</p>
|
||||
<p className="mt-2 text-sm text-white">{report.npm_outdated_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{report.error_message ? (
|
||||
<div className="mt-6 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{report.error_message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6">
|
||||
<a href={report.index_url} className="inline-flex rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-medium text-white transition hover:bg-white/[0.08]">
|
||||
Back to Reports
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
<div className="rounded-2xl border border-rose-400/15 bg-rose-500/10 p-5 text-rose-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-rose-200/70">Critical</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_critical}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-orange-400/15 bg-orange-500/10 p-5 text-orange-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-orange-200/70">High</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_high}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-amber-400/15 bg-amber-500/10 p-5 text-amber-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-amber-200/70">Medium</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_medium}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-sky-400/15 bg-sky-500/10 p-5 text-sky-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-200/70">Low</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_low}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.07] bg-white/[0.02] p-5 text-white">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">Unknown</p>
|
||||
<p className="mt-3 text-3xl font-semibold">{report.total_unknown}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FindingTable
|
||||
title="Composer Advisories"
|
||||
rows={report.composer_advisories ?? []}
|
||||
columns={[
|
||||
{ key: 'package', label: 'Package' },
|
||||
{ key: 'severity', label: 'Severity' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'cve', label: 'CVE / Ref' },
|
||||
{ key: 'affected_versions', label: 'Affected versions' },
|
||||
{ key: 'link', label: 'Source URL' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<FindingTable
|
||||
title="npm Vulnerabilities"
|
||||
rows={report.npm_vulnerabilities ?? []}
|
||||
columns={[
|
||||
{ key: 'package', label: 'Package' },
|
||||
{ key: 'severity', label: 'Severity' },
|
||||
{ key: 'title', label: 'Title' },
|
||||
{ key: 'cve', label: 'CVE' },
|
||||
{ key: 'range', label: 'Affected range' },
|
||||
{ key: 'fix_available', label: 'Fix available' },
|
||||
{ key: 'url', label: 'Source URL' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<JsonPanel title="Composer Audit Raw JSON" value={report.composer_audit} />
|
||||
<JsonPanel title="npm Audit Raw JSON" value={report.npm_audit} />
|
||||
<JsonPanel title="Composer Outdated Raw JSON" value={report.composer_outdated} />
|
||||
<JsonPanel title="npm Outdated Raw JSON" value={report.npm_outdated} />
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
@@ -324,7 +324,6 @@ export default function FeaturedArtworksAdmin() {
|
||||
const { props } = usePage()
|
||||
const composerRef = React.useRef(null)
|
||||
const rosterRef = React.useRef(null)
|
||||
const loadMoreRef = React.useRef(null)
|
||||
const endpoints = props.endpoints || {}
|
||||
const capabilities = props.capabilities || {}
|
||||
const seo = props.seo || {}
|
||||
@@ -344,7 +343,7 @@ export default function FeaturedArtworksAdmin() {
|
||||
const [selectedArtwork, setSelectedArtwork] = React.useState(null)
|
||||
const [editingId, setEditingId] = React.useState(null)
|
||||
const [form, setForm] = React.useState(emptyForm())
|
||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE)
|
||||
const [currentPage, setCurrentPage] = React.useState(1)
|
||||
|
||||
React.useEffect(() => {
|
||||
setEntries(Array.isArray(props.entries) ? props.entries : [])
|
||||
@@ -353,7 +352,7 @@ export default function FeaturedArtworksAdmin() {
|
||||
}, [props.entries, props.stats, props.winner])
|
||||
|
||||
React.useEffect(() => {
|
||||
setVisibleCount(PAGE_SIZE)
|
||||
setCurrentPage(1)
|
||||
}, [deferredListQuery, filter, sortDirection, sortKey])
|
||||
|
||||
function scrollToSection(ref) {
|
||||
@@ -631,27 +630,13 @@ export default function FeaturedArtworksAdmin() {
|
||||
.sort((left, right) => compareEntries(left, right, sortKey, sortDirection))
|
||||
}, [deferredListQuery, entries, filter, sortDirection, sortKey])
|
||||
|
||||
const visibleEntries = React.useMemo(() => filteredEntries.slice(0, visibleCount), [filteredEntries, visibleCount])
|
||||
const hasMoreEntries = visibleEntries.length < filteredEntries.length
|
||||
const totalPages = Math.max(1, Math.ceil(filteredEntries.length / PAGE_SIZE))
|
||||
const visibleEntries = React.useMemo(() => filteredEntries.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), [filteredEntries, currentPage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasMoreEntries || typeof IntersectionObserver === 'undefined' || !loadMoreRef.current) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((observerEntries) => {
|
||||
if (observerEntries.some((observerEntry) => observerEntry.isIntersecting)) {
|
||||
setVisibleCount((current) => Math.min(current + PAGE_SIZE, filteredEntries.length))
|
||||
}
|
||||
}, { rootMargin: '320px 0px' })
|
||||
|
||||
observer.observe(loadMoreRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [filteredEntries.length, hasMoreEntries, visibleCount])
|
||||
|
||||
function loadMoreEntries() {
|
||||
setVisibleCount((current) => Math.min(current + PAGE_SIZE, filteredEntries.length))
|
||||
function goToPage(next) {
|
||||
const page = Math.max(1, Math.min(totalPages, next))
|
||||
setCurrentPage(page)
|
||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -900,20 +885,27 @@ export default function FeaturedArtworksAdmin() {
|
||||
</article>
|
||||
))}
|
||||
|
||||
{hasMoreEntries ? (
|
||||
<div ref={loadMoreRef} className="rounded-[24px] border border-dashed border-white/10 bg-black/20 px-5 py-6 text-center">
|
||||
<div className="text-sm text-slate-300">Loading more rows as you reach the bottom.</div>
|
||||
{filteredEntries.length > PAGE_SIZE ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMoreEntries}
|
||||
className="mt-4 rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition hover:border-white/20 hover:bg-white/5"
|
||||
onClick={() => goToPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed hover:border-white/20 hover:bg-white/5"
|
||||
>
|
||||
Load 24 more
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-slate-300">Page {currentPage} of {totalPages}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToPage(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded-full border border-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-slate-100 transition disabled:opacity-50 disabled:cursor-not-allowed hover:border-white/20 hover:bg-white/5"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
) : filteredEntries.length > PAGE_SIZE ? (
|
||||
<div className="text-center text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
All matching rows loaded
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
@if(request()->hasSession() && ! request()->attributes->get('skinbase.session_skipped'))
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||
@endif
|
||||
@vite(['resources/js/academy.jsx'])
|
||||
<script type="module" src="{{ Vite::asset('resources/js/academy.jsx') }}"></script>
|
||||
<style>
|
||||
body.page-academy main { padding-top: 4rem; }
|
||||
</style>
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
request()->is(...array_merge(['browse', 'tags', 'tags/*'], $toolbarContentTypeSlugs)) => 'browse',
|
||||
request()->is('groups', 'groups/*') => 'groups',
|
||||
request()->is('creators', 'creators/*', 'stories', 'stories/*', 'following', 'leaderboard') => 'creators',
|
||||
request()->is('academy', 'academy/*') => 'academy',
|
||||
request()->is('forum', 'forum/*', 'news', 'news/*') => 'community',
|
||||
default => null,
|
||||
};
|
||||
@@ -193,6 +194,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(config('academy.enabled', true))
|
||||
<div class="relative">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'academy' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
data-dd="academy"
|
||||
{{ $navSection === 'academy' ? 'aria-current=page' : '' }}>
|
||||
Academy
|
||||
<svg class="w-4 h-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6" /></svg>
|
||||
</button>
|
||||
<div id="dd-academy" class="dd-menu absolute left-0 mt-1 w-56 rounded-xl bg-panel border border-panel shadow-sb overflow-hidden">
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.index') }}">
|
||||
<i class="fa-solid fa-graduation-cap w-4 text-center text-sb-muted"></i>Academy Home
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.courses.index') }}">
|
||||
<i class="fa-solid fa-road w-4 text-center text-sb-muted"></i>Courses
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.lessons.index') }}">
|
||||
<i class="fa-solid fa-book-open w-4 text-center text-sb-muted"></i>Lessons
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.prompts.index') }}">
|
||||
<i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-sb-muted"></i>Prompts
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.packs.index') }}">
|
||||
<i class="fa-solid fa-box-archive w-4 text-center text-sb-muted"></i>Packs
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm hover:bg-white/5" href="{{ route('academy.challenges.index') }}">
|
||||
<i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Challenges
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-2.5 text-sm border-t border-white/5 bg-white/[0.02] hover:bg-white/5" href="{{ route('academy.pricing') }}">
|
||||
<i class="fa-solid fa-credit-card w-4 text-center text-amber-200"></i>Pricing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- COMMUNITY --}}
|
||||
<div class="relative">
|
||||
<button class="inline-flex items-center gap-1 px-3 py-2 rounded-lg transition-colors {{ $navSection === 'community' ? 'text-white bg-white/10' : 'hover:text-white hover:bg-white/5' }}"
|
||||
@@ -578,6 +613,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(config('academy.enabled', true))
|
||||
<div class="pt-1">
|
||||
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionAcademy" aria-expanded="{{ request()->is('academy', 'academy/*') ? 'true' : 'false' }}" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
|
||||
<span>Academy</span>
|
||||
<i data-mobile-section-icon class="fa-solid fa-chevron-down text-xs transition-transform {{ request()->is('academy', 'academy/*') ? 'rotate-180' : '' }}"></i>
|
||||
</button>
|
||||
<div id="mobileSectionAcademy" data-mobile-section-panel class="{{ request()->is('academy', 'academy/*') ? '' : 'hidden ' }}mt-0.5 space-y-0.5">
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.index') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.index') }}"><i class="fa-solid fa-graduation-cap w-4 text-center text-sb-muted"></i>Academy Home</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.courses.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.courses.index') }}"><i class="fa-solid fa-road w-4 text-center text-sb-muted"></i>Courses</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.lessons.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.lessons.index') }}"><i class="fa-solid fa-book-open w-4 text-center text-sb-muted"></i>Lessons</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.prompts.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.prompts.index') }}"><i class="fa-solid fa-wand-magic-sparkles w-4 text-center text-sb-muted"></i>Prompts</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.packs.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.packs.index') }}"><i class="fa-solid fa-box-archive w-4 text-center text-sb-muted"></i>Packs</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.challenges.*') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.challenges.index') }}"><i class="fa-solid fa-trophy w-4 text-center text-sb-muted"></i>Challenges</a>
|
||||
<a class="flex items-center gap-3 py-2.5 px-3 rounded-lg {{ request()->routeIs('academy.pricing', 'academy.billing.pricing') ? 'bg-white/10 text-white' : 'hover:bg-white/5' }}" href="{{ route('academy.pricing') }}"><i class="fa-solid fa-credit-card w-4 text-center text-sb-muted"></i>Pricing</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="pt-1">
|
||||
<button type="button" data-mobile-section-toggle aria-controls="mobileSectionCommunity" aria-expanded="false" class="w-full flex items-center justify-between py-2.5 px-3 rounded-lg text-[11px] font-semibold uppercase tracking-widest text-sb-muted hover:bg-white/5">
|
||||
<span>Community</span>
|
||||
|
||||
Reference in New Issue
Block a user