Initial commit
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { FormEvent, ChangeEvent } from 'react';
|
||||
import { createBrowserSupabase } from '../../lib/supabase';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
import type { EventRow } from '../../lib/db';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
event?: EventRow | null;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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="grid-2">
|
||||
<div className="form-field">
|
||||
<label htmlFor="desc-ua">{t.admin.eventDescUa}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="desc-ua"
|
||||
value={descriptionUa}
|
||||
onChange={(e) => setDescriptionUa(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="desc-en">{t.admin.eventDescEn}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="desc-en"
|
||||
value={descriptionEn}
|
||||
onChange={(e) => setDescriptionEn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user