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:
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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: '© <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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user