Optimize academy
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user