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
+4 -3
View File
@@ -1,17 +1,18 @@
---
import type { HTMLTag } from 'astro/types';
import type { HTMLAttributes, HTMLTag } from 'astro/types';
export interface Props {
export interface Props extends Omit<HTMLAttributes<'div'>, 'class'> {
as?: HTMLTag;
/** Adds hover lift; the card's own link should cover it via `after:absolute after:inset-0`. */
interactive?: boolean;
class?: string;
}
const { as: Tag = 'article', interactive = false, class: className } = Astro.props;
const { as: Tag = 'article', interactive = false, class: className, ...rest } = Astro.props;
---
<Tag
{...rest}
class:list={[
'bg-surface border-line relative flex flex-col border',
interactive &&
+34
View File
@@ -0,0 +1,34 @@
---
// Wraps rendered Markdown and adds a copy button to every code block.
import { blog } from '../lib/data';
---
<div data-code-copy={blog.post.copyCode} data-code-copied={blog.post.codeCopied}>
<slot />
</div>
<script>
for (const root of document.querySelectorAll<HTMLElement>('[data-code-copy]')) {
const { codeCopy = '', codeCopied = '' } = root.dataset;
for (const pre of root.querySelectorAll('pre')) {
const wrap = document.createElement('div');
wrap.className = 'code-block';
pre.replaceWith(wrap);
wrap.append(pre);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'code-copy';
btn.textContent = codeCopy;
btn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(
pre.querySelector('code')?.innerText ?? pre.innerText,
);
btn.textContent = codeCopied;
setTimeout(() => (btn.textContent = codeCopy), 2000);
} catch {}
});
wrap.append(btn);
}
}
</script>
+30
View File
@@ -0,0 +1,30 @@
---
// Khaki page header with grid lines and the page's single h1.
import { Icon } from 'astro-icon/components';
import Container from './Container.astro';
import GridLines from './GridLines.astro';
import SectionHeading from './SectionHeading.astro';
export interface Props {
eyebrow: string;
title: string;
lead?: string;
icon?: string;
}
const { eyebrow, title, lead, icon } = Astro.props;
---
<section class="surface-alt relative overflow-hidden">
<GridLines />
<Container class="relative flex items-end gap-6 py-14 md:py-20">
{icon && (
<Icon name={icon} class="hidden size-20 shrink-0 sm:block md:size-24" aria-hidden="true" />
)}
<div class="flex flex-col gap-4">
<SectionHeading level={1} eyebrow={eyebrow} title={title} />
{lead && <p class="max-w-2xl text-lg">{lead}</p>}
<slot />
</div>
</Container>
</section>
+2 -9
View File
@@ -4,8 +4,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import { blog, fmt } from '../../lib/data';
import type { Post } from '../../lib/posts';
import Container from '../Container.astro';
import GridLines from '../GridLines.astro';
import SectionHeading from '../SectionHeading.astro';
import PageHeader from '../PageHeader.astro';
import Pager from './Pager.astro';
import PostCard from './PostCard.astro';
import Sidebar from './Sidebar.astro';
@@ -43,13 +42,7 @@ const seoTitle = current > 1 ? fmt(blog.list.pageTitle, { title, n: current }) :
---
<BaseLayout title={seoTitle} description={description} jsonLd={jsonLd}>
<section class="surface-alt relative overflow-hidden">
<GridLines />
<Container class="relative py-14 md:py-20">
<SectionHeading level={1} eyebrow={eyebrow} title={title} />
<p class="mt-4 max-w-2xl text-lg">{description}</p>
</Container>
</section>
<PageHeader eyebrow={eyebrow} title={title} lead={description} />
<Container class="grid gap-12 py-14 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div>
+11 -1
View File
@@ -30,4 +30,14 @@ const blog = defineCollection({
}),
});
export const collections = { blog };
// Legal pages: src/content/legal/<slug>.md → /<slug>/
const legal = defineCollection({
loader: glob({ pattern: '*.md', base: './src/content/legal' }),
schema: z.object({
title: z.string().max(60),
description: z.string().min(50).max(160),
updated: z.coerce.date(),
}),
});
export const collections = { blog, legal };
+57
View File
@@ -0,0 +1,57 @@
---
title: 'Gizlilik Politikası'
description: 'ta1sph.info hangi verileri işler, hangilerini işlemez: çerez yok, takip yok, anonim görüntülenme sayacı. KVKK kapsamındaki haklarınız.'
updated: 2026-10-16
---
Bu sayfa, **ta1sph.info** sitesini ziyaret ettiğinizde hangi verilerin işlendiğini 6698 sayılı Kişisel Verilerin Korunması Kanunu (KVKK) çerçevesinde açıklar.
## Veri sorumlusu
Suphi Çakır (TA1SPH) · E-posta: suphi@radio.org.tr
## Kısaca
- Üyelik, yorum, iletişim formu veya bülten **yoktur**.
- Reklam, analiz (analytics) ya da üçüncü taraf takip çerezi **kullanılmaz**.
- Site çerez (cookie) yerleştirmez.
## Tarayıcınızda saklanan bilgi
Aydınlık/karanlık tema tercihiniz, yalnızca tarayıcınızın yerel depolamasında (`localStorage`, anahtar: `theme`) tutulur. Bu bilgi sunucuya gönderilmez ve kişisel veri değildir. Tarayıcınızın site verilerini silerek kaldırabilirsiniz.
## Görüntülenme sayacı
Blog yazılarının kaç kez okunduğunu saymak için anonim bir sayaç kullanılır:
- IP adresiniz **saklanmaz**. Aynı yazının 24 saat içinde birden fazla sayılmaması için IP adresi, her gün rastgele yenilenen bir değerle (tuz) birlikte tek yönlü olarak özetlenir (hash). Bu özet en fazla 24 saat tutulur ve ardından silinir; eski günlerin tuzları da silindiği için özetler sonradan kimseyle ilişkilendirilemez.
- Kalıcı olarak tutulan tek bilgi, her yazının toplam okunma sayısıdır.
- Arama motoru botları ve otomatik tarayıcılar sayılmaz.
## Site içi arama
Arama, tarayıcınızda çalışır. Yazdığınız arama ifadesi sunucuya gönderilmez ve kaydedilmez.
## Harita
İletişim sayfasındaki harita, OpenStreetMap karolarını kullanır. Harita görüntülendiğinde tarayıcınız OpenStreetMap sunucularına bağlanır ve bu sunucular IP adresinizi kendi politikalarına göre işleyebilir: [OSMF Gizlilik Politikası](https://osmfoundation.org/wiki/Privacy_Policy).
## Paylaşım butonları ve dış bağlantılar
Yazılardaki paylaşım butonları basit bağlantılardır; sayfa açılırken hiçbir sosyal ağın kodu yüklenmez. Bir butona tıkladığınızda ilgili platforma yönlendirilirsiniz. Telegram, QRZ, sosyal medya ve diğer dış siteler kendi gizlilik politikalarına tabidir.
Yazı tipleri de dahil olmak üzere sitenin tüm dosyaları bu sunucudan yüklenir; Google Fonts gibi harici servisler kullanılmaz.
## Sunucu kayıtları
Site, Türkiye'de bulunan kişisel bir sunucuda barındırılır. Sunucu erişim kayıtları (IP adresi, tarih, istenen sayfa gibi) yalnızca güvenlik ve arıza tespiti amacıyla sınırlı bir süre tutulabilir; başka bir amaçla kullanılmaz ve üçüncü kişilerle paylaşılmaz.
## Haklarınız
KVKK'nın 11. maddesi uyarınca; kişisel verilerinizin işlenip işlenmediğini öğrenme, işlenmişse buna ilişkin bilgi talep etme, işlenme amacını öğrenme, aktarıldığı üçüncü kişileri bilme, eksik veya yanlış işlenmişse düzeltilmesini, silinmesini ya da yok edilmesini isteme ve kanuna aykırı işleme nedeniyle zarara uğramanız hâlinde zararın giderilmesini talep etme haklarına sahipsiniz.
Başvurularınızı **suphi@radio.org.tr** adresine iletebilirsiniz.
## Değişiklikler
Bu politika güncellendiğinde sayfanın başındaki tarih değişir.
+35
View File
@@ -0,0 +1,35 @@
---
title: 'Lisans'
description: "ta1sph.info'nun kaynak kodu MIT, yazıları ve içeriği CC BY-SA 4.0 lisanslıdır. Kaynak göstererek nasıl kullanabileceğinizi öğrenin."
updated: 2026-10-16
---
Bu sitede iki ayrı lisans geçerlidir: biri **kaynak kod**, diğeri **yazılar ve içerik** için.
## Kaynak kod: MIT Lisansı
Sitenin kaynak kodu [MIT Lisansı](https://opensource.org/license/mit) ile lisanslanmıştır. Kodu kopyalayabilir, değiştirebilir, dağıtabilir ve ticari amaçla kullanabilirsiniz; tek şart, telif ve lisans bildiriminin korunmasıdır.
## Yazılar ve içerik: CC BY-SA 4.0
Makaleler, görseller ve sitedeki diğer içerik [Creative Commons Atıf-AynıLisanslaPaylaş 4.0 Uluslararası (CC BY-SA 4.0)](https://creativecommons.org/licenses/by-sa/4.0/deed.tr) lisansıyla paylaşılır.
İçeriği kopyalayabilir, yeniden düzenleyebilir ve dağıtabilirsiniz; ticari kullanım dahil. Şartlar şunlardır:
1. **Atıf:** Kaynak olarak "Suphi Çakır – TA1SPH, ta1sph.info" yazın ve ilgili yazının bağlantısını verin.
2. **Lisans bağlantısı:** CC BY-SA 4.0 lisansına bağlantı verin.
3. **Değişiklik bildirimi:** İçerikte değişiklik yaptıysanız bunu belirtin.
4. **Aynı lisansla paylaşım:** İçerikten türettiğiniz eserleri de CC BY-SA 4.0 ile paylaşın.
### Örnek atıf
Aşağıdaki metni kopyalayıp yazının adresiyle birlikte kullanabilirsiniz:
```text
Kaynak: Suphi Çakır – TA1SPH, ta1sph.info — <yazının bağlantısı>
Lisans: CC BY-SA 4.0 — https://creativecommons.org/licenses/by-sa/4.0/deed.tr
```
## Üçüncü taraf içerik
Sitede geçen kuruluş adları, logolar ve markalar ile dış kaynaklardan alınmış görseller kendi sahiplerinin lisanslarına tabidir; yukarıdaki lisanslar bunları kapsamaz.
+28
View File
@@ -0,0 +1,28 @@
---
title: 'Sorumluluk Reddi'
description: "ta1sph.info'daki DIY anten, cihaz modifikasyonu ve yazılım içerikleri eğitim ve hobi amaçlıdır; uygulamanın tüm sorumluluğu uygulayana aittir."
updated: 2026-10-16
---
## Bilgilerin niteliği
Bu sitedeki yazılar, özellikle **kendin yap (DIY) anten yapımı, cihaz modifikasyonu, güç kaynakları ve yazılım/firmware yükleme** konularındaki içerikler, **eğitim ve hobi amaçlıdır** ve "olduğu gibi" sunulur. Bilgilerin doğruluğu, eksiksizliği, güncelliği veya belirli bir amaca uygunluğu konusunda hiçbir garanti verilmez.
## Sorumluluk
Sitedeki uygulamaları gerçekleştirmekten doğabilecek her türlü **maddi veya manevi zarar, cihaz arızası, yaralanma, mülk hasarı, garanti kaybı ve mevzuata aykırılık** sorumluluğu tamamen uygulayan kişiye aittir. İçerikleri uygulayan kişi bu riskleri bilerek ve kabul ederek hareket etmiş sayılır.
## Lisans ve mevzuat
Yayın yapabilmek için geçerli bir amatör telsizci lisansına sahip olmak ve Bilgi Teknolojileri ve İletişim Kurumu (BTK) mevzuatına uymak kullanıcının sorumluluğundadır. Lisans sınıfınıza ait frekans ve güç sınırlarına uyun.
## Güvenlik uyarıları
- **RF maruziyeti:** Yüksek güçte ve antenlere yakın çalışırken maruziyet sınırlarına dikkat edin.
- **Yüksek gerilim:** Güç kaynakları ve amplifikatörler üzerinde çalışmadan önce cihazın enerjisini kesin; kondansatörlerin boşaldığından emin olun.
- **Yıldırım ve topraklama:** Dış antenleri ve direkleri uygun şekilde topraklayın; fırtınada anten bağlantılarını ayırın.
- **Çatı ve direk çalışmaları:** Yüksekte tek başınıza çalışmayın, uygun emniyet ekipmanı kullanın ve enerji hatlarından uzak durun.
## Dış bağlantılar
Bu siteden bağlantı verilen dış sitelerin içeriğinden ve gizlilik uygulamalarından sorumluluk kabul edilmez.
+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>;
+31
View File
@@ -0,0 +1,31 @@
---
import Button from '../components/Button.astro';
import Container from '../components/Container.astro';
import GridLines from '../components/GridLines.astro';
import SectionHeading from '../components/SectionHeading.astro';
import Numeral from '../components/home/Numeral.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { pages } from '../lib/data';
const t = pages.notFound;
---
<BaseLayout title={t.seoTitle} description={t.description} noindex>
<section class="surface-alt relative min-h-[60vh] overflow-hidden">
<GridLines />
<Container class="relative grid gap-8 py-20 md:grid-cols-[1.4fr_1.6fr_4.2fr] md:gap-0 md:py-28">
<Numeral value="404" alt class="text-[clamp(4rem,9vw,8rem)] md:col-span-2" />
<div class="flex flex-col gap-6 md:pl-8">
<SectionHeading level={1} eyebrow={t.eyebrow} title={t.title} />
<p class="max-w-xl text-lg">{t.lead}</p>
<div class="flex flex-wrap gap-4">
{t.links.map((l) => (
<Button href={l.href} variant={l.variant} icon={l.icon}>
{l.label}
</Button>
))}
</div>
</div>
</Container>
</section>
</BaseLayout>
+36
View File
@@ -0,0 +1,36 @@
---
// /gizlilik/, /lisans/, /sorumluluk-reddi/ from src/content/legal/*.md
import type { GetStaticPaths } from 'astro';
import { getCollection, render } from 'astro:content';
import CodeCopy from '../components/CodeCopy.astro';
import Container from '../components/Container.astro';
import PageHeader from '../components/PageHeader.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { pages } from '../lib/data';
import { formatDate, isoDay } from '../lib/dates';
export const getStaticPaths = (async () => {
const docs = await getCollection('legal');
return docs.map((doc) => ({ params: { legal: doc.id }, props: { doc } }));
}) satisfies GetStaticPaths;
const { doc } = Astro.props;
const { Content } = await render(doc);
---
<BaseLayout title={doc.data.title} description={doc.data.description}>
<PageHeader eyebrow={pages.legal.eyebrow} title={doc.data.title}>
<p class="text-sm font-semibold">
{pages.legal.updated}:{' '}
<time datetime={isoDay(doc.data.updated)}>{formatDate(doc.data.updated)}</time>
</p>
</PageHeader>
<Container class="max-w-[52rem] py-14 md:py-20">
<div class="prose article-prose md:prose-lg max-w-[72ch]">
<CodeCopy>
<Content />
</CodeCopy>
</div>
<p class="border-line text-fg-muted mt-12 border-t pt-6 text-sm">{pages.legal.notice}</p>
</Container>
</BaseLayout>
+5 -33
View File
@@ -9,6 +9,7 @@ import DateBadges from '../../components/blog/DateBadges.astro';
import DiyNotice from '../../components/blog/DiyNotice.astro';
import ShareBar from '../../components/blog/ShareBar.astro';
import Toc from '../../components/blog/Toc.astro';
import CodeCopy from '../../components/CodeCopy.astro';
import Container from '../../components/Container.astro';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { blog, site } from '../../lib/data';
@@ -119,13 +120,10 @@ const jsonLd = [
<Toc headings={headings} />
</div>
<div
class="prose article-prose md:prose-lg max-w-[72ch]"
data-view-slug={post.id}
data-code-copy={blog.post.copyCode}
data-code-copied={blog.post.codeCopied}
>
<Content />
<div class="prose article-prose md:prose-lg max-w-[72ch]" data-view-slug={post.id}>
<CodeCopy>
<Content />
</CodeCopy>
</div>
<footer data-pagefind-ignore class="border-line flex flex-col gap-6 border-t pt-8">
@@ -218,30 +216,4 @@ const jsonLd = [
if (viewed && 'sendBeacon' in navigator) {
navigator.sendBeacon(`/api/views/${encodeURIComponent(viewed)}/`);
}
// Copy button on every code block.
const prose = document.querySelector<HTMLElement>('[data-code-copy]');
if (prose) {
const { codeCopy = '', codeCopied = '' } = prose.dataset;
for (const pre of prose.querySelectorAll('pre')) {
const wrap = document.createElement('div');
wrap.className = 'code-block';
pre.replaceWith(wrap);
wrap.append(pre);
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'code-copy';
btn.textContent = codeCopy;
btn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(
pre.querySelector('code')?.innerText ?? pre.innerText,
);
btn.textContent = codeCopied;
setTimeout(() => (btn.textContent = codeCopy), 2000);
} catch {}
});
wrap.append(btn);
}
}
</script>
+64
View File
@@ -0,0 +1,64 @@
---
import { Icon } from 'astro-icon/components';
import Card from '../components/Card.astro';
import Container from '../components/Container.astro';
import PageHeader from '../components/PageHeader.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { about, site } from '../lib/data';
const jsonLd = [
{
'@type': 'ProfilePage',
'@id': `${site.url}/hakkimda/#page`,
url: `${site.url}/hakkimda/`,
name: about.seo.title,
inLanguage: site.lang,
mainEntity: { '@id': `${site.url}/#person` },
},
];
const sectionTitle =
'font-display flex items-center gap-3 text-sm font-bold tracking-wide uppercase';
---
<BaseLayout title={about.seo.title} description={about.seo.description} jsonLd={jsonLd}>
<PageHeader eyebrow={about.eyebrow} title={about.title} lead={about.lead} />
<Container class="flex flex-col gap-14 py-14 md:py-20">
<section aria-labelledby="identity-title">
<h2 id="identity-title" class={sectionTitle}>
{about.identity.title}
<span aria-hidden="true" class="bg-accent h-0.5 w-12"></span>
</h2>
<dl class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{about.identity.items.map((item) => (
<div class="bg-surface border-line flex flex-col gap-2 border p-5">
<dt class="text-fg-muted flex items-center gap-2 text-sm font-semibold">
<Icon name={item.icon} class="text-accent-text size-5" aria-hidden="true" />
{item.label}
</dt>
<dd class="font-display text-2xl leading-tight font-black">{item.value}</dd>
</div>
))}
</dl>
</section>
<div class="grid gap-6 lg:grid-cols-3">
{about.equipment.map((group, gi) => (
<Card as="section" aria-labelledby={`equipment-${gi}`} class="p-6">
<h2 id={`equipment-${gi}`} class="flex items-center gap-3 text-xl font-bold">
<Icon name={group.icon} class="text-accent-text size-7" aria-hidden="true" />
{group.title}
</h2>
<ul class="mt-5 flex flex-col">
{group.items.map((item) => (
<li class="border-line flex items-baseline justify-between gap-3 border-b py-3 last:border-b-0">
<span class="font-semibold">{item.name}</span>
{item.note && <span class="text-fg-muted text-right text-sm">{item.note}</span>}
</li>
))}
</ul>
</Card>
))}
</div>
</Container>
</BaseLayout>
+150
View File
@@ -0,0 +1,150 @@
---
import { Icon } from 'astro-icon/components';
import Container from '../components/Container.astro';
import ExternalLink from '../components/ExternalLink.astro';
import PageHeader from '../components/PageHeader.astro';
import BaseLayout from '../layouts/BaseLayout.astro';
import { contact, site } from '../lib/data';
const { map } = contact;
const jsonLd = [
{
'@type': 'ContactPage',
url: `${site.url}/iletisim/`,
name: contact.seo.title,
inLanguage: site.lang,
about: { '@id': `${site.url}/#person` },
},
];
const sectionTitle =
'font-display flex items-center gap-3 text-sm font-bold tracking-wide uppercase';
---
<BaseLayout title={contact.seo.title} description={contact.seo.description} jsonLd={jsonLd}>
<PageHeader
eyebrow={contact.eyebrow}
title={contact.title}
lead={contact.lead}
icon={contact.icon}
/>
<Container class="grid gap-12 py-14 md:py-20 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<section aria-labelledby="info-title">
<h2 id="info-title" class={sectionTitle}>
{contact.infoTitle}
<span aria-hidden="true" class="bg-accent h-0.5 w-12"></span>
</h2>
<dl class="mt-6 flex flex-col">
{contact.items.map((item) => {
const [user, domain] = item.email?.split('@') ?? [];
return (
<div class="border-line grid grid-cols-[2rem_minmax(0,1fr)] gap-x-3 gap-y-1 border-b py-4">
<dt class="font-display col-start-2 text-sm font-bold tracking-wide uppercase">
{item.label}
</dt>
<dd class="col-start-1 row-span-2 row-start-1">
<Icon name={item.icon} class="text-accent-text size-7" aria-hidden="true" />
</dd>
<dd class="col-start-2">
{item.email ? (
<span data-email data-u={user} data-d={domain} class="font-semibold">
{user} [at] {domain}
</span>
) : item.href ? (
<ExternalLink
href={item.href}
class="decoration-accent font-semibold underline underline-offset-4"
>
{item.values?.[0] ?? item.href}
</ExternalLink>
) : (
<ul class="flex flex-col gap-0.5">
{item.values?.map((v) => (
<li>{v}</li>
))}
</ul>
)}
</dd>
</div>
);
})}
</dl>
</section>
<section aria-labelledby="map-title" class="flex flex-col gap-4">
<h2 id="map-title" class={sectionTitle}>
{map.title}
<span aria-hidden="true" class="bg-accent h-0.5 w-12"></span>
</h2>
<div
id="map"
role="region"
aria-label={map.ariaLabel}
data-lat={map.lat}
data-lng={map.lng}
data-zoom={map.zoom}
data-popup={map.popup}
class="border-line bg-bg-alt relative z-0 h-[26rem] w-full border"
>
<p
class="text-fg-muted absolute inset-0 flex items-center justify-center text-sm"
data-map-loading
>
{map.loading}
</p>
</div>
<p class="text-fg-muted text-sm">{map.note}</p>
<template id="tower-icon">
<Icon name="tabler:building-broadcast-tower" class="size-6" aria-hidden="true" />
</template>
</section>
</Container>
</BaseLayout>
<script>
// De-obfuscate e-mail addresses (kept out of the HTML for simple bots).
for (const el of document.querySelectorAll<HTMLElement>('[data-email]')) {
const address = `${el.dataset.u}@${el.dataset.d}`;
const a = document.createElement('a');
a.href = `mailto:${address}`;
a.textContent = address;
a.className = 'decoration-accent font-semibold underline underline-offset-4';
el.replaceWith(a);
}
// Leaflet + OSM, loaded only when the map scrolls into view.
const el = document.getElementById('map');
if (el) {
const init = async () => {
const [{ default: L }] = await Promise.all([
import('leaflet'),
import('leaflet/dist/leaflet.css'),
]);
const { lat, lng, zoom, popup = '' } = el.dataset;
const center: [number, number] = [Number(lat), Number(lng)];
el.querySelector('[data-map-loading]')?.remove();
const m = L.map(el, { scrollWheelZoom: false }).setView(center, Number(zoom));
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(m);
const icon = L.divIcon({
html: `<span class="tower-pin">${(document.getElementById('tower-icon') as HTMLTemplateElement).innerHTML}</span>`,
className: 'tower-marker',
iconSize: [40, 40],
iconAnchor: [20, 48], // tip of the rotated pin
popupAnchor: [0, -46],
});
L.marker(center, { icon, title: popup, alt: popup }).addTo(m).bindPopup(popup);
};
new IntersectionObserver(
(entries, obs) => {
if (entries.some((e) => e.isIntersecting)) {
obs.disconnect();
void init();
}
},
{ rootMargin: '200px' },
).observe(el);
}
</script>
+50
View File
@@ -200,6 +200,10 @@
.code-block {
position: relative;
}
/* Room for the copy button so it never covers the first line. */
.code-block > pre {
padding-top: 2.5rem;
}
.code-copy {
position: absolute;
top: 0.5rem;
@@ -217,3 +221,49 @@
background: var(--accent-fill);
color: var(--on-accent-fill);
}
/* ---------- Contact map (Leaflet) ---------- */
.tower-marker {
background: none;
border: 0;
}
/* Rotate an inner element: `rotate` on the marker itself would also rotate Leaflet's translate3d. */
.tower-pin {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
background: var(--accent-fill);
color: var(--on-accent-fill);
border: 2px solid var(--fg);
border-radius: 50% 50% 50% 0;
rotate: -45deg;
}
.tower-pin svg {
rotate: 45deg;
}
.leaflet-container .leaflet-control-attribution {
background: var(--surface);
color: var(--fg);
}
.leaflet-container .leaflet-control-attribution a {
color: var(--accent-strong);
}
.leaflet-container {
font: inherit;
background: var(--bg-alt);
}
.leaflet-popup-content {
font-family: var(--font-display);
font-weight: 700;
}
/* Dark tiles without a second tile provider. */
[data-theme='dark'] .leaflet-tile-pane {
filter: invert(1) hue-rotate(180deg) brightness(0.85) contrast(0.9);
}
.leaflet-container .leaflet-popup-content-wrapper,
.leaflet-container .leaflet-popup-tip {
background: var(--surface);
color: var(--fg);
}