Initial commit
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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/)
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)?
|
||||
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 284 B |
|
After Width: | Height: | Size: 474 B |
|
After Width: | Height: | Size: 655 B |
@@ -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 |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
import { localizedPath } from '../lib/i18n';
|
||||
import { VENUE } from '../lib/venue';
|
||||
import type { Locale, Dict } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
const { locale, dict: t } = Astro.props;
|
||||
---
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer__grid">
|
||||
<div>
|
||||
<div class="footer__title">Devodytyji</div>
|
||||
<p>{t.footer.tagline}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="footer__title">{t.footer.navTitle}</div>
|
||||
<nav>
|
||||
<p><a href={localizedPath(locale, '/')}>{t.nav.home}</a></p>
|
||||
<p><a href={localizedPath(locale, '/events')}>{t.nav.events}</a></p>
|
||||
<p><a href={localizedPath(locale, '/about')}>{t.nav.about}</a></p>
|
||||
<p><a href={localizedPath(locale, '/map')}>{t.nav.map}</a></p>
|
||||
<p><a href={localizedPath(locale, '/contacts')}>{t.nav.contacts}</a></p>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<div class="footer__title">{t.footer.contactTitle}</div>
|
||||
<p>{VENUE.name}</p>
|
||||
<p><a href={localizedPath(locale, '/map')}>{locale === 'en' ? '12 Vifleemska St, Kyiv' : 'вул. Вифлеємська, 12, Київ'}</a></p>
|
||||
<p>
|
||||
<a href={`mailto:${VENUE.email}`}>{VENUE.email}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer__bottom">
|
||||
© {new Date().getFullYear()} Devodytyji. {t.footer.rights}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
import { localizedPath } from '../lib/i18n';
|
||||
import type { Locale, Dict } from '../lib/i18n';
|
||||
import LangSwitcher from './islands/LangSwitcher.astro';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
pagePath?: string;
|
||||
user?: App.Locals['user'];
|
||||
}
|
||||
|
||||
const { locale, dict: t, pagePath = '/', user } = Astro.props;
|
||||
|
||||
const links = [
|
||||
{ href: '/', label: t.nav.home },
|
||||
{ href: '/events', label: t.nav.events },
|
||||
{ href: '/about', label: t.nav.about },
|
||||
{ href: '/map', label: t.nav.map },
|
||||
{ href: '/contacts', label: t.nav.contacts },
|
||||
];
|
||||
|
||||
const current = (path: string) =>
|
||||
path === '/' ? pagePath === '/' : pagePath.startsWith(path);
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
<div class="container header__inner">
|
||||
<a class="logo" href={localizedPath(locale, '/')}>
|
||||
<span class="logo__mark">D</span>
|
||||
<span>Devodytyji</span>
|
||||
</a>
|
||||
|
||||
<nav class="nav" aria-label="main">
|
||||
{links.map((link) => (
|
||||
<a class="nav__link" href={localizedPath(locale, link.href)} aria-current={current(link.href) ? 'page' : undefined}>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<span class="nav__sep" aria-hidden="true"></span>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
{user.isAdmin && (
|
||||
<a class="nav__link" href="/admin" aria-current={pagePath.startsWith('/admin') ? 'page' : undefined}>
|
||||
{t.nav.admin}
|
||||
</a>
|
||||
)}
|
||||
<form action="/api/auth/logout" method="post">
|
||||
<button class="btn btn--ghost btn--sm" type="submit">
|
||||
{t.nav.logout}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a class="nav__link" href={localizedPath(locale, '/login')}>
|
||||
{t.nav.login}
|
||||
</a>
|
||||
<a class="btn btn--primary btn--sm" href={localizedPath(locale, '/register')}>
|
||||
{t.nav.register}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
<LangSwitcher locale={locale} pagePath={pagePath} labels={{ ua: 'UA', en: 'EN' }} />
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { Dict, Locale } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
mode: 'login' | 'register';
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export default function AuthForm({ mode, locale, dict: t, redirectTo }: Props) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [oauthBusy, setOauthBusy] = useState<string | null>(null);
|
||||
|
||||
const target = redirectTo || `/${locale}/`;
|
||||
|
||||
async function handleOAuth(provider: 'google' | 'github') {
|
||||
setOauthBusy(provider);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/auth/login?provider=${provider}&next=${encodeURIComponent(target)}`,
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.url) {
|
||||
setError(t.auth.errorGeneric);
|
||||
setOauthBusy(null);
|
||||
return;
|
||||
}
|
||||
window.location.href = data.url;
|
||||
} catch {
|
||||
setError(t.auth.errorGeneric);
|
||||
setOauthBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError(t.auth.errorGeneric);
|
||||
return;
|
||||
}
|
||||
if (mode === 'register' && password.length < 6) {
|
||||
setError(t.auth.passwordLength);
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/auth/${mode}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, fullName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
data?.error === 'invalid_credentials'
|
||||
? t.auth.errorInvalid
|
||||
: data?.error === 'email_exists'
|
||||
? t.auth.errorEmailExists
|
||||
: t.auth.errorGeneric,
|
||||
);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
window.location.href = target;
|
||||
} catch {
|
||||
setError(t.auth.errorGeneric);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const buttonLabel =
|
||||
mode === 'login'
|
||||
? busy
|
||||
? t.auth.loggingIn
|
||||
: t.auth.loginButton
|
||||
: busy
|
||||
? t.auth.registering
|
||||
: t.auth.registerButton;
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="form-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'register' && (
|
||||
<div className="form-field">
|
||||
<label htmlFor="full-name">{t.auth.fullName}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="full-name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="email">{t.auth.email}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="password">{t.auth.password}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
minLength={6}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary btn--block" type="submit" disabled={busy}>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
|
||||
<div className="oauth-divider">
|
||||
<span>{t.auth.orContinueWith}</span>
|
||||
</div>
|
||||
|
||||
<div className="oauth-buttons">
|
||||
<button
|
||||
className="btn btn--outline"
|
||||
type="button"
|
||||
disabled={oauthBusy !== null}
|
||||
onClick={() => handleOAuth('google')}
|
||||
>
|
||||
{oauthBusy === 'google' ? '…' : t.auth.oauthGoogle}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--outline"
|
||||
type="button"
|
||||
disabled={oauthBusy !== null}
|
||||
onClick={() => handleOAuth('github')}
|
||||
>
|
||||
{oauthBusy === 'github' ? '…' : t.auth.oauthGitHub}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
export default function ContactForm({ dict: t }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (honeypot) return;
|
||||
|
||||
setStatus('sending');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, message, honeypot }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setStatus('success');
|
||||
setName('');
|
||||
setEmail('');
|
||||
setMessage('');
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="form-success" role="status">
|
||||
<strong>{t.contacts.successTitle}</strong>
|
||||
<p style={{ marginBottom: 0 }}>{t.contacts.successText}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
{status === 'error' && (
|
||||
<div className="form-error" role="alert">
|
||||
<strong>{t.contacts.errorTitle}</strong>
|
||||
<p style={{ marginBottom: 0 }}>{t.contacts.errorText}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="contact-name">{t.contacts.name}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="contact-name"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder={t.contacts.namePlaceholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="contact-email">{t.contacts.email}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="contact-email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t.contacts.emailPlaceholder}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="contact-message">{t.contacts.message}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="contact-message"
|
||||
required
|
||||
placeholder={t.contacts.messagePlaceholder}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="honeypot" aria-hidden="true">
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary" type="submit" disabled={status === 'sending'}>
|
||||
{status === 'sending' ? t.contacts.sending : t.contacts.send}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { FormEvent, ChangeEvent } from 'react';
|
||||
import { createBrowserSupabase } from '../../lib/supabase';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
import type { EventRow } from '../../lib/db';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
event?: EventRow | null;
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function toLocalInput(iso?: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/['']/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
export default function EventEditor({ dict: t, event }: Props) {
|
||||
const [titleUa, setTitleUa] = useState(event?.title?.ua ?? '');
|
||||
const [titleEn, setTitleEn] = useState(event?.title?.en ?? '');
|
||||
const [descriptionUa, setDescriptionUa] = useState(event?.description?.ua ?? '');
|
||||
const [descriptionEn, setDescriptionEn] = useState(event?.description?.en ?? '');
|
||||
const [slug, setSlug] = useState(event?.slug ?? '');
|
||||
const [slugTouched, setSlugTouched] = useState(Boolean(event));
|
||||
const [startsAt, setStartsAt] = useState(toLocalInput(event?.starts_at));
|
||||
const [endsAt, setEndsAt] = useState(toLocalInput(event?.ends_at));
|
||||
const [venueName, setVenueName] = useState(event?.venue_name ?? '');
|
||||
const [status, setStatus] = useState<'draft' | 'published'>(event?.status ?? 'draft');
|
||||
const [cover, setCover] = useState<string | null>(event?.cover_image_url ?? null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleTitleUa(value: string) {
|
||||
setTitleUa(value);
|
||||
if (!slugTouched) setSlug(slugify(value));
|
||||
}
|
||||
|
||||
async function handleCoverUpload(e: ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
|
||||
const path = `covers/${crypto.randomUUID()}.${ext}`;
|
||||
const supabase = createBrowserSupabase();
|
||||
const { data, error: uploadError } = await supabase.storage
|
||||
.from('event-covers')
|
||||
.upload(path, file, { upsert: false, contentType: file.type });
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
if (!data) throw new Error('No upload path');
|
||||
|
||||
const { data: publicUrl } = supabase.storage.from('event-covers').getPublicUrl(data.path);
|
||||
setCover(publicUrl.publicUrl);
|
||||
} catch {
|
||||
setError(t.common.error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInput.current) fileInput.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveCover() {
|
||||
setCover(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!titleUa.trim() || !titleEn.trim() || !slug.trim() || !startsAt || !endsAt) {
|
||||
setError(t.common.required);
|
||||
return;
|
||||
}
|
||||
if (new Date(endsAt) <= new Date(startsAt)) {
|
||||
setError(t.common.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: { ua: titleUa.trim(), en: titleEn.trim() },
|
||||
description: { ua: descriptionUa.trim(), en: descriptionEn.trim() },
|
||||
slug: slug.trim(),
|
||||
starts_at: new Date(startsAt).toISOString(),
|
||||
ends_at: new Date(endsAt).toISOString(),
|
||||
venue_name: venueName.trim() || null,
|
||||
cover_image_url: cover,
|
||||
status,
|
||||
};
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const url = event ? `/api/admin/events/${event.id}` : '/api/admin/events';
|
||||
const res = await fetch(url, {
|
||||
method: event ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data?.error || t.common.error);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
window.location.href = '/admin/events';
|
||||
} catch {
|
||||
setError(t.common.error);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="form-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-field">
|
||||
<label htmlFor="title-ua">{t.admin.eventTitleUa} *</label>
|
||||
<input
|
||||
className="input"
|
||||
id="title-ua"
|
||||
required
|
||||
value={titleUa}
|
||||
onChange={(e) => handleTitleUa(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="title-en">{t.admin.eventTitleEn} *</label>
|
||||
<input
|
||||
className="input"
|
||||
id="title-en"
|
||||
required
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-field">
|
||||
<label htmlFor="desc-ua">{t.admin.eventDescUa}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="desc-ua"
|
||||
value={descriptionUa}
|
||||
onChange={(e) => setDescriptionUa(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="desc-en">{t.admin.eventDescEn}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="desc-en"
|
||||
value={descriptionEn}
|
||||
onChange={(e) => setDescriptionEn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-field">
|
||||
<label htmlFor="slug">{t.admin.slug} *</label>
|
||||
<input
|
||||
className="input"
|
||||
id="slug"
|
||||
required
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
setSlugTouched(true);
|
||||
setSlug(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="venue-name">{t.admin.venueName}</label>
|
||||
<input
|
||||
className="input"
|
||||
id="venue-name"
|
||||
value={venueName}
|
||||
onChange={(e) => setVenueName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid-2">
|
||||
<div className="form-field">
|
||||
<label htmlFor="starts-at">{t.admin.startsAt} *</label>
|
||||
<input
|
||||
className="input"
|
||||
id="starts-at"
|
||||
type="datetime-local"
|
||||
required
|
||||
value={startsAt}
|
||||
onChange={(e) => setStartsAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="ends-at">{t.admin.endsAt} *</label>
|
||||
<input
|
||||
className="input"
|
||||
id="ends-at"
|
||||
type="datetime-local"
|
||||
required
|
||||
value={endsAt}
|
||||
onChange={(e) => setEndsAt(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="status">{t.admin.status}</label>
|
||||
<select
|
||||
className="select"
|
||||
id="status"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
|
||||
>
|
||||
<option value="draft">{t.common.draft}</option>
|
||||
<option value="published">{t.common.published}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<span className="form-field__label">{t.admin.coverImage}</span>
|
||||
{cover && (
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
style={{ width: 180, height: 102, objectFit: 'cover', borderRadius: 8, border: '1px solid var(--border)' }}
|
||||
/>
|
||||
<button type="button" className="btn btn--ghost btn--sm" onClick={handleRemoveCover}>
|
||||
{t.common.cancel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!cover && (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
id="cover-file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleCoverUpload}
|
||||
className="input"
|
||||
/>
|
||||
{uploading && <span className="text-muted">{t.common.loading}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
|
||||
<button className="btn btn--primary" type="submit" disabled={busy || uploading}>
|
||||
{busy
|
||||
? t.admin.saving
|
||||
: event
|
||||
? t.admin.save
|
||||
: t.admin.create}
|
||||
</button>
|
||||
<a className="btn btn--ghost" href="/admin/events">
|
||||
{t.common.cancel}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FormEvent } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
currentFilter: string;
|
||||
currentQuery: string;
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'upcoming' | 'past';
|
||||
|
||||
function navigate(params: Record<string, string | null>) {
|
||||
const url = new URL(window.location.href);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
|
||||
export default function EventFilters({ dict: t, currentFilter, currentQuery }: Props) {
|
||||
const [query, setQuery] = useState(currentQuery);
|
||||
|
||||
const tabs: { key: Filter; label: string }[] = [
|
||||
{ key: 'all', label: t.events.allTab },
|
||||
{ key: 'upcoming', label: t.events.upcomingTab },
|
||||
{ key: 'past', label: t.events.pastTab },
|
||||
];
|
||||
|
||||
function submitSearch(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
navigate({ q: query.trim() || null });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filter-bar">
|
||||
<div className="tabs" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={currentFilter === tab.key}
|
||||
className={currentFilter === tab.key ? 'is-active' : ''}
|
||||
onClick={() => navigate({ filter: tab.key === 'all' ? null : tab.key })}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="search-input" onSubmit={submitSearch} role="search">
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
placeholder={t.events.searchPlaceholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import { LOCALES, localizedPath } from '../../lib/i18n';
|
||||
import type { Locale } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
locale: Locale;
|
||||
pagePath?: string;
|
||||
labels: Record<Locale, string>;
|
||||
}
|
||||
|
||||
const { locale, pagePath = '/', labels } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="lang-switch" role="group" aria-label="Language">
|
||||
{LOCALES.map((l) => (
|
||||
<a href={localizedPath(l, pagePath)} aria-current={l === locale ? 'true' : undefined}>
|
||||
{labels[l]}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface Props {
|
||||
lat: number;
|
||||
lng: number;
|
||||
name: string;
|
||||
address: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const pinIcon = L.divIcon({
|
||||
className: '',
|
||||
html: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="44" viewBox="0 0 36 44" aria-hidden="true">
|
||||
<path d="M18 0C8 0 0 8 0 18c0 13 18 26 18 26s18-13 18-26C36 8 28 0 18 0z" fill="#d96c3f"/>
|
||||
<circle cx="18" cy="18" r="7" fill="#ffffff"/>
|
||||
</svg>`,
|
||||
iconSize: [36, 44],
|
||||
iconAnchor: [18, 44],
|
||||
popupAnchor: [0, -40],
|
||||
});
|
||||
|
||||
export default function MapView({ lat, lng, name, address, hint }: Props) {
|
||||
return (
|
||||
<MapContainer
|
||||
center={[lat, lng]}
|
||||
zoom={15}
|
||||
scrollWheelZoom={false}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
aria-label={hint}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<Marker position={[lat, lng]} icon={pinIcon}>
|
||||
<Popup>
|
||||
<strong>{name}</strong>
|
||||
<br />
|
||||
{address}
|
||||
</Popup>
|
||||
</Marker>
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
export default function ReviewForm({ dict: t, eventId }: Props) {
|
||||
const [rating, setRating] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (rating < 1) return;
|
||||
setStatus('sending');
|
||||
|
||||
try {
|
||||
const url = eventId ? '/api/reviews' : '/api/venue-reviews';
|
||||
const body = eventId ? { event_id: eventId, rating, comment } : { rating, comment };
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (res.ok) {
|
||||
setStatus('success');
|
||||
setComment('');
|
||||
setRating(0);
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
} else {
|
||||
setStatus('error');
|
||||
}
|
||||
} catch {
|
||||
setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="form" onSubmit={handleSubmit}>
|
||||
{status === 'success' && (
|
||||
<div className="form-success" role="status">
|
||||
{t.reviewForm.success}
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="form-error" role="alert">
|
||||
{t.reviewForm.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="review-rating">{t.reviewForm.ratingLabel}</label>
|
||||
<div className="star-rating" id="review-rating" role="radiogroup" aria-label={t.reviewForm.ratingLabel}>
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={rating === value}
|
||||
className={value <= rating ? 'is-selected' : ''}
|
||||
aria-label={`${value}`}
|
||||
onClick={() => setRating(value)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted" style={{ fontSize: '0.85rem' }}>
|
||||
{t.reviewForm.starsHint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="review-comment">{t.reviewForm.commentLabel}</label>
|
||||
<textarea
|
||||
className="textarea"
|
||||
id="review-comment"
|
||||
placeholder={t.reviewForm.commentPlaceholder}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="btn btn--primary" type="submit" disabled={status === 'sending' || rating < 1}>
|
||||
{status === 'sending' ? t.reviewForm.submitting : t.reviewForm.submit}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import ReviewForm from './ReviewForm';
|
||||
import type { Dict } from '../../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
export default function VenueReviewForm({ dict }: Props) {
|
||||
return <ReviewForm dict={dict} />;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
import { localizedPath } from '../../lib/i18n';
|
||||
import { titleFor } from '../../lib/db';
|
||||
import { formatShortDate, formatTime } from '../../lib/format';
|
||||
import type { Locale, Dict } from '../../lib/i18n';
|
||||
import type { EventRow } from '../../lib/db';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
interface Props {
|
||||
event: EventRow;
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
}
|
||||
|
||||
const { event, locale, dict: t } = Astro.props;
|
||||
const href = localizedPath(locale, `/events/${event.slug}`);
|
||||
const isPast = new Date(event.starts_at).getTime() < Date.now();
|
||||
const title = titleFor(locale, event);
|
||||
const initial = (title || event.slug).trim().charAt(0).toUpperCase();
|
||||
---
|
||||
|
||||
<a class="event-card" href={href}>
|
||||
<div class="event-card__media">
|
||||
{event.cover_image_url ? (
|
||||
<img src={event.cover_image_url} alt={title} loading="lazy" />
|
||||
) : (
|
||||
<div class="event-card__media-placeholder" aria-hidden="true">
|
||||
{initial}
|
||||
</div>
|
||||
)}
|
||||
<span class="event-card__date-badge">{formatShortDate(locale, event.starts_at)}</span>
|
||||
</div>
|
||||
<div class="event-card__body">
|
||||
<h3 class="event-card__title">{title || event.slug}</h3>
|
||||
<div class="event-card__meta">
|
||||
<span><Icon name="calendar" size={14} /> {formatShortDate(locale, event.starts_at)}</span>
|
||||
<span><Icon name="clock" size={14} /> {formatTime(locale, event.starts_at)}</span>
|
||||
{event.venue_name && (
|
||||
<span>
|
||||
<Icon name="pin" size={14} /> {event.venue_name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="event-card__foot">
|
||||
{isPast ? (
|
||||
<span class="badge badge--past">{t.events.pastLabel}</span>
|
||||
) : (
|
||||
<span class="badge badge--published">{t.events.upcomingLabel}</span>
|
||||
)}
|
||||
<span class="text-muted">{t.common.view} →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
interface Props {
|
||||
name: 'calendar' | 'clock' | 'pin' | 'phone' | 'mail' | 'chat' | 'tent' | 'tree' | 'ticket';
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { name, size = 18, class: className } = Astro.props;
|
||||
|
||||
const paths: Record<Props['name'], string> = {
|
||||
calendar:
|
||||
'<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>',
|
||||
clock:
|
||||
'<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/>',
|
||||
pin:
|
||||
'<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>',
|
||||
phone:
|
||||
'<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.91.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92Z"/>',
|
||||
mail:
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 6L2 7"/>',
|
||||
chat:
|
||||
'<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5Z"/>',
|
||||
tent:
|
||||
'<path d="M12 3 3 20h18L12 3Z"/><path d="M12 3v17"/>',
|
||||
tree:
|
||||
'<path d="M8 19a4 4 0 0 1-2.24-7.32A3.5 3.5 0 0 1 9 6.03V6a3 3 0 0 1 6 0v.03a3.5 3.5 0 0 1 3.24 5.65A4 4 0 0 1 16 19Z"/><path d="M12 19v3"/>',
|
||||
ticket:
|
||||
'<path d="M3 9V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v4"/><circle cx="15" cy="12" r="1"/><path d="M3 15v4a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-4"/>',
|
||||
};
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
class={className}
|
||||
set:html={paths[name]}
|
||||
/>
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
import Stars from './Stars.astro';
|
||||
import { initials, formatRelative } from '../../lib/format';
|
||||
import type { Locale, Dict } from '../../lib/i18n';
|
||||
import type { ReviewRow } from '../../lib/db';
|
||||
|
||||
interface Props {
|
||||
review: ReviewRow;
|
||||
locale: Locale;
|
||||
dict: Dict;
|
||||
deleteUrl?: string;
|
||||
}
|
||||
|
||||
const { review, locale, dict: t, deleteUrl } = Astro.props;
|
||||
const name = review.user?.full_name || 'Guest';
|
||||
---
|
||||
|
||||
<article class="review">
|
||||
<div class="review__head">
|
||||
<div>
|
||||
<span class="review__author">
|
||||
{review.user?.avatar_url ? (
|
||||
<img class="avatar" src={review.user.avatar_url} alt="" style="display:inline-block;margin-right:8px;vertical-align:middle;" />
|
||||
) : (
|
||||
<span class="avatar" style="display:inline-grid;margin-right:8px;vertical-align:middle;">{initials(name)}</span>
|
||||
)}
|
||||
{name}
|
||||
</span>
|
||||
<div class="review__date">{formatRelative(locale, review.created_at)}</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px;">
|
||||
<Stars rating={review.rating} label={`${review.rating} / 5`} />
|
||||
{deleteUrl && (
|
||||
<form
|
||||
method="post"
|
||||
action={deleteUrl}
|
||||
onsubmit={`return confirm(${JSON.stringify(t.reviewForm.deleteConfirm)})`}
|
||||
>
|
||||
<button class="btn btn--danger btn--sm" type="submit">
|
||||
{t.reviewForm.delete}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{review.comment && <p style="margin:0;">{review.comment}</p>}
|
||||
</article>
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
interface Props {
|
||||
rating: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const { rating, label } = Astro.props;
|
||||
const clamped = Math.max(0, Math.min(5, Math.round(rating)));
|
||||
---
|
||||
|
||||
<span class="stars" role="img" aria-label={label ?? `${clamped} / 5`}>
|
||||
{Array.from({ length: 5 }, (_, i) => (i < clamped ? '★' : '☆')).join('')}
|
||||
</span>
|
||||
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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, Київ, Україна';
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' } });
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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/`);
|
||||
};
|
||||
@@ -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' },
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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: '/' } });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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: '/' } });
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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.
|
||||
-- ============================================================
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
||||