Add homepage announcement module

This commit is contained in:
2026-05-01 11:43:08 +02:00
parent 961d21e91e
commit 874f8feb9c
16 changed files with 2968 additions and 2 deletions

View File

@@ -0,0 +1,667 @@
import React from 'react'
import { Head, Link, useForm, usePage } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import HomepageAnnouncement from '../../../components/homepage/HomepageAnnouncement'
import HomepageAnnouncementEditor from '../../../components/homepage/HomepageAnnouncementEditor'
import Checkbox from '../../../components/ui/Checkbox'
import DateTimePicker from '../../../components/ui/DateTimePicker'
import NovaSelect from '../../../components/ui/NovaSelect'
import ShareToast from '../../../components/ui/ShareToast'
const BACKGROUND_IMAGE_ACCEPT = 'image/jpeg,image/jpg,image/png,image/webp'
const BACKGROUND_IMAGE_MAX_BYTES = 5 * 1024 * 1024
const FORM_TABS = [
{ id: 'overview', label: 'Overview', description: 'Identity, status, and schedule.' },
{ id: 'content', label: 'Content', description: 'Message body and CTA links.' },
{ id: 'design', label: 'Design', description: 'Visual treatment and background media.' },
{ id: 'behavior', label: 'Behavior', description: 'Dismiss rules and placement.' },
]
const FIELD_TAB_MAP = {
title: 'overview',
badge_text: 'overview',
subtitle: 'overview',
type: 'overview',
status: 'overview',
priority: 'overview',
is_active: 'overview',
starts_at: 'overview',
ends_at: 'overview',
content_html: 'content',
primary_link_label: 'content',
primary_link_type: 'content',
primary_link_url: 'content',
primary_link_target_id: 'content',
secondary_link_label: 'content',
secondary_link_type: 'content',
secondary_link_url: 'content',
secondary_link_target_id: 'content',
gradient_preset: 'design',
theme_preset: 'design',
overlay_opacity: 'design',
background_image: 'design',
background_image_file: 'design',
remove_background_image: 'design',
dismiss_version: 'behavior',
placement: 'behavior',
is_dismissible: 'behavior',
}
function isIntegerLike(value) {
if (typeof value === 'number') return Number.isInteger(value)
if (typeof value !== 'string') return false
const normalized = value.trim()
return normalized !== '' && /^-?\d+$/.test(normalized)
}
function isSafeClientUrl(value) {
const normalized = String(value || '').trim()
if (!normalized) return true
return normalized.startsWith('/') || normalized.startsWith('https://')
}
function firstErrorMessage(errors) {
const firstKey = Object.keys(errors || {})[0]
if (!firstKey) return null
const value = errors[firstKey]
return Array.isArray(value) ? value[0] : value
}
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function FieldError({ error }) {
if (!error) return null
return <p className="text-xs text-rose-300">{error}</p>
}
function resolveTabFromErrors(errors) {
const firstKey = Object.keys(errors || {})[0]
return FIELD_TAB_MAP[firstKey] || 'overview'
}
function Section({ title, description, children }) {
return (
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
<div className="mb-5">
<h2 className="text-lg font-semibold text-white">{title}</h2>
{description ? <p className="mt-1 text-sm 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-white outline-none" {...rest} />
<FieldError error={error} />
</label>
)
}
function ToggleField({ label, checked, onChange, help }) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
<div>
<div className="font-semibold text-white">{label}</div>
{help ? <div className="mt-1 text-xs leading-6 text-slate-400">{help}</div> : null}
</div>
<div className="mt-3">
<Checkbox checked={checked} onChange={onChange} aria-label={label} variant="sky" size={20} />
</div>
</div>
)
}
function SelectField({ label, value, onChange, options, error }) {
return (
<NovaSelect
label={label}
value={value}
onChange={onChange}
options={options || []}
error={error}
searchable={false}
className="rounded-2xl bg-black/20"
/>
)
}
function DateTimeField({ label, value, onChange, error }) {
return (
<DateTimePicker
label={label}
value={value}
onChange={onChange}
error={error}
clearable
className="rounded-2xl bg-black/20"
/>
)
}
function BackgroundImageDropzone({ previewUrl, storedValue, selectedFileName, error, onSelect }) {
const inputRef = React.useRef(null)
const [dragging, setDragging] = React.useState(false)
const handleFile = React.useCallback((file) => {
onSelect?.(file || null)
}, [onSelect])
return (
<div className="grid gap-3 text-sm text-slate-200">
<div className="flex items-center justify-between gap-3">
<span>Upload background image</span>
<button
type="button"
onClick={() => inputRef.current?.click()}
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]"
>
Browse
</button>
</div>
<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)
handleFile(event.dataTransfer?.files?.[0] || null)
}}
className={[
'rounded-[28px] border border-dashed px-5 py-5 transition outline-none',
dragging
? 'border-sky-300/50 bg-sky-400/12'
: 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]',
].join(' ')}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex 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-cloud-arrow-up" />
</div>
<div>
<div className="text-sm font-semibold text-white">Drop image here or browse</div>
<div className="mt-1 text-xs leading-5 text-slate-400">JPG, PNG, or WEBP. Maximum 5 MB. The selected image is previewed here and on the card preview.</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="h-28 w-full overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 lg:w-44">
{previewUrl ? (
<img src={previewUrl} alt="Background preview" className="h-full w-full object-cover" />
) : (
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-500">No background image selected</div>
)}
</div>
</div>
{selectedFileName ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-300">Selected file: <span className="text-white">{selectedFileName}</span></div> : null}
{!selectedFileName && storedValue ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-400">Stored path: <span className="text-slate-200">{storedValue}</span></div> : null}
{error ? <div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
<input
ref={inputRef}
type="file"
accept={BACKGROUND_IMAGE_ACCEPT}
className="hidden"
onChange={(event) => {
handleFile(event.target.files?.[0] || null)
event.target.value = ''
}}
/>
</div>
</div>
)
}
function LinkFields({ title, prefix, form, options }) {
return (
<div className="grid gap-4 rounded-[28px] border border-white/10 bg-black/20 p-4">
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-white/75">{title}</h3>
<div className="grid gap-4 md:grid-cols-2">
<TextField label="Label" value={form.data[`${prefix}_link_label`]} onChange={(event) => form.setData(`${prefix}_link_label`, event.target.value)} error={form.errors[`${prefix}_link_label`]} maxLength={80} />
<SelectField label="Link type" value={form.data[`${prefix}_link_type`]} onChange={(nextValue) => form.setData(`${prefix}_link_type`, nextValue)} options={options.linkTypes} error={form.errors[`${prefix}_link_type`]} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<TextField label="Fallback URL" value={form.data[`${prefix}_link_url`]} onChange={(event) => form.setData(`${prefix}_link_url`, event.target.value)} error={form.errors[`${prefix}_link_url`]} placeholder="/explore or https://example.com" maxLength={2048} />
<TextField label="Target ID" value={form.data[`${prefix}_link_target_id`]} onChange={(event) => form.setData(`${prefix}_link_target_id`, event.target.value)} error={form.errors[`${prefix}_link_target_id`]} placeholder="Optional entity id" inputMode="numeric" />
</div>
</div>
)
}
export default function HomepageAnnouncementForm({ announcement, previewAnnouncement, options, submitUrl, previewUrl, indexUrl, destroyUrl }) {
const { props } = usePage()
const isEditing = Boolean(announcement?.id)
const flash = props.flash ?? {}
const [activeTab, setActiveTab] = React.useState('overview')
const [preview, setPreview] = React.useState(previewAnnouncement || null)
const [previewBusy, setPreviewBusy] = React.useState(false)
const [previewError, setPreviewError] = React.useState('')
const [backgroundImageError, setBackgroundImageError] = React.useState('')
const [backgroundPreviewUrl, setBackgroundPreviewUrl] = React.useState(announcement?.background_image_url || '')
const [toast, setToast] = React.useState({ id: 0, visible: false, message: '', variant: 'success' })
const form = useForm({
...announcement,
background_image_file: null,
})
const showToast = React.useCallback((message, variant = 'success') => {
setToast({
id: Date.now(),
visible: true,
message,
variant,
})
}, [])
React.useEffect(() => {
return () => {
if (backgroundPreviewUrl?.startsWith?.('blob:')) {
URL.revokeObjectURL(backgroundPreviewUrl)
}
}
}, [backgroundPreviewUrl])
const previewWithLocalImage = React.useMemo(() => {
if (!preview) return null
if (!backgroundPreviewUrl) return preview
return { ...preview, background_image_url: backgroundPreviewUrl }
}, [backgroundPreviewUrl, preview])
const validateForm = React.useCallback((statusOverride = null) => {
const data = { ...form.data, status: statusOverride || form.data.status }
const errors = []
if (!String(data.title || '').trim()) {
errors.push('Title is required.')
}
if (!String(data.type || '').trim()) {
errors.push('Type is required.')
}
if (!String(data.status || '').trim()) {
errors.push('Status is required.')
}
if (!String(data.placement || '').trim()) {
errors.push('Placement is required.')
}
if (!isIntegerLike(data.priority)) {
errors.push('Priority must be a whole number.')
}
if (!isIntegerLike(data.dismiss_version) || Number(data.dismiss_version) < 1) {
errors.push('Dismiss version must be a whole number greater than or equal to 1.')
}
if (String(data.overlay_opacity || '').trim() !== '' && (!isIntegerLike(data.overlay_opacity) || Number(data.overlay_opacity) < 0 || Number(data.overlay_opacity) > 100)) {
errors.push('Overlay opacity must be a whole number between 0 and 100.')
}
if (data.starts_at && Number.isNaN(Date.parse(data.starts_at))) {
errors.push('Starts at must be a valid date and time.')
}
if (data.ends_at && Number.isNaN(Date.parse(data.ends_at))) {
errors.push('Ends at must be a valid date and time.')
}
if (data.starts_at && data.ends_at && Date.parse(data.ends_at) < Date.parse(data.starts_at)) {
errors.push('Ends at must be after or equal to starts at.')
}
for (const prefix of ['primary', 'secondary']) {
const type = String(data[`${prefix}_link_type`] || 'none')
const label = String(data[`${prefix}_link_label`] || '').trim()
const url = String(data[`${prefix}_link_url`] || '').trim()
const targetId = data[`${prefix}_link_target_id`]
if (type !== 'none' && !label) {
errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} CTA label is required when that link is enabled.`)
}
if (url && !isSafeClientUrl(url)) {
errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} CTA URL must start with / or https://.`)
}
if (type === 'custom_url' && !url) {
errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} custom CTA requires a URL.`)
}
if (!['none', 'custom_url', 'explore', 'upload'].includes(type) && !url) {
if (String(targetId || '').trim() === '') {
errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} CTA requires a target id or fallback URL.`)
} else if (!isIntegerLike(targetId) || Number(targetId) < 1) {
errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} target id must be a whole number greater than or equal to 1.`)
}
}
}
return errors
}, [form.data])
const applyBackgroundFile = React.useCallback((file) => {
setBackgroundImageError('')
if (!file) {
form.setData('background_image_file', null)
return
}
const fileType = String(file.type || '').toLowerCase()
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
if (!allowedTypes.includes(fileType)) {
setBackgroundImageError('Use a JPG, PNG, or WEBP image for the announcement background.')
return
}
if (file.size > BACKGROUND_IMAGE_MAX_BYTES) {
setBackgroundImageError('Background images must be 5 MB or smaller.')
return
}
form.setData('background_image_file', file)
form.setData('remove_background_image', false)
if (backgroundPreviewUrl?.startsWith?.('blob:')) {
URL.revokeObjectURL(backgroundPreviewUrl)
}
setBackgroundPreviewUrl(URL.createObjectURL(file))
}, [backgroundPreviewUrl, form])
const submit = (statusOverride = null) => {
const validationErrors = validateForm(statusOverride)
if (validationErrors.length > 0) {
showToast(validationErrors[0], 'error')
return
}
form.transform((data) => {
const payload = { ...data, status: statusOverride || data.status }
if (isEditing) payload._method = 'patch'
return payload
})
form.post(submitUrl, {
forceFormData: true,
preserveScroll: true,
onError: (errors) => {
setActiveTab(resolveTabFromErrors(errors))
const message = firstErrorMessage(errors) || 'Please correct the form and try again.'
showToast(message, 'error')
},
onSuccess: () => {
showToast(isEditing ? 'Homepage announcement updated.' : 'Homepage announcement created.', 'success')
},
onFinish: () => form.transform((data) => data),
})
}
const runPreview = async () => {
const validationErrors = validateForm()
if (validationErrors.length > 0) {
const message = validationErrors[0]
setPreviewError(message)
showToast(message, 'error')
return
}
setPreviewBusy(true)
setPreviewError('')
try {
const formData = new FormData()
const payload = form.data
Object.entries(payload).forEach(([key, value]) => {
if (value === null || value === undefined || key === 'id') return
if (value instanceof File) {
formData.append(key, value)
return
}
formData.append(key, String(value))
})
const response = await fetch(previewUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: formData,
})
const body = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(body?.message || 'Preview failed.')
}
setPreview(body.announcement || null)
} catch (error) {
const message = error.message || 'Preview failed.'
setPreviewError(message)
showToast(message, 'error')
} finally {
setPreviewBusy(false)
}
}
return (
<AdminLayout title={isEditing ? 'Edit Homepage Announcement' : 'Create Homepage Announcement'} subtitle="Compose, schedule, preview, and publish a premium homepage announcement card.">
<Head title={isEditing ? 'Admin · Edit Homepage Announcement' : 'Admin · Create Homepage Announcement'} />
<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 }))}
/>
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
{flash.error ? <div className="mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
<div className="sticky top-4 z-30 mb-6 rounded-[28px] border border-white/10 bg-slate-950/85 px-4 py-4 shadow-[0_20px_60px_rgba(0,0,0,0.35)] backdrop-blur-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<Link href={indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 3.5 6 8l4.5 4.5" />
</svg>
Back to announcements
</Link>
<div className="flex flex-wrap gap-3">
<button type="button" onClick={() => submit('draft')} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Save draft</button>
<button type="button" onClick={() => submit('published')} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Publish</button>
<button type="button" onClick={() => submit('archived')} className="rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100">Archive</button>
{destroyUrl ? (
<button
type="button"
onClick={() => {
if (!window.confirm('Delete this homepage announcement?')) return
form.delete(destroyUrl, { preserveScroll: true })
}}
className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100"
>
Delete
</button>
) : null}
</div>
</div>
</div>
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_26rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
<div className="space-y-6">
<div className="sticky top-[6.75rem] z-20 rounded-[28px] border border-white/10 bg-slate-950/80 p-2 backdrop-blur-xl">
<div className="flex flex-wrap gap-2">
{FORM_TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={[
'rounded-2xl px-4 py-3 text-sm font-semibold transition',
activeTab === tab.id
? 'bg-sky-300/14 text-sky-100 ring-1 ring-sky-300/25'
: 'text-slate-300 hover:bg-white/[0.04] hover:text-white',
].join(' ')}
>
{tab.label}
</button>
))}
</div>
<p className="px-2 pt-3 text-sm text-slate-400">{FORM_TABS.find((tab) => tab.id === activeTab)?.description}</p>
</div>
{activeTab === 'overview' ? (
<>
<Section title="Basic" description="Core identity, status, and visibility controls.">
<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} />
<TextField label="Badge text" value={form.data.badge_text} onChange={(event) => form.setData('badge_text', event.target.value)} error={form.errors.badge_text} maxLength={100} />
</div>
<TextField label="Subtitle" value={form.data.subtitle} onChange={(event) => form.setData('subtitle', event.target.value)} error={form.errors.subtitle} maxLength={255} />
<div className="grid gap-4 md:grid-cols-3">
<SelectField label="Type" value={form.data.type} onChange={(nextValue) => form.setData('type', nextValue)} options={options.types} error={form.errors.type} />
<SelectField label="Status" value={form.data.status} onChange={(nextValue) => form.setData('status', nextValue)} options={options.statuses} error={form.errors.status} />
<TextField label="Priority" value={form.data.priority} onChange={(event) => form.setData('priority', event.target.value)} error={form.errors.priority} inputMode="numeric" />
</div>
<ToggleField label="Announcement is active" checked={Boolean(form.data.is_active)} onChange={(event) => form.setData('is_active', event.target.checked)} help="Inactive announcements never surface even when published." />
</Section>
<Section title="Schedule" description="Keep launch cards time-bound and predictable.">
<div className="grid gap-4 md:grid-cols-2">
<DateTimeField label="Starts at" value={form.data.starts_at} onChange={(nextValue) => form.setData('starts_at', nextValue)} error={form.errors.starts_at} />
<DateTimeField label="Ends at" value={form.data.ends_at} onChange={(nextValue) => form.setData('ends_at', nextValue)} error={form.errors.ends_at} />
</div>
</Section>
</>
) : null}
{activeTab === 'content' ? (
<>
<Section title="Content" description="Only sanitized HTML is stored and rendered on the homepage.">
<div className="grid gap-3 text-sm text-slate-200">
<span>Announcement message</span>
<HomepageAnnouncementEditor
content={form.data.content_html || ''}
onChange={(nextValue) => form.setData('content_html', nextValue)}
placeholder="Write the launch message with headings, lists, links, quotes, and highlighted copy."
error={form.errors.content_html}
minHeight={14}
/>
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
Supported formatting matches the homepage sanitizer: paragraphs, bold, italic, links, lists, H2, H3, and blockquotes.
</div>
</div>
</Section>
<Section title="Links" description="Use entity targets when you know the id, or provide a fallback URL for a stable manual route.">
<LinkFields title="Primary CTA" prefix="primary" form={form} options={options} />
<LinkFields title="Secondary CTA" prefix="secondary" form={form} options={options} />
</Section>
</>
) : null}
{activeTab === 'design' ? (
<Section title="Design" description="Choose a launch-ready gradient, optional background image, and glass intensity.">
<div className="grid gap-4 md:grid-cols-2">
<SelectField label="Gradient preset" value={form.data.gradient_preset} onChange={(nextValue) => form.setData('gradient_preset', nextValue)} options={options.gradients} error={form.errors.gradient_preset} />
<SelectField label="Theme preset" value={form.data.theme_preset} onChange={(nextValue) => form.setData('theme_preset', nextValue)} options={options.themes} error={form.errors.theme_preset} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<TextField label="Overlay opacity" value={form.data.overlay_opacity} onChange={(event) => form.setData('overlay_opacity', event.target.value)} error={form.errors.overlay_opacity} inputMode="numeric" />
<div className="hidden md:block" />
</div>
<TextField label="Background image path or URL" value={form.data.background_image} onChange={(event) => form.setData('background_image', event.target.value)} error={form.errors.background_image} placeholder="/storage/homepage-announcements/... or https://..." />
<BackgroundImageDropzone
previewUrl={backgroundPreviewUrl}
storedValue={form.data.background_image}
selectedFileName={form.data.background_image_file?.name || ''}
error={backgroundImageError || form.errors.background_image_file}
onSelect={applyBackgroundFile}
/>
<ToggleField label="Remove stored background image" checked={Boolean(form.data.remove_background_image)} onChange={(event) => {
form.setData('remove_background_image', event.target.checked)
if (event.target.checked) {
setBackgroundImageError('')
form.setData('background_image_file', null)
if (backgroundPreviewUrl?.startsWith?.('blob:')) {
URL.revokeObjectURL(backgroundPreviewUrl)
}
setBackgroundPreviewUrl('')
}
}} help="Turn this on to clear the saved background image on the next save." />
</Section>
) : null}
{activeTab === 'behavior' ? (
<Section title="Behavior" description="Dismiss controls let you force a fresh surface when the message materially changes.">
<div className="grid gap-4 md:grid-cols-2">
<TextField label="Dismiss version" value={form.data.dismiss_version} onChange={(event) => form.setData('dismiss_version', event.target.value)} error={form.errors.dismiss_version} inputMode="numeric" />
<SelectField label="Placement" value={form.data.placement} onChange={(nextValue) => form.setData('placement', nextValue)} options={options.placements} error={form.errors.placement} />
</div>
<ToggleField label="Users can dismiss this card" checked={Boolean(form.data.is_dismissible)} onChange={(event) => form.setData('is_dismissible', event.target.checked)} help="When disabled, the card remains visible and no restore pill is shown." />
</Section>
) : null}
</div>
<aside className="space-y-6 xl:sticky xl:top-[7.5rem] xl:self-start">
<div>
<Section title="Preview" description="Refresh the preview to render the sanitized content and resolved CTA payload exactly as the homepage card sees it.">
<div className="-mx-6 -mt-6 mb-5 border-b border-white/10 bg-slate-950/92 px-6 py-4 backdrop-blur-xl">
<div className="flex flex-wrap gap-3">
<button type="button" onClick={runPreview} disabled={previewBusy} className="rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18 disabled:opacity-60">{previewBusy ? 'Refreshing preview…' : 'Refresh preview'}</button>
<button type="button" onClick={() => submit()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Save changes</button>
</div>
{previewError ? <p className="mt-3 text-sm text-rose-300">{previewError}</p> : null}
</div>
<div className="overflow-hidden rounded-[30px] border border-white/10 bg-black/20 py-2">
<HomepageAnnouncement announcement={previewWithLocalImage} mode="preview" />
</div>
</Section>
</div>
</aside>
</div>
</AdminLayout>
)
}

View File

@@ -0,0 +1,110 @@
import React from 'react'
import { Head, Link, router, usePage } from '@inertiajs/react'
import AdminLayout from '../../../Layouts/AdminLayout'
import HomepageAnnouncement from '../../../components/homepage/HomepageAnnouncement'
function formatDateRange(startsAt, endsAt) {
const formatter = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
const start = startsAt ? formatter.format(new Date(startsAt)) : 'Now'
const end = endsAt ? formatter.format(new Date(endsAt)) : 'Open ended'
return `${start}${end}`
}
function StatusBadge({ status, active }) {
const tone = status === 'published'
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
: status === 'archived'
? 'border-amber-300/20 bg-amber-300/10 text-amber-100'
: 'border-slate-300/15 bg-slate-300/10 text-slate-200'
return (
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tone}`}>
<span className={`h-2 w-2 rounded-full ${active ? 'bg-emerald-300' : 'bg-slate-500'}`} />
{status}
</span>
)
}
export default function HomepageAnnouncementsIndex({ announcements, createUrl }) {
const { props } = usePage()
const flash = props.flash ?? {}
return (
<AdminLayout title="Homepage Announcements" subtitle="Schedule launch cards, homepage notices, and editorial announcements below the featured artwork hero.">
<Head title="Admin · Homepage Announcements" />
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
{flash.error ? <div className="mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
<div className="mb-6 flex items-center justify-between gap-4">
<div className="max-w-2xl text-sm leading-6 text-slate-400">
Only the highest-priority published announcement that is active and inside its visibility window appears on the homepage.
</div>
<Link href={createUrl} className="rounded-full border border-sky-300/20 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18">
Create announcement
</Link>
</div>
<div className="space-y-6">
{(announcements?.data || []).length === 0 ? (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-slate-400">
No homepage announcements exist yet.
</div>
) : (announcements.data.map((announcement) => (
<article key={announcement.id} className="overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03]">
<div className="grid gap-6 border-b border-white/8 px-6 py-6 lg:grid-cols-[minmax(0,1.2fr)_auto] lg:items-start">
<div>
<div className="flex flex-wrap items-center gap-3">
<StatusBadge status={announcement.status} active={announcement.is_active} />
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">{announcement.type}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">Priority {announcement.priority}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">Dismiss v{announcement.dismiss_version}</span>
</div>
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{announcement.title}</h2>
<p className="mt-2 text-sm text-slate-400">{formatDateRange(announcement.starts_at, announcement.ends_at)}</p>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{announcement.placement.replaceAll('_', ' ')}</p>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
<Link href={announcement.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Edit</Link>
<button
type="button"
onClick={() => {
if (!window.confirm('Delete this homepage announcement?')) return
router.delete(announcement.destroy_url, { preserveScroll: true })
}}
className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:bg-rose-300/16"
>
Delete
</button>
</div>
</div>
<div className="bg-black/10 py-2">
<HomepageAnnouncement announcement={announcement.preview} mode="preview" />
</div>
</article>
))) }
</div>
{announcements?.last_page > 1 ? (
<div className="mt-8 flex items-center justify-between gap-4">
<p className="text-xs text-slate-500">Showing {announcements.from}{announcements.to} of {announcements.total} announcements</p>
<div className="flex gap-2">
{announcements.links.map((link, index) => (
link.url ? (
<button
key={`${link.label}-${index}`}
type="button"
onClick={() => router.get(link.url, {}, { preserveScroll: true })}
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-sky-300/15 text-sky-100' : 'bg-white/[0.04] text-slate-400 hover:bg-white/[0.08] hover:text-white'}`}
dangerouslySetInnerHTML={{ __html: link.label }}
/>
) : <span key={`${link.label}-${index}`} className="rounded-lg px-3 py-1.5 text-xs text-slate-600" dangerouslySetInnerHTML={{ __html: link.label }} />
))}
</div>
</div>
) : null}
</AdminLayout>
)
}

View File

@@ -0,0 +1,241 @@
import React from 'react'
export const HOMEPAGE_ANNOUNCEMENT_STORAGE_KEY = 'skinbase:hidden_homepage_announcements'
const PRESETS = {
nova_aurora: {
shell: 'border-cyan-300/15 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.26),transparent_30%),radial-gradient(circle_at_80%_20%,rgba(168,85,247,0.22),transparent_28%),linear-gradient(135deg,rgba(6,12,24,0.96),rgba(10,17,34,0.9))] text-white',
glow: 'from-cyan-400/25 via-fuchsia-400/10 to-transparent',
badge: 'border-cyan-200/20 bg-cyan-300/10 text-cyan-100',
primary: 'border-cyan-300/30 bg-cyan-300/15 text-cyan-50 hover:bg-cyan-300/22',
secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]',
prose: 'prose-invert prose-a:text-cyan-200 prose-strong:text-white',
},
deep_space: {
shell: 'border-indigo-300/15 bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.18),transparent_34%),linear-gradient(145deg,rgba(5,10,20,0.98),rgba(11,18,36,0.94))] text-white',
glow: 'from-indigo-400/20 via-sky-400/12 to-transparent',
badge: 'border-indigo-200/20 bg-indigo-300/10 text-indigo-100',
primary: 'border-indigo-300/30 bg-indigo-300/15 text-indigo-50 hover:bg-indigo-300/22',
secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]',
prose: 'prose-invert prose-a:text-indigo-200 prose-strong:text-white',
},
sunrise: {
shell: 'border-amber-300/20 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.28),transparent_28%),linear-gradient(145deg,rgba(44,15,18,0.95),rgba(17,9,24,0.96))] text-white',
glow: 'from-amber-300/25 via-rose-300/10 to-transparent',
badge: 'border-amber-100/20 bg-amber-300/15 text-amber-50',
primary: 'border-amber-300/35 bg-amber-300/18 text-amber-50 hover:bg-amber-300/24',
secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]',
prose: 'prose-invert prose-a:text-amber-100 prose-strong:text-white',
},
ocean_glow: {
shell: 'border-sky-300/15 bg-[radial-gradient(circle_at_top_right,rgba(56,189,248,0.24),transparent_28%),linear-gradient(135deg,rgba(3,20,38,0.98),rgba(8,27,45,0.92))] text-white',
glow: 'from-sky-400/24 via-emerald-300/10 to-transparent',
badge: 'border-sky-200/20 bg-sky-300/10 text-sky-100',
primary: 'border-sky-300/30 bg-sky-300/15 text-sky-50 hover:bg-sky-300/22',
secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]',
prose: 'prose-invert prose-a:text-sky-200 prose-strong:text-white',
},
spring_vibes: {
shell: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(74,222,128,0.2),transparent_28%),linear-gradient(145deg,rgba(8,24,22,0.98),rgba(14,28,38,0.92))] text-white',
glow: 'from-emerald-300/24 via-lime-200/8 to-transparent',
badge: 'border-emerald-200/20 bg-emerald-300/10 text-emerald-100',
primary: 'border-emerald-300/30 bg-emerald-300/15 text-emerald-50 hover:bg-emerald-300/22',
secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]',
prose: 'prose-invert prose-a:text-emerald-200 prose-strong:text-white',
},
fantasy_realms: {
shell: 'border-fuchsia-300/18 bg-[radial-gradient(circle_at_15%_10%,rgba(232,121,249,0.2),transparent_26%),linear-gradient(145deg,rgba(23,8,35,0.97),rgba(12,16,36,0.93))] text-white',
glow: 'from-fuchsia-300/24 via-violet-300/10 to-transparent',
badge: 'border-fuchsia-200/20 bg-fuchsia-300/10 text-fuchsia-100',
primary: 'border-fuchsia-300/30 bg-fuchsia-300/15 text-fuchsia-50 hover:bg-fuchsia-300/22',
secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]',
prose: 'prose-invert prose-a:text-fuchsia-200 prose-strong:text-white',
},
minimal_light: {
shell: 'border-slate-300/35 bg-[linear-gradient(140deg,rgba(255,255,255,0.96),rgba(240,247,255,0.92))] text-slate-900',
glow: 'from-sky-200/50 via-transparent to-transparent',
badge: 'border-slate-300/60 bg-white/70 text-slate-700',
primary: 'border-slate-900/10 bg-slate-900 text-white hover:bg-slate-800',
secondary: 'border-slate-300/60 bg-white text-slate-700 hover:bg-slate-50',
prose: 'prose prose-slate prose-a:text-sky-700 prose-strong:text-slate-900',
},
dark_glass: {
shell: 'border-white/12 bg-[linear-gradient(145deg,rgba(12,16,24,0.82),rgba(6,10,18,0.76))] text-white backdrop-blur-xl',
glow: 'from-white/10 via-white/0 to-transparent',
badge: 'border-white/12 bg-white/[0.06] text-white/90',
primary: 'border-white/18 bg-white/[0.09] text-white hover:bg-white/[0.14]',
secondary: 'border-white/12 bg-black/20 text-white/80 hover:bg-white/[0.08]',
prose: 'prose-invert prose-a:text-slate-200 prose-strong:text-white',
},
}
function cx(...parts) {
return parts.filter(Boolean).join(' ')
}
function readHiddenAnnouncements() {
if (typeof window === 'undefined') return {}
try {
const raw = window.localStorage.getItem(HOMEPAGE_ANNOUNCEMENT_STORAGE_KEY)
const parsed = raw ? JSON.parse(raw) : {}
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function writeHiddenAnnouncements(payload) {
if (typeof window === 'undefined') return
window.localStorage.setItem(HOMEPAGE_ANNOUNCEMENT_STORAGE_KEY, JSON.stringify(payload))
}
export default function HomepageAnnouncement({ announcement, mode = 'live' }) {
const [hidden, setHidden] = React.useState(false)
const isLiveMode = mode === 'live'
const isPreviewMode = mode === 'preview'
React.useEffect(() => {
if (!isLiveMode || !announcement?.id) {
setHidden(false)
return
}
const hiddenAnnouncements = readHiddenAnnouncements()
setHidden(Number(hiddenAnnouncements[String(announcement.id)] || 0) === Number(announcement.dismiss_version || 1))
}, [announcement, isLiveMode])
if (!announcement) {
return null
}
const preset = PRESETS[announcement.gradient_preset] || PRESETS.nova_aurora
const overlayOpacity = Math.max(0, Math.min(100, Number(announcement.overlay_opacity ?? 55)))
const dismiss = () => {
if (!isLiveMode || !announcement?.id) return
const next = {
...readHiddenAnnouncements(),
[String(announcement.id)]: Number(announcement.dismiss_version || 1),
}
writeHiddenAnnouncements(next)
setHidden(true)
}
const restore = () => {
if (!isLiveMode || !announcement?.id) return
const next = readHiddenAnnouncements()
delete next[String(announcement.id)]
writeHiddenAnnouncements(next)
setHidden(false)
}
if (hidden && isLiveMode) {
return (
<section className="px-4 pt-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<button
type="button"
onClick={restore}
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-sm font-semibold text-white/90 transition hover:border-white/20 hover:bg-white/[0.1]"
>
<span aria-hidden="true"></span>
<span>Show Skinbase Nova announcement</span>
</button>
</div>
</section>
)
}
return (
<section className="px-4 pt-8 sm:px-6 lg:px-8">
<div className="mx-auto max-w-7xl">
<div className={cx('relative overflow-hidden rounded-[2rem] border shadow-[0_28px_90px_rgba(0,0,0,0.35)]', preset.shell)}>
{announcement.background_image_url ? (
<div className="absolute inset-0">
<img src={announcement.background_image_url} alt="" className="h-full w-full object-cover" />
<div className="absolute inset-0 bg-slate-950" style={{ opacity: overlayOpacity / 100 }} />
</div>
) : null}
<div className={cx('pointer-events-none absolute inset-0 bg-gradient-to-br', preset.glow)} />
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),transparent_22%,rgba(2,6,23,0.15)_100%)]" />
{announcement.is_dismissible && isLiveMode ? (
<button
type="button"
onClick={dismiss}
className="absolute right-5 top-5 z-10 inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/70 transition hover:border-white/20 hover:bg-black/45 hover:text-white sm:right-6 sm:top-6 lg:right-8 lg:top-8"
aria-label="Dismiss homepage announcement"
>
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3.5 3.5 12.5 12.5" />
<path d="M12.5 3.5 3.5 12.5" />
</svg>
Dismiss
</button>
) : null}
<div className={cx(
'relative px-6 py-7 sm:px-8 lg:px-10 lg:py-10',
isPreviewMode
? 'flex min-h-[42rem] flex-col gap-8'
: 'grid gap-8 lg:grid-cols-[minmax(0,1.25fr)_auto] lg:items-end'
)}>
<div className={cx(isPreviewMode ? 'w-full flex-1' : 'max-w-3xl')}>
{announcement.badge_text ? (
<div className={cx('inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em]', preset.badge)}>
{announcement.badge_text}
</div>
) : null}
<h2 className="mt-4 text-3xl font-semibold tracking-[-0.05em] sm:text-4xl lg:text-[3.15rem]">
{announcement.title}
</h2>
{announcement.subtitle ? (
<p className={cx('mt-3 text-base leading-7 text-current/80 sm:text-lg', isPreviewMode ? 'w-full max-w-none' : 'max-w-2xl')}>
{announcement.subtitle}
</p>
) : null}
{announcement.content_html ? (
<div
className={cx(
'mt-5 text-sm leading-7 sm:text-base',
'[&_p]:my-0 [&_p+p]:mt-6 [&_ul]:my-5 [&_ol]:my-5 [&_li+li]:mt-1.5 [&_blockquote]:my-5 [&_h2]:mb-3 [&_h2]:mt-7 [&_h3]:mb-2 [&_h3]:mt-6',
preset.prose,
isPreviewMode ? 'w-full max-w-none' : 'max-w-2xl'
)}
dangerouslySetInnerHTML={{ __html: announcement.content_html }}
/>
) : null}
</div>
<div className={cx(
isPreviewMode
? 'mt-auto flex w-full flex-col gap-4 border-t border-white/10 pt-5'
: 'flex flex-col items-start gap-3 lg:items-end'
)}>
<div className={cx('flex flex-wrap gap-3', isPreviewMode ? 'w-full' : 'lg:justify-end')}>
{announcement.primary_link ? (
<a href={announcement.primary_link.url} className={cx('inline-flex items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition', isPreviewMode ? 'min-w-[11rem]' : '', preset.primary)}>
{announcement.primary_link.label}
</a>
) : null}
{announcement.secondary_link ? (
<a href={announcement.secondary_link.url} className={cx('inline-flex items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition', isPreviewMode ? 'min-w-[11rem]' : '', preset.secondary)}>
{announcement.secondary_link.label}
</a>
) : null}
</div>
</div>
</div>
</div>
</div>
</section>
)
}

View File

@@ -0,0 +1,172 @@
import React, { useCallback, useEffect } from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
import Placeholder from '@tiptap/extension-placeholder'
function ToolbarButton({ onClick, active, disabled, title, children }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
className={[
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
active
? 'bg-sky-600/25 text-sky-300'
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
disabled ? 'pointer-events-none opacity-30' : '',
].join(' ')}
>
{children}
</button>
)
}
function Divider() {
return <div className="mx-1 h-5 w-px bg-white/10" />
}
function Toolbar({ editor }) {
if (!editor) return null
const addLink = useCallback(() => {
const previous = editor.getAttributes('link').href
const url = window.prompt('URL', previous ?? 'https://')
if (url === null) return
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
}, [editor])
return (
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z" /></svg>
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4" /><line x1="14" y1="20" x2="5" y2="20" /><line x1="15" y1="4" x2="9" y2="20" /></svg>
</ToolbarButton>
<Divider />
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">
<span className="text-xs font-bold">H2</span>
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">
<span className="text-xs font-bold">H3</span>
</ToolbarButton>
<Divider />
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6" /><line x1="9" y1="12" x2="20" y2="12" /><line x1="9" y1="18" x2="20" y2="18" /><circle cx="4.5" cy="6" r="1" fill="currentColor" /><circle cx="4.5" cy="12" r="1" fill="currentColor" /><circle cx="4.5" cy="18" r="1" fill="currentColor" /></svg>
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6" /><line x1="10" y1="12" x2="21" y2="12" /><line x1="10" y1="18" x2="21" y2="18" /><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
</ToolbarButton>
<Divider />
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote">
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z" /></svg>
</ToolbarButton>
<ToolbarButton onClick={addLink} active={editor.isActive('link')} title="Link">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></svg>
</ToolbarButton>
<div className="ml-auto flex items-center gap-0.5">
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 102.13-9.36L1 10" /></svg>
</ToolbarButton>
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10" /></svg>
</ToolbarButton>
</div>
</div>
)
}
export default function HomepageAnnouncementEditor({
content = '',
onChange,
placeholder = 'Write the announcement message…',
error,
minHeight = 14,
}) {
const editor = useEditor({
extensions: [
StarterKit.configure({
link: false,
heading: { levels: [2, 3] },
code: false,
codeBlock: false,
horizontalRule: false,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
class: 'text-sky-300 underline hover:text-sky-200',
rel: 'noopener noreferrer nofollow',
},
}),
Placeholder.configure({ placeholder }),
],
immediatelyRender: false,
content,
editorProps: {
attributes: {
class: [
'prose prose-invert prose-sm max-w-none',
'focus:outline-none',
'px-4 py-3',
'prose-headings:text-white prose-headings:font-bold',
'prose-p:text-zinc-200 prose-p:leading-relaxed',
'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200',
'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400',
'prose-ul:text-zinc-200 prose-ol:text-zinc-200',
].join(' '),
style: `min-height: ${minHeight}rem`,
},
},
onUpdate: ({ editor: currentEditor }) => {
onChange?.(currentEditor.getHTML())
},
})
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content || '', false)
onChange?.(content || '')
}
}, [content, editor, onChange])
return (
<div className="flex flex-col gap-1.5">
<div
className={[
'overflow-hidden rounded-[28px] border bg-black/20 transition-colors',
error
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
].join(' ')}
>
<Toolbar editor={editor} />
<EditorContent editor={editor} />
</div>
{error ? <p role="alert" className="text-xs text-red-400">{error}</p> : null}
</div>
)
}