Optimize academy

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

View File

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

View File

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

View File

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