Initial commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import { localizedPath } from '../lib/i18n';
|
||||
import { VENUE } from '../lib/venue';
|
||||
import type { Locale, Dict } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
const { locale, dict: t } = Astro.props;
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer__grid">
|
||||
<div>
|
||||
<div class="footer__title">Devodytyji</div>
|
||||
<p>{t.footer.tagline}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="footer__title">{t.footer.navTitle}</div>
|
||||
<nav>
|
||||
<p><a href={localizedPath(locale, '/')}>{t.nav.home}</a></p>
|
||||
<p><a href={localizedPath(locale, '/events')}>{t.nav.events}</a></p>
|
||||
<p><a href={localizedPath(locale, '/about')}>{t.nav.about}</a></p>
|
||||
<p><a href={localizedPath(locale, '/map')}>{t.nav.map}</a></p>
|
||||
<p><a href={localizedPath(locale, '/contacts')}>{t.nav.contacts}</a></p>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<div class="footer__title">{t.footer.contactTitle}</div>
|
||||
<p>{VENUE.name}</p>
|
||||
<p><a href={localizedPath(locale, '/map')}>{locale === 'en' ? '12 Vifleemska St, Kyiv' : 'вул. Вифлеємська, 12, Київ'}</a></p>
|
||||
<p>
|
||||
<a href={`mailto:${VENUE.email}`}>{VENUE.email}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__bottom">
|
||||
© {new Date().getFullYear()} Devodytyji. {t.footer.rights}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
import { localizedPath } from '../lib/i18n';
|
||||
import type { Locale, Dict } from '../lib/i18n';
|
||||
import LangSwitcher from './islands/LangSwitcher.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
pagePath?: string;
|
||||
user?: App.Locals['user'];
|
||||
}
|
||||
|
||||
const { locale, dict: t, pagePath = '/', user } = Astro.props;
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: t.nav.home },
|
||||
{ href: '/events', label: t.nav.events },
|
||||
{ href: '/about', label: t.nav.about },
|
||||
{ href: '/map', label: t.nav.map },
|
||||
{ href: '/contacts', label: t.nav.contacts },
|
||||
];
|
||||
|
||||
const current = (path: string) =>
|
||||
path === '/' ? pagePath === '/' : pagePath.startsWith(path);
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
<div class="container header__inner">
|
||||
<a class="logo" href={localizedPath(locale, '/')}>
|
||||
<span class="logo__mark">D</span>
|
||||
<span>Devodytyji</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav" aria-label="main">
|
||||
{links.map((link) => (
|
||||
<a class="nav__link" href={localizedPath(locale, link.href)} aria-current={current(link.href) ? 'page' : undefined}>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<span class="nav__sep" aria-hidden="true"></span>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{user.isAdmin && (
|
||||
<a class="nav__link" href="/admin" aria-current={pagePath.startsWith('/admin') ? 'page' : undefined}>
|
||||
{t.nav.admin}
|
||||
</a>
|
||||
)}
|
||||
<form action="/api/auth/logout" method="post">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">
|
||||
{t.nav.logout}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a class="nav__link" href={localizedPath(locale, '/login')}>
|
||||
{t.nav.login}
|
||||
</a>
|
||||
<a class="btn btn--primary btn--sm" href={localizedPath(locale, '/register')}>
|
||||
{t.nav.register}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LangSwitcher locale={locale} pagePath={pagePath} labels={{ ua: 'UA', en: 'EN' }} />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { Dict, Locale } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
mode: 'login' | 'register';
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export default function AuthForm({ mode, locale, dict: t, redirectTo }: Props) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [oauthBusy, setOauthBusy] = useState<string | null>(null);
|
||||
|
||||
const target = redirectTo || `/${locale}/`;
|
||||
|
||||
async function handleOAuth(provider: 'google' | 'github') {
|
||||
setOauthBusy(provider);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/auth/login?provider=${provider}&next=${encodeURIComponent(target)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.url) {
|
||||
setError(t.auth.errorGeneric);
|
||||
setOauthBusy(null);
|
||||
return;
|
||||
}
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
setError(t.auth.errorGeneric);
|
||||
setOauthBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError(t.auth.errorGeneric);
|
||||
return;
|
||||
}
|
||||
if (mode === 'register' && password.length < 6) {
|
||||
setError(t.auth.passwordLength);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/auth/${mode}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, fullName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
data?.error === 'invalid_credentials'
|
||||
? t.auth.errorInvalid
|
||||
: data?.error === 'email_exists'
|
||||
? t.auth.errorEmailExists
|
||||
: t.auth.errorGeneric,
|
||||
);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
window.location.href = target;
|
||||
} catch {
|
||||
setError(t.auth.errorGeneric);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonLabel =
|
||||
mode === 'login'
|
||||
? busy
|
||||
? t.auth.loggingIn
|
||||
: t.auth.loginButton
|
||||
: busy
|
||||
? t.auth.registering
|
||||
: t.auth.registerButton;
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="form-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'register' && (
|
||||
<div className="form-field">
|
||||
<label htmlFor="full-name">{t.auth.fullName}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="full-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="email">{t.auth.email}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="password">{t.auth.password}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary btn--block" type="submit" disabled={busy}>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
|
||||
<div className="oauth-divider">
|
||||
<span>{t.auth.orContinueWith}</span>
|
||||
</div>
|
||||
|
||||
<div className="oauth-buttons">
|
||||
<button
|
||||
className="btn btn--outline"
|
||||
type="button"
|
||||
disabled={oauthBusy !== null}
|
||||
onClick={() => handleOAuth('google')}
|
||||
>
|
||||
{oauthBusy === 'google' ? '…' : t.auth.oauthGoogle}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--outline"
|
||||
type="button"
|
||||
disabled={oauthBusy !== null}
|
||||
onClick={() => handleOAuth('github')}
|
||||
>
|
||||
{oauthBusy === 'github' ? '…' : t.auth.oauthGitHub}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
export default function ContactForm({ dict: t }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (honeypot) return;
|
||||
|
||||
setStatus('sending');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, message, honeypot }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setStatus('success');
|
||||
setName('');
|
||||
setEmail('');
|
||||
setMessage('');
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="form-success" role="status">
|
||||
<strong>{t.contacts.successTitle}</strong>
|
||||
<p style={{ marginBottom: 0 }}>{t.contacts.successText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
{status === 'error' && (
|
||||
<div className="form-error" role="alert">
|
||||
<strong>{t.contacts.errorTitle}</strong>
|
||||
<p style={{ marginBottom: 0 }}>{t.contacts.errorText}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="contact-name">{t.contacts.name}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="contact-name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder={t.contacts.namePlaceholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="contact-email">{t.contacts.email}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="contact-email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t.contacts.emailPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="contact-message">{t.contacts.message}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="contact-message"
|
||||
required
|
||||
placeholder={t.contacts.messagePlaceholder}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="honeypot" aria-hidden="true">
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary" type="submit" disabled={status === 'sending'}>
|
||||
{status === 'sending' ? t.contacts.sending : t.contacts.send}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
currentFilter: string;
|
||||
currentQuery: string;
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'upcoming' | 'past';
|
||||
|
||||
function navigate(params: Record<string, string | null>) {
|
||||
const url = new URL(window.location.href);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
export default function EventFilters({ dict: t, currentFilter, currentQuery }: Props) {
|
||||
const [query, setQuery] = useState(currentQuery);
|
||||
|
||||
const tabs: { key: Filter; label: string }[] = [
|
||||
{ key: 'all', label: t.events.allTab },
|
||||
{ key: 'upcoming', label: t.events.upcomingTab },
|
||||
{ key: 'past', label: t.events.pastTab },
|
||||
];
|
||||
|
||||
function submitSearch(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
navigate({ q: query.trim() || null });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-bar">
|
||||
<div className="tabs" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={currentFilter === tab.key}
|
||||
className={currentFilter === tab.key ? 'is-active' : ''}
|
||||
onClick={() => navigate({ filter: tab.key === 'all' ? null : tab.key })}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="search-input" onSubmit={submitSearch} role="search">
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder={t.events.searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import { LOCALES, localizedPath } from '../../lib/i18n';
|
||||
import type { Locale } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pagePath?: string;
|
||||
labels: Record<Locale, string>;
|
||||
}
|
||||
|
||||
const { locale, pagePath = '/', labels } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
{LOCALES.map((l) => (
|
||||
<a href={localizedPath(l, pagePath)} aria-current={l === locale ? 'true' : undefined}>
|
||||
{labels[l]}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface Props {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
address: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const pinIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="44" viewBox="0 0 36 44" aria-hidden="true">
|
||||
<path d="M18 0C8 0 0 8 0 18c0 13 18 26 18 26s18-13 18-26C36 8 28 0 18 0z" fill="#d96c3f"/>
|
||||
<circle cx="18" cy="18" r="7" fill="#ffffff"/>
|
||||
</svg>`,
|
||||
iconSize: [36, 44],
|
||||
iconAnchor: [18, 44],
|
||||
popupAnchor: [0, -40],
|
||||
});
|
||||
|
||||
export default function MapView({ lat, lng, name, address, hint }: Props) {
|
||||
return (
|
||||
<MapContainer
|
||||
center={[lat, lng]}
|
||||
zoom={15}
|
||||
scrollWheelZoom={false}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
aria-label={hint}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={[lat, lng]} icon={pinIcon}>
|
||||
<Popup>
|
||||
<strong>{name}</strong>
|
||||
<br />
|
||||
{address}
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
export default function ReviewForm({ dict: t, eventId }: Props) {
|
||||
const [rating, setRating] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (rating < 1) return;
|
||||
setStatus('sending');
|
||||
|
||||
try {
|
||||
const url = eventId ? '/api/reviews' : '/api/venue-reviews';
|
||||
const body = eventId ? { event_id: eventId, rating, comment } : { rating, comment };
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
setStatus('success');
|
||||
setComment('');
|
||||
setRating(0);
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
{status === 'success' && (
|
||||
<div className="form-success" role="status">
|
||||
{t.reviewForm.success}
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="form-error" role="alert">
|
||||
{t.reviewForm.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="review-rating">{t.reviewForm.ratingLabel}</label>
|
||||
<div className="star-rating" id="review-rating" role="radiogroup" aria-label={t.reviewForm.ratingLabel}>
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={rating === value}
|
||||
className={value <= rating ? 'is-selected' : ''}
|
||||
aria-label={`${value}`}
|
||||
onClick={() => setRating(value)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted" style={{ fontSize: '0.85rem' }}>
|
||||
{t.reviewForm.starsHint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="review-comment">{t.reviewForm.commentLabel}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="review-comment"
|
||||
placeholder={t.reviewForm.commentPlaceholder}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary" type="submit" disabled={status === 'sending' || rating < 1}>
|
||||
{status === 'sending' ? t.reviewForm.submitting : t.reviewForm.submit}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ReviewForm from './ReviewForm';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
export default function VenueReviewForm({ dict }: Props) {
|
||||
return <ReviewForm dict={dict} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import { localizedPath } from '../../lib/i18n';
|
||||
import { titleFor } from '../../lib/db';
|
||||
import { formatShortDate, formatTime } from '../../lib/format';
|
||||
import type { Locale, Dict } from '../../lib/i18n';
|
||||
import type { EventRow } from '../../lib/db';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
interface Props {
|
||||
event: EventRow;
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
const { event, locale, dict: t } = Astro.props;
|
||||
const href = localizedPath(locale, `/events/${event.slug}`);
|
||||
const isPast = new Date(event.starts_at).getTime() < Date.now();
|
||||
const title = titleFor(locale, event);
|
||||
const initial = (title || event.slug).trim().charAt(0).toUpperCase();
|
||||
---
|
||||
|
||||
<a class="event-card" href={href}>
|
||||
<div class="event-card__media">
|
||||
{event.cover_image_url ? (
|
||||
<img src={event.cover_image_url} alt={title} loading="lazy" />
|
||||
) : (
|
||||
<div class="event-card__media-placeholder" aria-hidden="true">
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<span class="event-card__date-badge">{formatShortDate(locale, event.starts_at)}</span>
|
||||
</div>
|
||||
<div class="event-card__body">
|
||||
<h3 class="event-card__title">{title || event.slug}</h3>
|
||||
<div class="event-card__meta">
|
||||
<span><Icon name="calendar" size={14} /> {formatShortDate(locale, event.starts_at)}</span>
|
||||
<span><Icon name="clock" size={14} /> {formatTime(locale, event.starts_at)}</span>
|
||||
{event.venue_name && (
|
||||
<span>
|
||||
<Icon name="pin" size={14} /> {event.venue_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="event-card__foot">
|
||||
{isPast ? (
|
||||
<span class="badge badge--past">{t.events.pastLabel}</span>
|
||||
) : (
|
||||
<span class="badge badge--published">{t.events.upcomingLabel}</span>
|
||||
)}
|
||||
<span class="text-muted">{t.common.view} →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
interface Props {
|
||||
name: 'calendar' | 'clock' | 'pin' | 'phone' | 'mail' | 'chat' | 'tent' | 'tree' | 'ticket';
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { name, size = 18, class: className } = Astro.props;
|
||||
|
||||
const paths: Record<Props['name'], string> = {
|
||||
calendar:
|
||||
'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
clock:
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
|
||||
pin:
|
||||
'<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>',
|
||||
phone:
|
||||
'<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z"/>',
|
||||
mail:
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/>',
|
||||
chat:
|
||||
'<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z"/>',
|
||||
tent:
|
||||
'<path d="M12 3 3 20h18L12 3Z"/><path d="M12 3v17"/>',
|
||||
tree:
|
||||
'<path d="M8 19a4 4 0 0 1-2.24-7.32A3.5 3.5 0 0 1 9 6.03V6a3 3 0 0 1 6 0v.03a3.5 3.5 0 0 1 3.24 5.65A4 4 0 0 1 16 19Z"/><path d="M12 19v3"/>',
|
||||
ticket:
|
||||
'<path d="M3 9V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4"/><circle cx="15" cy="12" r="1"/><path d="M3 15v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/>',
|
||||
};
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class={className}
|
||||
set:html={paths[name]}
|
||||
/>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import Stars from './Stars.astro';
|
||||
import { initials, formatRelative } from '../../lib/format';
|
||||
import type { Locale, Dict } from '../../lib/i18n';
|
||||
import type { ReviewRow } from '../../lib/db';
|
||||
|
||||
interface Props {
|
||||
review: ReviewRow;
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
deleteUrl?: string;
|
||||
}
|
||||
|
||||
const { review, locale, dict: t, deleteUrl } = Astro.props;
|
||||
const name = review.user?.full_name || 'Guest';
|
||||
---
|
||||
|
||||
<article class="review">
|
||||
<div class="review__head">
|
||||
<div>
|
||||
<span class="review__author">
|
||||
{review.user?.avatar_url ? (
|
||||
<img class="avatar" src={review.user.avatar_url} alt="" style="display:inline-block;margin-right:8px;vertical-align:middle;" />
|
||||
) : (
|
||||
<span class="avatar" style="display:inline-grid;margin-right:8px;vertical-align:middle;">{initials(name)}</span>
|
||||
)}
|
||||
{name}
|
||||
</span>
|
||||
<div class="review__date">{formatRelative(locale, review.created_at)}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<Stars rating={review.rating} label={`${review.rating} / 5`} />
|
||||
{deleteUrl && (
|
||||
<form
|
||||
method="post"
|
||||
action={deleteUrl}
|
||||
onsubmit={`return confirm(${JSON.stringify(t.reviewForm.deleteConfirm)})`}
|
||||
>
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{t.reviewForm.delete}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{review.comment && <p style="margin:0;">{review.comment}</p>}
|
||||
</article>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
interface Props {
|
||||
rating: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const { rating, label } = Astro.props;
|
||||
const clamped = Math.max(0, Math.min(5, Math.round(rating)));
|
||||
---
|
||||
|
||||
<span class="stars" role="img" aria-label={label ?? `${clamped} / 5`}>
|
||||
{Array.from({ length: 5 }, (_, i) => (i < clamped ? '★' : '☆')).join('')}
|
||||
</span>
|
||||
Reference in New Issue
Block a user