352 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|