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
+10
View File
@@ -0,0 +1,10 @@
# Public site URL (used for canonical/OG/OAuth callback)
PUBLIC_SITE_URL=https://your-site.netlify.app
# Supabase — Project Settings → API
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Used by Netlify build (https://app.netlify.com/sites/<site>/settings/deploys)
# NETLIFY_SITE_ID=your-site-id
# NETLIFY_AUTH_TOKEN=your-token
+27
View File
@@ -0,0 +1,27 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
# Local Netlify folder
.netlify
+4
View File
@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}
+22
View File
@@ -0,0 +1,22 @@
## Development
When starting the dev server, use background mode:
```
astro dev --background
```
Manage the background server with `astro dev stop`, `astro dev status`, and `astro dev logs`.
## Documentation
Full documentation: https://docs.astro.build
Consult these guides before working on related tasks:
- [Adding pages, dynamic routes, or middleware](https://docs.astro.build/en/guides/routing/)
- [Working with Astro components](https://docs.astro.build/en/basics/astro-components/)
- [Using React, Vue, Svelte, or other framework components](https://docs.astro.build/en/guides/framework-components/)
- [Adding or managing content](https://docs.astro.build/en/guides/content-collections/)
- [Adding styles or using Tailwind](https://docs.astro.build/en/guides/styling/)
- [Supporting multiple languages](https://docs.astro.build/en/guides/internationalization/)
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+57
View File
@@ -0,0 +1,57 @@
# Devodytyji — open-air cultural venue website
Website for an open-air cultural venue ("відкритий культурний майданчик") with events, venue reviews, event reviews, a venue map, and a contact inbox. Built with Astro 7 (SSR, Netlify adapter), React islands, Supabase, Leaflet, and a UA/EN i18n setup.
## Stack
- **Astro 7** — `output: 'server'`, `@astrojs/netlify` adapter, i18n `prefixDefaultLocale`
- **React islands** — auth form, contact form, review forms, map (`react-leaflet`), event filters/editor
- **Supabase** — Postgres, Auth, Storage (event covers), RLS, DB-triggered admin grant
- **npm** — Node 20+, sharp is pinned via `overrides` (`sharp: 0.35.3`) to fix a native-build issue on this machine
## Project structure
```text
src/
components/
islands/ # React + framework islands (AuthForm, MapView, EventEditor, ...)
ui/ # Stars, EventCard, ReviewCard
Header.astro, Footer.astro
layouts/ # BaseLayout (public, SEO/i18n), AdminLayout
lib/ # supabase clients, db helpers, i18n dictionaries, format, venue, request
middleware.ts # locale redirects, session refresh, admin guards
pages/
[lang]/ # public pages (ua/en)
admin/ # admin area (UA only, not locale-prefixed)
api/ # auth, reviews, contact, admin API routes
supabase/
migrations/0001_init.sql # schema, RLS, storage, helper functions
seed.sql # sample events + contact messages
```
## Local development
1. Install dependencies: `npm install`
2. Set up Supabase:
- Create a project and run `supabase/migrations/0001_init.sql` (and `supabase/seed.sql`) in the SQL editor.
- Grant yourself admin: `select promote_user_to_admin('you@example.com');`
- Enable Google/GitHub providers in **Auth → Providers**; add the OAuth callback URL `{PUBLIC_SITE_URL}/api/auth/callback`.
- Create an `event-covers` storage bucket (also created by the migration).
3. Configure env: `cp .env.example .env` and fill in `PUBLIC_SUPABASE_URL`, `PUBLIC_SUPABASE_ANON_KEY`, `PUBLIC_SITE_URL`.
4. Run `npm run dev` (or `npx astro dev --background` / `stop` / `status` / `logs`).
## Commands
| Command | Action |
| :--- | :--- |
| `npm run dev` | Start dev server at `localhost:4321` |
| `npm run build` | Build SSR output to `./dist/` |
| `npm run preview` | Preview the build locally |
| `npm run typecheck` | Type-check with `astro check` |
| `npx astro dev --background` | Background dev server (stop/status/logs) |
## Deploy to Netlify
- Connect the repo to Netlify; build command `npm run build`, publish directory `dist`.
- Set `PUBLIC_SUPABASE_URL`, `PUBLIC_SUPABASE_ANON_KEY`, `PUBLIC_SITE_URL` in Netlify env vars.
- `netlify.toml` adds security headers; the Netlify adapter emits the SSR function + redirects automatically.
+19
View File
@@ -0,0 +1,19 @@
// @ts-check
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import netlify from '@astrojs/netlify';
// https://astro.build/config
export default defineConfig({
integrations: [react()],
output: 'server',
adapter: netlify(),
site: process.env.PUBLIC_SITE_URL ?? 'http://localhost:4321',
i18n: {
locales: ['ua', 'en'],
defaultLocale: 'ua',
routing: {
prefixDefaultLocale: true,
},
},
});
+12
View File
@@ -0,0 +1,12 @@
[build]
command = "npm run build"
publish = "dist"
# SSR functions are created automatically by the @astrojs/netlify adapter.
# Headers for static assets & security defaults.
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "SAMEORIGIN"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
+13642
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "devodytyji",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/netlify": "^8.2.1",
"@astrojs/react": "^6.0.2",
"@supabase/ssr": "^0.7.0",
"@supabase/supabase-js": "^2.55.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"astro": "^7.2.1",
"leaflet": "^1.9.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-leaflet": "^5.0.0"
},
"overrides": {
"sharp": "0.35.3"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@types/leaflet": "^1.9.22",
"typescript": "^6.0.3"
}
}
+66
View File
@@ -0,0 +1,66 @@
Plan: devodytyji — open-air cultural venue website
Stack decisions
- Astro 7 (SSR mode via @astrojs/node) — fast static pages + dynamic SSR pages. Astro's i18n routing handles /en + /ua.
- React islands for all interactive parts: auth UI, review forms, admin editor, map. Server-rendered Astro components for content pages (SEO-friendly).
- Supabase (Postgres + Auth + Storage) — sessions via @supabase/ssr 0.12 with cookie management in Astro middleware.
- Leaflet + react-leaflet for the Map page — single venue marker, no API key needed (OSM tiles).
- i18n: Astro i18n config, locales ['ua','en'], default ua, locale-prefixed routes + translation dictionaries + hreflang alternates. React islands receive translations via props/context.
- Package manager: npm (Node 20 present).
Supabase data model (SQL migration in supabase/migrations/)
profiles (id uuid PK → auth.users, full_name text, avatar_url text, is_admin bool default false)
events (id uuid PK, slug text unique, title jsonb, description jsonb, cover_image_url text,
starts_at timestamptz, ends_at timestamptz, venue_name text, status text check draft|published,
created_by uuid → profiles, created_at, updated_at)
event_reviews (id uuid PK, event_id → events, user_id → profiles, rating int 1-5, comment text, created_at)
venue_reviews (id uuid PK, user_id → profiles, rating int 1-5, comment text, created_at)
contact_messages (id, name, email, message, created_at, read bool)
- title/description as jsonb {ua, en} for bilingual content.
- RLS policies: events (published visible to all; admin full CRUD), reviews (public read, authenticated insert, owner update/delete, rating validation 1–5), profiles (public read, self-edit), contact_messages (anon insert with honeypot + rate-limit, admin read).
- Seed: one admin profile + a few published sample events.
- Auth providers enabled in dashboard: email/password + Google + GitHub (multiple OAuth).
- Cover images → Supabase Storage bucket event-covers (public read, authenticated write for admins).
Pages & routing
Public (locale-prefixed: /ua/..., /en/...):
- index.astro — Home: hero, next 3 upcoming published events, latest venue reviews, CTA
- events/index.astro — Events list with filters (upcoming/past, search)
- events/[slug].astro — Event detail: info, cover, event reviews section (list + ReviewForm island for logged-in users)
- map.astro — single venue location via Leaflet + contact/address info
- about.astro — about the venue + venue reviews + VenueReviewForm
- contacts.astro — contact form (→ contact_messages), contact details, map mini-embed
- login.astro / register.astro — auth UI island (email/password + OAuth buttons)
Admin (/admin, middleware-protected, checked for is_admin):
- admin/index.astro — dashboard (stats: published/draft counts, unread messages)
- admin/events/index.astro — table with create/edit/publish-toggle
- admin/events/new.astro + admin/events/[id]/edit.astro — EventEditor island (bilingual fields, cover upload, dates, status)
- admin/reviews.astro — review moderation (delete)
- admin/messages.astro — contact messages inbox
Auth flow
- src/middleware.ts: create server client from cookies, refresh session, protect /admin/**, redirect authed users away from login, redirect / to default locale.
- createServerClient helper in src/lib/supabase.ts (server) + createBrowserClient (browser islands).
- Endpoints: api/auth/login, api/auth/register, api/auth/callback (OAuth), api/auth/logout, api/auth/me.
Project structure
astro.config.mjs (react, node adapter, i18n config, output: server)
supabase/migrations/0001_init.sql
.env.example (PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, site URL)
src/
lib/ supabase.ts, i18n.ts, dictionaries (ua.ts, en.ts), db helpers
components/ ui/* (Button, Card…), islands (AuthForm, ReviewForm, EventEditor, MapView, LangSwitcher)
layouts/ BaseLayout.astro (SEO, OG, hreflang, header/nav, footer)
middleware.ts
pages/ …as above
styles/ global.css (design tokens)
Execution order
1. Scaffold: npm create astro@latest (TS, React integration), node adapter, SSR + i18n config
2. Supabase: project, SQL migration, RLS, storage bucket, seed, auth providers
3. Core: supabase client helpers, middleware, i18n dictionaries + layout
4. Public pages: Home → Events list → Event detail → About → Contacts → Map
5. Auth: login/register/OAuth/logout + session-aware nav
6. Reviews: event + venue forms and display
7. Admin: route guard, dashboard, event CRUD + publish toggle, moderation inbox
8. Polish: SEO/OG/i18n meta, empty/loading states, error handling, npm run build + typecheck
9. Deploy: Netlify or Vercel adapter + env vars + webhook to rebuild
Open items
- Admin grants: no public signup grants is_admin — set via SQL (seed) or Supabase dashboard. OK?
- Deploy target: Netlify or Vercel (affects adapter choice)?
- Contact emails: just stored in DB inbox, or also sent via email (Resend edge function)?
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "Devodytyji",
"short_name": "Devodytyji",
"description": "Відкритий культурний майданчик Devodytyji — події просто неба в Києві.",
"lang": "uk",
"start_url": "/ua/",
"display": "standalone",
"background_color": "#faf6ef",
"theme_color": "#2e6b4f",
"icons": [
{ "src": "/favicon-16.png", "sizes": "16x16", "type": "image/png" },
{ "src": "/favicon-32.png", "sizes": "32x32", "type": "image/png" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+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>
+24
View File
@@ -0,0 +1,24 @@
/// <reference types="astro/client" />
/// <reference path="../.astro/types.d.ts" />
/// <reference types="@astrojs/netlify/functions" />
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Locale } from './lib/i18n';
declare global {
namespace App {
interface Locals {
locale: Locale;
supabase: SupabaseClient;
user: {
id: string;
email?: string;
fullName?: string | null;
avatarUrl?: string | null;
isAdmin: boolean;
} | null;
}
}
}
export {};
+67
View File
@@ -0,0 +1,67 @@
---
import '../styles/global.css';
import { getDict } from '../lib/i18n';
interface Props {
title: string;
current?: 'dashboard' | 'events' | 'reviews' | 'messages';
}
const { title, current } = Astro.props;
const dict = getDict('ua');
const navItems = [
{ key: 'dashboard', href: '/admin', label: dict.admin.navDashboard },
{ key: 'events', href: '/admin/events', label: dict.admin.navEvents },
{ key: 'reviews', href: '/admin/reviews', label: dict.admin.navReviews },
{ key: 'messages', href: '/admin/messages', label: dict.admin.navMessages },
] as const;
---
<!doctype html>
<html lang="ua">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<header class="header">
<div class="container header__inner">
<a class="logo" href="/admin">
<span class="logo__mark">D</span>
<span>Devodytyji / Admin</span>
</a>
<nav class="nav" aria-label="admin">
<a class="btn btn--outline btn--sm" href="/ua/">
{dict.admin.backToSite}
</a>
<form action="/api/auth/logout" method="post">
<button class="btn btn--ghost btn--sm" type="submit">
{dict.nav.logout}
</button>
</form>
</nav>
</div>
</header>
<div class="container admin-shell">
<nav class="admin-nav" aria-label="admin">
{navItems.map((item) => (
<a href={item.href} aria-current={current === item.key ? 'page' : undefined}>
{item.label}
</a>
))}
</nav>
<div>
<slot />
</div>
</div>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
---
import '../styles/global.css';
import { LOCALES, localizedPath, getAlternateLocales } from '../lib/i18n';
import type { Locale, Dict } from '../lib/i18n';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
interface Props {
locale: Locale;
dict: Dict;
pagePath?: string;
title?: string;
description?: string;
ogImage?: string;
user?: App.Locals['user'];
}
const {
locale,
dict,
pagePath = '/',
title = dict.meta.homeTitle,
description = dict.meta.homeDescription,
ogImage = dict.meta.defaultOgImage,
user,
} = Astro.props;
const siteUrl = import.meta.env.PUBLIC_SITE_URL ?? 'http://localhost:4321';
const canonical = `${siteUrl}${localizedPath(locale, pagePath)}`;
const fullOgImage = ogImage.startsWith('http') ? ogImage : `${siteUrl}${ogImage}`;
const alternates = getAlternateLocales(locale);
const t = dict;
---
<!doctype html>
<html lang={locale}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<meta name="theme-color" content="#2e6b4f" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="canonical" href={canonical} />
{alternates.map((l) => (
<link rel="alternate" hreflang={l} href={`${siteUrl}${localizedPath(l, pagePath)}`} />
))}
<link rel="alternate" hreflang="x-default" href={`${siteUrl}/${LOCALES[0]}/`} />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={dict.meta.siteName} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={fullOgImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content={title} />
<meta property="og:locale" content={locale === 'ua' ? 'uk_UA' : 'en_US'} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={fullOgImage} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<slot name="head" />
</head>
<body>
<Header locale={locale} dict={t} user={user} pagePath={pagePath} />
<main>
<slot />
</main>
<Footer locale={locale} dict={t} />
</body>
</html>
+249
View File
@@ -0,0 +1,249 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Locale } from './i18n';
export type EventStatus = 'draft' | 'published';
export interface EventRow {
id: string;
slug: string;
title: { ua: string; en: string };
description: { ua: string; en: string };
cover_image_url: string | null;
starts_at: string;
ends_at: string;
venue_name: string | null;
status: EventStatus;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface ReviewRow {
id: string;
user_id: string;
rating: number;
comment: string | null;
created_at: string;
user: { full_name: string | null; avatar_url: string | null } | null;
event_id?: string | null;
}
export interface MessageRow {
id: string;
name: string;
email: string;
message: string;
read: boolean;
created_at: string;
}
export type EventsFilter = 'all' | 'upcoming' | 'past';
export function titleFor(locale: Locale, event: EventRow): string {
return event.title?.[locale] || event.title?.['ua'] || event.title?.['en'] || '';
}
export function descriptionFor(locale: Locale, event: EventRow): string {
return event.description?.[locale] || event.description?.['ua'] || event.description?.['en'] || '';
}
export async function getPublishedEvents(
supabase: SupabaseClient,
filter: EventsFilter,
query: string,
limit = 50,
): Promise<EventRow[]> {
const now = new Date().toISOString();
let request = supabase
.from('events')
.select('*')
.eq('status', 'published')
.order('starts_at', { ascending: filter !== 'past' })
.limit(limit);
if (filter === 'upcoming') request = request.gte('starts_at', now);
if (filter === 'past') request = request.lt('starts_at', now);
if (query.trim()) {
request = request.or(`slug.ilike.%${query}%,title->>ua.ilike.%${query}%,title->>en.ilike.%${query}%`);
}
const { data, error } = await request;
if (error) {
console.error('getPublishedEvents error', error);
return [];
}
return (data ?? []) as EventRow[];
}
export async function getUpcomingEvents(supabase: SupabaseClient, limit = 3): Promise<EventRow[]> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('status', 'published')
.gte('starts_at', new Date().toISOString())
.order('starts_at', { ascending: true })
.limit(limit);
if (error) {
console.error('getUpcomingEvents error', error);
return [];
}
return (data ?? []) as EventRow[];
}
export async function getEventBySlug(
supabase: SupabaseClient,
slug: string,
): Promise<EventRow | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('slug', slug)
.eq('status', 'published')
.maybeSingle();
if (error) {
console.error('getEventBySlug error', error);
return null;
}
return (data ?? null) as EventRow | null;
}
export async function getEventReviews(
supabase: SupabaseClient,
eventId: string,
): Promise<ReviewRow[]> {
const { data, error } = await supabase
.from('event_reviews')
.select('*, user:profiles(full_name, avatar_url)')
.eq('event_id', eventId)
.order('created_at', { ascending: false });
if (error) {
console.error('getEventReviews error', error);
return [];
}
return (data ?? []) as ReviewRow[];
}
export async function getVenueReviews(supabase: SupabaseClient, limit = 50): Promise<ReviewRow[]> {
const { data, error } = await supabase
.from('venue_reviews')
.select('*, user:profiles(full_name, avatar_url)')
.order('created_at', { ascending: false })
.limit(limit);
if (error) {
console.error('getVenueReviews error', error);
return [];
}
return (data ?? []) as ReviewRow[];
}
// Admin helpers
export async function getAdminEvents(supabase: SupabaseClient): Promise<EventRow[]> {
const { data, error } = await supabase
.from('events')
.select('*')
.order('starts_at', { ascending: false })
.limit(200);
if (error) {
console.error('getAdminEvents error', error);
return [];
}
return (data ?? []) as EventRow[];
}
export async function getAdminEvent(supabase: SupabaseClient, id: string): Promise<EventRow | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) {
console.error('getAdminEvent error', error);
return null;
}
return (data ?? null) as EventRow | null;
}
export async function getAdminStats(supabase: SupabaseClient) {
const [published, drafts, unread, eventReviews, venueReviews] = await Promise.all([
supabase.from('events').select('id', { count: 'exact', head: true }).eq('status', 'published'),
supabase.from('events').select('id', { count: 'exact', head: true }).eq('status', 'draft'),
supabase
.from('contact_messages')
.select('id', { count: 'exact', head: true })
.eq('read', false),
supabase.from('event_reviews').select('id', { count: 'exact', head: true }),
supabase.from('venue_reviews').select('id', { count: 'exact', head: true }),
]);
return {
published: published.count ?? 0,
drafts: drafts.count ?? 0,
unread: unread.count ?? 0,
reviews: (eventReviews.count ?? 0) + (venueReviews.count ?? 0),
};
}
export interface AdminReviewRow {
id: string;
type: 'event' | 'venue';
rating: number;
comment: string | null;
created_at: string;
user_name: string | null;
event_title: string | null;
}
export async function getAdminReviews(supabase: SupabaseClient): Promise<AdminReviewRow[]> {
const [eventReviews, venueReviews] = await Promise.all([
supabase
.from('event_reviews')
.select('id, rating, comment, created_at, user:profiles(full_name), event:events(title)'),
supabase
.from('venue_reviews')
.select('id, rating, comment, created_at, user:profiles(full_name)'),
]);
const events: AdminReviewRow[] = (eventReviews.data ?? []).map((r: any) => ({
id: r.id,
type: 'event',
rating: r.rating,
comment: r.comment,
created_at: r.created_at,
user_name: r.user?.full_name ?? null,
event_title: r.event?.title?.['ua'] ?? r.event?.title?.['en'] ?? null,
}));
const venue: AdminReviewRow[] = (venueReviews.data ?? []).map((r: any) => ({
id: r.id,
type: 'venue',
rating: r.rating,
comment: r.comment,
created_at: r.created_at,
user_name: r.user?.full_name ?? null,
event_title: null,
}));
return [...events, ...venue].sort((a, b) =>
b.created_at.localeCompare(a.created_at),
);
}
export async function getAdminMessages(supabase: SupabaseClient): Promise<MessageRow[]> {
const { data, error } = await supabase
.from('contact_messages')
.select('*')
.order('created_at', { ascending: false })
.limit(200);
if (error) {
console.error('getAdminMessages error', error);
return [];
}
return (data ?? []) as MessageRow[];
}
+231
View File
@@ -0,0 +1,231 @@
import type { Dict } from './ua';
export const en: Dict = {
meta: {
siteName: 'Devodytyji',
homeTitle: 'Devodytyji — open-air cultural venue',
homeDescription:
'Devodytyji is an open-air cultural space: concerts, movies, talks and markets under the open sky.',
defaultOgImage: '/og-cover.jpg',
},
nav: {
home: 'Home',
events: 'Events',
about: 'About',
map: 'How to get there',
contacts: 'Contacts',
admin: 'Admin panel',
login: 'Sign in',
register: 'Sign up',
logout: 'Log out',
},
common: {
loading: 'Loading…',
saving: 'Saving…',
save: 'Save',
cancel: 'Cancel',
submit: 'Submit',
close: 'Close',
search: 'Search',
all: 'All',
upcoming: 'Upcoming',
past: 'Past',
published: 'Published',
draft: 'Draft',
edit: 'Edit',
delete: 'Delete',
read: 'Read',
unread: 'Unread',
back: 'Back',
noResults: 'Nothing found',
error: 'Something went wrong',
success: 'Success',
required: 'Required field',
optional: 'optional',
reviews: 'reviews',
rating: 'Rating',
writeReview: 'Write a review',
view: 'View',
minutes: 'min',
},
hero: {
badge: 'Open air • Always open',
title: 'A cultural venue under the open sky',
subtitle:
'Devodytyji is a space for concerts, movies, talks and markets. One yard, one stage, endless stories.',
ctaEvents: 'See events',
ctaMap: 'Get directions',
},
home: {
nextEvents: 'Upcoming events',
nextEventsSubtitle: 'What is happening at the venue in the near future.',
viewAllEvents: 'All events',
venueReviewsTitle: 'What guests say',
venueReviewsSubtitle: 'Impressions of visitors about the Devodytyji venue.',
ctaTitle: 'Come out into the fresh air',
ctaText: 'Subscribe to announcements or just drop by — admission is free.',
ctaButton: 'Write to us',
},
events: {
pageTitle: 'Events',
pageDescription: 'All events at the Devodytyji venue: concerts, movies, talks and markets.',
filters: 'Filters',
searchPlaceholder: 'Search events…',
upcomingTab: 'Upcoming',
pastTab: 'Past',
allTab: 'All',
emptyUpcoming: 'No upcoming events yet — check back later.',
emptyPast: 'No past events yet.',
emptySearch: 'Nothing matches your search.',
pastLabel: 'Past',
upcomingLabel: 'Upcoming',
},
event: {
date: 'Date',
time: 'Time',
venue: 'Venue',
statusDraft: 'Draft',
statusPublished: 'Published',
reviewsTitle: 'Event reviews',
reviewCount: 'reviews',
noReviews: 'No reviews yet. Be the first!',
writeReview: 'Write a review',
backToEvents: 'Back to all events',
share: 'Share',
signInToReview: 'Sign in to leave a review',
},
about: {
pageTitle: 'About the venue',
pageDescription: 'The story and atmosphere of the Devodytyji cultural venue.',
storyTitle: 'Our story',
story:
'Devodytyji began as an ordinary yard where neighbors gathered. Over time it became an open cultural venue: music, movies under the stars, talks, fairs and workshops. We believe culture lives best in the open air — among people, under the open sky.',
venueReviewsTitle: 'Venue reviews',
venueReviewCount: 'reviews',
writeVenueReview: 'Leave a review about the venue',
noVenueReviews: 'No reviews yet. Be the first!',
contactsTitle: 'Contacts',
},
contacts: {
pageTitle: 'Contacts',
pageDescription: 'Get in touch with the Devodytyji team or send us a message.',
formTitle: 'Write to us',
name: 'Name',
email: 'Email',
message: 'Message',
namePlaceholder: 'Your name',
emailPlaceholder: 'you@example.com',
messagePlaceholder: 'Your message…',
send: 'Send',
sending: 'Sending…',
successTitle: 'Message sent!',
successText: 'Thank you, we will get back to you soon.',
errorTitle: 'Could not send',
errorText: 'Please try again a bit later.',
phone: 'Phone',
emailLabel: 'Email',
addressLabel: 'Address',
hoursLabel: 'Opening hours',
hoursValue: 'Every day, 10:00 — 23:00',
followUs: 'Follow us',
},
map: {
pageTitle: 'How to get there',
pageDescription: 'Map and address of the Devodytyji cultural venue.',
howToGet: 'How to get there',
address: 'Address',
hint: 'A tiny map with a single marker — you cannot get lost.',
},
auth: {
loginTitle: 'Sign in',
loginSubtitle: 'Good to see you again!',
registerTitle: 'Create an account',
registerSubtitle: 'Join us to leave reviews.',
email: 'Email',
password: 'Password',
fullName: 'Name',
loginButton: 'Sign in',
registerButton: 'Sign up',
loggingIn: 'Signing in…',
registering: 'Signing up…',
oauthGoogle: 'Continue with Google',
oauthGitHub: 'Continue with GitHub',
noAccount: 'No account yet?',
hasAccount: 'Already have an account?',
loginLink: 'Sign in',
registerLink: 'Sign up',
orContinueWith: 'or continue with',
passwordLength: 'Password must be at least 6 characters',
errorInvalid: 'Invalid email or password.',
errorEmailExists: 'A user with this email already exists.',
errorGeneric: 'Something went wrong. Please try again.',
backHome: 'Back home',
},
reviewForm: {
ratingLabel: 'Your rating',
commentLabel: 'Comment',
commentPlaceholder: 'Share your impressions…',
submit: 'Submit review',
submitting: 'Submitting…',
success: 'Thank you for your review!',
error: 'Could not submit the review.',
errorNotLoggedIn: 'Please sign in to leave a review.',
delete: 'Delete',
deleteConfirm: 'Delete this review?',
starsHint: 'Choose from 1 to 5 stars',
},
admin: {
dashboardTitle: 'Dashboard',
eventsTitle: 'Events',
reviewsTitle: 'Reviews',
messagesTitle: 'Messages',
backToSite: 'Back to site',
navDashboard: 'Overview',
navEvents: 'Events',
navReviews: 'Reviews',
navMessages: 'Messages',
statsPublished: 'Published events',
statsDraft: 'Drafts',
statsUnread: 'Unread messages',
statsReviews: 'Total reviews',
newEvent: 'New event',
editEvent: 'Edit event',
backToList: 'Back to events list',
eventTitleUa: 'Title (UA)',
eventTitleEn: 'Title (EN)',
eventDescUa: 'Description (UA)',
eventDescEn: 'Description (EN)',
slug: 'Slug (URL)',
startsAt: 'Starts at',
endsAt: 'Ends at',
venueName: 'Venue name',
coverImage: 'Cover image',
uploadCover: 'Upload cover',
replacingCover: 'Uploaded!',
status: 'Status',
publish: 'Publish',
unpublish: 'Unpublish',
save: 'Save',
create: 'Create',
saving: 'Saving…',
saved: 'Saved!',
deleteEvent: 'Delete event',
deleteEventConfirm: 'Delete this event? This cannot be undone.',
deleteReview: 'Delete review',
deleteMessage: 'Delete message',
emptyEvents: 'No events yet. Create the first one!',
emptyReviews: 'No reviews yet.',
emptyMessages: 'No messages yet.',
markRead: 'Mark as read',
markUnread: 'Mark as unread',
from: 'From',
viewOnSite: 'View on site',
notFound: 'Event not found',
},
footer: {
tagline: 'An open-air cultural venue.',
rights: 'All rights reserved.',
navTitle: 'Navigation',
contactTitle: 'Contacts',
},
};
+231
View File
@@ -0,0 +1,231 @@
export const ua = {
meta: {
siteName: 'Devodytyji',
homeTitle: 'Devodytyji — відкритий культурний майданчик',
homeDescription:
'Devodytyji — відкритий простір на свіжому повітрі: концерти, кіно, лекції та маркети просто неба.',
defaultOgImage: '/og-cover.jpg',
},
nav: {
home: 'Головна',
events: 'Події',
about: 'Про майданчик',
map: 'Як дістатись',
contacts: 'Контакти',
admin: 'Адмін-панель',
login: 'Увійти',
register: 'Реєстрація',
logout: 'Вийти',
},
common: {
loading: 'Завантаження…',
saving: 'Збереження…',
save: 'Зберегти',
cancel: 'Скасувати',
submit: 'Надіслати',
close: 'Закрити',
search: 'Пошук',
all: 'Усі',
upcoming: 'Найближчі',
past: 'Минулі',
published: 'Опубліковано',
draft: 'Чернетка',
edit: 'Редагувати',
delete: 'Видалити',
read: 'Прочитано',
unread: 'Не прочитано',
back: 'Назад',
noResults: 'Нічого не знайдено',
error: 'Сталася помилка',
success: 'Успішно',
required: "Обов'язкове поле",
optional: 'необов’язково',
reviews: 'відгуки',
rating: 'Оцінка',
writeReview: 'Залишити відгук',
view: 'Переглянути',
minutes: 'хв',
},
hero: {
badge: 'Просто неба • Завжди відкрито',
title: 'Культурний майданчик під відкритим небом',
subtitle:
'Devodytyji — це простір для концертів, кіно, лекцій і маркетів. Один двір, одна сцена, безліч історій.',
ctaEvents: 'Дивитись події',
ctaMap: 'Як дістатись',
},
home: {
nextEvents: 'Найближчі події',
nextEventsSubtitle: 'Що відбуватиметься на майданчику найближчим часом.',
viewAllEvents: 'Усі події',
venueReviewsTitle: 'Що кажуть гості',
venueReviewsSubtitle: 'Враження відвідувачів про майданчик Devodytyji.',
ctaTitle: 'Приходьте на свіже повітря',
ctaText: 'Підпишіться на анонси або просто завітайте — вхід вільний.',
ctaButton: 'Написати нам',
},
events: {
pageTitle: 'Події',
pageDescription: 'Усі події на майданчику Devodytyji: концерти, кіно, лекції та маркети.',
filters: 'Фільтри',
searchPlaceholder: 'Шукати події…',
upcomingTab: 'Найближчі',
pastTab: 'Минулі',
allTab: 'Усі',
emptyUpcoming: 'Найближчих подій поки немає — зазирніть пізніше.',
emptyPast: 'Минулих подій поки немає.',
emptySearch: 'За вашим запитом нічого не знайдено.',
pastLabel: 'Минула',
upcomingLabel: 'Найближча',
},
event: {
date: 'Дата',
time: 'Час',
venue: 'Локація',
statusDraft: 'Чернетка',
statusPublished: 'Опубліковано',
reviewsTitle: 'Відгуки про подію',
reviewCount: 'відгуків',
noReviews: 'Відгуків поки немає. Будьте першим!',
writeReview: 'Залишити відгук',
backToEvents: 'До всіх подій',
share: 'Поділитись',
signInToReview: 'Увійдіть, щоб залишити відгук',
},
about: {
pageTitle: 'Про майданчик',
pageDescription: 'Історія та атмосфера культурного майданчика Devodytyji.',
storyTitle: 'Наша історія',
story:
'Devodytyji починався як звичайний двір, де збиралися сусіди. З часом він перетворився на відкритий культурний майданчик: тут грають музику, показують кіно під зорями, проводять лекції, ярмарки та майстер-класи. Ми віримо, що культура найкраще живе просто неба — під відкритим небом, у колі людей.',
venueReviewsTitle: 'Відгуки про майданчик',
venueReviewCount: 'відгуків',
writeVenueReview: 'Залишити відгук про майданчик',
noVenueReviews: 'Відгуків поки немає. Будьте першим!',
contactsTitle: 'Контакти',
},
contacts: {
pageTitle: 'Контакти',
pageDescription: 'Зв’яжіться з командою Devodytyji або напишіть нам повідомлення.',
formTitle: 'Напишіть нам',
name: "Ім'я",
email: 'Email',
message: 'Повідомлення',
namePlaceholder: "Ваше ім'я",
emailPlaceholder: 'you@example.com',
messagePlaceholder: 'Ваше повідомлення…',
send: 'Надіслати',
sending: 'Надсилання…',
successTitle: 'Повідомлення надіслано!',
successText: 'Дякуємо, ми зв’яжемося з вами найближчим часом.',
errorTitle: 'Не вдалося надіслати',
errorText: 'Спробуйте ще раз трохи згодом.',
phone: 'Телефон',
emailLabel: 'Email',
addressLabel: 'Адреса',
hoursLabel: 'Години роботи',
hoursValue: 'Щодня з 10:00 до 23:00',
followUs: 'Стежте за нами',
},
map: {
pageTitle: 'Як дістатись',
pageDescription: 'Карта та адреса культурного майданчика Devodytyji.',
howToGet: 'Як дістатись',
address: 'Адреса',
hint: 'Маленька карта з одним маркером — ви точно не заблукаєте.',
},
auth: {
loginTitle: 'Увійти',
loginSubtitle: 'Раді бачити вас знову!',
registerTitle: 'Створити акаунт',
registerSubtitle: 'Приєднуйтесь, щоб залишати відгуки.',
email: 'Email',
password: 'Пароль',
fullName: "Ім'я",
loginButton: 'Увійти',
registerButton: 'Зареєструватися',
loggingIn: 'Вхід…',
registering: 'Реєстрація…',
oauthGoogle: 'Увійти через Google',
oauthGitHub: 'Увійти через GitHub',
noAccount: 'Немає акаунта?',
hasAccount: 'Вже маєте акаунт?',
loginLink: 'Увійти',
registerLink: 'Зареєструватися',
orContinueWith: 'або продовжити через',
passwordLength: 'Пароль має містити щонайменше 6 символів',
errorInvalid: 'Неправильний email або пароль.',
errorEmailExists: 'Користувач із таким email уже існує.',
errorGeneric: 'Щось пішло не так. Спробуйте ще раз.',
backHome: 'На головну',
},
reviewForm: {
ratingLabel: 'Ваша оцінка',
commentLabel: 'Коментар',
commentPlaceholder: 'Поділіться враженнями…',
submit: 'Надіслати відгук',
submitting: 'Надсилання…',
success: 'Дякуємо за відгук!',
error: 'Не вдалося надіслати відгук.',
errorNotLoggedIn: 'Увійдіть, щоб залишити відгук.',
delete: 'Видалити',
deleteConfirm: 'Видалити цей відгук?',
starsHint: 'Оберіть від 1 до 5 зірок',
},
admin: {
dashboardTitle: 'Панель керування',
eventsTitle: 'Події',
reviewsTitle: 'Відгуки',
messagesTitle: 'Повідомлення',
backToSite: 'На сайт',
navDashboard: 'Огляд',
navEvents: 'Події',
navReviews: 'Відгуки',
navMessages: 'Повідомлення',
statsPublished: 'Опубліковано подій',
statsDraft: 'Чернеток',
statsUnread: 'Непрочитаних повідомлень',
statsReviews: 'Всього відгуків',
newEvent: 'Нова подія',
editEvent: 'Редагувати подію',
backToList: 'До списку подій',
eventTitleUa: 'Назва (укр.)',
eventTitleEn: 'Назва (англ.)',
eventDescUa: 'Опис (укр.)',
eventDescEn: 'Опис (англ.)',
slug: 'Слаг (URL)',
startsAt: 'Початок',
endsAt: 'Кінець',
venueName: 'Назва локації',
coverImage: 'Обкладинка',
uploadCover: 'Завантажити обкладинку',
replacingCover: 'Завантажено!',
status: 'Статус',
publish: 'Опублікувати',
unpublish: 'Зняти з публікації',
save: 'Зберегти',
create: 'Створити',
saving: 'Збереження…',
saved: 'Збережено!',
deleteEvent: 'Видалити подію',
deleteEventConfirm: 'Видалити цю подію? Цю дію не можна скасувати.',
deleteReview: 'Видалити відгук',
deleteMessage: 'Видалити повідомлення',
emptyEvents: 'Подій поки немає. Створіть першу!',
emptyReviews: 'Відгуків поки немає.',
emptyMessages: 'Повідомлень поки немає.',
markRead: 'Позначити прочитаним',
markUnread: 'Позначити непрочитаним',
from: 'Від',
viewOnSite: 'Переглянути на сайті',
notFound: 'Подію не знайдено',
},
footer: {
tagline: 'Відкритий культурний майданчик просто неба.',
rights: 'Всі права захищено.',
navTitle: 'Навігація',
contactTitle: 'Контакти',
},
};
export type Dict = typeof ua;
+57
View File
@@ -0,0 +1,57 @@
import type { Locale } from './i18n';
export function intlLocale(locale: Locale): string {
return locale === 'ua' ? 'uk-UA' : 'en-GB';
}
export function formatDate(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(iso));
}
export function formatTime(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
export function formatDateTime(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
export function formatShortDate(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'short',
}).format(new Date(iso));
}
export function formatRelative(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
export function initials(name: string | null | undefined): string {
if (!name) return '?';
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
+37
View File
@@ -0,0 +1,37 @@
import { ua, type Dict } from './dictionaries/ua';
import { en } from './dictionaries/en';
export type { Dict } from './dictionaries/ua';
export type Locale = 'ua' | 'en';
export const LOCALES: Locale[] = ['ua', 'en'];
export const DEFAULT_LOCALE: Locale = 'ua';
const dictionaries: Record<Locale, Dict> = { ua, en };
export function getDict(locale: string | undefined): Dict {
return dictionaries[(locale as Locale) ?? DEFAULT_LOCALE] ?? ua;
}
export function isLocale(value: string | undefined): value is Locale {
return value === 'ua' || value === 'en';
}
export function getLocaleFromUrl(url: URL): Locale {
const first = url.pathname.split('/')[1];
return isLocale(first) ? first : DEFAULT_LOCALE;
}
export function localizedPath(locale: Locale, path: string): string {
const clean = path.startsWith('/') ? path : `/${path}`;
return `/${locale}${clean}`;
}
export function getAlternateLocales(current: Locale): Locale[] {
return LOCALES.filter((l) => l !== current);
}
export function stripLocaleFromPath(locale: Locale, pathname: string): string {
if (pathname === `/${locale}` || pathname === `/${locale}/`) return '/';
if (pathname.startsWith(`/${locale}/`)) return pathname.slice(`/${locale}`.length);
return pathname;
}
+21
View File
@@ -0,0 +1,21 @@
export async function readBody<T extends Record<string, unknown>>(request: Request): Promise<T> {
const contentType = request.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
return (await request.json()) as T;
}
if (contentType.includes('multipart/form-data')) {
const form = await request.formData();
const out: Record<string, unknown> = {};
form.forEach((value, key) => {
out[key] = value;
});
return out as T;
}
const urlEncoded = await request.text();
const params = new URLSearchParams(urlEncoded);
const out: Record<string, unknown> = {};
for (const [key, value] of params.entries()) {
out[key] = value;
}
return out as T;
}
+62
View File
@@ -0,0 +1,62 @@
import { createBrowserClient, createServerClient } from '@supabase/ssr';
import type { AstroCookies } from 'astro';
import type { SupabaseClient } from '@supabase/supabase-js';
interface ServerContext {
cookies: AstroCookies;
request: Request;
}
export function getSupabaseUrl(): string {
const url = import.meta.env.PUBLIC_SUPABASE_URL;
if (!url) throw new Error('PUBLIC_SUPABASE_URL is not set');
return url;
}
export function getSupabaseAnonKey(): string {
const key = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
if (!key) throw new Error('PUBLIC_SUPABASE_ANON_KEY is not set');
return key;
}
function parseCookieHeader(cookieHeader: string): { name: string; value: string }[] {
if (!cookieHeader) return [];
const out: { name: string; value: string }[] = [];
for (const pair of cookieHeader.split(';')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
const name = pair.slice(0, eq).trim();
if (!name) continue;
let value = pair.slice(eq + 1).trim();
try {
value = decodeURIComponent(value);
} catch {
// keep raw value
}
out.push({ name, value });
}
return out;
}
export function createServerSupabase(context: ServerContext): SupabaseClient {
return createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), {
cookies: {
getAll() {
return parseCookieHeader(context.request.headers.get('cookie') ?? '');
},
setAll(cookiesToSet) {
for (const { name, value, options } of cookiesToSet) {
context.cookies.set(name, value, options);
}
},
},
});
}
export function createBrowserSupabase(): SupabaseClient {
return createBrowserClient(getSupabaseUrl(), getSupabaseAnonKey());
}
export function getSiteUrl(): string {
return import.meta.env.PUBLIC_SITE_URL ?? 'http://localhost:4321';
}
+23
View File
@@ -0,0 +1,23 @@
import type { Locale } from './i18n';
export interface VenueInfo {
name: string;
lat: number;
lng: number;
phone: string;
email: string;
hours: string;
}
export const VENUE: VenueInfo = {
name: 'Devodytyji',
lat: 50.4584,
lng: 30.5199,
phone: '+380 44 000 00 00',
email: 'hello@devodytyji.example',
hours: '10:00 – 23:00',
};
export function venueAddress(locale: Locale): string {
return locale === 'en' ? '12 Vifleemska St, Kyiv, Ukraine' : 'вул. Вифлеємська, 12, Київ, Україна';
}
+95
View File
@@ -0,0 +1,95 @@
import { defineMiddleware } from 'astro:middleware';
import { createServerSupabase } from './lib/supabase';
import { DEFAULT_LOCALE, isLocale, type Locale } from './lib/i18n';
export const onRequest = defineMiddleware(async (context, next) => {
const { url, cookies, locals } = context;
const pathname = url.pathname;
if (pathname === '/') {
return context.redirect(`/${DEFAULT_LOCALE}/`);
}
const first = pathname.split('/')[1];
const prefersHtml = (context.request.headers.get('accept') ?? '').includes('text/html');
// Unknown locale prefix (e.g. /fr/events) → default locale.
// Only for HTML requests so static assets and API calls pass through.
if (
first &&
!isLocale(first) &&
!['admin', 'api', '_astro', 'sitemap.xml', 'robots.txt', 'favicon.ico', 'favicon.svg', 'og-cover.jpg', 'site.webmanifest'].includes(first) &&
prefersHtml
) {
const rest = pathname.slice(first.length + 1);
return context.redirect(`/${DEFAULT_LOCALE}${rest === '' ? '/' : rest}`);
}
const locale: Locale = isLocale(first) ? first : DEFAULT_LOCALE;
if (pathname === `/${locale}` && !pathname.endsWith('/')) {
return context.redirect(`${pathname}/`);
}
const supabase = createServerSupabase(context);
let userInfo: App.Locals['user'] = null;
try {
const {
data: { user },
} = await supabase.auth.getUser();
if (user) {
const { data: profile } = await supabase
.from('profiles')
.select('full_name, avatar_url, is_admin')
.eq('id', user.id)
.maybeSingle();
userInfo = {
id: user.id,
email: user.email,
fullName: profile?.full_name ?? user.user_metadata?.full_name ?? null,
avatarUrl: profile?.avatar_url ?? null,
isAdmin: profile?.is_admin ?? false,
};
}
} catch (error) {
console.error('Supabase auth check failed', error);
}
locals.locale = locale;
locals.supabase = supabase;
locals.user = userInfo;
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
const isAdminApi = pathname.startsWith('/api/admin/');
if (isAdminRoute || isAdminApi) {
if (!userInfo) {
if (isAdminApi) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
return context.redirect(`/${locale}/login?redirect=${encodeURIComponent(pathname)}`);
}
if (!userInfo.isAdmin) {
if (isAdminApi) {
return new Response(JSON.stringify({ error: 'Forbidden' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response('Forbidden', { status: 403 });
}
}
const page = pathname.split('/')[2];
if (userInfo && (page === 'login' || page === 'register')) {
return context.redirect(`/${locale}/`);
}
return next();
});
+114
View File
@@ -0,0 +1,114 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ReviewCard from '../../components/ui/ReviewCard.astro';
import Icon from '../../components/ui/Icon.astro';
import VenueReviewForm from '../../components/islands/VenueReviewForm';
import { localizedPath, getDict } from '../../lib/i18n';
import type { Locale } from '../../lib/i18n';
import { getVenueReviews } from '../../lib/db';
import { VENUE } from '../../lib/venue';
const { lang } = Astro.params;
const locale = (lang ?? 'ua') as Locale;
const dict = getDict(locale);
const supabase = Astro.locals.supabase;
const user = Astro.locals.user;
const reviews = await getVenueReviews(supabase);
const canDelete = (reviewUserId: string) => user !== null && (user.isAdmin || reviewUserId === user.id);
---
<BaseLayout
locale={locale}
dict={dict}
pagePath="/about"
user={user}
title={`${dict.about.pageTitle} — ${dict.meta.siteName}`}
description={dict.about.pageDescription}
>
<section class="section">
<div class="container">
<div class="split">
<div>
<span class="eyebrow">{dict.meta.siteName}</span>
<h1>{dict.about.pageTitle}</h1>
<h2>{dict.about.storyTitle}</h2>
<p>{dict.about.story}</p>
<h2>{dict.about.contactsTitle}</h2>
<div class="contact-details">
<div class="contact-detail">
<span><Icon name="pin" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.addressLabel}</div>
{locale === 'en' ? '12 Vifleemska St, Kyiv, Ukraine' : 'вул. Вифлеємська, 12, Київ, Україна'}
</div>
</div>
<div class="contact-detail">
<span><Icon name="mail" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.emailLabel}</div>
<a href={`mailto:${VENUE.email}`}>{VENUE.email}</a>
</div>
</div>
<div class="contact-detail">
<span><Icon name="clock" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.hoursLabel}</div>
{dict.contacts.hoursValue}
</div>
</div>
</div>
</div>
<div>
<div class="card" style="padding:24px;margin-bottom:24px;">
<h3>{dict.about.writeVenueReview}</h3>
{user ? (
<VenueReviewForm dict={dict} />
) : (
<p>
<a href={localizedPath(locale, `/login?redirect=${encodeURIComponent(`/${locale}/about`)}`)}>
{dict.event.signInToReview}
</a>
</p>
)}
</div>
</div>
</div>
</div>
</section>
<section class="section section--alt">
<div class="container">
<div class="section-head">
<div>
<span class="eyebrow">{dict.about.venueReviewsTitle}</span>
<h2>
{dict.about.venueReviewsTitle}{' '}
<span class="text-muted" style="font-family:var(--font-sans);font-size:1rem;">
({reviews.length})
</span>
</h2>
</div>
</div>
{reviews.length > 0 ? (
<div class="grid-2">
{reviews.map((review) => (
<ReviewCard
review={review}
locale={locale}
dict={dict}
deleteUrl={canDelete(review.user_id) ? `/api/venue-reviews/${review.id}` : undefined}
/>
))}
</div>
) : (
<div class="empty-state">
<div class="empty-state__icon"><Icon name="chat" size={40} /></div>
<p>{dict.about.noVenueReviews}</p>
</div>
)}
</div>
</section>
</BaseLayout>
+88
View File
@@ -0,0 +1,88 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ContactForm from '../../components/islands/ContactForm';
import MapView from '../../components/islands/MapView';
import Icon from '../../components/ui/Icon.astro';
import { getDict } from '../../lib/i18n';
import type { Locale } from '../../lib/i18n';
import { VENUE, venueAddress } from '../../lib/venue';
const { lang } = Astro.params;
const locale = (lang ?? 'ua') as Locale;
const dict = getDict(locale);
const user = Astro.locals.user;
---
<BaseLayout
locale={locale}
dict={dict}
pagePath="/contacts"
user={user}
title={`${dict.contacts.pageTitle} — ${dict.meta.siteName}`}
description={dict.contacts.pageDescription}
>
<section class="section">
<div class="container">
<div class="section-head">
<div>
<span class="eyebrow">{dict.meta.siteName}</span>
<h1>{dict.contacts.pageTitle}</h1>
<p>{dict.contacts.pageDescription}</p>
</div>
</div>
<div class="split">
<div>
<div class="card" style="padding:24px;">
<h2>{dict.contacts.formTitle}</h2>
<ContactForm client:load dict={dict} />
</div>
</div>
<div>
<div class="contact-details">
<div class="contact-detail">
<span><Icon name="pin" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.addressLabel}</div>
<strong>{venueAddress(locale)}</strong>
</div>
</div>
<div class="contact-detail">
<span><Icon name="phone" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.phone}</div>
<a href={`tel:${VENUE.phone.replace(/\s/g, '')}`}>{VENUE.phone}</a>
</div>
</div>
<div class="contact-detail">
<span><Icon name="mail" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.emailLabel}</div>
<a href={`mailto:${VENUE.email}`}>{VENUE.email}</a>
</div>
</div>
<div class="contact-detail">
<span><Icon name="clock" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.hoursLabel}</div>
{dict.contacts.hoursValue}
</div>
</div>
</div>
<div class="map-wrap" style="height:300px;margin-top:24px;" aria-hidden="true">
<MapView
client:only="react"
lat={VENUE.lat}
lng={VENUE.lng}
name={VENUE.name}
address={venueAddress(locale)}
hint={dict.map.hint}
/>
</div>
</div>
</div>
</div>
</section>
</BaseLayout>
+166
View File
@@ -0,0 +1,166 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import ReviewCard from '../../../components/ui/ReviewCard.astro';
import Icon from '../../../components/ui/Icon.astro';
import ReviewForm from '../../../components/islands/ReviewForm';
import { localizedPath, getDict, isLocale } from '../../../lib/i18n';
import type { Locale } from '../../../lib/i18n';
import { getEventBySlug, getEventReviews, titleFor, descriptionFor } from '../../../lib/db';
import { formatDate, formatTime } from '../../../lib/format';
const { lang, slug } = Astro.params;
const locale: Locale = isLocale(lang) ? lang : 'ua';
const dict = getDict(locale);
const supabase = Astro.locals.supabase;
const user = Astro.locals.user;
const event = await getEventBySlug(supabase, slug ?? '');
if (!event) {
Astro.response.status = 404;
}
const title = event ? titleFor(locale, event) : '404';
const description = event ? descriptionFor(locale, event) : 'Not found';
const reviews = event ? await getEventReviews(supabase, event.id) : [];
const canDelete = (reviewUserId: string) => user !== null && (user.isAdmin || reviewUserId === user.id);
---
<BaseLayout
locale={locale}
dict={dict}
pagePath={`/events/${slug ?? ''}`}
user={user}
title={event ? `${title} — ${dict.meta.siteName}` : '404'}
description={description.slice(0, 160) || dict.meta.homeDescription}
ogImage={event?.cover_image_url ?? undefined}
>
{!event ? (
<section class="section">
<div class="container">
<div class="empty-state">
<div class="empty-state__icon"><Icon name="ticket" size={40} /></div>
<p>{dict.common.noResults}</p>
<p>
<a class="btn btn--outline" href={localizedPath(locale, '/events')}>
{dict.event.backToEvents}
</a>
</p>
</div>
</div>
</section>
) : (
<section class="event-hero">
<div class="container">
<div class="event-hero__media">
{event.cover_image_url ? (
<img src={event.cover_image_url} alt={title} />
) : (
<div class="event-card__media-placeholder" style="height:100%;">{title.slice(0, 1)}</div>
)}
</div>
<div class="event-hero__body">
<div>
<a href={localizedPath(locale, '/events')} class="text-muted" style="display:inline-block;margin-bottom:12px;">
← {dict.event.backToEvents}
</a>
<h1>{title}</h1>
<div class="event-card__meta" style="margin-bottom:24px;">
<span><Icon name="calendar" size={16} /> {formatDate(locale, event.starts_at)}</span>
<span><Icon name="clock" size={16} /> {formatTime(locale, event.starts_at)}</span>
{event.venue_name && (
<span>
<Icon name="pin" size={16} /> {event.venue_name}
</span>
)}
</div>
<div class="prose">{description && <p>{description}</p>}</div>
</div>
<aside class="card" style="padding:24px;">
<div class="event-facts">
<div class="event-fact">
<span class="event-fact__icon"><Icon name="calendar" size={18} /></span>
<div>
<div class="event-fact__label">{dict.event.date}</div>
<div class="event-fact__value">{formatDate(locale, event.starts_at)}</div>
</div>
</div>
<div class="event-fact">
<span class="event-fact__icon"><Icon name="clock" size={18} /></span>
<div>
<div class="event-fact__label">{dict.event.time}</div>
<div class="event-fact__value">
{formatTime(locale, event.starts_at)} – {formatTime(locale, event.ends_at)}
</div>
</div>
</div>
{event.venue_name && (
<div class="event-fact">
<span class="event-fact__icon"><Icon name="pin" size={18} /></span>
<div>
<div class="event-fact__label">{dict.event.venue}</div>
<div class="event-fact__value">{event.venue_name}</div>
</div>
</div>
)}
</div>
</aside>
</div>
</div>
</section>
)}
{event && (
<section class="section section--alt">
<div class="container">
<div class="section-head">
<div>
<span class="eyebrow">{dict.event.reviewsTitle}</span>
<h2>
{dict.event.reviewsTitle}{' '}
<span class="text-muted" style="font-family:var(--font-sans);font-size:1rem;">
({reviews.length})
</span>
</h2>
</div>
</div>
<div class="split">
<div class="review-list">
{reviews.length > 0 ? (
reviews.map((review) => (
<ReviewCard
review={review}
locale={locale}
dict={dict}
deleteUrl={canDelete(review.user_id) ? `/api/reviews/${review.id}` : undefined}
/>
))
) : (
<div class="empty-state">
<p>{dict.event.noReviews}</p>
</div>
)}
</div>
<div>
<div class="card" style="padding:24px;">
<h3>{dict.event.writeReview}</h3>
{user ? (
<ReviewForm eventId={event.id} dict={dict} />
) : (
<p>
<a href={localizedPath(locale, `/login?redirect=${encodeURIComponent(`/${locale}/events/${event.slug}`)}`)}>
{dict.event.signInToReview}
</a>
</p>
)}
</div>
</div>
</div>
</div>
</section>
)}
</BaseLayout>
+67
View File
@@ -0,0 +1,67 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import EventCard from '../../../components/ui/EventCard.astro';
import Icon from '../../../components/ui/Icon.astro';
import EventFilters from '../../../components/islands/EventFilters';
import { getDict } from '../../../lib/i18n';
import type { Locale } from '../../../lib/i18n';
import { getPublishedEvents, type EventsFilter } from '../../../lib/db';
const { lang } = Astro.params;
const locale = (lang ?? 'ua') as Locale;
const dict = getDict(locale);
const supabase = Astro.locals.supabase;
const user = Astro.locals.user;
const url = new URL(Astro.url);
const rawFilter = url.searchParams.get('filter');
const filter: EventsFilter = rawFilter === 'upcoming' || rawFilter === 'past' ? rawFilter : 'all';
const query = url.searchParams.get('q') ?? '';
const events = await getPublishedEvents(supabase, filter, query);
const emptyMessage =
query.trim()
? dict.events.emptySearch
: filter === 'upcoming'
? dict.events.emptyUpcoming
: filter === 'past'
? dict.events.emptyPast
: dict.events.emptyUpcoming;
---
<BaseLayout
locale={locale}
dict={dict}
pagePath="/events"
user={user}
title={`${dict.events.pageTitle} — ${dict.meta.siteName}`}
description={dict.events.pageDescription}
>
<section class="section">
<div class="container">
<div class="section-head">
<div>
<span class="eyebrow">{dict.meta.siteName}</span>
<h1>{dict.events.pageTitle}</h1>
<p>{dict.events.pageDescription}</p>
</div>
</div>
<EventFilters client:load dict={dict} currentFilter={filter} currentQuery={query} />
{events.length > 0 ? (
<div class="grid-3">
{events.map((event) => (
<EventCard event={event} locale={locale} dict={dict} />
))}
</div>
) : (
<div class="empty-state">
<div class="empty-state__icon"><Icon name="tent" size={40} /></div>
<p>{emptyMessage}</p>
</div>
)}
</div>
</section>
</BaseLayout>
+141
View File
@@ -0,0 +1,141 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import EventCard from '../../components/ui/EventCard.astro';
import ReviewCard from '../../components/ui/ReviewCard.astro';
import Icon from '../../components/ui/Icon.astro';
import { localizedPath, getDict } from '../../lib/i18n';
import type { Locale } from '../../lib/i18n';
import { getUpcomingEvents, getVenueReviews } from '../../lib/db';
import { VENUE } from '../../lib/venue';
const { lang } = Astro.params;
const locale = (lang ?? 'ua') as Locale;
const dict = getDict(locale);
const supabase = Astro.locals.supabase;
const user = Astro.locals.user;
const events = await getUpcomingEvents(supabase, 3);
const venueReviews = await getVenueReviews(supabase, 3);
const siteUrl = import.meta.env.PUBLIC_SITE_URL ?? 'http://localhost:4321';
const schema = {
'@context': 'https://schema.org',
'@type': 'TouristAttraction',
name: VENUE.name,
url: `${siteUrl}/ua/`,
image: `${siteUrl}/og-cover.jpg`,
description: dict.meta.homeDescription,
telephone: VENUE.phone,
email: VENUE.email,
address: {
'@type': 'PostalAddress',
streetAddress: '12 Vifleemska St',
addressLocality: 'Kyiv',
addressCountry: 'UA',
},
geo: {
'@type': 'GeoCoordinates',
latitude: VENUE.lat,
longitude: VENUE.lng,
},
openingHours: 'Mo-Su 10:00-23:00',
};
---
<BaseLayout
locale={locale}
dict={dict}
pagePath="/"
user={user}
title={dict.meta.homeTitle}
description={dict.meta.homeDescription}
>
<Fragment slot="head">
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
</Fragment>
<section class="hero">
<div class="container hero__inner">
<span class="eyebrow">{dict.hero.badge}</span>
<h1>{dict.hero.title}</h1>
<p>{dict.hero.subtitle}</p>
<div class="hero__actions">
<a class="btn btn--primary" href={localizedPath(locale, '/events')}>
{dict.hero.ctaEvents}
</a>
<a class="btn btn--outline" href={localizedPath(locale, '/map')}>
{dict.hero.ctaMap}
</a>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="section-head">
<div>
<span class="eyebrow">{dict.home.viewAllEvents}</span>
<h2>{dict.home.nextEvents}</h2>
<p>{dict.home.nextEventsSubtitle}</p>
</div>
<a class="btn btn--outline" href={localizedPath(locale, '/events')}>
{dict.home.viewAllEvents}
</a>
</div>
{events.length > 0 ? (
<div class="grid-3">
{events.map((event) => (
<EventCard event={event} locale={locale} dict={dict} />
))}
</div>
) : (
<div class="empty-state">
<div class="empty-state__icon"><Icon name="tree" size={40} /></div>
<p>{dict.events.emptyUpcoming}</p>
</div>
)}
</div>
</section>
<section class="section section--alt">
<div class="container">
<div class="section-head">
<div>
<span class="eyebrow">{dict.home.venueReviewsTitle}</span>
<h2>{dict.home.venueReviewsTitle}</h2>
<p>{dict.home.venueReviewsSubtitle}</p>
</div>
<a class="btn btn--outline" href={localizedPath(locale, '/about')}>
{dict.nav.about}
</a>
</div>
{venueReviews.length > 0 ? (
<div class="grid-2">
{venueReviews.map((review) => (
<ReviewCard review={review} locale={locale} dict={dict} />
))}
</div>
) : (
<div class="empty-state">
<div class="empty-state__icon"><Icon name="chat" size={40} /></div>
<p>{dict.about.noVenueReviews}</p>
</div>
)}
</div>
</section>
<section class="section">
<div class="container">
<div class="cta-band">
<h2>{dict.home.ctaTitle}</h2>
<p>{dict.home.ctaText}</p>
<div class="mt-6">
<a class="btn btn--accent" href={localizedPath(locale, '/contacts')}>
{dict.home.ctaButton}
</a>
</div>
</div>
</div>
</section>
</BaseLayout>
+39
View File
@@ -0,0 +1,39 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import AuthForm from '../../components/islands/AuthForm';
import { localizedPath, getDict } from '../../lib/i18n';
import type { Locale } from '../../lib/i18n';
const { lang } = Astro.params;
const locale = (lang ?? 'ua') as Locale;
const dict = getDict(locale);
const user = Astro.locals.user;
const redirect = new URL(Astro.url).searchParams.get('redirect') ?? `/${locale}/`;
const safeRedirect = redirect.startsWith('/') ? redirect : `/${locale}/`;
---
<BaseLayout
locale={locale}
dict={dict}
pagePath="/login"
user={user}
title={`${dict.auth.loginTitle} — ${dict.meta.siteName}`}
description={dict.auth.loginSubtitle}
>
<div class="auth-wrap">
<div class="card auth-card">
<div>
<h1>{dict.auth.loginTitle}</h1>
<p class="text-muted">{dict.auth.loginSubtitle}</p>
</div>
<AuthForm client:load mode="login" locale={locale} dict={dict} redirectTo={safeRedirect} />
<p class="text-muted" style="margin:0;">
{dict.auth.noAccount}{' '}
<a href={localizedPath(locale, '/register')}>{dict.auth.registerLink}</a>
</p>
</div>
</div>
</BaseLayout>
+80
View File
@@ -0,0 +1,80 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import MapView from '../../components/islands/MapView';
import Icon from '../../components/ui/Icon.astro';
import { getDict } from '../../lib/i18n';
import type { Locale } from '../../lib/i18n';
import { VENUE, venueAddress } from '../../lib/venue';
const { lang } = Astro.params;
const locale = (lang ?? 'ua') as Locale;
const dict = getDict(locale);
const user = Astro.locals.user;
---
<BaseLayout
locale={locale}
dict={dict}
pagePath="/map"
user={user}
title={`${dict.map.pageTitle} — ${dict.meta.siteName}`}
description={dict.map.pageDescription}
>
<section class="section">
<div class="container">
<div class="section-head">
<div>
<span class="eyebrow">{dict.meta.siteName}</span>
<h1>{dict.map.pageTitle}</h1>
<p>{dict.map.hint}</p>
</div>
</div>
<div class="map-page">
<div class="card" style="padding:24px;">
<div class="contact-details">
<div class="contact-detail">
<span><Icon name="pin" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.addressLabel}</div>
<strong>{venueAddress(locale)}</strong>
</div>
</div>
<div class="contact-detail">
<span><Icon name="clock" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.hoursLabel}</div>
{dict.contacts.hoursValue}
</div>
</div>
<div class="contact-detail">
<span><Icon name="phone" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.phone}</div>
<a href={`tel:${VENUE.phone.replace(/\s/g, '')}`}>{VENUE.phone}</a>
</div>
</div>
<div class="contact-detail">
<span><Icon name="mail" size={18} /></span>
<div>
<div class="contact-detail__label">{dict.contacts.emailLabel}</div>
<a href={`mailto:${VENUE.email}`}>{VENUE.email}</a>
</div>
</div>
</div>
</div>
<div class="map-wrap">
<MapView
client:only="react"
lat={VENUE.lat}
lng={VENUE.lng}
name={VENUE.name}
address={venueAddress(locale)}
hint={dict.map.hint}
/>
</div>
</div>
</div>
</section>
</BaseLayout>
+39
View File
@@ -0,0 +1,39 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import AuthForm from '../../components/islands/AuthForm';
import { localizedPath, getDict } from '../../lib/i18n';
import type { Locale } from '../../lib/i18n';
const { lang } = Astro.params;
const locale = (lang ?? 'ua') as Locale;
const dict = getDict(locale);
const user = Astro.locals.user;
const redirect = new URL(Astro.url).searchParams.get('redirect') ?? `/${locale}/`;
const safeRedirect = redirect.startsWith('/') ? redirect : `/${locale}/`;
---
<BaseLayout
locale={locale}
dict={dict}
pagePath="/register"
user={user}
title={`${dict.auth.registerTitle} — ${dict.meta.siteName}`}
description={dict.auth.registerSubtitle}
>
<div class="auth-wrap">
<div class="card auth-card">
<div>
<h1>{dict.auth.registerTitle}</h1>
<p class="text-muted">{dict.auth.registerSubtitle}</p>
</div>
<AuthForm client:load mode="register" locale={locale} dict={dict} redirectTo={safeRedirect} />
<p class="text-muted" style="margin:0;">
{dict.auth.hasAccount}{' '}
<a href={localizedPath(locale, '/login')}>{dict.auth.loginLink}</a>
</p>
</div>
</div>
</BaseLayout>
+29
View File
@@ -0,0 +1,29 @@
---
import AdminLayout from '../../../../layouts/AdminLayout.astro';
import EventEditor from '../../../../components/islands/EventEditor';
import { getDict } from '../../../../lib/i18n';
import { getAdminEvent, titleFor } from '../../../../lib/db';
const dict = getDict('ua');
const { id } = Astro.params;
const event = id ? await getAdminEvent(Astro.locals.supabase, id) : null;
if (!event) {
Astro.response.status = 404;
}
---
<AdminLayout
title={`${dict.admin.editEvent} — Devodytyji`}
current="events"
>
<a href="/admin/events" class="text-muted">← {dict.admin.backToList}</a>
<h1>{event ? titleFor('ua', event) : dict.admin.notFound}</h1>
{event && (
<div class="card" style="padding:24px;max-width:900px;">
<EventEditor client:load dict={dict} event={event} />
</div>
)}
</AdminLayout>
+95
View File
@@ -0,0 +1,95 @@
---
import AdminLayout from '../../../layouts/AdminLayout.astro';
import Icon from '../../../components/ui/Icon.astro';
import { getDict, localizedPath } from '../../../lib/i18n';
import { getAdminEvents, titleFor } from '../../../lib/db';
import { formatDateTime } from '../../../lib/format';
const dict = getDict('ua');
const events = await getAdminEvents(Astro.locals.supabase);
---
<AdminLayout title={`${dict.admin.eventsTitle} — Devodytyji`} current="events">
<div style="display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
<div>
<h1>{dict.admin.eventsTitle}</h1>
<p class="text-muted">{events.length} record(s)</p>
</div>
<a class="btn btn--primary" href="/admin/events/new">{dict.admin.newEvent}</a>
</div>
{events.length === 0 ? (
<div class="empty-state mt-6">
<div class="empty-state__icon"><Icon name="tent" size={40} /></div>
<p>{dict.admin.emptyEvents}</p>
</div>
) : (
<div class="table-wrap mt-6">
<table class="table">
<thead>
<tr>
<th>{dict.admin.eventTitleUa}</th>
<th>{dict.admin.slug}</th>
<th>{dict.admin.startsAt}</th>
<th>{dict.admin.status}</th>
<th></th>
</tr>
</thead>
<tbody>
{events.map((event) => {
const isPublished = event.status === 'published';
return (
<tr>
<td>
<strong>{titleFor('ua', event) || '—'}</strong>
{event.venue_name && <div class="text-muted">{event.venue_name}</div>}
</td>
<td>
<code>{event.slug}</code>
</td>
<td>{formatDateTime('ua', event.starts_at)}</td>
<td>
<span class={`badge ${isPublished ? 'badge--published' : 'badge--draft'}`}>
{isPublished ? dict.common.published : dict.common.draft}
</span>
</td>
<td>
<div class="row-actions">
<a class="btn btn--sm btn--outline" href={`/admin/events/${event.id}/edit`}>
{dict.common.edit}
</a>
{isPublished && (
<a class="btn btn--sm btn--ghost" href={localizedPath('ua', `/events/${event.slug}`)} target="_blank">
{dict.admin.viewOnSite}
</a>
)}
<form
method="post"
action={`/api/admin/events/${event.id}`}
onsubmit="return confirm('Confirm publish toggle?')"
>
<input type="hidden" name="_action" value={isPublished ? 'unpublish' : 'publish'} />
<button class="btn btn--sm btn--outline" type="submit">
{isPublished ? dict.admin.unpublish : dict.admin.publish}
</button>
</form>
<form
method="post"
action={`/api/admin/events/${event.id}`}
onsubmit={`return confirm(${JSON.stringify(dict.admin.deleteEventConfirm)})`}
>
<input type="hidden" name="_action" value="delete" />
<button class="btn btn--sm btn--danger" type="submit">
{dict.common.delete}
</button>
</form>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</AdminLayout>
+18
View File
@@ -0,0 +1,18 @@
---
import AdminLayout from '../../../layouts/AdminLayout.astro';
import EventEditor from '../../../components/islands/EventEditor';
import { getDict } from '../../../lib/i18n';
const dict = getDict('ua');
---
<AdminLayout title={`${dict.admin.newEvent} — Devodytyji`} current="events">
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
<a href="/admin/events" class="text-muted">← {dict.admin.backToList}</a>
</div>
<h1>{dict.admin.newEvent}</h1>
<div class="card" style="padding:24px;max-width:900px;">
<EventEditor client:load dict={dict} event={null} />
</div>
</AdminLayout>
+34
View File
@@ -0,0 +1,34 @@
---
import AdminLayout from '../../layouts/AdminLayout.astro';
import { getDict } from '../../lib/i18n';
import { getAdminStats } from '../../lib/db';
const dict = getDict('ua');
const user = Astro.locals.user;
const stats = await getAdminStats(Astro.locals.supabase);
const cards = [
{ value: stats.published, label: dict.admin.statsPublished },
{ value: stats.drafts, label: dict.admin.statsDraft },
{ value: stats.unread, label: dict.admin.statsUnread },
{ value: stats.reviews, label: dict.admin.statsReviews },
];
---
<AdminLayout title={`${dict.admin.dashboardTitle} — Devodytyji`} current="dashboard">
<h1>{dict.admin.dashboardTitle}</h1>
<p class="text-muted">Welcome back, {user?.fullName || user?.email}</p>
<div class="stats-grid mt-6">
{cards.map((card) => (
<div class="card stat-card">
<div class="stat-card__value">{card.value}</div>
<div class="stat-card__label">{card.label}</div>
</div>
))}
</div>
<p>
<a class="btn btn--primary" href="/admin/events/new">{dict.admin.newEvent}</a>
</p>
</AdminLayout>
+59
View File
@@ -0,0 +1,59 @@
---
import AdminLayout from '../../layouts/AdminLayout.astro';
import Icon from '../../components/ui/Icon.astro';
import { getDict } from '../../lib/i18n';
import { getAdminMessages } from '../../lib/db';
import { formatRelative } from '../../lib/format';
const dict = getDict('ua');
const messages = await getAdminMessages(Astro.locals.supabase);
---
<AdminLayout title={`${dict.admin.messagesTitle} — Devodytyji`} current="messages">
<div>
<h1>{dict.admin.messagesTitle}</h1>
<p class="text-muted">{messages.length} record(s)</p>
</div>
{messages.length === 0 ? (
<div class="empty-state mt-6">
<div class="empty-state__icon"><Icon name="mail" size={40} /></div>
<p>{dict.admin.emptyMessages}</p>
</div>
) : (
<div class="review-list mt-6">
{messages.map((message) => (
<article class={`review ${message.read ? '' : 'card'}`} style={message.read ? undefined : 'border-color:var(--primary);background:var(--primary-soft);'}>
<div class="review__head">
<div>
<span class="review__author">
{message.name}
{!message.read && <span class="badge badge--published" style="margin-left:8px;">{dict.common.unread}</span>}
</span>
<div class="review__date">
{formatRelative('ua', message.created_at)} · <a href={`mailto:${message.email}`}>{message.email}</a>
</div>
</div>
<div class="row-actions">
<form method="post" action="/api/admin/messages">
<input type="hidden" name="id" value={message.id} />
<input type="hidden" name="_action" value={message.read ? 'unread' : 'read'} />
<button class="btn btn--sm btn--outline" type="submit">
{message.read ? dict.admin.markUnread : dict.admin.markRead}
</button>
</form>
<form method="post" action="/api/admin/messages" onsubmit="return confirm('Delete message?')">
<input type="hidden" name="id" value={message.id} />
<input type="hidden" name="_action" value="delete" />
<button class="btn btn--sm btn--danger" type="submit">
{dict.admin.deleteMessage}
</button>
</form>
</div>
</div>
<p style="margin:0;white-space:pre-line;">{message.message}</p>
</article>
))}
</div>
)}
</AdminLayout>
+75
View File
@@ -0,0 +1,75 @@
---
import AdminLayout from '../../layouts/AdminLayout.astro';
import Stars from '../../components/ui/Stars.astro';
import Icon from '../../components/ui/Icon.astro';
import { getDict } from '../../lib/i18n';
import { getAdminReviews } from '../../lib/db';
import { formatRelative } from '../../lib/format';
const dict = getDict('ua');
const reviews = await getAdminReviews(Astro.locals.supabase);
---
<AdminLayout title={`${dict.admin.reviewsTitle} — Devodytyji`} current="reviews">
<div>
<h1>{dict.admin.reviewsTitle}</h1>
<p class="text-muted">{reviews.length} record(s)</p>
</div>
{reviews.length === 0 ? (
<div class="empty-state mt-6">
<div class="empty-state__icon"><Icon name="chat" size={40} /></div>
<p>{dict.admin.emptyReviews}</p>
</div>
) : (
<div class="table-wrap mt-6">
<table class="table">
<thead>
<tr>
<th>{dict.reviewForm.ratingLabel}</th>
<th>{dict.reviewForm.commentLabel}</th>
<th>Тип</th>
<th>{dict.admin.from}</th>
<th>Дата</th>
<th></th>
</tr>
</thead>
<tbody>
{reviews.map((review) => (
<tr>
<td>
<Stars rating={review.rating} />
</td>
<td style="max-width:360px;">
<span style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{review.comment || '—'}
</span>
{review.event_title && (
<div class="text-muted">
<Icon name="tent" size={14} /> {review.event_title}
</div>
)}
</td>
<td>
<span class={`badge ${review.type === 'event' ? 'badge--published' : 'badge--draft'}`}>
{review.type === 'event' ? 'event' : 'venue'}
</span>
</td>
<td>{review.user_name || '—'}</td>
<td>{formatRelative('ua', review.created_at)}</td>
<td>
<form method="post" action="/api/admin/reviews" onsubmit="return confirm('Delete review?')">
<input type="hidden" name="type" value={review.type} />
<input type="hidden" name="id" value={review.id} />
<button class="btn btn--sm btn--danger" type="submit">
{dict.admin.deleteReview}
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AdminLayout>
+74
View File
@@ -0,0 +1,74 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
interface EventPayload {
title?: { ua?: string; en?: string };
description?: { ua?: string; en?: string };
slug?: string;
starts_at?: string;
ends_at?: string;
venue_name?: string | null;
cover_image_url?: string | null;
status?: string;
}
function validate(payload: EventPayload): string | null {
if (!payload.title?.ua?.trim() || !payload.title?.en?.trim()) return 'missing_title';
if (!payload.slug?.trim()) return 'missing_slug';
if (!payload.starts_at || !payload.ends_at) return 'missing_dates';
if (new Date(payload.ends_at) <= new Date(payload.starts_at)) return 'invalid_dates';
if (payload.status !== 'draft' && payload.status !== 'published') return 'invalid_status';
return null;
}
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) return json({ error: 'unauthorized' }, 401);
let payload: EventPayload;
try {
payload = await request.json();
} catch {
return json({ error: 'bad_request' }, 400);
}
const invalid = validate(payload);
if (invalid) return json({ error: invalid }, 400);
// Slug uniqueness
const { data: existing } = await locals.supabase
.from('events')
.select('id')
.eq('slug', payload.slug!.trim())
.maybeSingle();
if (existing) return json({ error: 'slug_exists' }, 409);
const { data, error } = await locals.supabase
.from('events')
.insert({
title: { ua: payload.title!.ua!.trim(), en: payload.title!.en!.trim() },
description: {
ua: payload.description?.ua?.trim() ?? '',
en: payload.description?.en?.trim() ?? '',
},
slug: payload.slug!.trim(),
starts_at: payload.starts_at,
ends_at: payload.ends_at,
venue_name: payload.venue_name || null,
cover_image_url: payload.cover_image_url || null,
status: payload.status,
created_by: user.id,
})
.select('id')
.single();
if (error) return json({ error: 'db_error' }, 500);
return json({ ok: true, id: data?.id });
};
+124
View File
@@ -0,0 +1,124 @@
import type { APIRoute } from 'astro';
import { readBody } from '../../../../lib/request';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
interface EventPayload {
title?: { ua?: string; en?: string };
description?: { ua?: string; en?: string };
slug?: string;
starts_at?: string;
ends_at?: string;
venue_name?: string | null;
cover_image_url?: string | null;
status?: string;
}
function validate(payload: EventPayload): string | null {
if (!payload.title?.ua?.trim() || !payload.title?.en?.trim()) return 'missing_title';
if (!payload.slug?.trim()) return 'missing_slug';
if (!payload.starts_at || !payload.ends_at) return 'missing_dates';
if (new Date(payload.ends_at) <= new Date(payload.starts_at)) return 'invalid_dates';
if (payload.status !== 'draft' && payload.status !== 'published') return 'invalid_status';
return null;
}
export const PUT: APIRoute = async ({ request, params, locals }) => {
const id = params.id;
if (!id) return json({ error: 'bad_request' }, 400);
let payload: EventPayload;
try {
payload = await request.json();
} catch {
return json({ error: 'bad_request' }, 400);
}
const invalid = validate(payload);
if (invalid) return json({ error: invalid }, 400);
// Slug uniqueness (excluding this event)
const { data: existing } = await locals.supabase
.from('events')
.select('id')
.eq('slug', payload.slug!.trim())
.neq('id', id)
.maybeSingle();
if (existing) return json({ error: 'slug_exists' }, 409);
const { error } = await locals.supabase
.from('events')
.update({
title: { ua: payload.title!.ua!.trim(), en: payload.title!.en!.trim() },
description: {
ua: payload.description?.ua?.trim() ?? '',
en: payload.description?.en?.trim() ?? '',
},
slug: payload.slug!.trim(),
starts_at: payload.starts_at,
ends_at: payload.ends_at,
venue_name: payload.venue_name || null,
cover_image_url: payload.cover_image_url || null,
status: payload.status,
})
.eq('id', id);
if (error) return json({ error: 'db_error' }, 500);
return json({ ok: true });
};
export const DELETE: APIRoute = async ({ params, locals }) => {
const id = params.id;
if (!id) return json({ error: 'bad_request' }, 400);
const { error } = await locals.supabase.from('events').delete().eq('id', id);
if (error) return json({ error: 'db_error' }, 500);
return json({ ok: true });
};
export const POST: APIRoute = async ({ request, params, locals }) => {
const id = params.id;
if (!id) return json({ error: 'bad_request' }, 400);
let body: { _action?: string };
try {
body = await readBody<{ _action?: string }>(request);
} catch {
return json({ error: 'bad_request' }, 400);
}
const action = body._action;
let error: { message: string } | null = null;
if (action === 'publish' || action === 'unpublish') {
({ error } = await locals.supabase
.from('events')
.update({ status: action === 'publish' ? 'published' : 'draft' })
.eq('id', id));
} else if (action === 'delete') {
({ error } = await locals.supabase.from('events').delete().eq('id', id));
} else {
return json({ error: 'invalid_action' }, 400);
}
if (error) return json({ error: 'db_error' }, 500);
const referer = request.headers.get('referer');
if (referer) {
try {
const url = new URL(referer);
if (url.pathname.startsWith('/')) {
return new Response(null, { status: 302, headers: { Location: url.pathname } });
}
} catch {
// ignore
}
}
return new Response(null, { status: 302, headers: { Location: '/admin/events' } });
};
+54
View File
@@ -0,0 +1,54 @@
import type { APIRoute } from 'astro';
import { readBody } from '../../../lib/request';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
function redirectBack(request: Request, fallback = '/admin/messages') {
const referer = request.headers.get('referer');
if (referer) {
try {
const url = new URL(referer);
if (url.pathname.startsWith('/')) {
return new Response(null, { status: 302, headers: { Location: url.pathname + url.search } });
}
} catch {
// ignore
}
}
return new Response(null, { status: 302, headers: { Location: fallback } });
}
export const POST: APIRoute = async ({ request, locals }) => {
let body: { _action?: string; id?: string } = {};
try {
body = await readBody<{ _action?: string; id?: string }>(request);
} catch {
return json({ error: 'bad_request' }, 400);
}
const { _action, id } = body;
if (!id) return json({ error: 'bad_request' }, 400);
let error: { message: string } | null = null;
if (_action === 'read' || _action === 'unread') {
({ error } = await locals.supabase
.from('contact_messages')
.update({ read: _action === 'read' })
.eq('id', id));
} else if (_action === 'delete') {
({ error } = await locals.supabase.from('contact_messages').delete().eq('id', id));
} else {
return json({ error: 'invalid_action' }, 400);
}
if (error) return json({ error: 'db_error' }, 500);
return redirectBack(request);
};
+61
View File
@@ -0,0 +1,61 @@
import type { APIRoute } from 'astro';
import { readBody } from '../../../lib/request';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
function redirectBack(request: Request, fallback = '/admin/reviews') {
const referer = request.headers.get('referer');
if (referer) {
try {
const url = new URL(referer);
if (url.pathname.startsWith('/')) {
return new Response(null, { status: 302, headers: { Location: url.pathname + url.search } });
}
} catch {
// ignore
}
}
return new Response(null, { status: 302, headers: { Location: fallback } });
}
export const DELETE: APIRoute = async ({ request, locals }) => {
let body: { type?: string; id?: string } = {};
try {
body = await readBody<{ type?: string; id?: string }>(request);
} catch {
return json({ error: 'bad_request' }, 400);
}
const { type, id } = body;
if (type !== 'event' && type !== 'venue') return json({ error: 'invalid_type' }, 400);
if (!id) return json({ error: 'bad_request' }, 400);
const table = type === 'event' ? 'event_reviews' : 'venue_reviews';
const { error } = await locals.supabase.from(table).delete().eq('id', id);
if (error) return json({ error: 'db_error' }, 500);
return json({ ok: true });
};
export const POST: APIRoute = async ({ request, locals }) => {
let body: { type?: string; id?: string } = {};
try {
body = await readBody<{ type?: string; id?: string }>(request);
} catch {
return json({ error: 'bad_request' }, 400);
}
const { type, id } = body;
if (type !== 'event' && type !== 'venue') return json({ error: 'invalid_type' }, 400);
if (!id) return json({ error: 'bad_request' }, 400);
const table = type === 'event' ? 'event_reviews' : 'venue_reviews';
await locals.supabase.from(table).delete().eq('id', id);
return redirectBack(request);
};
+20
View File
@@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { getSiteUrl } from '../../../lib/supabase';
export const prerender = false;
export const GET: APIRoute = async (context) => {
const supabase = context.locals.supabase;
const code = context.url.searchParams.get('code');
const next = context.url.searchParams.get('next') ?? '/';
const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/';
if (code) {
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
return context.redirect(`${getSiteUrl()}${safeNext}`);
}
}
return context.redirect(`${getSiteUrl()}/ua/login`);
};
+53
View File
@@ -0,0 +1,53 @@
import type { APIRoute } from 'astro';
import { getSiteUrl } from '../../../lib/supabase';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
export const GET: APIRoute = async (context) => {
const supabase = context.locals.supabase;
const provider = context.url.searchParams.get('provider');
const next = context.url.searchParams.get('next') ?? '/';
if (provider !== 'google' && provider !== 'github') {
return json({ error: 'invalid_provider' }, 400);
}
const callbackUrl = `${getSiteUrl()}/api/auth/callback?next=${encodeURIComponent(next)}`;
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: { redirectTo: callbackUrl },
});
if (error || !data.url) {
return json({ error: 'oauth_failed' }, 500);
}
return json({ url: data.url });
};
export const POST: APIRoute = async ({ request, locals }) => {
const supabase = locals.supabase;
let body: { email?: string; password?: string };
try {
body = await request.json();
} catch {
return json({ error: 'bad_request' }, 400);
}
const { email, password } = body;
if (!email || !password) {
return json({ error: 'bad_request' }, 400);
}
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
return json({ error: 'invalid_credentials' }, 401);
}
return json({ ok: true });
};
+21
View File
@@ -0,0 +1,21 @@
import type { APIRoute } from 'astro';
import { getSiteUrl } from '../../../lib/supabase';
export const prerender = false;
export const POST: APIRoute = async (context) => {
await context.locals.supabase.auth.signOut();
const referer = context.request.headers.get('referer');
if (referer) {
try {
const url = new URL(referer);
if (url.pathname.startsWith('/')) {
return context.redirect(url.pathname + url.search);
}
} catch {
// ignore malformed referer
}
}
return context.redirect(`${getSiteUrl()}/ua/`);
};
+10
View File
@@ -0,0 +1,10 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = ({ locals }) => {
return new Response(JSON.stringify({ user: locals.user }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
+38
View File
@@ -0,0 +1,38 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
export const POST: APIRoute = async ({ request, locals }) => {
const supabase = locals.supabase;
let body: { email?: string; password?: string; fullName?: string };
try {
body = await request.json();
} catch {
return json({ error: 'bad_request' }, 400);
}
const { email, password, fullName } = body;
if (!email || !password) {
return json({ error: 'bad_request' }, 400);
}
const { data, error } = await supabase.auth.signUp({
email,
password,
options: { data: { full_name: fullName || null } },
});
if (error) {
const message = error.message?.toLowerCase() ?? '';
const status = message.includes('already') ? 409 : 400;
return json({ error: message.includes('already') ? 'email_exists' : 'generic' }, status);
}
return json({ ok: true, needsConfirmation: !data.session });
};
+52
View File
@@ -0,0 +1,52 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const POST: APIRoute = async ({ request, locals }) => {
let body: { name?: string; email?: string; message?: string; honeypot?: string };
try {
body = await request.json();
} catch {
return json({ error: 'bad_request' }, 400);
}
// Honeypot: silently accept bots
if (body.honeypot) {
return json({ ok: true });
}
const name = (body.name ?? '').trim();
const email = (body.email ?? '').trim();
const message = (body.message ?? '').trim();
if (!name || !email || !message || !EMAIL_RE.test(email)) {
return json({ error: 'invalid_input' }, 400);
}
// Rate limit: max 3 messages per email per 10 minutes
const { data: recent, error: rpcError } = await locals.supabase.rpc('recent_contact_messages', {
p_email: email,
p_within: '10 minutes',
});
if (!rpcError && typeof recent === 'number' && recent >= 3) {
return json({ error: 'rate_limited' }, 429);
}
const { error } = await locals.supabase.from('contact_messages').insert({
name,
email,
message,
});
if (error) return json({ error: 'db_error' }, 500);
return json({ ok: true });
};
+54
View File
@@ -0,0 +1,54 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) return json({ error: 'unauthorized' }, 401);
let body: { event_id?: string; rating?: number; comment?: string };
try {
body = await request.json();
} catch {
return json({ error: 'bad_request' }, 400);
}
const rating = Number(body.rating);
if (!body.event_id || !Number.isInteger(rating) || rating < 1 || rating > 5) {
return json({ error: 'invalid_rating' }, 400);
}
const comment = typeof body.comment === 'string' ? body.comment.trim() : '';
// Upsert: one review per user per event
const { data: existing } = await locals.supabase
.from('event_reviews')
.select('id')
.eq('event_id', body.event_id)
.eq('user_id', user.id)
.maybeSingle();
let error;
if (existing) {
({ error } = await locals.supabase
.from('event_reviews')
.update({ rating, comment })
.eq('id', existing.id));
} else {
({ error } = await locals.supabase.from('event_reviews').insert({
event_id: body.event_id,
user_id: user.id,
rating,
comment,
}));
}
if (error) return json({ error: 'db_error' }, 500);
return json({ ok: true });
};
+42
View File
@@ -0,0 +1,42 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
export const DELETE: APIRoute = async ({ params, locals }) => {
const user = locals.user;
if (!user) return json({ error: 'unauthorized' }, 401);
const { data, error } = await locals.supabase
.from('event_reviews')
.delete()
.eq('id', params.id ?? '')
.select('id');
if (error) return json({ error: 'db_error' }, 500);
if ((data?.length ?? 0) === 0) return json({ error: 'not_found' }, 404);
return json({ ok: true });
};
export const POST: APIRoute = async ({ params, locals, request }) => {
const user = locals.user;
if (!user) return json({ error: 'unauthorized' }, 401);
await locals.supabase.from('event_reviews').delete().eq('id', params.id ?? '');
const referer = request.headers.get('referer');
if (referer) {
try {
const url = new URL(referer);
if (url.pathname.startsWith('/')) return new Response(null, { status: 302, headers: { Location: url.pathname + url.search } });
} catch {
// ignore
}
}
return new Response(null, { status: 302, headers: { Location: '/' } });
};
+37
View File
@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
export const POST: APIRoute = async ({ request, locals }) => {
const user = locals.user;
if (!user) return json({ error: 'unauthorized' }, 401);
let body: { rating?: number; comment?: string };
try {
body = await request.json();
} catch {
return json({ error: 'bad_request' }, 400);
}
const rating = Number(body.rating);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
return json({ error: 'invalid_rating' }, 400);
}
const comment = typeof body.comment === 'string' ? body.comment.trim() : '';
const { error } = await locals.supabase.from('venue_reviews').insert({
user_id: user.id,
rating,
comment,
});
if (error) return json({ error: 'db_error' }, 500);
return json({ ok: true });
};
+42
View File
@@ -0,0 +1,42 @@
import type { APIRoute } from 'astro';
export const prerender = false;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
export const DELETE: APIRoute = async ({ params, locals }) => {
const user = locals.user;
if (!user) return json({ error: 'unauthorized' }, 401);
const { data, error } = await locals.supabase
.from('venue_reviews')
.delete()
.eq('id', params.id ?? '')
.select('id');
if (error) return json({ error: 'db_error' }, 500);
if ((data?.length ?? 0) === 0) return json({ error: 'not_found' }, 404);
return json({ ok: true });
};
export const POST: APIRoute = async ({ params, locals, request }) => {
const user = locals.user;
if (!user) return json({ error: 'unauthorized' }, 401);
await locals.supabase.from('venue_reviews').delete().eq('id', params.id ?? '');
const referer = request.headers.get('referer');
if (referer) {
try {
const url = new URL(referer);
if (url.pathname.startsWith('/')) return new Response(null, { status: 302, headers: { Location: url.pathname + url.search } });
} catch {
// ignore
}
}
return new Response(null, { status: 302, headers: { Location: '/' } });
};
+21
View File
@@ -0,0 +1,21 @@
import type { APIRoute } from 'astro';
export const prerender = false;
export const GET: APIRoute = () => {
const siteUrl = import.meta.env.PUBLIC_SITE_URL ?? 'http://localhost:4321';
const body = `User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: ${siteUrl}/sitemap.xml
`;
return new Response(body, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
};
+40
View File
@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { LOCALES, localizedPath } from '../lib/i18n';
import { getPublishedEvents } from '../lib/db';
export const prerender = false;
const staticPaths = ['/', '/events', '/about', '/contacts', '/map'];
export const GET: APIRoute = async ({ locals }) => {
const siteUrl = import.meta.env.PUBLIC_SITE_URL ?? 'http://localhost:4321';
const events = await getPublishedEvents(locals.supabase, 'all', '', 500);
const now = new Date().toISOString();
const urls = new Set<string>();
for (const locale of LOCALES) {
for (const path of staticPaths) {
urls.add(`${siteUrl}${localizedPath(locale, path)}`);
}
for (const event of events) {
urls.add(`${siteUrl}${localizedPath(locale, `/events/${event.slug}`)}`);
}
}
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
${[...urls]
.map(
(url) =>
` <url>\n <loc>${url}</loc>\n <lastmod>${now}</lastmod>\n <changefreq>weekly</changefreq>\n </url>`,
)
.join('\n')}
</urlset>`;
return new Response(body, {
headers: {
'Content-Type': 'application/xml; charset=utf-8',
'Cache-Control': 'public, max-age=3600',
},
});
};
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
-- ============================================================
-- 0001_init.sql — Devodytyji schema, RLS, storage, seed helpers
-- Run in the Supabase SQL editor or via `supabase db push`.
-- ============================================================
-- ---------- profiles ----------
create table public.profiles (
id uuid primary key references auth.users (id) on delete cascade,
full_name text,
avatar_url text,
is_admin boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- Auto-create a profile for every new auth user
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.profiles (id, full_name, avatar_url)
values (
new.id,
coalesce(
new.raw_user_meta_data->>'full_name',
new.raw_user_meta_data->>'name',
split_part(coalesce(new.email, ''), '@', 1)
),
new.raw_user_meta_data->>'avatar_url'
)
on conflict (id) do nothing;
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
-- Helper to promote a user to admin by email (run from dashboard/psql)
create or replace function public.promote_user_to_admin(target_email text)
returns void
language plpgsql
security definer
set search_path = public
as $$
begin
update public.profiles p
set is_admin = true
from auth.users u
where u.id = p.id
and lower(u.email) = lower(target_email);
if not found then
raise exception 'No user found with email %', target_email;
end if;
end;
$$;
-- ---------- events ----------
create table public.events (
id uuid primary key default gen_random_uuid(),
slug text unique not null,
title jsonb not null,
description jsonb not null default '{"ua":"","en":""}'::jsonb,
cover_image_url text,
starts_at timestamptz not null,
ends_at timestamptz not null,
venue_name text,
status text not null default 'draft' check (status in ('draft', 'published')),
created_by uuid references public.profiles (id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index events_slug_idx on public.events (slug);
create index events_starts_at_idx on public.events (starts_at);
create index events_status_idx on public.events (status);
-- ---------- event_reviews ----------
create table public.event_reviews (
id uuid primary key default gen_random_uuid(),
event_id uuid not null references public.events (id) on delete cascade,
user_id uuid not null references public.profiles (id) on delete cascade,
rating int not null check (rating between 1 and 5),
comment text,
created_at timestamptz not null default now(),
unique (event_id, user_id)
);
create index event_reviews_event_id_idx on public.event_reviews (event_id);
-- ---------- venue_reviews ----------
create table public.venue_reviews (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles (id) on delete cascade,
rating int not null check (rating between 1 and 5),
comment text,
created_at timestamptz not null default now()
);
-- ---------- contact_messages ----------
create table public.contact_messages (
id uuid primary key default gen_random_uuid(),
name text not null,
email text not null,
message text not null,
read boolean not null default false,
created_at timestamptz not null default now()
);
create index contact_messages_read_idx on public.contact_messages (read);
-- ---------- updated_at trigger ----------
create or replace function public.set_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger profiles_set_updated_at
before update on public.profiles
for each row execute function public.set_updated_at();
create trigger events_set_updated_at
before update on public.events
for each row execute function public.set_updated_at();
-- ---------- contact rate-limit helper (security definer so anon can use it) ----------
create or replace function public.recent_contact_messages(p_email text, p_within interval default interval '10 minutes')
returns bigint
language sql
security definer
set search_path = public
stable
as $$
select count(*)::bigint
from public.contact_messages
where email = p_email
and created_at > now() - p_within;
$$;
grant execute on function public.recent_contact_messages(text, interval) to anon, authenticated;
-- ============================================================
-- Row Level Security
-- ============================================================
alter table public.profiles enable row level security;
alter table public.events enable row level security;
alter table public.event_reviews enable row level security;
alter table public.venue_reviews enable row level security;
alter table public.contact_messages enable row level security;
-- profiles: public read, self edit
create policy "profiles_select_public" on public.profiles
for select using (true);
create policy "profiles_update_self" on public.profiles
for update using (auth.uid() = id);
-- events: published visible to all; admins full CRUD
create policy "events_select_published" on public.events
for select using (status = 'published');
create policy "events_select_admin" on public.events
for select using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
create policy "events_insert_admin" on public.events
for insert with check (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
create policy "events_update_admin" on public.events
for update using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
create policy "events_delete_admin" on public.events
for delete using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- event_reviews: public read, authenticated insert, owner update/delete, admin delete
create policy "event_reviews_select_public" on public.event_reviews
for select using (true);
create policy "event_reviews_insert_auth" on public.event_reviews
for insert with check (auth.uid() = user_id);
create policy "event_reviews_update_owner" on public.event_reviews
for update using (auth.uid() = user_id);
create policy "event_reviews_delete_owner" on public.event_reviews
for delete using (auth.uid() = user_id);
create policy "event_reviews_delete_admin" on public.event_reviews
for delete using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- venue_reviews: public read, authenticated insert, owner update/delete, admin delete
create policy "venue_reviews_select_public" on public.venue_reviews
for select using (true);
create policy "venue_reviews_insert_auth" on public.venue_reviews
for insert with check (auth.uid() = user_id);
create policy "venue_reviews_update_owner" on public.venue_reviews
for update using (auth.uid() = user_id);
create policy "venue_reviews_delete_owner" on public.venue_reviews
for delete using (auth.uid() = user_id);
create policy "venue_reviews_delete_admin" on public.venue_reviews
for delete using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- contact_messages: anon insert, admin read/update/delete
create policy "contact_messages_insert_anon" on public.contact_messages
for insert with check (true);
create policy "contact_messages_select_admin" on public.contact_messages
for select using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
create policy "contact_messages_update_admin" on public.contact_messages
for update using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
create policy "contact_messages_delete_admin" on public.contact_messages
for delete using (
exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ============================================================
-- Storage: event-covers bucket (public read, admin write)
-- ============================================================
insert into storage.buckets (id, name, public)
values ('event-covers', 'event-covers', true)
on conflict (id) do nothing;
create policy "event_covers_select_public" on storage.objects
for select using (bucket_id = 'event-covers');
create policy "event_covers_insert_admin" on storage.objects
for insert with check (
bucket_id = 'event-covers'
and exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
create policy "event_covers_update_admin" on storage.objects
for update using (
bucket_id = 'event-covers'
and exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
create policy "event_covers_delete_admin" on storage.objects
for delete using (
bucket_id = 'event-covers'
and exists (select 1 from public.profiles p where p.id = auth.uid() and p.is_admin)
);
-- ============================================================
-- Auth providers (Google + GitHub) must be enabled in the
-- Supabase dashboard: Authentication → Providers → Google/GitHub.
-- ============================================================
+59
View File
@@ -0,0 +1,59 @@
-- ============================================================
-- seed.sql — sample events and demo messages.
-- Run AFTER 0001_init.sql.
--
-- Promote yourself to admin (replace with your email):
-- select public.promote_user_to_admin('you@example.com');
-- ============================================================
insert into public.events (slug, title, description, starts_at, ends_at, venue_name, status) values
(
'open-air-cinema-under-the-stars',
'{"ua": "Кіно просто неба: «Тіні забутих предків»", "en": "Open-air cinema: Shadows of Forgotten Ancestors"}',
'{"ua": "Культова стрічка на великому екрані під зоряним небом. Беріть плед, гарячий чай і друзів — початок після заходу сонця.", "en": "A cult film on the big screen under the starry sky. Bring a blanket, hot tea and friends — we start after sunset."}',
now() + interval '3 days',
now() + interval '3 days' + interval '2 hours',
'Devodytyji',
'published'
),
(
'summer-evening-acoustic-concert',
'{"ua": "Акустичний вечір: українські виконавці", "en": "Acoustic evening: Ukrainian artists"}',
'{"ua": "Теплий літній вечір, живий звук і кращі українські інді-гурти. Вхід вільний, обовʼязковий гарний настрій.", "en": "A warm summer evening, live sound and the best Ukrainian indie bands. Free entry, good mood mandatory."}',
now() + interval '10 days',
now() + interval '10 days' + interval '3 hours',
'Devodytyji',
'published'
),
(
'feria-of-crafts-and-food',
'{"ua": "Ярмарок крафту та їжі", "en": "Crafts and food fair"}',
'{"ua": "Десятки локальних майстрів, фермерські продукти, жива музика та дитяча зона. Ідеальний день для всієї родини.", "en": "Dozens of local makers, farm produce, live music and a kids area. A perfect day for the whole family."}',
now() + interval '21 days',
now() + interval '21 days' + interval '6 hours',
'Devodytyji',
'published'
),
(
'night-lecture-on-astronomy',
'{"ua": "Нічна лекція з астрономії", "en": "Night lecture on astronomy"}',
'{"ua": "Дивимось у небо і розбираємось, що там відбувається. Телескопи — наші, запитання — ваші.", "en": "We look at the sky and figure out what is going on up there. Telescopes are ours, questions are yours."}',
now() + interval '6 days',
now() + interval '6 days' + interval '2 hours',
'Devodytyji',
'draft'
);
insert into public.contact_messages (name, email, message, read) values
(
'Оксана Мельник',
'oksana@example.com',
'Добрий день! Чи можна прийти з собакою на кіновечір? І чи є теплі пледи?',
false
),
(
'Ivan Petrenko',
'ivan@example.com',
'Hi! Do you host private events / birthday parties? What are the rates for a Saturday?',
true
);
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}