2615 lines
123 KiB
JavaScript
2615 lines
123 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Head, Link, router, useForm } from '@inertiajs/react'
|
|
import { createPortal } from 'react-dom'
|
|
import AdminLayout from '../../../Layouts/AdminLayout'
|
|
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
|
import NovaSelect from '../../../components/ui/NovaSelect'
|
|
import ShareToast from '../../../components/ui/ShareToast'
|
|
import ChallengeEditor from './ChallengeEditor'
|
|
import CourseEditor from './CourseEditor'
|
|
import LessonEditor from './LessonEditor'
|
|
|
|
function normalizePayload(fields, data) {
|
|
const payload = { ...data }
|
|
|
|
fields.forEach((field) => {
|
|
if (field.type === 'csv') {
|
|
payload[field.name] = String(payload[field.name] || '')
|
|
.split(/[,\n]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
if (field.type === 'json') {
|
|
if (typeof payload[field.name] === 'string') {
|
|
const trimmed = payload[field.name].trim()
|
|
|
|
if (!trimmed) {
|
|
payload[field.name] = null
|
|
return
|
|
}
|
|
|
|
try {
|
|
payload[field.name] = JSON.parse(trimmed)
|
|
} catch {
|
|
// Keep the original string so the caller can surface a field-specific validation error.
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
return payload
|
|
}
|
|
|
|
function serializeStructuredJson(value) {
|
|
if (value == null || value === '') return ''
|
|
if (typeof value === 'string') return value
|
|
|
|
try {
|
|
return JSON.stringify(value, null, 2)
|
|
} catch {
|
|
return ''
|
|
}
|
|
}
|
|
|
|
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'))
|
|
|
|
if (typeof navigator !== 'undefined' && navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
return navigator.clipboard.writeText(source)
|
|
}
|
|
|
|
if (typeof document === 'undefined' || !document.body) {
|
|
return Promise.reject(new Error('Clipboard unavailable'))
|
|
}
|
|
|
|
const textarea = document.createElement('textarea')
|
|
textarea.value = source
|
|
textarea.setAttribute('readonly', 'true')
|
|
textarea.style.position = 'fixed'
|
|
textarea.style.top = '-1000px'
|
|
textarea.style.left = '-1000px'
|
|
document.body.appendChild(textarea)
|
|
textarea.select()
|
|
|
|
try {
|
|
if (document.execCommand('copy')) {
|
|
return Promise.resolve()
|
|
}
|
|
} finally {
|
|
document.body.removeChild(textarea)
|
|
}
|
|
|
|
return Promise.reject(new Error('Clipboard unavailable'))
|
|
}
|
|
|
|
function getField(fields, name) {
|
|
return fields.find((field) => field.name === name) || null
|
|
}
|
|
|
|
function firstErrorMessage(errors, fallback = 'Please correct the highlighted fields and try again.') {
|
|
const queue = [errors]
|
|
|
|
while (queue.length > 0) {
|
|
const current = queue.shift()
|
|
|
|
if (typeof current === 'string') {
|
|
const message = current.trim()
|
|
|
|
if (message) {
|
|
return message
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
if (Array.isArray(current)) {
|
|
queue.push(...current)
|
|
continue
|
|
}
|
|
|
|
if (current && typeof current === 'object') {
|
|
queue.push(...Object.values(current))
|
|
}
|
|
}
|
|
|
|
return fallback
|
|
}
|
|
|
|
function SectionCard({ eyebrow, title, description, children, className = '' }) {
|
|
return (
|
|
<section className={`w-full min-w-0 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_20px_80px_rgba(15,23,42,0.18)] ${className}`.trim()}>
|
|
<div className="mb-5">
|
|
{eyebrow ? <p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80">{eyebrow}</p> : null}
|
|
<h2 className="mt-2 text-xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
|
{description ? <p className="mt-2 text-sm leading-7 text-slate-400">{description}</p> : null}
|
|
</div>
|
|
<div className="grid gap-5">{children}</div>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function TextField({ label, value, onChange, error, ...rest }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-200">
|
|
<span>{label}</span>
|
|
<input value={value ?? ''} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" {...rest} />
|
|
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function TextAreaField({ label, value, onChange, error, rows = 6, hint }) {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-200">
|
|
<span>{label}</span>
|
|
<textarea value={value ?? ''} onChange={onChange} rows={rows} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none" />
|
|
{hint ? <span className="text-xs leading-5 text-slate-500">{hint}</span> : null}
|
|
{error ? <p className="text-xs text-rose-300">{error}</p> : null}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function ToggleField({ label, checked, onChange, help, error }) {
|
|
return (
|
|
<label className={`flex cursor-pointer items-start gap-4 rounded-[28px] border px-5 py-4 transition ${checked ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
|
<input type="checkbox" checked={Boolean(checked)} onChange={onChange} className="sr-only" />
|
|
<span className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border text-sm transition ${checked ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-transparent'}`}>
|
|
<i className="fa-solid fa-check" />
|
|
</span>
|
|
<span className="min-w-0">
|
|
<span className="block text-base font-semibold tracking-[-0.02em] text-white">{label}</span>
|
|
{help ? <span className="mt-1 block text-sm leading-6 text-slate-300">{help}</span> : null}
|
|
{error ? <span className="mt-2 block text-xs text-rose-300">{error}</span> : null}
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|
|
const PROMPT_EDITOR_TABS = [
|
|
{
|
|
id: 'overview',
|
|
label: 'Overview',
|
|
description: 'Set the prompt identity, category, access level, and the short summary shown in the library.',
|
|
icon: 'fa-compass-drafting',
|
|
sections: ['prompt-identity'],
|
|
},
|
|
{
|
|
id: 'prompt',
|
|
label: 'Prompt Body',
|
|
description: 'Write the main prompt, exclusions, usage notes, and workflow direction without crowding the rest of the form.',
|
|
icon: 'fa-wand-magic-sparkles',
|
|
sections: ['prompt-body'],
|
|
},
|
|
{
|
|
id: 'advanced',
|
|
label: 'Advanced Docs',
|
|
description: 'Store structured documentation, placeholders, helper prompts, and prompt variants without burying them in plain text notes.',
|
|
icon: 'fa-layer-group',
|
|
sections: ['prompt-advanced'],
|
|
},
|
|
{
|
|
id: 'comparisons',
|
|
label: 'AI Model Comparisons',
|
|
description: 'Compare how different AI models or providers behave on the same prompt so editors can keep the guidance reusable.',
|
|
icon: 'fa-scale-balanced',
|
|
sections: ['prompt-comparisons'],
|
|
},
|
|
{
|
|
id: 'media',
|
|
label: 'Media',
|
|
description: 'Upload the preview image used across the prompt library and public prompt detail page.',
|
|
icon: 'fa-image',
|
|
sections: ['prompt-media'],
|
|
},
|
|
{
|
|
id: 'publish',
|
|
label: 'Publish',
|
|
description: 'Control timing, SEO, and promotion state without showing every publishing option at once.',
|
|
icon: 'fa-rocket-launch',
|
|
sections: ['prompt-publishing', 'prompt-preview'],
|
|
},
|
|
]
|
|
|
|
const PROMPT_FIELD_TAB_MAP = {
|
|
category_id: 'overview',
|
|
title: 'overview',
|
|
slug: 'overview',
|
|
excerpt: 'overview',
|
|
difficulty: 'overview',
|
|
access_level: 'overview',
|
|
aspect_ratio: 'overview',
|
|
tags: 'overview',
|
|
prompt: 'prompt',
|
|
negative_prompt: 'prompt',
|
|
usage_notes: 'prompt',
|
|
workflow_notes: 'prompt',
|
|
documentation: 'advanced',
|
|
placeholders: 'advanced',
|
|
helper_prompts: 'advanced',
|
|
prompt_variants: 'advanced',
|
|
filled_examples: 'advanced',
|
|
preview_image: 'media',
|
|
preview_image_file: 'media',
|
|
published_at: 'publish',
|
|
seo_title: 'publish',
|
|
seo_description: 'publish',
|
|
featured: 'publish',
|
|
prompt_of_week: 'publish',
|
|
active: 'publish',
|
|
}
|
|
|
|
function slugifyPromptTitle(value) {
|
|
return String(value || '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 180)
|
|
}
|
|
|
|
function stripPlainText(value) {
|
|
return String(value || '').replace(/\s+/g, ' ').trim()
|
|
}
|
|
|
|
function countPlainWords(value) {
|
|
const text = stripPlainText(value)
|
|
return text ? text.split(/\s+/).length : 0
|
|
}
|
|
|
|
function emptyPromptComparison() {
|
|
return {
|
|
client_key: `comparison-${Math.random().toString(36).slice(2, 10)}`,
|
|
display_type: '',
|
|
provider: '',
|
|
model_name: '',
|
|
notes: '',
|
|
strengths: '',
|
|
weaknesses: '',
|
|
best_for: '',
|
|
image_path: '',
|
|
image_url: '',
|
|
thumb_path: '',
|
|
thumb_url: '',
|
|
settings: '',
|
|
score: '',
|
|
active: true,
|
|
}
|
|
}
|
|
|
|
function sanitizePromptComparison(value) {
|
|
if (!value || typeof value !== 'object') {
|
|
return emptyPromptComparison()
|
|
}
|
|
|
|
return {
|
|
client_key: String(value.client_key || emptyPromptComparison().client_key),
|
|
display_type: String(value.display_type || '').trim(),
|
|
provider: String(value.provider || '').trim(),
|
|
model_name: String(value.model_name || '').trim(),
|
|
notes: String(value.notes || '').trim(),
|
|
strengths: String(value.strengths || '').trim(),
|
|
weaknesses: String(value.weaknesses || '').trim(),
|
|
best_for: String(value.best_for || '').trim(),
|
|
image_path: String(value.image_path || '').trim(),
|
|
image_url: String(value.image_url || '').trim(),
|
|
thumb_path: String(value.thumb_path || '').trim(),
|
|
thumb_url: String(value.thumb_url || '').trim(),
|
|
settings: String(value.settings || '').trim(),
|
|
score: value.score === '' || value.score === null || typeof value.score === 'undefined' ? '' : String(value.score).trim(),
|
|
active: typeof value.active === 'boolean' ? value.active : true,
|
|
}
|
|
}
|
|
|
|
function normalizePromptComparisons(value, { preserveEmpty = false } = {}) {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
return value
|
|
.map((item) => {
|
|
if (typeof item === 'string') {
|
|
const normalized = { ...emptyPromptComparison(), notes: item.trim() }
|
|
return normalized.notes || preserveEmpty ? normalized : null
|
|
}
|
|
|
|
if (!item || typeof item !== 'object') return preserveEmpty ? emptyPromptComparison() : null
|
|
|
|
const normalized = sanitizePromptComparison(item)
|
|
|
|
const hasContent = [
|
|
normalized.display_type,
|
|
normalized.provider,
|
|
normalized.model_name,
|
|
normalized.notes,
|
|
normalized.strengths,
|
|
normalized.weaknesses,
|
|
normalized.best_for,
|
|
normalized.image_path,
|
|
normalized.thumb_path,
|
|
normalized.settings,
|
|
normalized.score,
|
|
].some(Boolean)
|
|
|
|
return hasContent || preserveEmpty ? normalized : null
|
|
})
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function serializePromptComparisons(value) {
|
|
return normalizePromptComparisons(value)
|
|
.map((comparison) => ({
|
|
display_type: comparison.display_type,
|
|
provider: comparison.provider,
|
|
model_name: comparison.model_name,
|
|
notes: comparison.notes,
|
|
strengths: comparison.strengths,
|
|
weaknesses: comparison.weaknesses,
|
|
best_for: comparison.best_for,
|
|
image_path: comparison.image_path,
|
|
thumb_path: comparison.thumb_path,
|
|
settings: comparison.settings,
|
|
score: comparison.score === '' ? null : Number(comparison.score),
|
|
active: Boolean(comparison.active),
|
|
}))
|
|
}
|
|
|
|
const PROMPT_COMPARISON_TYPE_OPTIONS = [
|
|
{ value: '', label: 'Default' },
|
|
{ value: 'Comparison', label: 'Comparison' },
|
|
{ value: 'Variation', label: 'Variation' },
|
|
{ value: 'Iteration', label: 'Iteration' },
|
|
{ value: 'Refinement', label: 'Refinement' },
|
|
{ value: 'Remix', label: 'Remix' },
|
|
]
|
|
|
|
const PROMPT_COMPARISON_EDITOR_TABS = [
|
|
{
|
|
id: 'summary',
|
|
label: 'Summary',
|
|
description: 'Keep the main comparison details visible while editing this block.',
|
|
},
|
|
{
|
|
id: 'setup',
|
|
label: 'Setup',
|
|
description: 'Store generation settings and workflow context for this model output.',
|
|
},
|
|
{
|
|
id: 'review',
|
|
label: 'Review',
|
|
description: 'Capture strengths and weaknesses without crowding the main editor view.',
|
|
},
|
|
]
|
|
|
|
function normalizeCodeList(values) {
|
|
return Array.from(new Set((Array.isArray(values) ? values : [])
|
|
.map((value) => String(value || '').trim())
|
|
.filter(Boolean)))
|
|
}
|
|
|
|
function loadCodeList(storageKey) {
|
|
if (typeof window === 'undefined') return []
|
|
|
|
try {
|
|
return normalizeCodeList(JSON.parse(window.localStorage.getItem(storageKey) || '[]'))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function saveCodeList(storageKey, values) {
|
|
if (typeof window === 'undefined') return
|
|
window.localStorage.setItem(storageKey, JSON.stringify(normalizeCodeList(values)))
|
|
}
|
|
|
|
function parsePromptImport(rawText, categoryOptions) {
|
|
let parsed
|
|
|
|
try {
|
|
parsed = JSON.parse(String(rawText || ''))
|
|
} catch {
|
|
throw new Error('Could not parse JSON.')
|
|
}
|
|
|
|
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
|
|
throw new Error('Import JSON must be an object.')
|
|
}
|
|
|
|
const next = {}
|
|
const applied = []
|
|
|
|
const apply = (key, value) => {
|
|
next[key] = value
|
|
applied.push(key)
|
|
}
|
|
|
|
if (parsed.title != null) apply('title', String(parsed.title))
|
|
if (parsed.slug != null) apply('slug', String(parsed.slug))
|
|
if (parsed.excerpt != null) apply('excerpt', String(parsed.excerpt))
|
|
if (parsed.difficulty != null) apply('difficulty', String(parsed.difficulty))
|
|
if (parsed.access_level != null) apply('access_level', String(parsed.access_level))
|
|
if (parsed.access != null && parsed.access_level == null) apply('access_level', String(parsed.access))
|
|
if (parsed.aspect_ratio != null) apply('aspect_ratio', String(parsed.aspect_ratio))
|
|
if (parsed.tags != null) apply('tags', Array.isArray(parsed.tags) ? parsed.tags.map((tag) => typeof tag === 'string' ? tag : (tag?.name || tag?.label || tag?.title || tag?.slug || '')).filter(Boolean).join(', ') : String(parsed.tags))
|
|
if (parsed.prompt != null) apply('prompt', String(parsed.prompt))
|
|
if (parsed.negative_prompt != null) apply('negative_prompt', String(parsed.negative_prompt))
|
|
if (parsed.usage_notes != null) apply('usage_notes', String(parsed.usage_notes))
|
|
if (parsed.workflow_notes != null) apply('workflow_notes', String(parsed.workflow_notes))
|
|
if (parsed.documentation != null) apply('documentation', serializeStructuredJson(parsed.documentation))
|
|
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))
|
|
if (parsed.seo_title != null) apply('seo_title', String(parsed.seo_title))
|
|
if (parsed.seo_description != null) apply('seo_description', String(parsed.seo_description))
|
|
if (parsed.featured != null) apply('featured', Boolean(parsed.featured))
|
|
if (parsed.prompt_of_week != null) apply('prompt_of_week', Boolean(parsed.prompt_of_week))
|
|
if (parsed.active != null) apply('active', Boolean(parsed.active))
|
|
|
|
if (parsed.tool_notes != null || parsed.comparisons != null) {
|
|
const comparisonSource = Array.isArray(parsed.tool_notes) ? parsed.tool_notes : (Array.isArray(parsed.comparisons) ? parsed.comparisons : [])
|
|
apply('tool_notes', normalizePromptComparisons(comparisonSource, { preserveEmpty: true }))
|
|
}
|
|
|
|
if (parsed.category_id != null || parsed.category_slug != null || parsed.category != null) {
|
|
const requested = String(parsed.category_id ?? parsed.category_slug ?? parsed.category).trim().toLowerCase()
|
|
const match = (Array.isArray(categoryOptions) ? categoryOptions : []).find((option) => [option.id, option.value, option.slug, option.name, option.label]
|
|
.filter((candidate) => candidate != null)
|
|
.map((candidate) => String(candidate).trim().toLowerCase())
|
|
.includes(requested))
|
|
|
|
if (match) {
|
|
apply('category_id', String(match.id ?? match.value ?? ''))
|
|
}
|
|
}
|
|
|
|
if (applied.length === 0) {
|
|
throw new Error('The JSON did not contain any recognized prompt fields.')
|
|
}
|
|
|
|
return { next, applied }
|
|
}
|
|
|
|
function getCsrfToken() {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
|
|
async function uploadPromptComparisonMedia(uploadUrl, file) {
|
|
const formData = new FormData()
|
|
formData.append('slot', 'body')
|
|
formData.append('image', file)
|
|
|
|
const response = await fetch(uploadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
Accept: 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
body: formData,
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
|
|
if (!response.ok) {
|
|
throw new Error(payload?.message || 'Could not upload comparison image right now.')
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
async function deletePromptComparisonMedia(deleteUrl, path) {
|
|
if (!deleteUrl || !path) return
|
|
|
|
await fetch(deleteUrl, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
Accept: 'application/json',
|
|
},
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({ path }),
|
|
})
|
|
}
|
|
|
|
function isSupportedPromptComparisonImage(file) {
|
|
if (!(file instanceof File)) return false
|
|
|
|
if (['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
|
return true
|
|
}
|
|
|
|
return /\.(jpe?g|png|webp)$/i.test(String(file.name || ''))
|
|
}
|
|
|
|
function CodeListEditor({ title, description, items, customItems, draftValue, setDraftValue, onAdd, onRemove }) {
|
|
return (
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{title}</p>
|
|
<p className="mt-1 text-sm text-slate-300">{description}</p>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
{items.map((item) => {
|
|
const removable = customItems.includes(item)
|
|
|
|
return (
|
|
<span key={item} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white">
|
|
<span>{item}</span>
|
|
{removable ? <button type="button" onClick={() => onRemove(item)} className="text-slate-300 transition hover:text-rose-200"><i className="fa-solid fa-xmark" /></button> : null}
|
|
</span>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<input value={draftValue} onChange={(event) => setDraftValue(event.target.value)} className="min-w-[220px] flex-1 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-white outline-none" placeholder={`Add ${title.toLowerCase().slice(0, -1)}`} />
|
|
<button type="button" onClick={onAdd} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-sm font-semibold text-sky-100">Add</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PromptJsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
|
const backdropRef = useRef(null)
|
|
const [activeImportTab, setActiveImportTab] = useState('input')
|
|
const [copyFeedback, setCopyFeedback] = useState('')
|
|
|
|
const importTabs = [
|
|
{ id: 'input', label: 'Input', description: 'Paste structured prompt JSON and apply it.' },
|
|
{ id: 'structure', label: 'Structure example', description: 'Reference payload for prompt templates.' },
|
|
{ id: 'docs', label: 'Documentation', description: 'Field rules and import notes.' },
|
|
{ id: 'prompts', label: 'AI prompts', description: 'Prompt templates to generate valid JSON.' },
|
|
]
|
|
|
|
const structureExample = {
|
|
title: 'Peaceful Fantasy Forest Wallpaper',
|
|
slug: 'peaceful-fantasy-forest-wallpaper',
|
|
excerpt: 'Create a calm fantasy forest wallpaper with glowing flowers, soft morning light, and gentle mist.',
|
|
category: 'Academy',
|
|
difficulty: 'beginner',
|
|
access_level: 'free',
|
|
aspect_ratio: '16:9',
|
|
tags: ['wallpaper', 'fantasy', 'forest', 'glowing flowers', 'morning light'],
|
|
prompt: 'Create a calm fantasy forest wallpaper with glowing flowers, soft morning light, gentle mist, and a peaceful magical atmosphere.',
|
|
negative_prompt: 'blurry, muddy lighting, distorted tree trunks, low detail, oversaturated highlights',
|
|
usage_notes: 'Start with the base prompt, then increase atmosphere and foliage density gradually.',
|
|
workflow_notes: 'Good candidate for comparison across ChatGPT, Gemini, and Leonardo image models.',
|
|
documentation: {
|
|
summary: 'Create a city wallpaper that blends landmark imagery with climate context.',
|
|
best_for: ['travel wallpapers', 'editorial posters'],
|
|
how_to_use: ['Choose a city', 'Collect climate data', 'Insert the placeholders', 'Generate and review the final image'],
|
|
required_inputs: ['City name', 'Country', 'Landmarks', 'Monthly weather data'],
|
|
workflow: ['Research', 'Prompt preparation', 'Image generation'],
|
|
tips: ['Keep the climate ribbon subtle and secondary to the city artwork.'],
|
|
common_mistakes: ['Inventing weather data'],
|
|
data_accuracy_notes: ['Use long-term averages where available.'],
|
|
display_notes: 'Use the image-safe variant when your image model struggles with text.',
|
|
},
|
|
placeholders: [
|
|
{
|
|
key: 'CITY_NAME',
|
|
label: 'City name',
|
|
description: 'The city featured in the final image.',
|
|
required: true,
|
|
example: 'Paris',
|
|
type: 'text',
|
|
},
|
|
],
|
|
helper_prompts: [
|
|
{
|
|
title: 'Collect city climate data',
|
|
type: 'data_collection',
|
|
description: 'Gather landmark and climate references before using the main prompt.',
|
|
prompt: 'Collect city and climate data for [CITY_NAME].',
|
|
expected_output: 'json',
|
|
active: true,
|
|
},
|
|
],
|
|
prompt_variants: [
|
|
{
|
|
title: 'Image-safe version',
|
|
slug: 'image-safe-version',
|
|
description: 'Reduced text pressure for image models.',
|
|
prompt: 'Create an image-safe city climate portrait.',
|
|
negative_prompt: 'tiny text, clutter',
|
|
recommended: true,
|
|
recommended_for: ['general image generation'],
|
|
risk_notes: ['Climate icons may still be abstract'],
|
|
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,
|
|
active: true,
|
|
tool_notes: [
|
|
{
|
|
provider: 'ChatGPT',
|
|
model_name: '4o Image',
|
|
settings: 'High detail, cinematic natural light, 16:9',
|
|
notes: 'Strong mood and color harmony, slightly idealized lighting.',
|
|
strengths: 'Atmosphere, foliage glow, readable composition.',
|
|
weaknesses: 'Can over-soften foreground detail.',
|
|
best_for: 'Wallpaper-style fantasy environments.',
|
|
score: 9,
|
|
active: true,
|
|
},
|
|
],
|
|
}
|
|
|
|
const promptJsonSchemaSummary = `You are generating a Skinbase Academy prompt template JSON object.
|
|
|
|
Return only valid JSON. No markdown, no commentary, no code fences.
|
|
|
|
Recommended fields:
|
|
- title: string
|
|
- slug: SEO-friendly slug
|
|
- excerpt: concise summary for cards and search results
|
|
- category_id or category/category_slug
|
|
- difficulty: beginner|intermediate|advanced|pro
|
|
- access_level: free|creator|pro|admin
|
|
- aspect_ratio: string like 1:1, 16:9, 3:2
|
|
- tags: array of strings or objects with name/title/label/slug
|
|
- prompt: main prompt text
|
|
- negative_prompt: optional exclusions
|
|
- usage_notes: practical usage guidance
|
|
- workflow_notes: internal/editorial workflow notes
|
|
- documentation: object with structured guidance for how to use the prompt
|
|
- 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
|
|
- active: boolean
|
|
- published_at: YYYY-MM-DD HH:MM:SS when known
|
|
- seo_title, seo_description
|
|
- tool_notes: array of model comparison objects
|
|
|
|
tool_notes object fields:
|
|
- provider
|
|
- model_name
|
|
- settings
|
|
- notes
|
|
- strengths
|
|
- weaknesses
|
|
- best_for
|
|
- image_path or image_url when available
|
|
- score (1-10)
|
|
- active boolean
|
|
|
|
helper_prompts object fields:
|
|
- title
|
|
- type: data_collection|prompt_preparation|refinement|validation|variation|translation|seo|other
|
|
- description
|
|
- prompt
|
|
- expected_output: json|text|markdown|image_prompt
|
|
- active boolean
|
|
|
|
prompt_variants object fields:
|
|
- title
|
|
- slug
|
|
- description
|
|
- prompt
|
|
- negative_prompt
|
|
- recommended boolean
|
|
- recommended_for
|
|
- 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 = [
|
|
{
|
|
title: 'Prompt template generator',
|
|
prompt: `${promptJsonSchemaSummary}
|
|
|
|
Create a Skinbase Academy prompt template JSON object from the following creative brief.
|
|
- Keep the title concise and catalog-friendly.
|
|
- 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:
|
|
{{CREATIVE_BRIEF}}`,
|
|
},
|
|
{
|
|
title: 'Provider comparison generator',
|
|
prompt: `${promptJsonSchemaSummary}
|
|
|
|
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:
|
|
{{MODEL_COMPARISON_NOTES}}`,
|
|
},
|
|
{
|
|
title: 'Prompt migration import',
|
|
prompt: `${promptJsonSchemaSummary}
|
|
|
|
Convert the following source prompt page into structured Skinbase Academy prompt JSON.
|
|
- 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.
|
|
|
|
Source content:
|
|
{{SOURCE_PROMPT_PAGE}}`,
|
|
},
|
|
]
|
|
|
|
function tabButtonClass(active) {
|
|
return `flex-1 rounded-2xl border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:bg-white/[0.05] hover:text-slate-200'}`
|
|
}
|
|
|
|
const copyText = async (text, label) => {
|
|
try {
|
|
await copyTextToClipboard(String(text))
|
|
setCopyFeedback(`${label} copied`)
|
|
window.setTimeout(() => setCopyFeedback(''), 1800)
|
|
} catch {
|
|
setCopyFeedback('Copy failed')
|
|
window.setTimeout(() => setCopyFeedback(''), 1800)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (!open) return undefined
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
onClose?.()
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [onClose, open])
|
|
|
|
if (!open) return null
|
|
|
|
return createPortal(
|
|
<div
|
|
ref={backdropRef}
|
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
|
onClick={(event) => {
|
|
if (event.target === backdropRef.current) {
|
|
onClose?.()
|
|
}
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div role="dialog" aria-modal="true" aria-labelledby="prompt-json-import-title" className="flex h-[min(90vh,780px)] w-full max-w-5xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
|
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Structured import</p>
|
|
<h3 id="prompt-json-import-title" className="mt-2 text-lg font-semibold text-white">Paste prompt JSON</h3>
|
|
<p className="mt-2 text-sm leading-6 text-white/65">Use this to seed the prompt form from AI output, documentation drafts, or migrated prompt library content.</p>
|
|
</div>
|
|
|
|
<div className="border-b border-white/[0.06] px-4 py-4">
|
|
<div className="grid gap-2 md:grid-cols-4">
|
|
{importTabs.map((tab) => (
|
|
<button key={tab.id} type="button" onClick={() => setActiveImportTab(tab.id)} className={tabButtonClass(activeImportTab === tab.id)}>
|
|
<div className="text-sm font-semibold">{tab.label}</div>
|
|
<div className="mt-1 text-xs leading-5 text-current/70">{tab.description}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="nova-scrollbar flex-1 min-h-0 overflow-y-auto px-6 py-5">
|
|
{activeImportTab === 'input' ? (
|
|
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
|
<div className="grid gap-3">
|
|
<textarea
|
|
value={value}
|
|
onChange={(event) => onChange?.(event.target.value)}
|
|
rows={18}
|
|
placeholder={'{\n "title": "Peaceful Fantasy Forest Wallpaper",\n "excerpt": "Short summary...",\n "prompt": "Main prompt text...",\n "tool_notes": []\n}'}
|
|
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none placeholder:text-white/30"
|
|
/>
|
|
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recognized keys</div>
|
|
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
|
<p>title, slug, excerpt</p>
|
|
<p>category_id, category, category_slug</p>
|
|
<p>difficulty, access_level, aspect_ratio</p>
|
|
<p>tags</p>
|
|
<p>prompt, negative_prompt</p>
|
|
<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>
|
|
<p>tool_notes, comparisons</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeImportTab === 'structure' ? (
|
|
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Structure example</div>
|
|
<button type="button" onClick={() => copyText(JSON.stringify(structureExample, null, 2), 'Structure example')} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy example</button>
|
|
</div>
|
|
<pre className="nova-scrollbar max-h-[52vh] overflow-auto rounded-[20px] border border-white/10 bg-slate-950/80 p-4 text-xs leading-6 text-slate-200">{JSON.stringify(structureExample, null, 2)}</pre>
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
|
<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`, `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>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeImportTab === 'docs' ? (
|
|
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-7 text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Field guide</div>
|
|
<div className="mt-3 space-y-3 text-slate-400">
|
|
<p><strong className="text-slate-200">title</strong> - public prompt name used in the library and detail page.</p>
|
|
<p><strong className="text-slate-200">excerpt</strong> - short summary for cards, preview blocks, and search results.</p>
|
|
<p><strong className="text-slate-200">prompt</strong> - the main instruction body shown to creators.</p>
|
|
<p><strong className="text-slate-200">negative_prompt</strong> - exclusions, defects, or anti-patterns.</p>
|
|
<p><strong className="text-slate-200">documentation</strong> - structured user-facing guidance for summary, workflow, tips, and common mistakes.</p>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Import rules</div>
|
|
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
|
<p>Unknown keys are ignored, so broader AI output is safe to paste.</p>
|
|
<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, `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>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeImportTab === 'prompts' ? (
|
|
<div className="grid h-full min-h-0 items-start gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
|
<div className="grid min-w-0 gap-4">
|
|
{aiPromptExamples.map((example) => (
|
|
<div key={example.title} className="min-w-0 overflow-hidden rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0 flex-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-200/70">{example.title}</div>
|
|
<button type="button" onClick={() => copyText(example.prompt, example.title)} className="shrink-0 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy prompt</button>
|
|
</div>
|
|
<pre className="nova-scrollbar mt-3 max-h-56 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-[18px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-6 text-slate-200 [overflow-wrap:anywhere]">{example.prompt}</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="min-w-0 self-start rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Prompt tips</div>
|
|
<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`, `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>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{copyFeedback ? <div className="px-6 pb-2 text-right text-xs font-medium text-sky-200/80">{copyFeedback}</div> : null}
|
|
|
|
<div className="flex items-center justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
|
<button type="button" onClick={() => onClose?.()} className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white">Cancel</button>
|
|
<button type="button" onClick={() => onApply?.()} className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110">Apply JSON</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
)
|
|
}
|
|
|
|
function firstPromptErrorTab(errors) {
|
|
const firstKey = Object.keys(errors || {})[0]
|
|
if (!firstKey) return null
|
|
return PROMPT_FIELD_TAB_MAP[firstKey] || null
|
|
}
|
|
|
|
function promptTabErrorCounts(errors) {
|
|
const counts = {}
|
|
|
|
Object.keys(errors || {}).forEach((key) => {
|
|
const tabId = PROMPT_FIELD_TAB_MAP[key]
|
|
if (!tabId) return
|
|
counts[tabId] = Number(counts[tabId] || 0) + 1
|
|
})
|
|
|
|
return counts
|
|
}
|
|
|
|
function PromptEditorTabs({ activeTab, onChange, errorCounts }) {
|
|
const activeMeta = PROMPT_EDITOR_TABS.find((tab) => tab.id === activeTab) || PROMPT_EDITOR_TABS[0]
|
|
|
|
return (
|
|
<div className="sticky top-4 z-20 rounded-[24px] border border-white/10 bg-[linear-gradient(180deg,rgba(7,11,18,0.92),rgba(5,8,14,0.88))] px-3 py-3 shadow-[0_18px_50px_rgba(2,6,23,0.18)] backdrop-blur">
|
|
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label="Prompt editor sections">
|
|
{PROMPT_EDITOR_TABS.map((tab) => {
|
|
const isActive = tab.id === activeTab
|
|
const errorCount = Number(errorCounts?.[tab.id] || 0)
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={`prompt-editor-panel-${tab.id}`}
|
|
id={`prompt-editor-tab-${tab.id}`}
|
|
onClick={() => onChange(tab.id)}
|
|
className={[
|
|
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
|
|
isActive
|
|
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
|
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
|
].join(' ')}
|
|
>
|
|
<i className={`fa-solid ${tab.icon} text-xs`} />
|
|
<span>{tab.label}</span>
|
|
{errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-300/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-rose-100">{errorCount}</span> : null}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3 px-1">
|
|
<p className="text-sm leading-6 text-slate-400">{activeMeta.description}</p>
|
|
<div className="flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
|
{activeMeta.sections.map((section) => (
|
|
<span key={section} className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">{section.replace('prompt-', '').replace(/-/g, ' ')}</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PromptComparisonEditor({ comparisons, setComparisons, editorContext }) {
|
|
const [busyIndex, setBusyIndex] = useState(null)
|
|
const [comparisonEditorTabs, setComparisonEditorTabs] = useState({})
|
|
const [uploadError, setUploadError] = useState('')
|
|
const [bulkUploadState, setBulkUploadState] = useState(null)
|
|
const [bulkComparisonType, setBulkComparisonType] = useState('')
|
|
const [isBulkDropActive, setIsBulkDropActive] = useState(false)
|
|
const [draftProvider, setDraftProvider] = useState('')
|
|
const [draftModel, setDraftModel] = useState('')
|
|
const [customProviders, setCustomProviders] = useState([])
|
|
const [customModels, setCustomModels] = useState([])
|
|
const bulkFileInputRef = useRef(null)
|
|
const comparisonsRef = useRef(Array.isArray(comparisons) ? comparisons : [])
|
|
const providerStorageKey = 'academy.prompt-comparison.providers'
|
|
const modelStorageKey = 'academy.prompt-comparison.models'
|
|
const defaultProviders = normalizeCodeList(editorContext?.comparisonCodeLists?.providers || [])
|
|
const defaultModels = normalizeCodeList(editorContext?.comparisonCodeLists?.models || [])
|
|
const providerOptions = normalizeCodeList([...defaultProviders, ...customProviders, ...comparisons.map((comparison) => comparison.provider)])
|
|
.map((value) => ({ value, label: value }))
|
|
const modelOptions = normalizeCodeList([...defaultModels, ...customModels, ...comparisons.map((comparison) => comparison.model_name)])
|
|
.map((value) => ({ value, label: value }))
|
|
|
|
useEffect(() => {
|
|
setCustomProviders(loadCodeList(providerStorageKey))
|
|
setCustomModels(loadCodeList(modelStorageKey))
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
comparisonsRef.current = Array.isArray(comparisons) ? comparisons : []
|
|
}, [comparisons])
|
|
|
|
const commitComparisons = (nextComparisons) => {
|
|
const normalized = normalizePromptComparisons(nextComparisons, { preserveEmpty: true })
|
|
comparisonsRef.current = normalized
|
|
setComparisons(normalized)
|
|
return normalized
|
|
}
|
|
|
|
const replaceComparison = (index, nextComparison) => {
|
|
const currentComparisons = comparisonsRef.current
|
|
if (index < 0 || index >= currentComparisons.length) return
|
|
|
|
commitComparisons(currentComparisons.map((comparison, currentIndex) => currentIndex === index ? sanitizePromptComparison(nextComparison) : comparison))
|
|
}
|
|
|
|
const updateComparison = (index, field, value) => {
|
|
const currentComparison = comparisonsRef.current[index]
|
|
if (!currentComparison) return
|
|
|
|
replaceComparison(index, { ...currentComparison, [field]: value })
|
|
}
|
|
|
|
const removeStoredMedia = async (comparison) => {
|
|
const deleteUrl = editorContext?.comparisonMediaDeleteUrl || ''
|
|
const imagePaths = [comparison?.image_path, comparison?.thumb_path].filter(Boolean)
|
|
await Promise.all(imagePaths.map((path) => deletePromptComparisonMedia(deleteUrl, path)))
|
|
}
|
|
|
|
const removeComparison = async (index) => {
|
|
const currentComparisons = comparisonsRef.current
|
|
const comparison = currentComparisons[index]
|
|
if (!comparison) return
|
|
|
|
commitComparisons(currentComparisons.filter((_, currentIndex) => currentIndex !== index))
|
|
|
|
try {
|
|
await removeStoredMedia(comparison)
|
|
} catch {
|
|
// Ignore cleanup failures; the saved payload still controls final media retention.
|
|
}
|
|
}
|
|
|
|
const moveComparison = (index, direction) => {
|
|
const nextIndex = index + direction
|
|
const currentComparisons = comparisonsRef.current
|
|
|
|
if (nextIndex < 0 || nextIndex >= currentComparisons.length) return
|
|
|
|
const nextComparisons = [...currentComparisons]
|
|
const [entry] = nextComparisons.splice(index, 1)
|
|
nextComparisons.splice(nextIndex, 0, entry)
|
|
commitComparisons(nextComparisons)
|
|
}
|
|
|
|
const resolvePreviewUrl = (comparison) => comparison.thumb_url || comparison.image_url || ''
|
|
const addComparison = () => commitComparisons([
|
|
...comparisonsRef.current,
|
|
sanitizePromptComparison({
|
|
...emptyPromptComparison(),
|
|
display_type: bulkComparisonType,
|
|
}),
|
|
])
|
|
|
|
const addCustomProvider = () => {
|
|
const nextValue = String(draftProvider || '').trim()
|
|
if (!nextValue) return
|
|
const nextItems = normalizeCodeList([...customProviders, nextValue])
|
|
setCustomProviders(nextItems)
|
|
saveCodeList(providerStorageKey, nextItems)
|
|
setDraftProvider('')
|
|
}
|
|
|
|
const addCustomModel = () => {
|
|
const nextValue = String(draftModel || '').trim()
|
|
if (!nextValue) return
|
|
const nextItems = normalizeCodeList([...customModels, nextValue])
|
|
setCustomModels(nextItems)
|
|
saveCodeList(modelStorageKey, nextItems)
|
|
setDraftModel('')
|
|
}
|
|
|
|
const removeCustomProvider = (value) => {
|
|
const nextItems = customProviders.filter((item) => item !== value)
|
|
setCustomProviders(nextItems)
|
|
saveCodeList(providerStorageKey, nextItems)
|
|
}
|
|
|
|
const removeCustomModel = (value) => {
|
|
const nextItems = customModels.filter((item) => item !== value)
|
|
setCustomModels(nextItems)
|
|
saveCodeList(modelStorageKey, nextItems)
|
|
}
|
|
|
|
const uploadComparisonImage = async (index, file) => {
|
|
const uploadUrl = editorContext?.comparisonMediaUploadUrl || ''
|
|
if (!uploadUrl || !file) return
|
|
|
|
const previous = comparisonsRef.current[index]
|
|
if (!previous) return
|
|
|
|
setBusyIndex(index)
|
|
|
|
try {
|
|
const uploaded = await uploadPromptComparisonMedia(uploadUrl, file)
|
|
const currentComparison = comparisonsRef.current[index] || previous
|
|
|
|
replaceComparison(index, {
|
|
...currentComparison,
|
|
image_path: uploaded.path || '',
|
|
image_url: uploaded.url || '',
|
|
thumb_path: uploaded.thumb_path || uploaded.path || '',
|
|
thumb_url: uploaded.thumb_url || uploaded.url || '',
|
|
})
|
|
|
|
if (previous?.image_path && previous.image_path !== uploaded.path) {
|
|
await deletePromptComparisonMedia(editorContext?.comparisonMediaDeleteUrl || '', previous.image_path)
|
|
}
|
|
|
|
if (previous?.thumb_path && previous.thumb_path !== (uploaded.thumb_path || uploaded.path)) {
|
|
await deletePromptComparisonMedia(editorContext?.comparisonMediaDeleteUrl || '', previous.thumb_path)
|
|
}
|
|
} finally {
|
|
setBusyIndex(null)
|
|
}
|
|
}
|
|
|
|
const handleUpload = async (index, file) => {
|
|
setUploadError('')
|
|
|
|
try {
|
|
await uploadComparisonImage(index, file)
|
|
} catch (error) {
|
|
setUploadError(error instanceof Error ? error.message : 'Could not upload comparison image.')
|
|
}
|
|
}
|
|
|
|
const handleBulkUpload = async (fileList) => {
|
|
const incomingFiles = Array.from(fileList || []).filter((file) => file instanceof File)
|
|
if (incomingFiles.length === 0) return
|
|
|
|
const validFiles = incomingFiles.filter((file) => isSupportedPromptComparisonImage(file))
|
|
const invalidFiles = incomingFiles.filter((file) => !isSupportedPromptComparisonImage(file))
|
|
|
|
if (validFiles.length === 0) {
|
|
setUploadError('Select one or more JPG, PNG, or WebP images to create comparison blocks.')
|
|
return
|
|
}
|
|
|
|
setUploadError('')
|
|
|
|
const startIndex = comparisonsRef.current.length
|
|
commitComparisons([
|
|
...comparisonsRef.current,
|
|
...validFiles.map(() => sanitizePromptComparison({
|
|
...emptyPromptComparison(),
|
|
display_type: bulkComparisonType,
|
|
})),
|
|
])
|
|
|
|
const failedFiles = []
|
|
|
|
try {
|
|
for (let offset = 0; offset < validFiles.length; offset += 1) {
|
|
setBulkUploadState({ current: offset + 1, total: validFiles.length })
|
|
|
|
try {
|
|
await uploadComparisonImage(startIndex + offset, validFiles[offset])
|
|
} catch {
|
|
failedFiles.push(validFiles[offset].name || `Image ${offset + 1}`)
|
|
}
|
|
}
|
|
} finally {
|
|
setBulkUploadState(null)
|
|
setIsBulkDropActive(false)
|
|
}
|
|
|
|
const notices = []
|
|
|
|
if (invalidFiles.length > 0) {
|
|
notices.push(`Skipped ${invalidFiles.length} unsupported ${invalidFiles.length === 1 ? 'file' : 'files'}.`)
|
|
}
|
|
|
|
if (failedFiles.length > 0) {
|
|
notices.push(`${failedFiles.length} ${failedFiles.length === 1 ? 'image failed' : 'images failed'} to upload.`)
|
|
}
|
|
|
|
setUploadError(notices.join(' '))
|
|
}
|
|
|
|
const clearMedia = async (index) => {
|
|
const comparison = comparisonsRef.current[index]
|
|
if (!comparison) return
|
|
|
|
replaceComparison(index, {
|
|
...comparison,
|
|
image_path: '',
|
|
image_url: '',
|
|
thumb_path: '',
|
|
thumb_url: '',
|
|
})
|
|
|
|
try {
|
|
await removeStoredMedia(comparison)
|
|
} catch {
|
|
// Ignore cleanup failures; backend cleanup also runs on save.
|
|
}
|
|
}
|
|
|
|
const setComparisonEditorTab = (comparisonKey, tabId) => {
|
|
setComparisonEditorTabs((current) => current?.[comparisonKey] === tabId ? current : { ...current, [comparisonKey]: tabId })
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Structured blocks</p>
|
|
<p className="mt-1 text-sm text-slate-300">Upload the generated output for each provider, then document what it does well, where it fails, and which workflow it fits best.</p>
|
|
</div>
|
|
<button type="button" onClick={addComparison} 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">+ Add AI Comparison</button>
|
|
</div>
|
|
|
|
{uploadError ? <div className="rounded-[20px] border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{uploadError}</div> : null}
|
|
|
|
{comparisons.length ? comparisons.map((comparison, index) => {
|
|
const comparisonKey = comparison.client_key || `comparison-${index}`
|
|
const activeComparisonTab = comparisonEditorTabs[comparisonKey] || 'summary'
|
|
const activeComparisonTabMeta = PROMPT_COMPARISON_EDITOR_TABS.find((tab) => tab.id === activeComparisonTab) || PROMPT_COMPARISON_EDITOR_TABS[0]
|
|
|
|
return (
|
|
<section key={comparisonKey} className="overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.72),rgba(8,12,20,0.92))] p-5 shadow-[0_24px_70px_rgba(2,6,23,0.28)]">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-200/75">AI model comparison</p>
|
|
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white">{comparison.model_name || `${comparison.display_type || 'Comparison'} ${String(index + 1).padStart(2, '0')}`}</h3>
|
|
<p className="mt-1 text-sm text-slate-400">Document how this model handles the same prompt so creators can choose the right tool faster.</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => moveComparison(index, -1)} disabled={index === 0} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40"><i className="fa-solid fa-arrow-up" /></button>
|
|
<button type="button" onClick={() => moveComparison(index, 1)} disabled={index === comparisons.length - 1} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white disabled:opacity-40"><i className="fa-solid fa-arrow-down" /></button>
|
|
<button type="button" onClick={() => removeComparison(index)} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-3 py-2 text-xs font-semibold text-rose-100">Remove</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 grid gap-5 xl:grid-cols-[280px_minmax(0,1fr)]">
|
|
<div className="space-y-3 xl:sticky xl:top-5 xl:self-start">
|
|
<div className="rounded-[24px] border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.16),rgba(2,6,23,0.92))] p-3">
|
|
<div className="mb-3 flex flex-wrap gap-2">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.08] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-200">{comparison.display_type || 'Comparison'}</span>
|
|
{comparison.provider ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100">{comparison.provider}</span> : null}
|
|
{comparison.score ? <span className="rounded-full border border-amber-300/20 bg-amber-300/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">Score {comparison.score}</span> : null}
|
|
</div>
|
|
<div className="overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]">
|
|
{resolvePreviewUrl(comparison) ? (
|
|
<img src={resolvePreviewUrl(comparison)} alt={comparison.model_name || comparison.provider || `Comparison ${index + 1}`} className="h-72 w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-72 flex-col items-center justify-center gap-3 px-5 text-center text-sm text-slate-500">
|
|
<span className="flex h-12 w-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] text-slate-300"><i className="fa-regular fa-image" /></span>
|
|
<div>
|
|
<div className="font-semibold text-slate-200">No comparison image yet</div>
|
|
<div className="mt-1 text-xs leading-5 text-slate-500">Upload the generated result so editors can review differences at a glance.</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-1">
|
|
<label className="cursor-pointer rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2.5 text-center text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18">
|
|
<input
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
className="hidden"
|
|
disabled={busyIndex === index || Boolean(bulkUploadState)}
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0] || null
|
|
if (file) {
|
|
handleUpload(index, file)
|
|
}
|
|
event.target.value = ''
|
|
}}
|
|
/>
|
|
{busyIndex === index ? 'Uploading...' : (resolvePreviewUrl(comparison) ? 'Replace image' : 'Upload image')}
|
|
</label>
|
|
{comparison.image_path ? <button type="button" onClick={() => clearMedia(index)} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-slate-200 transition hover:bg-white/[0.08]">Clear image</button> : null}
|
|
</div>
|
|
|
|
<div className="rounded-[20px] border border-white/10 bg-black/30 px-4 py-3 text-xs leading-6 text-slate-400">
|
|
<div className="font-semibold text-white">Stored asset</div>
|
|
<div className="mt-1 break-all">{comparison.image_path || 'No uploaded comparison image yet.'}</div>
|
|
{comparison.model_name || comparison.provider ? (
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{comparison.model_name ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{comparison.model_name}</span> : null}
|
|
{comparison.provider ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-200">{comparison.provider}</span> : null}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2" role="tablist" aria-label={`Comparison ${index + 1} sections`}>
|
|
{PROMPT_COMPARISON_EDITOR_TABS.map((tab) => {
|
|
const isActive = tab.id === activeComparisonTab
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
onClick={() => setComparisonEditorTab(comparisonKey, tab.id)}
|
|
className={[
|
|
'inline-flex items-center gap-2 rounded-2xl border px-4 py-2.5 text-sm font-semibold transition',
|
|
isActive
|
|
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
|
: 'border-white/10 bg-white/[0.03] text-white/80 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
|
].join(' ')}
|
|
>
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
<p className="text-xs leading-5 text-slate-400">{activeComparisonTabMeta.description}</p>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
<div className="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Type</p>
|
|
<p className="mt-1 text-sm font-semibold text-white">{comparison.display_type || 'Default'}</p>
|
|
</div>
|
|
<div className="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Provider</p>
|
|
<p className="mt-1 text-sm font-semibold text-white">{comparison.provider || 'Not set'}</p>
|
|
</div>
|
|
<div className="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Model</p>
|
|
<p className="mt-1 text-sm font-semibold text-white">{comparison.model_name || 'Not set'}</p>
|
|
</div>
|
|
<div className="rounded-[20px] border border-white/10 bg-white/[0.03] px-4 py-3">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Visibility</p>
|
|
<p className="mt-1 text-sm font-semibold text-white">{comparison.active ? 'Visible' : 'Hidden'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{activeComparisonTab === 'summary' ? (
|
|
<>
|
|
<div className="mt-0 grid gap-4 md:grid-cols-2">
|
|
<NovaSelect label="Type" value={comparison.display_type || ''} onChange={(nextValue) => updateComparison(index, 'display_type', String(nextValue || ''))} options={PROMPT_COMPARISON_TYPE_OPTIONS} className="rounded-2xl bg-black/20" />
|
|
<TextField label="Score" type="number" min="1" max="10" value={comparison.score} onChange={(event) => updateComparison(index, 'score', event.target.value)} placeholder="1-10" />
|
|
</div>
|
|
|
|
<div className="mt-0 grid gap-4 md:grid-cols-2">
|
|
<NovaSelect label="Provider" value={comparison.provider || ''} onChange={(nextValue) => updateComparison(index, 'provider', String(nextValue || ''))} options={providerOptions} searchable className="rounded-2xl bg-black/20" />
|
|
<NovaSelect label="Model" value={comparison.model_name || ''} onChange={(nextValue) => updateComparison(index, 'model_name', String(nextValue || ''))} options={modelOptions} searchable className="rounded-2xl bg-black/20" />
|
|
</div>
|
|
|
|
<TextAreaField label="Notes" value={comparison.notes} onChange={(event) => updateComparison(index, 'notes', event.target.value)} rows={5} hint="How does this provider interpret the prompt overall?" />
|
|
|
|
<label className={`flex cursor-pointer items-center justify-between gap-4 rounded-[24px] border px-5 py-4 transition ${comparison.active ? 'border-[#f39a24]/35 bg-[#f39a24]/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]'}`}>
|
|
<input type="checkbox" checked={Boolean(comparison.active)} onChange={(event) => updateComparison(index, 'active', event.target.checked)} className="sr-only" />
|
|
<span className="min-w-0">
|
|
<span className="block text-sm font-semibold tracking-[-0.02em] text-white">Visible on frontend</span>
|
|
<span className="mt-1 block text-sm leading-6 text-slate-300">Turn this off to keep the comparison saved but hidden publicly.</span>
|
|
</span>
|
|
<span className={`inline-flex h-12 min-w-[92px] items-center justify-center rounded-full border px-4 text-sm font-semibold transition ${comparison.active ? 'border-[#f39a24] bg-[#f39a24] text-white' : 'border-white/10 bg-[#151a29] text-slate-300'}`}>
|
|
{comparison.active ? 'Visible' : 'Hidden'}
|
|
</span>
|
|
</label>
|
|
|
|
<TextAreaField label="Best for" value={comparison.best_for} onChange={(event) => updateComparison(index, 'best_for', event.target.value)} rows={4} hint="What type of creator or output is this model the best fit for?" />
|
|
</>
|
|
) : null}
|
|
|
|
{activeComparisonTab === 'setup' ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextAreaField label="Generation details" value={comparison.settings} onChange={(event) => updateComparison(index, 'settings', event.target.value)} rows={7} hint="Mention where it was generated, model mode, aspect ratio, or special settings." />
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-5 text-sm leading-7 text-slate-300">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">What to capture here</p>
|
|
<p className="mt-3">Record the setup details you would want when reproducing the result later: provider mode, prompt tweaks, seed or aspect ratio, and any notable generation constraints.</p>
|
|
<div className="mt-4 flex flex-wrap gap-2 text-[11px] uppercase tracking-[0.16em] text-slate-500">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">Mode</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">Aspect ratio</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">Prompt changes</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1.5">Seed</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeComparisonTab === 'review' ? (
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextAreaField label="Strengths" value={comparison.strengths} onChange={(event) => updateComparison(index, 'strengths', event.target.value)} rows={7} hint="What this model consistently does well with the prompt." />
|
|
<TextAreaField label="Weaknesses" value={comparison.weaknesses} onChange={(event) => updateComparison(index, 'weaknesses', event.target.value)} rows={7} hint="What tends to fail or need correction in post-processing." />
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}) : <div className="rounded-[28px] border border-dashed border-white/10 bg-black/20 px-6 py-8 text-sm text-slate-400">No comparison blocks yet. Add one when the same prompt needs model-specific guidance.</div>}
|
|
|
|
<div className="flex justify-center">
|
|
<button type="button" onClick={addComparison} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18">+ Add AI Comparison</button>
|
|
</div>
|
|
|
|
<div
|
|
className={[
|
|
'rounded-[28px] border border-dashed px-6 py-8 transition',
|
|
isBulkDropActive
|
|
? 'border-sky-300/40 bg-sky-300/10'
|
|
: 'border-white/10 bg-black/20 hover:border-sky-300/25 hover:bg-sky-300/[0.06]',
|
|
].join(' ')}
|
|
onDragOver={(event) => {
|
|
event.preventDefault()
|
|
if (!bulkUploadState) {
|
|
setIsBulkDropActive(true)
|
|
}
|
|
}}
|
|
onDragLeave={(event) => {
|
|
event.preventDefault()
|
|
if (event.currentTarget.contains(event.relatedTarget)) return
|
|
setIsBulkDropActive(false)
|
|
}}
|
|
onDrop={(event) => {
|
|
event.preventDefault()
|
|
if (bulkUploadState) return
|
|
setIsBulkDropActive(false)
|
|
handleBulkUpload(event.dataTransfer?.files)
|
|
}}
|
|
>
|
|
<input
|
|
ref={bulkFileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept="image/jpeg,image/png,image/webp"
|
|
className="hidden"
|
|
disabled={Boolean(bulkUploadState)}
|
|
onChange={(event) => {
|
|
handleBulkUpload(event.target.files)
|
|
event.target.value = ''
|
|
}}
|
|
/>
|
|
|
|
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
|
|
<span className="flex h-14 w-14 items-center justify-center rounded-[20px] border border-white/10 bg-white/[0.04] text-sky-100">
|
|
<i className="fa-solid fa-images text-lg" />
|
|
</span>
|
|
<p className="mt-4 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Bulk comparison uploads</p>
|
|
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">Drag and drop multiple images to create comparison blocks</h3>
|
|
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-300">Each uploaded image creates a new AI comparison block at the bottom, then runs through the same upload process used by the per-block image picker.</p>
|
|
|
|
<div className="mt-5 w-full max-w-md text-left">
|
|
<NovaSelect
|
|
label="Type for new blocks"
|
|
value={bulkComparisonType}
|
|
onChange={(nextValue) => setBulkComparisonType(String(nextValue || ''))}
|
|
options={PROMPT_COMPARISON_TYPE_OPTIONS}
|
|
searchable={false}
|
|
className="rounded-2xl bg-black/20"
|
|
/>
|
|
<p className="mt-2 text-xs leading-5 text-slate-500">The selected type is applied automatically to every block created by this multi-image upload.</p>
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => bulkFileInputRef.current?.click()}
|
|
disabled={Boolean(bulkUploadState)}
|
|
className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{bulkUploadState ? `Uploading ${bulkUploadState.current} of ${bulkUploadState.total}...` : 'Select multiple images'}
|
|
</button>
|
|
<span className="text-xs uppercase tracking-[0.16em] text-slate-500">or drop JPG, PNG, and WebP files here</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4 rounded-[28px] border border-white/10 bg-black/20 p-5">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Provider and model library</p>
|
|
<p className="mt-1 text-sm text-slate-300">Keep reusable provider and model names here so comparison entries stay consistent and easy to scan.</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
<CodeListEditor
|
|
title="Providers"
|
|
description="Use a short consistent provider name in the dropdown. Add custom ones when a new tool appears."
|
|
items={providerOptions.map((option) => option.value)}
|
|
customItems={customProviders}
|
|
draftValue={draftProvider}
|
|
setDraftValue={setDraftProvider}
|
|
onAdd={addCustomProvider}
|
|
onRemove={removeCustomProvider}
|
|
/>
|
|
<CodeListEditor
|
|
title="Models"
|
|
description="Keep model names standardized so frontend comparisons stay readable and sortable."
|
|
items={modelOptions.map((option) => option.value)}
|
|
customItems={customModels}
|
|
draftValue={draftModel}
|
|
setDraftValue={setDraftModel}
|
|
onAdd={addCustomModel}
|
|
onRemove={removeCustomModel}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Field({ field, form }) {
|
|
const value = form.data[field.name]
|
|
|
|
if (field.type === 'checkbox') {
|
|
return (
|
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
|
<input type="checkbox" checked={Boolean(value)} onChange={(event) => form.setData(field.name, event.target.checked)} />
|
|
{field.label}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'datetime-local') {
|
|
return (
|
|
<DateTimePicker
|
|
label={field.label}
|
|
value={value || ''}
|
|
onChange={(nextValue) => form.setData(field.name, nextValue || '')}
|
|
error={form.errors[field.name]}
|
|
clearable
|
|
className="bg-black/20"
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'textarea') {
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-200">
|
|
<span>{field.label}</span>
|
|
<textarea
|
|
value={value || ''}
|
|
onChange={(event) => form.setData(field.name, event.target.value)}
|
|
rows={field.rows || 6}
|
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-7 text-white outline-none"
|
|
/>
|
|
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'select') {
|
|
return (
|
|
<NovaSelect
|
|
label={field.label}
|
|
value={value ?? ''}
|
|
onChange={(nextValue) => form.setData(field.name, nextValue ?? '')}
|
|
options={field.options || []}
|
|
searchable={false}
|
|
className="rounded-2xl bg-black/20"
|
|
error={form.errors[field.name]}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (field.type === 'multiselect') {
|
|
return (
|
|
<NovaSelect
|
|
multi
|
|
label={field.label}
|
|
value={value || []}
|
|
onChange={(nextValue) => form.setData(field.name, Array.isArray(nextValue) ? nextValue : [])}
|
|
options={field.options || []}
|
|
className="rounded-2xl bg-black/20"
|
|
error={form.errors[field.name]}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<label className="grid gap-2 text-sm text-slate-200">
|
|
<span>{field.label}</span>
|
|
<input
|
|
type={field.type || 'text'}
|
|
value={value ?? ''}
|
|
onChange={(event) => form.setData(field.name, event.target.value)}
|
|
className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none"
|
|
/>
|
|
{form.errors[field.name] ? <p className="text-xs text-rose-300">{form.errors[field.name]}</p> : null}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
function PromptPreviewDropzone({ form, previewUrl }) {
|
|
const inputRef = useRef(null)
|
|
const [dragging, setDragging] = useState(false)
|
|
const [localPreviewUrl, setLocalPreviewUrl] = useState('')
|
|
const [selectedFileName, setSelectedFileName] = useState('')
|
|
|
|
const previewSrc = localPreviewUrl || previewUrl || form.data.preview_image || ''
|
|
|
|
useEffect(() => () => {
|
|
if (localPreviewUrl.startsWith('blob:')) {
|
|
URL.revokeObjectURL(localPreviewUrl)
|
|
}
|
|
}, [localPreviewUrl])
|
|
|
|
const setSelectedFile = (file) => {
|
|
if (localPreviewUrl.startsWith('blob:')) {
|
|
URL.revokeObjectURL(localPreviewUrl)
|
|
}
|
|
|
|
if (!file) {
|
|
setLocalPreviewUrl('')
|
|
setSelectedFileName('')
|
|
form.setData('preview_image_file', null)
|
|
form.clearErrors('preview_image_file')
|
|
return
|
|
}
|
|
|
|
const nextPreviewUrl = URL.createObjectURL(file)
|
|
setLocalPreviewUrl(nextPreviewUrl)
|
|
setSelectedFileName(file.name)
|
|
form.setData('preview_image_file', file)
|
|
form.clearErrors('preview_image_file')
|
|
}
|
|
|
|
const clearSelection = () => {
|
|
if (localPreviewUrl.startsWith('blob:')) {
|
|
URL.revokeObjectURL(localPreviewUrl)
|
|
}
|
|
|
|
setLocalPreviewUrl('')
|
|
setSelectedFileName('')
|
|
form.setData('preview_image_file', null)
|
|
form.clearErrors('preview_image_file')
|
|
|
|
if (inputRef.current) {
|
|
inputRef.current.value = ''
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SectionCard
|
|
eyebrow="Visual preview"
|
|
title="Preview image"
|
|
description="Drag an image here or paste a URL. Uploaded files are converted to WebP and stored on Contabo automatically."
|
|
>
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => inputRef.current?.click()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
inputRef.current?.click()
|
|
}
|
|
}}
|
|
onDragOver={(event) => {
|
|
event.preventDefault()
|
|
setDragging(true)
|
|
}}
|
|
onDragEnter={(event) => {
|
|
event.preventDefault()
|
|
setDragging(true)
|
|
}}
|
|
onDragLeave={(event) => {
|
|
event.preventDefault()
|
|
setDragging(false)
|
|
}}
|
|
onDrop={(event) => {
|
|
event.preventDefault()
|
|
setDragging(false)
|
|
setSelectedFile(event.dataTransfer?.files?.[0] || null)
|
|
}}
|
|
className={[
|
|
'w-full min-w-0 rounded-[28px] border border-dashed p-5 outline-none transition',
|
|
dragging ? 'border-sky-300/50 bg-sky-400/10' : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
|
|
].join(' ')}
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex min-w-0 items-start gap-4">
|
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
|
|
<i className="fa-solid fa-image" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-semibold text-white">Drop a preview image or browse</div>
|
|
<div className="mt-1 text-xs leading-5 text-slate-400">JPG, PNG, or WEBP. The server re-encodes the final asset to WebP before uploading it to the CDN.</div>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max 5 MB</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid w-full max-w-full gap-3">
|
|
<div className="overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
|
{previewSrc ? (
|
|
<img src={previewSrc} alt="Prompt preview" className="h-40 w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-40 items-center justify-center px-4 text-center text-sm text-slate-500">No preview image selected</div>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => inputRef.current?.click()} className="flex-1 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Browse</button>
|
|
{selectedFileName || localPreviewUrl ? <button type="button" onClick={clearSelection} className="rounded-full border border-white/10 bg-transparent px-4 py-2.5 text-sm font-semibold text-slate-300 transition hover:bg-white/[0.04]">Clear</button> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
setSelectedFile(event.target.files?.[0] || null)
|
|
event.target.value = ''
|
|
}}
|
|
/>
|
|
|
|
<div className="mt-4 grid min-w-0 gap-3 md:grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,220px)]">
|
|
<TextField
|
|
label="Preview image URL fallback"
|
|
value={form.data.preview_image || ''}
|
|
onChange={(event) => form.setData('preview_image', event.target.value)}
|
|
error={form.errors.preview_image}
|
|
placeholder="Paste a URL or leave empty if you upload a file"
|
|
/>
|
|
|
|
<div className="min-w-0 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-300">
|
|
<div className="font-semibold text-white">Stored value</div>
|
|
<div className="mt-1 break-all text-slate-400">{form.data.preview_image_file?.name || form.data.preview_image || previewUrl || 'None yet'}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{form.errors.preview_image_file ? <p className="mt-3 text-sm text-rose-300">{form.errors.preview_image_file}</p> : null}
|
|
</div>
|
|
</SectionCard>
|
|
)
|
|
}
|
|
|
|
function PromptEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
|
|
const form = useForm({ ...record, new_category_name: '', preview_image_file: null, tool_notes: normalizePromptComparisons(record.tool_notes, { preserveEmpty: true }) })
|
|
const categoryField = useMemo(() => getField(fields, 'category_id'), [fields])
|
|
const categoryOptions = useMemo(() => {
|
|
const options = Array.isArray(categoryField?.options) ? categoryField.options : []
|
|
const emptyOptions = options.filter((option) => String(option?.value ?? '') === '')
|
|
const filledOptions = options
|
|
.filter((option) => String(option?.value ?? '') !== '')
|
|
.slice()
|
|
.sort((left, right) => String(left?.label ?? '').localeCompare(String(right?.label ?? ''), undefined, { numeric: true, sensitivity: 'base' }))
|
|
|
|
return [...emptyOptions, ...filledOptions]
|
|
}, [categoryField])
|
|
const difficultyField = useMemo(() => getField(fields, 'difficulty'), [fields])
|
|
const accessField = useMemo(() => getField(fields, 'access_level'), [fields])
|
|
const publishedAtField = useMemo(() => getField(fields, 'published_at'), [fields])
|
|
const featuredField = useMemo(() => getField(fields, 'featured'), [fields])
|
|
const promptOfWeekField = useMemo(() => getField(fields, 'prompt_of_week'), [fields])
|
|
const activeField = useMemo(() => getField(fields, 'active'), [fields])
|
|
const seoDescriptionField = useMemo(() => getField(fields, 'seo_description'), [fields])
|
|
const documentationField = useMemo(() => getField(fields, 'documentation'), [fields])
|
|
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)
|
|
const [jsonImportValue, setJsonImportValue] = useState('')
|
|
const [jsonImportError, setJsonImportError] = useState('')
|
|
const [toast, setToast] = useState({ id: 0, visible: false, message: '', variant: 'success' })
|
|
const previewUrl = form.data.preview_image_url || ''
|
|
const tagCount = String(form.data.tags || '')
|
|
.split(/[,\n]/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean).length
|
|
const promptWordCount = useMemo(() => countPlainWords(form.data.prompt), [form.data.prompt])
|
|
const negativePromptWordCount = useMemo(() => countPlainWords(form.data.negative_prompt), [form.data.negative_prompt])
|
|
const comparisonCount = Array.isArray(form.data.tool_notes) ? form.data.tool_notes.length : 0
|
|
const tabErrorCounts = useMemo(() => promptTabErrorCounts(form.errors), [form.errors])
|
|
const activeTabMeta = useMemo(() => PROMPT_EDITOR_TABS.find((tab) => tab.id === activeTab) || PROMPT_EDITOR_TABS[0], [activeTab])
|
|
const visibleSections = useMemo(() => new Set(activeTabMeta.sections), [activeTabMeta])
|
|
const sectionClassName = (sectionId, className = '') => `${visibleSections.has(sectionId) ? '' : 'hidden'} ${className}`.trim()
|
|
const editorLinks = editorContext?.links || {}
|
|
const [heroPreviewObjectUrl, setHeroPreviewObjectUrl] = useState('')
|
|
const heroPreviewImage = heroPreviewObjectUrl || previewUrl || form.data.preview_image || ''
|
|
const showToast = (message, variant = 'error') => {
|
|
setToast({
|
|
id: Date.now() + Math.random(),
|
|
visible: true,
|
|
message,
|
|
variant,
|
|
})
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (slugTouchedRef.current) return
|
|
form.setData('slug', slugifyPromptTitle(form.data.title))
|
|
}, [form, form.data.title])
|
|
|
|
useEffect(() => {
|
|
const nextTab = firstPromptErrorTab(form.errors)
|
|
if (!nextTab) return
|
|
setActiveTab(nextTab)
|
|
}, [form.errors])
|
|
|
|
useEffect(() => {
|
|
const previewFile = form.data.preview_image_file
|
|
|
|
if (!(previewFile instanceof File)) {
|
|
setHeroPreviewObjectUrl('')
|
|
return undefined
|
|
}
|
|
|
|
const objectUrl = URL.createObjectURL(previewFile)
|
|
setHeroPreviewObjectUrl(objectUrl)
|
|
|
|
return () => {
|
|
URL.revokeObjectURL(objectUrl)
|
|
}
|
|
}, [form.data.preview_image_file])
|
|
|
|
const applyJsonImport = () => {
|
|
try {
|
|
const categoryOptions = Array.isArray(categoryField?.options) ? categoryField.options : []
|
|
const parsed = parsePromptImport(jsonImportValue, categoryOptions)
|
|
|
|
Object.entries(parsed.next).forEach(([key, value]) => {
|
|
form.setData(key, value)
|
|
})
|
|
|
|
if (parsed.next.slug != null) {
|
|
slugTouchedRef.current = true
|
|
}
|
|
|
|
setJsonImportError('')
|
|
setJsonImportOpen(false)
|
|
} catch (error) {
|
|
setJsonImportError(error instanceof Error ? error.message : 'Could not parse JSON.')
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
const advancedJsonFields = [
|
|
{ name: 'documentation', label: documentationField?.label || 'Documentation JSON' },
|
|
{ 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 = {}
|
|
|
|
for (const field of advancedJsonFields) {
|
|
form.clearErrors(field.name)
|
|
const value = form.data[field.name]
|
|
|
|
if (typeof value !== 'string') {
|
|
parsedJsonFields[field.name] = value ?? null
|
|
continue
|
|
}
|
|
|
|
const trimmed = value.trim()
|
|
|
|
if (!trimmed) {
|
|
parsedJsonFields[field.name] = null
|
|
continue
|
|
}
|
|
|
|
try {
|
|
parsedJsonFields[field.name] = JSON.parse(trimmed)
|
|
} catch {
|
|
const message = `${field.label} must be valid JSON.`
|
|
form.setError(field.name, message)
|
|
showToast(message, 'error')
|
|
setActiveTab('advanced')
|
|
return
|
|
}
|
|
}
|
|
|
|
const payload = normalizePayload(fields, {
|
|
...form.data,
|
|
...parsedJsonFields,
|
|
tool_notes: serializePromptComparisons(form.data.tool_notes),
|
|
})
|
|
form.transform(() => payload)
|
|
|
|
const submitOptions = {
|
|
preserveScroll: true,
|
|
onError: (errors) => {
|
|
const nextTab = firstPromptErrorTab(errors)
|
|
|
|
if (nextTab) {
|
|
setActiveTab(nextTab)
|
|
}
|
|
|
|
showToast(firstErrorMessage(errors), 'error')
|
|
},
|
|
onFinish: () => form.transform((data) => data),
|
|
}
|
|
|
|
if (method === 'patch') {
|
|
form.patch(submitUrl, submitOptions)
|
|
return
|
|
}
|
|
|
|
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}`} />
|
|
|
|
<form onSubmit={submit} className="space-y-6 pb-16">
|
|
{editorLinks.preview ? (
|
|
<div className="flex flex-wrap gap-3">
|
|
<Link href={editorLinks.preview} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Preview public page</Link>
|
|
</div>
|
|
) : null}
|
|
|
|
<section className="relative overflow-hidden rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_34%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.94))] shadow-[0_24px_70px_rgba(2,6,23,0.34)] backdrop-blur">
|
|
{heroPreviewImage ? (
|
|
<>
|
|
<div className="absolute inset-y-0 right-0 w-full bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.12),transparent_24%),linear-gradient(90deg,rgba(2,6,23,0.98)_0%,rgba(2,6,23,0.94)_34%,rgba(2,6,23,0.7)_100%)]" />
|
|
<img src={heroPreviewImage} alt="" aria-hidden="true" className="absolute inset-y-0 right-0 h-full w-full object-cover opacity-[0.08] blur-[5px]" />
|
|
</>
|
|
) : null}
|
|
|
|
<div className="relative grid gap-4 border-b border-white/10 px-5 py-3 lg:grid-cols-[140px_minmax(0,1fr)_auto] lg:items-stretch">
|
|
<div className="lg:min-h-[150px]">
|
|
{heroPreviewImage ? (
|
|
<div className="h-full overflow-hidden rounded-[20px] border border-white/10 bg-black/25 shadow-[0_16px_34px_rgba(2,6,23,0.26)] backdrop-blur-sm">
|
|
<div className="relative h-full min-h-[150px] overflow-hidden">
|
|
<img src={heroPreviewImage} alt={form.data.title || 'Prompt preview'} className="h-full w-full object-cover" />
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.48))]" />
|
|
<div className="absolute inset-x-0 bottom-0 border-t border-white/10 bg-[linear-gradient(180deg,rgba(2,6,23,0.32),rgba(2,6,23,0.78))] px-3 py-2.5">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Loaded preview</p>
|
|
<p className="mt-1 text-xs font-semibold text-white">Current prompt image</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full min-h-[150px] items-center justify-center rounded-[20px] border border-dashed border-white/10 bg-black/20 px-4 text-center text-xs leading-5 text-slate-400">
|
|
Upload a prompt preview image in the Media tab to surface it here.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1 self-center">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to prompts</Link>
|
|
<span>{destroyUrl ? 'Edit prompt' : 'New prompt'}</span>
|
|
</div>
|
|
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy prompt'}</h1>
|
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-300">Keep the prompt editor focused like a production worksheet: identity first, then the actual prompt body, then model comparisons and publishing details in separate tabs.</p>
|
|
</div>
|
|
<div className="self-start lg:justify-self-end">
|
|
<div className="flex flex-nowrap items-center gap-2 lg:justify-end">
|
|
<button type="button" onClick={() => setJsonImportOpen(true)} className="inline-flex items-center gap-2 whitespace-nowrap rounded-2xl border border-white/10 bg-slate-800/90 px-4 py-2 text-sm font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] transition hover:bg-slate-700/90">
|
|
<i className="fa-solid fa-file-import text-xs" />
|
|
<span>Import JSON</span>
|
|
</button>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<PromptEditorTabs activeTab={activeTab} onChange={setActiveTab} errorCounts={tabErrorCounts} />
|
|
|
|
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Current workspace</p>
|
|
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{activeTabMeta.label}</h2>
|
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">{activeTabMeta.description}</p>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-4">
|
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Prompt words</div><div className="mt-1 text-lg font-semibold text-white">{promptWordCount.toLocaleString()}</div></div>
|
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Negative words</div><div className="mt-1 text-lg font-semibold text-white">{negativePromptWordCount.toLocaleString()}</div></div>
|
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</div><div className="mt-1 text-lg font-semibold text-white">{tagCount}</div></div>
|
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Comparisons</div><div className="mt-1 text-lg font-semibold text-white">{comparisonCount}</div></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
|
|
<div className="min-w-0 space-y-6" role="tabpanel" id={`prompt-editor-panel-${activeTab}`} aria-labelledby={`prompt-editor-tab-${activeTab}`}>
|
|
<SectionCard eyebrow="Identity" title="Core prompt details" description="Set the catalog identity first so the prompt is easy to find, sort, and preview." className={sectionClassName('prompt-identity')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{categoryField ? <NovaSelect label={categoryField.label} value={form.data.category_id ?? ''} onChange={(nextValue) => {
|
|
form.setData('category_id', nextValue ?? '')
|
|
if (nextValue) {
|
|
form.setData('new_category_name', '')
|
|
}
|
|
}} 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>
|
|
|
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
|
|
Choose an existing category from the dropdown or type a new category name. When you save, a new prompt category will be created automatically and attached to this prompt.
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{accessField ? <NovaSelect label={accessField.label} value={form.data.access_level ?? ''} onChange={(nextValue) => form.setData('access_level', nextValue ?? '')} options={accessField.options || []} searchable={false} className="rounded-2xl bg-black/20" error={form.errors.access_level} /> : null}
|
|
<TextField label="Aspect ratio" value={form.data.aspect_ratio || ''} onChange={(event) => form.setData('aspect_ratio', event.target.value)} error={form.errors.aspect_ratio} placeholder="1:1, 16:9, 3:2" />
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextField label="Title" value={form.data.title || ''} onChange={(event) => form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} />
|
|
<label className="grid gap-2 text-sm text-slate-200">
|
|
<span className="flex items-center justify-between gap-3">
|
|
<span>Slug</span>
|
|
<button type="button" onClick={() => {
|
|
slugTouchedRef.current = false
|
|
form.setData('slug', slugifyPromptTitle(form.data.title))
|
|
}} className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold text-white">Sync</button>
|
|
</span>
|
|
<input value={form.data.slug || ''} onChange={(event) => {
|
|
slugTouchedRef.current = String(event.target.value).trim() !== ''
|
|
form.setData('slug', event.target.value)
|
|
}} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none" maxLength={180} placeholder="prompt-template-slug" />
|
|
{form.errors.slug ? <p className="text-xs text-rose-300">{form.errors.slug}</p> : null}
|
|
</label>
|
|
</div>
|
|
|
|
<TextAreaField label="Excerpt" value={form.data.excerpt || ''} onChange={(event) => form.setData('excerpt', event.target.value)} error={form.errors.excerpt} rows={4} hint="Short summary shown in the library and preview cards." />
|
|
|
|
<TextField label="Tags" value={form.data.tags || ''} onChange={(event) => form.setData('tags', event.target.value)} error={form.errors.tags} placeholder="wallpaper, cinematic, neon, portrait" />
|
|
</SectionCard>
|
|
|
|
<SectionCard eyebrow="Prompt body" title="Prompt instructions" description="Write the instruction stack, guardrails, and workflow notes without cramming publishing settings into the same view." className={sectionClassName('prompt-body')}>
|
|
<TextAreaField label="Prompt" value={form.data.prompt || ''} onChange={(event) => form.setData('prompt', event.target.value)} error={form.errors.prompt} rows={12} hint="This is the main model instruction used by creators." />
|
|
<TextAreaField label="Negative prompt" value={form.data.negative_prompt || ''} onChange={(event) => form.setData('negative_prompt', event.target.value)} error={form.errors.negative_prompt} rows={6} hint="Optional exclusions, artifacts, or anti-patterns to avoid." />
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<TextAreaField label="Usage notes" value={form.data.usage_notes || ''} onChange={(event) => form.setData('usage_notes', event.target.value)} error={form.errors.usage_notes} rows={6} hint="Explain how to apply the prompt in a practical workflow." />
|
|
<TextAreaField label="Workflow notes" value={form.data.workflow_notes || ''} onChange={(event) => form.setData('workflow_notes', event.target.value)} error={form.errors.workflow_notes} rows={6} hint="Internal editorial notes, camera settings, or prompt variants." />
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard eyebrow="Structured docs" title="Advanced prompt metadata" description="Use JSON editors for advanced prompt guidance, variables, supporting prompts, and reusable variants. Keep them valid JSON so the public prompt page can render them safely." className={sectionClassName('prompt-advanced')}>
|
|
<TextAreaField label={documentationField?.label || 'Documentation JSON'} value={form.data.documentation || ''} onChange={(event) => form.setData('documentation', event.target.value)} error={form.errors.documentation} rows={12} hint="Object with summary, best_for, how_to_use, required_inputs, workflow, tips, common_mistakes, data_accuracy_notes, and display_notes." />
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
<TextAreaField label={placeholdersField?.label || 'Placeholders JSON'} value={form.data.placeholders || ''} onChange={(event) => form.setData('placeholders', event.target.value)} error={form.errors.placeholders} rows={12} hint="Array of variable objects with key, label, description, required, example, default, and type." />
|
|
<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')}>
|
|
<PromptComparisonEditor comparisons={Array.isArray(form.data.tool_notes) ? form.data.tool_notes : []} setComparisons={(nextValue) => form.setData('tool_notes', normalizePromptComparisons(nextValue, { preserveEmpty: true }))} editorContext={editorContext} />
|
|
</SectionCard>
|
|
|
|
<div className={sectionClassName('prompt-media')}>
|
|
<PromptPreviewDropzone form={form} previewUrl={previewUrl} />
|
|
</div>
|
|
|
|
<SectionCard eyebrow="Publishing" title="Release controls" description="Choose when the prompt becomes visible and how it behaves in the academy." className={sectionClassName('prompt-publishing')}>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
{publishedAtField ? <DateTimePicker label={publishedAtField.label} value={form.data.published_at || ''} onChange={(nextValue) => form.setData('published_at', nextValue || '')} error={form.errors.published_at} clearable className="bg-black/20" /> : null}
|
|
<TextField label="SEO title" value={form.data.seo_title || ''} onChange={(event) => form.setData('seo_title', event.target.value)} error={form.errors.seo_title} maxLength={180} />
|
|
</div>
|
|
{seoDescriptionField ? <TextAreaField label={seoDescriptionField.label} value={form.data.seo_description || ''} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} /> : null}
|
|
|
|
<div className="grid gap-3 md:grid-cols-3">
|
|
{featuredField ? <ToggleField label={featuredField.label} checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} help="Highlight this prompt in featured rails." error={form.errors.featured} /> : null}
|
|
{promptOfWeekField ? <ToggleField label={promptOfWeekField.label} checked={Boolean(form.data.prompt_of_week)} onChange={(event) => form.setData('prompt_of_week', event.target.checked)} help="Promote this prompt as the current weekly pick." error={form.errors.prompt_of_week} /> : null}
|
|
{activeField ? <ToggleField label={activeField.label} checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} help="Keep draft prompts hidden until they are ready." error={form.errors.active} /> : null}
|
|
</div>
|
|
</SectionCard>
|
|
|
|
<SectionCard eyebrow="Preview" title="Public-facing snapshot" description="Check the prompt card summary, tags, and current image before publishing." className={sectionClassName('prompt-preview')}>
|
|
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/30">
|
|
{previewUrl || form.data.preview_image ? (
|
|
<img src={previewUrl || form.data.preview_image} alt="Prompt preview" className="h-64 w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-64 items-center justify-center px-6 text-center text-sm text-slate-500">No preview image selected yet.</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Prompt summary</p>
|
|
<h3 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{form.data.title || 'Untitled prompt'}</h3>
|
|
<p className="mt-2 text-sm leading-7 text-slate-400">{form.data.excerpt || 'Add a concise excerpt to give the prompt some context in the library.'}</p>
|
|
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs text-slate-400">
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Difficulty</dt><dd className="mt-1 text-sm text-white">{form.data.difficulty || '—'}</dd></div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Access</dt><dd className="mt-1 text-sm text-white">{form.data.access_level || '—'}</dd></div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Aspect</dt><dd className="mt-1 text-sm text-white">{form.data.aspect_ratio || '—'}</dd></div>
|
|
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-2"><dt className="uppercase tracking-[0.16em] text-slate-500">Comparisons</dt><dd className="mt-1 text-sm text-white">{comparisonCount}</dd></div>
|
|
</dl>
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
|
|
<div className="min-w-0 space-y-6 xl:sticky xl:top-6 xl:self-start">
|
|
<SectionCard eyebrow="At a glance" title="Prompt status" description="A compact summary while you work through the tabs.">
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
|
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Prompt words</p><p className="mt-2 text-lg font-semibold text-white">{promptWordCount.toLocaleString()}</p></div>
|
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tags</p><p className="mt-2 text-lg font-semibold text-white">{tagCount}</p></div>
|
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Comparisons</p><p className="mt-2 text-lg font-semibold text-white">{comparisonCount}</p></div>
|
|
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-4"><p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</p><p className="mt-2 text-lg font-semibold text-white">{form.data.active ? 'Active' : 'Draft'}</p></div>
|
|
</div>
|
|
<p className="text-xs leading-6 text-slate-500">Uploaded images are converted to WebP and stored on the Contabo S3-backed CDN before the record is saved.</p>
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3 rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
|
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save prompt'}</button>
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
|
{destroyUrl ? <button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
|
</div>
|
|
</form>
|
|
|
|
<PromptJsonImportDialog
|
|
open={jsonImportOpen}
|
|
value={jsonImportValue}
|
|
error={jsonImportError}
|
|
onChange={setJsonImportValue}
|
|
onClose={() => setJsonImportOpen(false)}
|
|
onApply={applyJsonImport}
|
|
/>
|
|
|
|
<ShareToast
|
|
key={toast.id}
|
|
message={toast.message}
|
|
visible={toast.visible}
|
|
variant={toast.variant}
|
|
duration={toast.variant === 'error' ? 3200 : 2200}
|
|
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
|
/>
|
|
</AdminLayout>
|
|
)
|
|
}
|
|
|
|
function GenericEditor({ title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
|
|
const form = useForm(record)
|
|
const editorLinks = editorContext?.links || {}
|
|
const [toast, setToast] = useState({ id: 0, visible: false, message: '', variant: 'success' })
|
|
|
|
const showToast = (message, variant = 'error') => {
|
|
setToast({
|
|
id: Date.now() + Math.random(),
|
|
visible: true,
|
|
message,
|
|
variant,
|
|
})
|
|
}
|
|
|
|
const submit = (event) => {
|
|
event.preventDefault()
|
|
const payload = normalizePayload(fields, form.data)
|
|
form.transform(() => payload)
|
|
|
|
const submitOptions = {
|
|
preserveScroll: true,
|
|
onError: (errors) => showToast(firstErrorMessage(errors), 'error'),
|
|
onFinish: () => form.transform((data) => data),
|
|
}
|
|
|
|
if (method === 'patch') {
|
|
form.patch(submitUrl, submitOptions)
|
|
return
|
|
}
|
|
|
|
form.post(submitUrl, submitOptions)
|
|
}
|
|
|
|
return (
|
|
<AdminLayout title={title} subtitle={subtitle}>
|
|
<Head title={`Admin · ${title}`} />
|
|
|
|
{(editorLinks.builder || editorLinks.preview) ? (
|
|
<div className="mb-5 flex flex-wrap gap-3">
|
|
{editorLinks.builder ? <Link href={editorLinks.builder} className="rounded-full border border-amber-300/20 bg-amber-300/10 px-5 py-3 text-sm font-semibold text-amber-100">Open builder</Link> : null}
|
|
{editorLinks.preview ? <Link href={editorLinks.preview} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Preview public page</Link> : null}
|
|
</div>
|
|
) : null}
|
|
|
|
<form onSubmit={submit} className="space-y-5 rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
|
|
<div className="grid gap-5">
|
|
{fields.map((field) => (
|
|
<Field key={field.name} field={field} form={form} />
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{form.processing ? 'Saving...' : 'Save'}</button>
|
|
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Back</Link>
|
|
{destroyUrl ? <button type="button" onClick={() => { if (!window.confirm('Delete this record?')) return; router.delete(destroyUrl) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-5 py-3 text-sm font-semibold text-rose-100">Delete</button> : null}
|
|
</div>
|
|
</form>
|
|
|
|
<ShareToast
|
|
key={toast.id}
|
|
message={toast.message}
|
|
visible={toast.visible}
|
|
variant={toast.variant}
|
|
duration={toast.variant === 'error' ? 3200 : 2200}
|
|
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
|
/>
|
|
</AdminLayout>
|
|
)
|
|
}
|
|
|
|
export default function AcademyCrudForm({ resource, title, subtitle, fields, record, submitUrl, indexUrl, destroyUrl, method, editorContext }) {
|
|
if (resource === 'courses') {
|
|
return (
|
|
<CourseEditor
|
|
title={title}
|
|
subtitle={subtitle}
|
|
fields={fields}
|
|
record={record}
|
|
submitUrl={submitUrl}
|
|
indexUrl={indexUrl}
|
|
destroyUrl={destroyUrl}
|
|
method={method}
|
|
editorContext={editorContext}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (resource === 'lessons') {
|
|
return (
|
|
<LessonEditor
|
|
title={title}
|
|
subtitle={subtitle}
|
|
fields={fields}
|
|
record={record}
|
|
submitUrl={submitUrl}
|
|
indexUrl={indexUrl}
|
|
destroyUrl={destroyUrl}
|
|
method={method}
|
|
editorContext={editorContext}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (resource === 'prompts') {
|
|
return (
|
|
<PromptEditor
|
|
title={title}
|
|
subtitle={subtitle}
|
|
fields={fields}
|
|
record={record}
|
|
submitUrl={submitUrl}
|
|
indexUrl={indexUrl}
|
|
destroyUrl={destroyUrl}
|
|
method={method}
|
|
editorContext={editorContext}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (resource === 'challenges') {
|
|
return (
|
|
<ChallengeEditor
|
|
title={title}
|
|
subtitle={subtitle}
|
|
fields={fields}
|
|
record={record}
|
|
submitUrl={submitUrl}
|
|
indexUrl={indexUrl}
|
|
destroyUrl={destroyUrl}
|
|
method={method}
|
|
editorContext={editorContext}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<GenericEditor
|
|
title={title}
|
|
subtitle={subtitle}
|
|
fields={fields}
|
|
record={record}
|
|
submitUrl={submitUrl}
|
|
indexUrl={indexUrl}
|
|
destroyUrl={destroyUrl}
|
|
method={method}
|
|
editorContext={editorContext}
|
|
/>
|
|
)
|
|
}
|