import { useRef, useState } from 'react'; import type { FormEvent, ChangeEvent } from 'react'; import { createBrowserSupabase } from '../../lib/supabase'; import { MDXEditor, headingsPlugin, listsPlugin, linkPlugin, imagePlugin, quotePlugin, thematicBreakPlugin, markdownShortcutPlugin, toolbarPlugin, UndoRedo, BoldItalicUnderlineToggles, ListsToggle, BlockTypeSelect, InsertImage, } from '@mdxeditor/editor'; import '@mdxeditor/editor/style.css'; import type { Dict } from '../../lib/i18n'; import type { EventRow } from '../../lib/db'; interface Props { dict: Dict; event?: EventRow | null; } interface ContentEditorProps { content: string; onChange: (content: string) => void; } function pad(n: number): string { return String(n).padStart(2, '0'); } function toLocalInput(iso?: string | null): string { if (!iso) return ''; const d = new Date(iso); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } function slugify(value: string): string { return value .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/['']/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80); } async function imageUploadHandler(file: File): Promise { try { const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'; const path = `content-images/${crypto.randomUUID()}.${ext}`; const supabase = createBrowserSupabase(); const { data, error: uploadError } = await supabase.storage .from('event-content-images') .upload(path, file, { upsert: false, contentType: file.type }); if (uploadError) throw uploadError; if (!data) throw new Error('No upload path'); const { data: publicUrl } = supabase.storage.from('event-content-images').getPublicUrl(data.path); return publicUrl.publicUrl; } catch { throw new Error('Image upload failed'); } } function ContentEditor({ content, onChange }: ContentEditorProps) { return ( ( <> ), }), ]} contentEditableClassName="prose prose-sm max-w-none textarea" /> ); } export default function EventEditor({ dict: t, event }: Props) { const [titleUa, setTitleUa] = useState(event?.title?.ua ?? ''); const [titleEn, setTitleEn] = useState(event?.title?.en ?? ''); const [descriptionUa, setDescriptionUa] = useState(event?.description?.ua ?? ''); const [descriptionEn, setDescriptionEn] = useState(event?.description?.en ?? ''); const [slug, setSlug] = useState(event?.slug ?? ''); const [slugTouched, setSlugTouched] = useState(Boolean(event)); const [startsAt, setStartsAt] = useState(toLocalInput(event?.starts_at)); const [endsAt, setEndsAt] = useState(toLocalInput(event?.ends_at)); const [venueName, setVenueName] = useState(event?.venue_name ?? ''); const [status, setStatus] = useState<'draft' | 'published'>(event?.status ?? 'draft'); const [cover, setCover] = useState(event?.cover_image_url ?? null); const [uploading, setUploading] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const fileInput = useRef(null); function handleTitleUa(value: string) { setTitleUa(value); if (!slugTouched) setSlug(slugify(value)); } async function handleCoverUpload(e: ChangeEvent) { const file = e.target.files?.[0]; if (!file) return; setUploading(true); setError(null); try { const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg'; const path = `covers/${crypto.randomUUID()}.${ext}`; const supabase = createBrowserSupabase(); const { data, error: uploadError } = await supabase.storage .from('event-covers') .upload(path, file, { upsert: false, contentType: file.type }); if (uploadError) throw uploadError; if (!data) throw new Error('No upload path'); const { data: publicUrl } = supabase.storage.from('event-covers').getPublicUrl(data.path); setCover(publicUrl.publicUrl); } catch { setError(t.common.error); } finally { setUploading(false); if (fileInput.current) fileInput.current.value = ''; } } function handleRemoveCover() { setCover(null); } async function handleSubmit(e: FormEvent) { e.preventDefault(); setError(null); if (!titleUa.trim() || !titleEn.trim() || !slug.trim() || !startsAt || !endsAt) { setError(t.common.required); return; } if (new Date(endsAt) <= new Date(startsAt)) { setError(t.common.error); return; } if (descriptionUa.trim().length > 20000 || descriptionEn.trim().length > 20000) { setError(t.common.error); return; } const payload = { title: { ua: titleUa.trim(), en: titleEn.trim() }, description: { ua: descriptionUa.trim(), en: descriptionEn.trim() }, slug: slug.trim(), starts_at: new Date(startsAt).toISOString(), ends_at: new Date(endsAt).toISOString(), venue_name: venueName.trim() || null, cover_image_url: cover, status, }; setBusy(true); try { const url = event ? `/api/admin/events/${event.id}` : '/api/admin/events'; const res = await fetch(url, { method: event ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); const data = await res.json(); if (!res.ok) { setError(data?.error || t.common.error); setBusy(false); return; } window.location.href = '/admin/events'; } catch { setError(t.common.error); setBusy(false); } } return (
{error && (
{error}
)}
handleTitleUa(e.target.value)} />
setTitleEn(e.target.value)} />
{ setSlugTouched(true); setSlug(e.target.value); }} />
setVenueName(e.target.value)} />
setStartsAt(e.target.value)} />
setEndsAt(e.target.value)} />
{t.admin.coverImage} {cover && (
)} {!cover && (
{uploading && {t.common.loading}}
)}
{t.common.cancel}
); }