Files
devodytyji/src/components/islands/EventEditor.tsx
T

352 lines
11 KiB
TypeScript

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<string> {
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 (
<MDXEditor
markdown={content}
onChange={onChange}
plugins={[
headingsPlugin(),
listsPlugin(),
linkPlugin(),
imagePlugin({ imageUploadHandler: imageUploadHandler }),
quotePlugin(),
thematicBreakPlugin(),
markdownShortcutPlugin(),
toolbarPlugin({
toolbarContents: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
<BlockTypeSelect />
<ListsToggle />
<InsertImage />
</>
),
}),
]}
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<string | null>(event?.cover_image_url ?? null);
const [uploading, setUploading] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
function handleTitleUa(value: string) {
setTitleUa(value);
if (!slugTouched) setSlug(slugify(value));
}
async function handleCoverUpload(e: ChangeEvent<HTMLInputElement>) {
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 (
<form className="form" onSubmit={handleSubmit}>
{error && (
<div className="form-error" role="alert">
{error}
</div>
)}
<div className="grid-2">
<div className="form-field">
<label htmlFor="title-ua">{t.admin.eventTitleUa} *</label>
<input
className="input"
id="title-ua"
required
value={titleUa}
onChange={(e) => handleTitleUa(e.target.value)}
/>
</div>
<div className="form-field">
<label htmlFor="title-en">{t.admin.eventTitleEn} *</label>
<input
className="input"
id="title-en"
required
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
/>
</div>
</div>
<div className="form-field">
<label htmlFor="desc-ua">{t.admin.eventDescUa}</label>
<ContentEditor content={descriptionUa} onChange={setDescriptionUa} />
</div>
<div className="form-field">
<label htmlFor="desc-en">{t.admin.eventDescEn}</label>
<ContentEditor content={descriptionEn} onChange={setDescriptionEn} />
</div>
<div className="grid-2">
<div className="form-field">
<label htmlFor="slug">{t.admin.slug} *</label>
<input
className="input"
id="slug"
required
value={slug}
onChange={(e) => {
setSlugTouched(true);
setSlug(e.target.value);
}}
/>
</div>
<div className="form-field">
<label htmlFor="venue-name">{t.admin.venueName}</label>
<input
className="input"
id="venue-name"
value={venueName}
onChange={(e) => setVenueName(e.target.value)}
/>
</div>
</div>
<div className="grid-2">
<div className="form-field">
<label htmlFor="starts-at">{t.admin.startsAt} *</label>
<input
className="input"
id="starts-at"
type="datetime-local"
required
value={startsAt}
onChange={(e) => setStartsAt(e.target.value)}
/>
</div>
<div className="form-field">
<label htmlFor="ends-at">{t.admin.endsAt} *</label>
<input
className="input"
id="ends-at"
type="datetime-local"
required
value={endsAt}
onChange={(e) => setEndsAt(e.target.value)}
/>
</div>
</div>
<div className="form-field">
<label htmlFor="status">{t.admin.status}</label>
<select
className="select"
id="status"
value={status}
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
>
<option value="draft">{t.common.draft}</option>
<option value="published">{t.common.published}</option>
</select>
</div>
<div className="form-field">
<span className="form-field__label">{t.admin.coverImage}</span>
{cover && (
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
<img
src={cover}
alt=""
style={{ width: 180, height: 102, objectFit: 'cover', borderRadius: 8, border: '1px solid var(--border)' }}
/>
<button type="button" className="btn btn--ghost btn--sm" onClick={handleRemoveCover}>
{t.common.cancel}
</button>
</div>
)}
{!cover && (
<div>
<input
ref={fileInput}
id="cover-file"
type="file"
accept="image/*"
onChange={handleCoverUpload}
className="input"
/>
{uploading && <span className="text-muted">{t.common.loading}</span>}
</div>
)}
</div>
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
<button className="btn btn--primary" type="submit" disabled={busy || uploading}>
{busy
? t.admin.saving
: event
? t.admin.save
: t.admin.create}
</button>
<a className="btn btn--ghost" href="/admin/events">
{t.common.cancel}
</a>
</div>
</form>
);
}