Initial commit

This commit is contained in:
ArtemBozhko
2026-08-14 08:51:55 +03:00
commit 957f1d4ba2
81 changed files with 19444 additions and 0 deletions
+44
View File
@@ -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>
+70
View File
@@ -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>
+167
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+288
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+20
View File
@@ -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>
+46
View File
@@ -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='&copy; <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>
);
}
+92
View File
@@ -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} />;
}
+53
View File
@@ -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>
+45
View File
@@ -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]}
/>
+47
View File
@@ -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>
+13
View File
@@ -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>