Initial commit

This commit is contained in:
ArtemBozhko
2026-08-14 08:51:55 +03:00
commit 957f1d4ba2
81 changed files with 19444 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Locale } from './i18n';
export type EventStatus = 'draft' | 'published';
export interface EventRow {
id: string;
slug: string;
title: { ua: string; en: string };
description: { ua: string; en: string };
cover_image_url: string | null;
starts_at: string;
ends_at: string;
venue_name: string | null;
status: EventStatus;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface ReviewRow {
id: string;
user_id: string;
rating: number;
comment: string | null;
created_at: string;
user: { full_name: string | null; avatar_url: string | null } | null;
event_id?: string | null;
}
export interface MessageRow {
id: string;
name: string;
email: string;
message: string;
read: boolean;
created_at: string;
}
export type EventsFilter = 'all' | 'upcoming' | 'past';
export function titleFor(locale: Locale, event: EventRow): string {
return event.title?.[locale] || event.title?.['ua'] || event.title?.['en'] || '';
}
export function descriptionFor(locale: Locale, event: EventRow): string {
return event.description?.[locale] || event.description?.['ua'] || event.description?.['en'] || '';
}
export async function getPublishedEvents(
supabase: SupabaseClient,
filter: EventsFilter,
query: string,
limit = 50,
): Promise<EventRow[]> {
const now = new Date().toISOString();
let request = supabase
.from('events')
.select('*')
.eq('status', 'published')
.order('starts_at', { ascending: filter !== 'past' })
.limit(limit);
if (filter === 'upcoming') request = request.gte('starts_at', now);
if (filter === 'past') request = request.lt('starts_at', now);
if (query.trim()) {
request = request.or(`slug.ilike.%${query}%,title->>ua.ilike.%${query}%,title->>en.ilike.%${query}%`);
}
const { data, error } = await request;
if (error) {
console.error('getPublishedEvents error', error);
return [];
}
return (data ?? []) as EventRow[];
}
export async function getUpcomingEvents(supabase: SupabaseClient, limit = 3): Promise<EventRow[]> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('status', 'published')
.gte('starts_at', new Date().toISOString())
.order('starts_at', { ascending: true })
.limit(limit);
if (error) {
console.error('getUpcomingEvents error', error);
return [];
}
return (data ?? []) as EventRow[];
}
export async function getEventBySlug(
supabase: SupabaseClient,
slug: string,
): Promise<EventRow | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('slug', slug)
.eq('status', 'published')
.maybeSingle();
if (error) {
console.error('getEventBySlug error', error);
return null;
}
return (data ?? null) as EventRow | null;
}
export async function getEventReviews(
supabase: SupabaseClient,
eventId: string,
): Promise<ReviewRow[]> {
const { data, error } = await supabase
.from('event_reviews')
.select('*, user:profiles(full_name, avatar_url)')
.eq('event_id', eventId)
.order('created_at', { ascending: false });
if (error) {
console.error('getEventReviews error', error);
return [];
}
return (data ?? []) as ReviewRow[];
}
export async function getVenueReviews(supabase: SupabaseClient, limit = 50): Promise<ReviewRow[]> {
const { data, error } = await supabase
.from('venue_reviews')
.select('*, user:profiles(full_name, avatar_url)')
.order('created_at', { ascending: false })
.limit(limit);
if (error) {
console.error('getVenueReviews error', error);
return [];
}
return (data ?? []) as ReviewRow[];
}
// Admin helpers
export async function getAdminEvents(supabase: SupabaseClient): Promise<EventRow[]> {
const { data, error } = await supabase
.from('events')
.select('*')
.order('starts_at', { ascending: false })
.limit(200);
if (error) {
console.error('getAdminEvents error', error);
return [];
}
return (data ?? []) as EventRow[];
}
export async function getAdminEvent(supabase: SupabaseClient, id: string): Promise<EventRow | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) {
console.error('getAdminEvent error', error);
return null;
}
return (data ?? null) as EventRow | null;
}
export async function getAdminStats(supabase: SupabaseClient) {
const [published, drafts, unread, eventReviews, venueReviews] = await Promise.all([
supabase.from('events').select('id', { count: 'exact', head: true }).eq('status', 'published'),
supabase.from('events').select('id', { count: 'exact', head: true }).eq('status', 'draft'),
supabase
.from('contact_messages')
.select('id', { count: 'exact', head: true })
.eq('read', false),
supabase.from('event_reviews').select('id', { count: 'exact', head: true }),
supabase.from('venue_reviews').select('id', { count: 'exact', head: true }),
]);
return {
published: published.count ?? 0,
drafts: drafts.count ?? 0,
unread: unread.count ?? 0,
reviews: (eventReviews.count ?? 0) + (venueReviews.count ?? 0),
};
}
export interface AdminReviewRow {
id: string;
type: 'event' | 'venue';
rating: number;
comment: string | null;
created_at: string;
user_name: string | null;
event_title: string | null;
}
export async function getAdminReviews(supabase: SupabaseClient): Promise<AdminReviewRow[]> {
const [eventReviews, venueReviews] = await Promise.all([
supabase
.from('event_reviews')
.select('id, rating, comment, created_at, user:profiles(full_name), event:events(title)'),
supabase
.from('venue_reviews')
.select('id, rating, comment, created_at, user:profiles(full_name)'),
]);
const events: AdminReviewRow[] = (eventReviews.data ?? []).map((r: any) => ({
id: r.id,
type: 'event',
rating: r.rating,
comment: r.comment,
created_at: r.created_at,
user_name: r.user?.full_name ?? null,
event_title: r.event?.title?.['ua'] ?? r.event?.title?.['en'] ?? null,
}));
const venue: AdminReviewRow[] = (venueReviews.data ?? []).map((r: any) => ({
id: r.id,
type: 'venue',
rating: r.rating,
comment: r.comment,
created_at: r.created_at,
user_name: r.user?.full_name ?? null,
event_title: null,
}));
return [...events, ...venue].sort((a, b) =>
b.created_at.localeCompare(a.created_at),
);
}
export async function getAdminMessages(supabase: SupabaseClient): Promise<MessageRow[]> {
const { data, error } = await supabase
.from('contact_messages')
.select('*')
.order('created_at', { ascending: false })
.limit(200);
if (error) {
console.error('getAdminMessages error', error);
return [];
}
return (data ?? []) as MessageRow[];
}
+231
View File
@@ -0,0 +1,231 @@
import type { Dict } from './ua';
export const en: Dict = {
meta: {
siteName: 'Devodytyji',
homeTitle: 'Devodytyji — open-air cultural venue',
homeDescription:
'Devodytyji is an open-air cultural space: concerts, movies, talks and markets under the open sky.',
defaultOgImage: '/og-cover.jpg',
},
nav: {
home: 'Home',
events: 'Events',
about: 'About',
map: 'How to get there',
contacts: 'Contacts',
admin: 'Admin panel',
login: 'Sign in',
register: 'Sign up',
logout: 'Log out',
},
common: {
loading: 'Loading…',
saving: 'Saving…',
save: 'Save',
cancel: 'Cancel',
submit: 'Submit',
close: 'Close',
search: 'Search',
all: 'All',
upcoming: 'Upcoming',
past: 'Past',
published: 'Published',
draft: 'Draft',
edit: 'Edit',
delete: 'Delete',
read: 'Read',
unread: 'Unread',
back: 'Back',
noResults: 'Nothing found',
error: 'Something went wrong',
success: 'Success',
required: 'Required field',
optional: 'optional',
reviews: 'reviews',
rating: 'Rating',
writeReview: 'Write a review',
view: 'View',
minutes: 'min',
},
hero: {
badge: 'Open air • Always open',
title: 'A cultural venue under the open sky',
subtitle:
'Devodytyji is a space for concerts, movies, talks and markets. One yard, one stage, endless stories.',
ctaEvents: 'See events',
ctaMap: 'Get directions',
},
home: {
nextEvents: 'Upcoming events',
nextEventsSubtitle: 'What is happening at the venue in the near future.',
viewAllEvents: 'All events',
venueReviewsTitle: 'What guests say',
venueReviewsSubtitle: 'Impressions of visitors about the Devodytyji venue.',
ctaTitle: 'Come out into the fresh air',
ctaText: 'Subscribe to announcements or just drop by — admission is free.',
ctaButton: 'Write to us',
},
events: {
pageTitle: 'Events',
pageDescription: 'All events at the Devodytyji venue: concerts, movies, talks and markets.',
filters: 'Filters',
searchPlaceholder: 'Search events…',
upcomingTab: 'Upcoming',
pastTab: 'Past',
allTab: 'All',
emptyUpcoming: 'No upcoming events yet — check back later.',
emptyPast: 'No past events yet.',
emptySearch: 'Nothing matches your search.',
pastLabel: 'Past',
upcomingLabel: 'Upcoming',
},
event: {
date: 'Date',
time: 'Time',
venue: 'Venue',
statusDraft: 'Draft',
statusPublished: 'Published',
reviewsTitle: 'Event reviews',
reviewCount: 'reviews',
noReviews: 'No reviews yet. Be the first!',
writeReview: 'Write a review',
backToEvents: 'Back to all events',
share: 'Share',
signInToReview: 'Sign in to leave a review',
},
about: {
pageTitle: 'About the venue',
pageDescription: 'The story and atmosphere of the Devodytyji cultural venue.',
storyTitle: 'Our story',
story:
'Devodytyji began as an ordinary yard where neighbors gathered. Over time it became an open cultural venue: music, movies under the stars, talks, fairs and workshops. We believe culture lives best in the open air — among people, under the open sky.',
venueReviewsTitle: 'Venue reviews',
venueReviewCount: 'reviews',
writeVenueReview: 'Leave a review about the venue',
noVenueReviews: 'No reviews yet. Be the first!',
contactsTitle: 'Contacts',
},
contacts: {
pageTitle: 'Contacts',
pageDescription: 'Get in touch with the Devodytyji team or send us a message.',
formTitle: 'Write to us',
name: 'Name',
email: 'Email',
message: 'Message',
namePlaceholder: 'Your name',
emailPlaceholder: 'you@example.com',
messagePlaceholder: 'Your message…',
send: 'Send',
sending: 'Sending…',
successTitle: 'Message sent!',
successText: 'Thank you, we will get back to you soon.',
errorTitle: 'Could not send',
errorText: 'Please try again a bit later.',
phone: 'Phone',
emailLabel: 'Email',
addressLabel: 'Address',
hoursLabel: 'Opening hours',
hoursValue: 'Every day, 10:00 — 23:00',
followUs: 'Follow us',
},
map: {
pageTitle: 'How to get there',
pageDescription: 'Map and address of the Devodytyji cultural venue.',
howToGet: 'How to get there',
address: 'Address',
hint: 'A tiny map with a single marker — you cannot get lost.',
},
auth: {
loginTitle: 'Sign in',
loginSubtitle: 'Good to see you again!',
registerTitle: 'Create an account',
registerSubtitle: 'Join us to leave reviews.',
email: 'Email',
password: 'Password',
fullName: 'Name',
loginButton: 'Sign in',
registerButton: 'Sign up',
loggingIn: 'Signing in…',
registering: 'Signing up…',
oauthGoogle: 'Continue with Google',
oauthGitHub: 'Continue with GitHub',
noAccount: 'No account yet?',
hasAccount: 'Already have an account?',
loginLink: 'Sign in',
registerLink: 'Sign up',
orContinueWith: 'or continue with',
passwordLength: 'Password must be at least 6 characters',
errorInvalid: 'Invalid email or password.',
errorEmailExists: 'A user with this email already exists.',
errorGeneric: 'Something went wrong. Please try again.',
backHome: 'Back home',
},
reviewForm: {
ratingLabel: 'Your rating',
commentLabel: 'Comment',
commentPlaceholder: 'Share your impressions…',
submit: 'Submit review',
submitting: 'Submitting…',
success: 'Thank you for your review!',
error: 'Could not submit the review.',
errorNotLoggedIn: 'Please sign in to leave a review.',
delete: 'Delete',
deleteConfirm: 'Delete this review?',
starsHint: 'Choose from 1 to 5 stars',
},
admin: {
dashboardTitle: 'Dashboard',
eventsTitle: 'Events',
reviewsTitle: 'Reviews',
messagesTitle: 'Messages',
backToSite: 'Back to site',
navDashboard: 'Overview',
navEvents: 'Events',
navReviews: 'Reviews',
navMessages: 'Messages',
statsPublished: 'Published events',
statsDraft: 'Drafts',
statsUnread: 'Unread messages',
statsReviews: 'Total reviews',
newEvent: 'New event',
editEvent: 'Edit event',
backToList: 'Back to events list',
eventTitleUa: 'Title (UA)',
eventTitleEn: 'Title (EN)',
eventDescUa: 'Description (UA)',
eventDescEn: 'Description (EN)',
slug: 'Slug (URL)',
startsAt: 'Starts at',
endsAt: 'Ends at',
venueName: 'Venue name',
coverImage: 'Cover image',
uploadCover: 'Upload cover',
replacingCover: 'Uploaded!',
status: 'Status',
publish: 'Publish',
unpublish: 'Unpublish',
save: 'Save',
create: 'Create',
saving: 'Saving…',
saved: 'Saved!',
deleteEvent: 'Delete event',
deleteEventConfirm: 'Delete this event? This cannot be undone.',
deleteReview: 'Delete review',
deleteMessage: 'Delete message',
emptyEvents: 'No events yet. Create the first one!',
emptyReviews: 'No reviews yet.',
emptyMessages: 'No messages yet.',
markRead: 'Mark as read',
markUnread: 'Mark as unread',
from: 'From',
viewOnSite: 'View on site',
notFound: 'Event not found',
},
footer: {
tagline: 'An open-air cultural venue.',
rights: 'All rights reserved.',
navTitle: 'Navigation',
contactTitle: 'Contacts',
},
};
+231
View File
@@ -0,0 +1,231 @@
export const ua = {
meta: {
siteName: 'Devodytyji',
homeTitle: 'Devodytyji — відкритий культурний майданчик',
homeDescription:
'Devodytyji — відкритий простір на свіжому повітрі: концерти, кіно, лекції та маркети просто неба.',
defaultOgImage: '/og-cover.jpg',
},
nav: {
home: 'Головна',
events: 'Події',
about: 'Про майданчик',
map: 'Як дістатись',
contacts: 'Контакти',
admin: 'Адмін-панель',
login: 'Увійти',
register: 'Реєстрація',
logout: 'Вийти',
},
common: {
loading: 'Завантаження…',
saving: 'Збереження…',
save: 'Зберегти',
cancel: 'Скасувати',
submit: 'Надіслати',
close: 'Закрити',
search: 'Пошук',
all: 'Усі',
upcoming: 'Найближчі',
past: 'Минулі',
published: 'Опубліковано',
draft: 'Чернетка',
edit: 'Редагувати',
delete: 'Видалити',
read: 'Прочитано',
unread: 'Не прочитано',
back: 'Назад',
noResults: 'Нічого не знайдено',
error: 'Сталася помилка',
success: 'Успішно',
required: "Обов'язкове поле",
optional: 'необов’язково',
reviews: 'відгуки',
rating: 'Оцінка',
writeReview: 'Залишити відгук',
view: 'Переглянути',
minutes: 'хв',
},
hero: {
badge: 'Просто неба • Завжди відкрито',
title: 'Культурний майданчик під відкритим небом',
subtitle:
'Devodytyji — це простір для концертів, кіно, лекцій і маркетів. Один двір, одна сцена, безліч історій.',
ctaEvents: 'Дивитись події',
ctaMap: 'Як дістатись',
},
home: {
nextEvents: 'Найближчі події',
nextEventsSubtitle: 'Що відбуватиметься на майданчику найближчим часом.',
viewAllEvents: 'Усі події',
venueReviewsTitle: 'Що кажуть гості',
venueReviewsSubtitle: 'Враження відвідувачів про майданчик Devodytyji.',
ctaTitle: 'Приходьте на свіже повітря',
ctaText: 'Підпишіться на анонси або просто завітайте — вхід вільний.',
ctaButton: 'Написати нам',
},
events: {
pageTitle: 'Події',
pageDescription: 'Усі події на майданчику Devodytyji: концерти, кіно, лекції та маркети.',
filters: 'Фільтри',
searchPlaceholder: 'Шукати події…',
upcomingTab: 'Найближчі',
pastTab: 'Минулі',
allTab: 'Усі',
emptyUpcoming: 'Найближчих подій поки немає — зазирніть пізніше.',
emptyPast: 'Минулих подій поки немає.',
emptySearch: 'За вашим запитом нічого не знайдено.',
pastLabel: 'Минула',
upcomingLabel: 'Найближча',
},
event: {
date: 'Дата',
time: 'Час',
venue: 'Локація',
statusDraft: 'Чернетка',
statusPublished: 'Опубліковано',
reviewsTitle: 'Відгуки про подію',
reviewCount: 'відгуків',
noReviews: 'Відгуків поки немає. Будьте першим!',
writeReview: 'Залишити відгук',
backToEvents: 'До всіх подій',
share: 'Поділитись',
signInToReview: 'Увійдіть, щоб залишити відгук',
},
about: {
pageTitle: 'Про майданчик',
pageDescription: 'Історія та атмосфера культурного майданчика Devodytyji.',
storyTitle: 'Наша історія',
story:
'Devodytyji починався як звичайний двір, де збиралися сусіди. З часом він перетворився на відкритий культурний майданчик: тут грають музику, показують кіно під зорями, проводять лекції, ярмарки та майстер-класи. Ми віримо, що культура найкраще живе просто неба — під відкритим небом, у колі людей.',
venueReviewsTitle: 'Відгуки про майданчик',
venueReviewCount: 'відгуків',
writeVenueReview: 'Залишити відгук про майданчик',
noVenueReviews: 'Відгуків поки немає. Будьте першим!',
contactsTitle: 'Контакти',
},
contacts: {
pageTitle: 'Контакти',
pageDescription: 'Зв’яжіться з командою Devodytyji або напишіть нам повідомлення.',
formTitle: 'Напишіть нам',
name: "Ім'я",
email: 'Email',
message: 'Повідомлення',
namePlaceholder: "Ваше ім'я",
emailPlaceholder: 'you@example.com',
messagePlaceholder: 'Ваше повідомлення…',
send: 'Надіслати',
sending: 'Надсилання…',
successTitle: 'Повідомлення надіслано!',
successText: 'Дякуємо, ми зв’яжемося з вами найближчим часом.',
errorTitle: 'Не вдалося надіслати',
errorText: 'Спробуйте ще раз трохи згодом.',
phone: 'Телефон',
emailLabel: 'Email',
addressLabel: 'Адреса',
hoursLabel: 'Години роботи',
hoursValue: 'Щодня з 10:00 до 23:00',
followUs: 'Стежте за нами',
},
map: {
pageTitle: 'Як дістатись',
pageDescription: 'Карта та адреса культурного майданчика Devodytyji.',
howToGet: 'Як дістатись',
address: 'Адреса',
hint: 'Маленька карта з одним маркером — ви точно не заблукаєте.',
},
auth: {
loginTitle: 'Увійти',
loginSubtitle: 'Раді бачити вас знову!',
registerTitle: 'Створити акаунт',
registerSubtitle: 'Приєднуйтесь, щоб залишати відгуки.',
email: 'Email',
password: 'Пароль',
fullName: "Ім'я",
loginButton: 'Увійти',
registerButton: 'Зареєструватися',
loggingIn: 'Вхід…',
registering: 'Реєстрація…',
oauthGoogle: 'Увійти через Google',
oauthGitHub: 'Увійти через GitHub',
noAccount: 'Немає акаунта?',
hasAccount: 'Вже маєте акаунт?',
loginLink: 'Увійти',
registerLink: 'Зареєструватися',
orContinueWith: 'або продовжити через',
passwordLength: 'Пароль має містити щонайменше 6 символів',
errorInvalid: 'Неправильний email або пароль.',
errorEmailExists: 'Користувач із таким email уже існує.',
errorGeneric: 'Щось пішло не так. Спробуйте ще раз.',
backHome: 'На головну',
},
reviewForm: {
ratingLabel: 'Ваша оцінка',
commentLabel: 'Коментар',
commentPlaceholder: 'Поділіться враженнями…',
submit: 'Надіслати відгук',
submitting: 'Надсилання…',
success: 'Дякуємо за відгук!',
error: 'Не вдалося надіслати відгук.',
errorNotLoggedIn: 'Увійдіть, щоб залишити відгук.',
delete: 'Видалити',
deleteConfirm: 'Видалити цей відгук?',
starsHint: 'Оберіть від 1 до 5 зірок',
},
admin: {
dashboardTitle: 'Панель керування',
eventsTitle: 'Події',
reviewsTitle: 'Відгуки',
messagesTitle: 'Повідомлення',
backToSite: 'На сайт',
navDashboard: 'Огляд',
navEvents: 'Події',
navReviews: 'Відгуки',
navMessages: 'Повідомлення',
statsPublished: 'Опубліковано подій',
statsDraft: 'Чернеток',
statsUnread: 'Непрочитаних повідомлень',
statsReviews: 'Всього відгуків',
newEvent: 'Нова подія',
editEvent: 'Редагувати подію',
backToList: 'До списку подій',
eventTitleUa: 'Назва (укр.)',
eventTitleEn: 'Назва (англ.)',
eventDescUa: 'Опис (укр.)',
eventDescEn: 'Опис (англ.)',
slug: 'Слаг (URL)',
startsAt: 'Початок',
endsAt: 'Кінець',
venueName: 'Назва локації',
coverImage: 'Обкладинка',
uploadCover: 'Завантажити обкладинку',
replacingCover: 'Завантажено!',
status: 'Статус',
publish: 'Опублікувати',
unpublish: 'Зняти з публікації',
save: 'Зберегти',
create: 'Створити',
saving: 'Збереження…',
saved: 'Збережено!',
deleteEvent: 'Видалити подію',
deleteEventConfirm: 'Видалити цю подію? Цю дію не можна скасувати.',
deleteReview: 'Видалити відгук',
deleteMessage: 'Видалити повідомлення',
emptyEvents: 'Подій поки немає. Створіть першу!',
emptyReviews: 'Відгуків поки немає.',
emptyMessages: 'Повідомлень поки немає.',
markRead: 'Позначити прочитаним',
markUnread: 'Позначити непрочитаним',
from: 'Від',
viewOnSite: 'Переглянути на сайті',
notFound: 'Подію не знайдено',
},
footer: {
tagline: 'Відкритий культурний майданчик просто неба.',
rights: 'Всі права захищено.',
navTitle: 'Навігація',
contactTitle: 'Контакти',
},
};
export type Dict = typeof ua;
+57
View File
@@ -0,0 +1,57 @@
import type { Locale } from './i18n';
export function intlLocale(locale: Locale): string {
return locale === 'ua' ? 'uk-UA' : 'en-GB';
}
export function formatDate(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(iso));
}
export function formatTime(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
export function formatDateTime(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
export function formatShortDate(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'short',
}).format(new Date(iso));
}
export function formatRelative(locale: Locale, iso: string): string {
return new Intl.DateTimeFormat(intlLocale(locale), {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(iso));
}
export function initials(name: string | null | undefined): string {
if (!name) return '?';
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
}
+37
View File
@@ -0,0 +1,37 @@
import { ua, type Dict } from './dictionaries/ua';
import { en } from './dictionaries/en';
export type { Dict } from './dictionaries/ua';
export type Locale = 'ua' | 'en';
export const LOCALES: Locale[] = ['ua', 'en'];
export const DEFAULT_LOCALE: Locale = 'ua';
const dictionaries: Record<Locale, Dict> = { ua, en };
export function getDict(locale: string | undefined): Dict {
return dictionaries[(locale as Locale) ?? DEFAULT_LOCALE] ?? ua;
}
export function isLocale(value: string | undefined): value is Locale {
return value === 'ua' || value === 'en';
}
export function getLocaleFromUrl(url: URL): Locale {
const first = url.pathname.split('/')[1];
return isLocale(first) ? first : DEFAULT_LOCALE;
}
export function localizedPath(locale: Locale, path: string): string {
const clean = path.startsWith('/') ? path : `/${path}`;
return `/${locale}${clean}`;
}
export function getAlternateLocales(current: Locale): Locale[] {
return LOCALES.filter((l) => l !== current);
}
export function stripLocaleFromPath(locale: Locale, pathname: string): string {
if (pathname === `/${locale}` || pathname === `/${locale}/`) return '/';
if (pathname.startsWith(`/${locale}/`)) return pathname.slice(`/${locale}`.length);
return pathname;
}
+21
View File
@@ -0,0 +1,21 @@
export async function readBody<T extends Record<string, unknown>>(request: Request): Promise<T> {
const contentType = request.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
return (await request.json()) as T;
}
if (contentType.includes('multipart/form-data')) {
const form = await request.formData();
const out: Record<string, unknown> = {};
form.forEach((value, key) => {
out[key] = value;
});
return out as T;
}
const urlEncoded = await request.text();
const params = new URLSearchParams(urlEncoded);
const out: Record<string, unknown> = {};
for (const [key, value] of params.entries()) {
out[key] = value;
}
return out as T;
}
+62
View File
@@ -0,0 +1,62 @@
import { createBrowserClient, createServerClient } from '@supabase/ssr';
import type { AstroCookies } from 'astro';
import type { SupabaseClient } from '@supabase/supabase-js';
interface ServerContext {
cookies: AstroCookies;
request: Request;
}
export function getSupabaseUrl(): string {
const url = import.meta.env.PUBLIC_SUPABASE_URL;
if (!url) throw new Error('PUBLIC_SUPABASE_URL is not set');
return url;
}
export function getSupabaseAnonKey(): string {
const key = import.meta.env.PUBLIC_SUPABASE_ANON_KEY;
if (!key) throw new Error('PUBLIC_SUPABASE_ANON_KEY is not set');
return key;
}
function parseCookieHeader(cookieHeader: string): { name: string; value: string }[] {
if (!cookieHeader) return [];
const out: { name: string; value: string }[] = [];
for (const pair of cookieHeader.split(';')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
const name = pair.slice(0, eq).trim();
if (!name) continue;
let value = pair.slice(eq + 1).trim();
try {
value = decodeURIComponent(value);
} catch {
// keep raw value
}
out.push({ name, value });
}
return out;
}
export function createServerSupabase(context: ServerContext): SupabaseClient {
return createServerClient(getSupabaseUrl(), getSupabaseAnonKey(), {
cookies: {
getAll() {
return parseCookieHeader(context.request.headers.get('cookie') ?? '');
},
setAll(cookiesToSet) {
for (const { name, value, options } of cookiesToSet) {
context.cookies.set(name, value, options);
}
},
},
});
}
export function createBrowserSupabase(): SupabaseClient {
return createBrowserClient(getSupabaseUrl(), getSupabaseAnonKey());
}
export function getSiteUrl(): string {
return import.meta.env.PUBLIC_SITE_URL ?? 'http://localhost:4321';
}
+23
View File
@@ -0,0 +1,23 @@
import type { Locale } from './i18n';
export interface VenueInfo {
name: string;
lat: number;
lng: number;
phone: string;
email: string;
hours: string;
}
export const VENUE: VenueInfo = {
name: 'Devodytyji',
lat: 50.4584,
lng: 30.5199,
phone: '+380 44 000 00 00',
email: 'hello@devodytyji.example',
hours: '10:00 – 23:00',
};
export function venueAddress(locale: Locale): string {
return locale === 'en' ? '12 Vifleemska St, Kyiv, Ukraine' : 'вул. Вифлеємська, 12, Київ, Україна';
}