feat: add about, contact with map, legal pages and 404

- About page with identity cards and equipment lists (about.json)
- Contact page: station info, obfuscated e-mail, lazy Leaflet/OSM map
  with antenna tower marker and dark-mode tiles
- Privacy, license and disclaimer pages from a legal collection
- QRT-themed 404 page
- LICENSE (MIT) and LICENSE-CONTENT (CC BY-SA 4.0 legal code)
- Shared PageHeader and CodeCopy components

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 01:59:45 +03:00
parent c1c57c4f85
commit df9c40847d
20 changed files with 1106 additions and 51 deletions
+66
View File
@@ -8,6 +8,9 @@ import socialJson from '../data/social.json';
import homeJson from '../data/home.json';
import categoriesJson from '../data/categories.json';
import blogJson from '../data/blog.json';
import aboutJson from '../data/about.json';
import contactJson from '../data/contact.json';
import pagesJson from '../data/pages.json';
const link = z.object({ label: z.string().min(1), href: z.string().min(1) });
const icon = z.string().startsWith('tabler:');
@@ -180,6 +183,66 @@ const blogSchema = z.object({
),
});
const seo = z.object({ title: z.string().max(60), description: z.string().max(160) });
const aboutSchema = z.object({
seo,
eyebrow: z.string(),
title: z.string(),
lead: z.string(),
identity: z.object({
title: z.string(),
items: z.array(z.object({ label: z.string(), value: z.string(), icon })),
}),
equipment: z.array(
z.object({
title: z.string(),
icon,
items: z.array(z.object({ name: z.string(), note: z.string().optional() })).min(1),
}),
),
});
const contactSchema = z.object({
seo,
eyebrow: z.string(),
title: z.string(),
icon,
lead: z.string(),
infoTitle: z.string(),
map: z.object({
title: z.string(),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
zoom: z.number().int().min(1).max(19),
popup: z.string(),
ariaLabel: z.string(),
note: z.string(),
loading: z.string(),
}),
items: z.array(
z.object({
label: z.string(),
icon,
values: z.array(z.string()).optional(),
href: z.url().optional(),
email: z.email().optional(),
}),
),
});
const pagesSchema = z.object({
legal: strings(['eyebrow', 'updated', 'notice']),
notFound: z.object({
seoTitle: z.string(),
description: z.string(),
eyebrow: z.string(),
title: z.string(),
lead: z.string(),
links: z.array(link.extend({ variant: z.enum(['primary', 'secondary', 'link']), icon })),
}),
});
function parse<T>(name: string, schema: z.ZodType<T>, data: unknown): T {
const result = schema.safeParse(data);
if (!result.success) {
@@ -192,6 +255,9 @@ export const site = parse('site.json', siteSchema, siteJson);
export const home = parse('home.json', homeSchema, homeJson);
export const categories = parse('categories.json', categoriesSchema, categoriesJson);
export const blog = parse('blog.json', blogSchema, blogJson);
export const about = parse('about.json', aboutSchema, aboutJson);
export const contact = parse('contact.json', contactSchema, contactJson);
export const pages = parse('pages.json', pagesSchema, pagesJson);
export const social = parse('social.json', socialSchema, socialJson).filter((s) => s.href);
export type Site = z.infer<typeof siteSchema>;