feat: show view count badge next to reading time

- GET /api/views/counts/ returns { slug: count } for all published posts
- Eye-icon badge on post cards and post header, filled client-side;
  space is reserved so there is no layout shift, hidden if the API fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 09:21:21 +03:00
parent a32c0d0911
commit b1f2c4c875
13 changed files with 252 additions and 38 deletions
+5 -2
View File
@@ -1,7 +1,7 @@
# PROJECT_MEMORY — ta1sph.info # PROJECT_MEMORY — ta1sph.info
> Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur. > Claude'un bu proje için kalıcı hafızası. Her görev sonunda güncellenir. Kısa ve güncel tutulur.
> Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Siteyi Kur — yayın) > Son güncelleme: 2026-10-16 · Güncelleyen: Claude (Uygulama Düzenle — görüntülenme rozeti)
## 1. Durum ## 1. Durum
@@ -37,7 +37,8 @@
- `src/components/blog/` — `BlogListing` (liste düzeni + sidebar), `PostCard` (row/stack, kapaksızsa kategori ikonu), `DateBadges`, `Pager`, `Sidebar`, `SearchBox` (Pagefind JS API, özel UI), `ShareBar`, `Toc`, `Breadcrumbs`, `DiyNotice` - `src/components/blog/` — `BlogListing` (liste düzeni + sidebar), `PostCard` (row/stack, kapaksızsa kategori ikonu), `DateBadges`, `Pager`, `Sidebar`, `SearchBox` (Pagefind JS API, özel UI), `ShareBar`, `Toc`, `Breadcrumbs`, `DiyNotice`
- `global.css` — `.article-prose` (prose token eşlemesi), Shiki çift tema, `.code-copy` - `global.css` — `.article-prose` (prose token eşlemesi), Shiki çift tema, `.code-copy`
- `src/lib/views.ts` — `node:sqlite` sayaç (`$DATA_DIR/views.db`, varsayılan `./data`; Docker'da `/app/data` volume). Tablolar: `views(slug,count)`, `hits(hash,at)` 24 sa, `salts(day,salt)` günlük tuz. IP saklanmaz; `clientIp` önce `X-Forwarded-For`; `isBot` UA filtresi (headless dahil) - `src/lib/views.ts` — `node:sqlite` sayaç (`$DATA_DIR/views.db`, varsayılan `./data`; Docker'da `/app/data` volume). Tablolar: `views(slug,count)`, `hits(hash,at)` 24 sa, `salts(day,salt)` günlük tuz. IP saklanmaz; `clientIp` önce `X-Forwarded-For`; `isBot` UA filtresi (headless dahil)
- `src/pages/api/views/[slug].ts` (POST, 204; bilinmeyen slug 404) · `src/pages/api/views/top.ts` (GET `?limit=1..10`, JSON `{slug,title,url,count}`, 5 dk cache) — ikisi `prerender = false` - `src/pages/api/views/[slug].ts` (POST, 204; bilinmeyen slug 404) · `src/pages/api/views/counts.ts` (GET `{slug: count}` tüm yayındaki yazılar, 0 dahil, 60 sn cache) · `src/pages/api/views/top.ts` (GET `?limit=1..10`, JSON `{slug,title,url,count}`, 5 dk cache) — ikisi `prerender = false`
- Görüntülenme rozeti: `DateBadges` `viewsSlug` prop'u → göz ikonlu outline rozet (`blog.json` `card.views` = "{n} görüntülenme"), `invisible` ile yer ayrılır (CLS 0), script `/api/views/counts/`'tan doldurur; API yoksa görünmez kalır. Kartlarda + yazı sayfasında. Her zaman gösterilir (0 dahil) — eşik istenirse script'te
- Yazı sayfası `sendBeacon('/api/views/<slug>/')`; Sidebar script'i `/api/views/top/` ile `ol[data-featured]`'ı değiştirir, eksikleri build-time yedekle tamamlar - Yazı sayfası `sendBeacon('/api/views/<slug>/')`; Sidebar script'i `/api/views/top/` ile `ol[data-featured]`'ı değiştirir, eksikleri build-time yedekle tamamlar
- `src/data/about.json` (künye + ekipman grupları) · `src/data/contact.json` (harita lat/lng/zoom, bilgi satırları; `email` HTML'de açık yazılmaz, JS ile birleştirilir) · `src/data/pages.json` (yasal sayfa ortak metinleri, 404) - `src/data/about.json` (künye + ekipman grupları) · `src/data/contact.json` (harita lat/lng/zoom, bilgi satırları; `email` HTML'de açık yazılmaz, JS ile birleştirilir) · `src/data/pages.json` (yasal sayfa ortak metinleri, 404)
- `src/pages/hakkimda.astro` (ProfilePage JSON-LD) · `src/pages/iletisim.astro` (Leaflet + OSM, görünür olunca dinamik import; `divIcon` içinde `.tower-pin`) · `src/pages/[legal].astro` + `src/content/legal/{gizlilik,lisans,sorumluluk-reddi}.md` (koleksiyon `legal`, frontmatter `updated`) · `src/pages/404.astro` - `src/pages/hakkimda.astro` (ProfilePage JSON-LD) · `src/pages/iletisim.astro` (Leaflet + OSM, görünür olunca dinamik import; `divIcon` içinde `.tower-pin`) · `src/pages/[legal].astro` + `src/content/legal/{gizlilik,lisans,sorumluluk-reddi}.md` (koleksiyon `legal`, frontmatter `updated`) · `src/pages/404.astro`
@@ -71,6 +72,7 @@
| 2026-10-15 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme | | 2026-10-15 | Kanonik alan: apex `ta1sph.info`, www → 301 | SEO tekilleştirme |
| 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik | | 2026-10-15 | Fontlar self-host (Fontsource: Rubik + Inter) | Performans + gizlilik |
| 2026-10-16 | Ek token'lar: `accent-fill`/`on-accent-fill`, `accent-text` (khaki'de fg'ye döner) | Ölçüm: ink/red 4.46, red-strong/khaki 3.85 → küçük metin AA'yı geçmiyordu. Kırmızı zeminli butonlar ≥19px bold | | 2026-10-16 | Ek token'lar: `accent-fill`/`on-accent-fill`, `accent-text` (khaki'de fg'ye döner) | Ölçüm: ink/red 4.46, red-strong/khaki 3.85 → küçük metin AA'yı geçmiyordu. Kırmızı zeminli butonlar ≥19px bold |
| 2026-10-16 | Görüntülenme sayısı kart + yazı sayfasında rozet, 0 dahil her zaman — kullanıcı isteği | Statik sayfa + istemci tarafı tek istek; yer ayırma ile kayma yok |
| 2026-10-16 | Docker testleri **temiz git klonundan** yapılır (çalışma dizininden değil) | Yoksayılan/commit'lenmemiş dosyalar yerel testte görünmez; Dokploy repo'dan klonlar | | 2026-10-16 | Docker testleri **temiz git klonundan** yapılır (çalışma dizininden değil) | Yoksayılan/commit'lenmemiş dosyalar yerel testte görünmez; Dokploy repo'dan klonlar |
| 2026-10-16 | Sıkıştırma konteyner içinde (`server.mjs` + `compression`), Traefik ayarı yok | Kullanıcının Dokploy'da ek middleware kurmasına gerek kalmaz | | 2026-10-16 | Sıkıştırma konteyner içinde (`server.mjs` + `compression`), Traefik ayarı yok | Kullanıcının Dokploy'da ek middleware kurmasına gerek kalmaz |
| 2026-10-16 | Docker taban imajı `node:24-slim` (LTS) | `node:sqlite` 24 ve 26'da uyarısız test edildi; 24 LTS Nisan 2028'e kadar destekli | | 2026-10-16 | Docker taban imajı `node:24-slim` (LTS) | `node:sqlite` 24 ve 26'da uyarısız test edildi; 24 LTS Nisan 2028'e kadar destekli |
@@ -107,6 +109,7 @@
## 6. Değişiklik Günlüğü (en yeni üstte) ## 6. Değişiklik Günlüğü (en yeni üstte)
- 2026-10-16 | Uygulama Düzenle | Okuma süresinin yanına göz ikonlu görüntülenme rozeti (kart + yazı); `/api/views/counts/`; iki tema, CLS 0, axe temiz | `src/components/blog/DateBadges.astro`, `src/components/Badge.astro`, `src/components/blog/PostCard.astro`, `src/pages/blog/[slug].astro`, `src/pages/api/views/counts.ts`, `src/lib/views.ts`, `src/data/blog.json`, `src/lib/data.ts`
- 2026-10-16 | Yayına Al | İlk yayın: DNS (apex+www), Dokploy uygulaması, Let's Encrypt; canlı doğrulama (sayfalar, API, br, başlıklar, sitemap, RSS) | — (sunucu tarafı) - 2026-10-16 | Yayına Al | İlk yayın: DNS (apex+www), Dokploy uygulaması, Let's Encrypt; canlı doğrulama (sayfalar, API, br, başlıklar, sitemap, RSS) | — (sunucu tarafı)
- 2026-10-16 | Yayına Al (fix) | `.gitignore` `data/` → `/data/`: `src/data/*.json` hiç commit'lenmemişti, Dokploy build'i kırıldı; temiz klon + Docker ile doğrulandı | `.gitignore`, `src/data/` - 2026-10-16 | Yayına Al (fix) | `.gitignore` `data/` → `/data/`: `src/data/*.json` hiç commit'lenmemişti, Dokploy build'i kırıldı; temiz klon + Docker ile doğrulandı | `.gitignore`, `src/data/`
- 2026-10-16 | Siteyi Kur A8 | Dockerfile, .dockerignore, compose, server.mjs (sıkıştırma + güvenlik başlıkları), /api/health, build-only bağımlılıklar devDeps'e; Docker'da uçtan uca test | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `server.mjs`, `src/pages/api/health.ts`, `src/lib/views.ts`, `package.json`, `README.md` - 2026-10-16 | Siteyi Kur A8 | Dockerfile, .dockerignore, compose, server.mjs (sıkıştırma + güvenlik başlıkları), /api/health, build-only bağımlılıklar devDeps'e; Docker'da uçtan uca test | `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `server.mjs`, `src/pages/api/health.ts`, `src/lib/views.ts`, `package.json`, `README.md`
+4 -2
View File
@@ -1,16 +1,17 @@
--- ---
import type { HTMLAttributes } from 'astro/types';
import { Icon } from 'astro-icon/components'; import { Icon } from 'astro-icon/components';
type Variant = 'solid' | 'outline' | 'category'; type Variant = 'solid' | 'outline' | 'category';
export interface Props { export interface Props extends Omit<HTMLAttributes<'span'>, 'class'> {
variant?: Variant; variant?: Variant;
icon?: string; icon?: string;
href?: string; href?: string;
class?: string; class?: string;
} }
const { variant = 'solid', icon, href, class: className } = Astro.props; const { variant = 'solid', icon, href, class: className, ...rest } = Astro.props;
const variants: Record<Variant, string> = { const variants: Record<Variant, string> = {
// accent-fill (not accent) keeps small text ≥ 4.5:1 in both themes. // accent-fill (not accent) keeps small text ≥ 4.5:1 in both themes.
@@ -23,6 +24,7 @@ const Tag = href ? 'a' : 'span';
--- ---
<Tag <Tag
{...rest}
href={href} href={href}
class:list={[ class:list={[
'inline-flex w-fit items-center gap-1 rounded-sm px-2 py-0.5 text-xs leading-5', 'inline-flex w-fit items-center gap-1 rounded-sm px-2 py-0.5 text-xs leading-5',
+35 -1
View File
@@ -8,10 +8,12 @@ export interface Props {
updatedDate?: Date; updatedDate?: Date;
/** Reading time in minutes, shown after the dates. */ /** Reading time in minutes, shown after the dates. */
minutes?: number; minutes?: number;
/** Post slug: shows a view-count badge filled from /api/views/counts/ after load. */
viewsSlug?: string;
class?: string; class?: string;
} }
const { pubDate, updatedDate, minutes, class: className } = Astro.props; const { pubDate, updatedDate, minutes, viewsSlug, class: className } = Astro.props;
const { primary, secondary } = dateBadges(pubDate, updatedDate); const { primary, secondary } = dateBadges(pubDate, updatedDate);
const meta = (b: DateBadge) => const meta = (b: DateBadge) =>
@@ -38,4 +40,36 @@ const s = secondary && meta(secondary);
{fmt(blog.card.readingTime, { n: minutes })} {fmt(blog.card.readingTime, { n: minutes })}
</Badge> </Badge>
)} )}
{viewsSlug && (
// Space is reserved (invisible) so filling it in causes no layout shift; stays hidden if the API is down.
<Badge
variant="outline"
icon="tabler:eye"
class="invisible"
data-views-slug={viewsSlug}
data-views-template={blog.card.views}
title={blog.card.viewsLabel}
>
<span data-views-text>{fmt(blog.card.views, { n: '–' })}</span>
</Badge>
)}
</div> </div>
<script>
const badges = document.querySelectorAll<HTMLElement>('[data-views-slug]');
if (badges.length) {
fetch('/api/views/counts/')
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((counts: Record<string, number>) => {
const nf = new Intl.NumberFormat('tr-TR');
for (const el of badges) {
const n = counts[el.dataset.viewsSlug ?? ''] ?? 0;
const text = el.querySelector('[data-views-text]');
if (text)
text.textContent = (el.dataset.viewsTemplate ?? '{n}').replace('{n}', nf.format(n));
el.classList.remove('invisible');
}
})
.catch(() => {});
}
</script>
+1
View File
@@ -59,6 +59,7 @@ const row = layout === 'row';
pubDate={pubDate} pubDate={pubDate}
updatedDate={updatedDate} updatedDate={updatedDate}
minutes={readingMinutes(post)} minutes={readingMinutes(post)}
viewsSlug={post.id}
class="mt-auto pt-2" class="mt-auto pt-2"
/> />
</div> </div>
+49 -9
View File
@@ -16,7 +16,9 @@
}, },
"card": { "card": {
"readMore": "Devamını oku", "readMore": "Devamını oku",
"readingTime": "{n} dk okuma" "readingTime": "{n} dk okuma",
"views": "{n} görüntülenme",
"viewsLabel": "Görüntülenme sayısı"
}, },
"dates": { "dates": {
"created": "Oluşturuldu", "created": "Oluşturuldu",
@@ -63,15 +65,53 @@
"diy": { "diy": {
"title": "Sorumluluk Reddi", "title": "Sorumluluk Reddi",
"body": "Bu yazıdaki uygulamalar eğitim ve hobi amaçlıdır. Uygulamadan doğabilecek her türlü zarar, cihaz arızası ve mevzuata aykırılık sorumluluğu uygulayan kişiye aittir.", "body": "Bu yazıdaki uygulamalar eğitim ve hobi amaçlıdır. Uygulamadan doğabilecek her türlü zarar, cihaz arızası ve mevzuata aykırılık sorumluluğu uygulayan kişiye aittir.",
"link": { "label": "Sorumluluk reddi metninin tamamı", "href": "/sorumluluk-reddi/" } "link": {
"label": "Sorumluluk reddi metninin tamamı",
"href": "/sorumluluk-reddi/"
}
}, },
"share": [ "share": [
{ "id": "x", "label": "X'te paylaş", "icon": "tabler:brand-x", "url": "https://x.com/intent/post?url={url}&text={title}" }, {
{ "id": "facebook", "label": "Facebook'ta paylaş", "icon": "tabler:brand-facebook", "url": "https://www.facebook.com/sharer/sharer.php?u={url}" }, "id": "x",
{ "id": "linkedin", "label": "LinkedIn'de paylaş", "icon": "tabler:brand-linkedin", "url": "https://www.linkedin.com/sharing/share-offsite/?url={url}" }, "label": "X'te paylaş",
{ "id": "whatsapp", "label": "WhatsApp ile gönder", "icon": "tabler:brand-whatsapp", "url": "https://wa.me/?text={title}%20{url}" }, "icon": "tabler:brand-x",
{ "id": "telegram", "label": "Telegram ile gönder", "icon": "tabler:brand-telegram", "url": "https://t.me/share/url?url={url}&text={title}" }, "url": "https://x.com/intent/post?url={url}&text={title}"
{ "id": "bluesky", "label": "Bluesky'da paylaş", "icon": "tabler:brand-bluesky", "url": "https://bsky.app/intent/compose?text={title}%20{url}" }, },
{ "id": "email", "label": "E-posta ile gönder", "icon": "tabler:mail", "url": "mailto:?subject={title}&body={url}" } {
"id": "facebook",
"label": "Facebook'ta paylaş",
"icon": "tabler:brand-facebook",
"url": "https://www.facebook.com/sharer/sharer.php?u={url}"
},
{
"id": "linkedin",
"label": "LinkedIn'de paylaş",
"icon": "tabler:brand-linkedin",
"url": "https://www.linkedin.com/sharing/share-offsite/?url={url}"
},
{
"id": "whatsapp",
"label": "WhatsApp ile gönder",
"icon": "tabler:brand-whatsapp",
"url": "https://wa.me/?text={title}%20{url}"
},
{
"id": "telegram",
"label": "Telegram ile gönder",
"icon": "tabler:brand-telegram",
"url": "https://t.me/share/url?url={url}&text={title}"
},
{
"id": "bluesky",
"label": "Bluesky'da paylaş",
"icon": "tabler:brand-bluesky",
"url": "https://bsky.app/intent/compose?text={title}%20{url}"
},
{
"id": "email",
"label": "E-posta ile gönder",
"icon": "tabler:mail",
"url": "mailto:?subject={title}&body={url}"
}
] ]
} }
+42 -7
View File
@@ -1,9 +1,44 @@
[ [
{ "slug": "dmr", "name": "DMR & Dijital Modlar", "description": "DMR, talkgroup'lar, kod plugları ve dijital ses modları.", "icon": "tabler:radio" }, {
{ "slug": "antenler", "name": "Antenler & DIY", "description": "Anten yapımı, ölçüm ve kendin yap projeleri.", "icon": "tabler:antenna" }, "slug": "dmr",
{ "slug": "cihazlar", "name": "Cihaz İncelemeleri", "description": "El telsizleri, mobil cihazlar ve aksesuarlar.", "icon": "tabler:device-mobile" }, "name": "DMR & Dijital Modlar",
{ "slug": "roleler", "name": "Röleler & Şebekeler", "description": "Röle kullanımı, frekanslar ve ton ayarları.", "icon": "tabler:building-broadcast-tower" }, "description": "DMR, talkgroup'lar, kod plugları ve dijital ses modları.",
{ "slug": "hotspot", "name": "Hotspot & Yazılım", "description": "Hotspot kurulumu, firmware ve yazılımlar.", "icon": "tabler:router" }, "icon": "tabler:radio"
{ "slug": "mevzuat", "name": "Lisans & Mevzuat", "description": "Amatör telsizcilik lisansı ve mevzuat notları.", "icon": "tabler:license" }, },
{ "slug": "genel", "name": "Genel", "description": "Diğer notlar ve duyurular.", "icon": "tabler:notes" } {
"slug": "antenler",
"name": "Antenler & DIY",
"description": "Anten yapımı, ölçüm ve kendin yap projeleri.",
"icon": "tabler:antenna"
},
{
"slug": "cihazlar",
"name": "Cihaz İncelemeleri",
"description": "El telsizleri, mobil cihazlar ve aksesuarlar.",
"icon": "tabler:device-mobile"
},
{
"slug": "roleler",
"name": "Röleler & Şebekeler",
"description": "Röle kullanımı, frekanslar ve ton ayarları.",
"icon": "tabler:building-broadcast-tower"
},
{
"slug": "hotspot",
"name": "Hotspot & Yazılım",
"description": "Hotspot kurulumu, firmware ve yazılımlar.",
"icon": "tabler:router"
},
{
"slug": "mevzuat",
"name": "Lisans & Mevzuat",
"description": "Amatör telsizcilik lisansı ve mevzuat notları.",
"icon": "tabler:license"
},
{
"slug": "genel",
"name": "Genel",
"description": "Diğer notlar ve duyurular.",
"icon": "tabler:notes"
}
] ]
+18 -3
View File
@@ -31,8 +31,23 @@
] ]
}, },
{ "label": "E-posta", "icon": "tabler:mail", "email": "suphi@radio.org.tr" }, { "label": "E-posta", "icon": "tabler:mail", "email": "suphi@radio.org.tr" },
{ "label": "QRZ.com", "icon": "tabler:world", "href": "https://www.qrz.com/db/TA1SPH", "values": ["qrz.com/db/TA1SPH"] }, {
{ "label": "QRZ73", "icon": "tabler:address-book", "href": "https://qrz73.org.tr/callsign.php?call=TA1SPH", "values": ["qrz73.org.tr · TA1SPH"] }, "label": "QRZ.com",
{ "label": "TA-CallBook", "icon": "tabler:book", "href": "http://www.tacallbook.org/", "values": ["tacallbook.org"] } "icon": "tabler:world",
"href": "https://www.qrz.com/db/TA1SPH",
"values": ["qrz.com/db/TA1SPH"]
},
{
"label": "QRZ73",
"icon": "tabler:address-book",
"href": "https://qrz73.org.tr/callsign.php?call=TA1SPH",
"values": ["qrz73.org.tr · TA1SPH"]
},
{
"label": "TA-CallBook",
"icon": "tabler:book",
"href": "http://www.tacallbook.org/",
"values": ["tacallbook.org"]
}
] ]
} }
+39 -7
View File
@@ -16,7 +16,12 @@
"title": "Amatör Telsizcilik Notları", "title": "Amatör Telsizcilik Notları",
"lead": "DMR, röleler, antenler ve cihazlar üzerine denemelerimi, öğrendiklerimi ve işe yarayan kaynakları paylaşıyorum.", "lead": "DMR, röleler, antenler ve cihazlar üzerine denemelerimi, öğrendiklerimi ve işe yarayan kaynakları paylaşıyorum.",
"ctas": [ "ctas": [
{ "label": "Blog'u Oku", "href": "/blog/", "variant": "primary", "icon": "tabler:arrow-right" }, {
"label": "Blog'u Oku",
"href": "/blog/",
"variant": "primary",
"icon": "tabler:arrow-right"
},
{ "label": "Hakkımda", "href": "/hakkimda/", "variant": "secondary" } { "label": "Hakkımda", "href": "/hakkimda/", "variant": "secondary" }
] ]
}, },
@@ -65,9 +70,21 @@
"eyebrow": "Topluluk", "eyebrow": "Topluluk",
"title": "Telegram Grupları", "title": "Telegram Grupları",
"items": [ "items": [
{ "title": "Amatör Telsizci", "href": "https://t.me/amatortelsizci", "icon": "tabler:brand-telegram" }, {
{ "title": "Çevrim Zamanı", "href": "https://t.me/cevrim_zamani", "icon": "tabler:brand-telegram" }, "title": "Amatör Telsizci",
{ "title": "Amatör Radyocular Derneği (YM1KTC)", "href": "https://t.me/ym1ktc", "icon": "tabler:brand-telegram" } "href": "https://t.me/amatortelsizci",
"icon": "tabler:brand-telegram"
},
{
"title": "Çevrim Zamanı",
"href": "https://t.me/cevrim_zamani",
"icon": "tabler:brand-telegram"
},
{
"title": "Amatör Radyocular Derneği (YM1KTC)",
"href": "https://t.me/ym1ktc",
"icon": "tabler:brand-telegram"
}
] ]
}, },
{ {
@@ -85,9 +102,24 @@
"eyebrow": "Kaynaklar", "eyebrow": "Kaynaklar",
"title": "Site Tanıtımları", "title": "Site Tanıtımları",
"items": [ "items": [
{ "title": "Türkiye Röleleri", "description": "Türkiye'deki VHF ve UHF, analog ve dijital amatör telsiz röle frekansları, bölge bölge.", "href": "https://ta-role.com", "icon": "tabler:building-broadcast-tower" }, {
{ "title": "Türkiye Amatör Telsiz Röle Listesi", "description": "Türkiye'deki amatör telsiz rölelerinin konum, frekans, CTCSS tonu ve bant bilgileri.", "href": "https://amatortelsizcilik.com.tr/roleler", "icon": "tabler:list-details" }, "title": "Türkiye Röleleri",
{ "title": "Türkçe Logbook", "description": "QRZ73! üzerinde QSO kayıtlarınızı yönetebileceğiniz Türkçe logbook.", "href": "https://qrz73.org.tr/", "icon": "tabler:notebook" } "description": "Türkiye'deki VHF ve UHF, analog ve dijital amatör telsiz röle frekansları, bölge bölge.",
"href": "https://ta-role.com",
"icon": "tabler:building-broadcast-tower"
},
{
"title": "Türkiye Amatör Telsiz Röle Listesi",
"description": "Türkiye'deki amatör telsiz rölelerinin konum, frekans, CTCSS tonu ve bant bilgileri.",
"href": "https://amatortelsizcilik.com.tr/roleler",
"icon": "tabler:list-details"
},
{
"title": "Türkçe Logbook",
"description": "QRZ73! üzerinde QSO kayıtlarınızı yönetebileceğiniz Türkçe logbook.",
"href": "https://qrz73.org.tr/",
"icon": "tabler:notebook"
}
] ]
} }
] ]
+31 -5
View File
@@ -1,7 +1,33 @@
[ [
{ "id": "instagram", "label": "Instagram", "href": "https://www.instagram.com/suphi.cakir/", "icon": "tabler:brand-instagram" }, {
{ "id": "facebook", "label": "Facebook", "href": "https://www.facebook.com/suphi.cakir", "icon": "tabler:brand-facebook" }, "id": "instagram",
{ "id": "x", "label": "X (Twitter)", "href": "https://x.com/suphicakir", "icon": "tabler:brand-x", "handle": "@suphicakir" }, "label": "Instagram",
{ "id": "bluesky", "label": "Bluesky", "href": "https://bsky.app/profile/ta1sph.bsky.social", "icon": "tabler:brand-bluesky" }, "href": "https://www.instagram.com/suphi.cakir/",
{ "id": "youtube", "label": "YouTube", "href": "https://www.youtube.com/@suphcakirr", "icon": "tabler:brand-youtube" } "icon": "tabler:brand-instagram"
},
{
"id": "facebook",
"label": "Facebook",
"href": "https://www.facebook.com/suphi.cakir",
"icon": "tabler:brand-facebook"
},
{
"id": "x",
"label": "X (Twitter)",
"href": "https://x.com/suphicakir",
"icon": "tabler:brand-x",
"handle": "@suphicakir"
},
{
"id": "bluesky",
"label": "Bluesky",
"href": "https://bsky.app/profile/ta1sph.bsky.social",
"icon": "tabler:brand-bluesky"
},
{
"id": "youtube",
"label": "YouTube",
"href": "https://www.youtube.com/@suphcakirr",
"icon": "tabler:brand-youtube"
}
] ]
+1 -1
View File
@@ -144,7 +144,7 @@ const blogSchema = z.object({
'pageTitle', 'pageTitle',
'empty', 'empty',
]), ]),
card: strings(['readMore', 'readingTime']), card: strings(['readMore', 'readingTime', 'views', 'viewsLabel']),
dates: strings(['created', 'updated']), dates: strings(['created', 'updated']),
pager: strings(['label', 'first', 'prev', 'next', 'last', 'page']), pager: strings(['label', 'first', 'prev', 'next', 'last', 'page']),
sidebar: strings([ sidebar: strings([
+9
View File
@@ -65,6 +65,15 @@ export function clientIp(request: Request, fallback: string): string {
export const isBot = (ua: string | null) => export const isBot = (ua: string | null) =>
!ua || /bot|crawl|spider|slurp|preview|facebookexternalhit|headless|lighthouse/i.test(ua); !ua || /bot|crawl|spider|slurp|preview|facebookexternalhit|headless|lighthouse/i.test(ua);
/** All counters as { slug: count } (slugs with no views are absent). */
export function allViews(): Record<string, number> {
const rows = open().prepare('SELECT slug, count FROM views').all() as {
slug: string;
count: number;
}[];
return Object.fromEntries(rows.map((r) => [r.slug, r.count]));
}
/** Health probe: the database opens and answers a trivial query. */ /** Health probe: the database opens and answers a trivial query. */
export function dbHealthy(): boolean { export function dbHealthy(): boolean {
try { try {
+12
View File
@@ -0,0 +1,12 @@
import type { APIRoute } from 'astro';
import { getPosts } from '../../../lib/posts';
import { allViews } from '../../../lib/views';
export const prerender = false;
// GET /api/views/counts/ — { slug: count } for every published post (0 when never viewed).
export const GET: APIRoute = async () => {
const views = allViews();
const counts = Object.fromEntries((await getPosts()).map((p) => [p.id, views[p.id] ?? 0]));
return Response.json(counts, { headers: { 'Cache-Control': 'public, max-age=60' } });
};
+6 -1
View File
@@ -100,7 +100,12 @@ const jsonLd = [
{title} {title}
</h1> </h1>
<p class="text-fg-muted text-lg">{description}</p> <p class="text-fg-muted text-lg">{description}</p>
<DateBadges pubDate={pubDate} updatedDate={updatedDate} minutes={readingMinutes(post)} /> <DateBadges
pubDate={pubDate}
updatedDate={updatedDate}
minutes={readingMinutes(post)}
viewsSlug={post.id}
/>
</header> </header>
{cover && ( {cover && (