From b1f2c4c875ac56bf45643897706eea7ba7def4f8 Mon Sep 17 00:00:00 2001 From: suphicakir Date: Fri, 16 Oct 2026 09:21:21 +0300 Subject: [PATCH] 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 --- PROJECT_MEMORY.md | 7 +++- src/components/Badge.astro | 6 ++- src/components/blog/DateBadges.astro | 36 ++++++++++++++++- src/components/blog/PostCard.astro | 1 + src/data/blog.json | 58 +++++++++++++++++++++++----- src/data/categories.json | 49 +++++++++++++++++++---- src/data/contact.json | 21 ++++++++-- src/data/home.json | 46 ++++++++++++++++++---- src/data/social.json | 36 ++++++++++++++--- src/lib/data.ts | 2 +- src/lib/views.ts | 9 +++++ src/pages/api/views/counts.ts | 12 ++++++ src/pages/blog/[slug].astro | 7 +++- 13 files changed, 252 insertions(+), 38 deletions(-) create mode 100644 src/pages/api/views/counts.ts diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index 41490ef..0242c09 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -1,7 +1,7 @@ # 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. -> 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 @@ -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` - `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/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//')`; 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/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 | 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 | 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 | 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 | @@ -107,6 +109,7 @@ ## 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 (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` diff --git a/src/components/Badge.astro b/src/components/Badge.astro index 53d3716..fc9d9f2 100644 --- a/src/components/Badge.astro +++ b/src/components/Badge.astro @@ -1,16 +1,17 @@ --- +import type { HTMLAttributes } from 'astro/types'; import { Icon } from 'astro-icon/components'; type Variant = 'solid' | 'outline' | 'category'; -export interface Props { +export interface Props extends Omit, 'class'> { variant?: Variant; icon?: string; href?: 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 = { // accent-fill (not accent) keeps small text ≥ 4.5:1 in both themes. @@ -23,6 +24,7 @@ const Tag = href ? 'a' : 'span'; --- @@ -38,4 +40,36 @@ const s = secondary && meta(secondary); {fmt(blog.card.readingTime, { n: minutes })} )} + {viewsSlug && ( + // Space is reserved (invisible) so filling it in causes no layout shift; stays hidden if the API is down. + + )} + + diff --git a/src/components/blog/PostCard.astro b/src/components/blog/PostCard.astro index 0e63598..c3edd1b 100644 --- a/src/components/blog/PostCard.astro +++ b/src/components/blog/PostCard.astro @@ -59,6 +59,7 @@ const row = layout === 'row'; pubDate={pubDate} updatedDate={updatedDate} minutes={readingMinutes(post)} + viewsSlug={post.id} class="mt-auto pt-2" /> diff --git a/src/data/blog.json b/src/data/blog.json index 33d6e59..2fd6886 100644 --- a/src/data/blog.json +++ b/src/data/blog.json @@ -16,7 +16,9 @@ }, "card": { "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": { "created": "Oluşturuldu", @@ -63,15 +65,53 @@ "diy": { "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.", - "link": { "label": "Sorumluluk reddi metninin tamamı", "href": "/sorumluluk-reddi/" } + "link": { + "label": "Sorumluluk reddi metninin tamamı", + "href": "/sorumluluk-reddi/" + } }, "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": "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}" } + { + "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": "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}" + } ] } diff --git a/src/data/categories.json b/src/data/categories.json index 186cef8..98addcc 100644 --- a/src/data/categories.json +++ b/src/data/categories.json @@ -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": "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" } + { + "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": "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" + } ] diff --git a/src/data/contact.json b/src/data/contact.json index 41df17f..bc8b12f 100644 --- a/src/data/contact.json +++ b/src/data/contact.json @@ -31,8 +31,23 @@ ] }, { "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": "TA-CallBook", "icon": "tabler:book", "href": "http://www.tacallbook.org/", "values": ["tacallbook.org"] } + { + "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": "TA-CallBook", + "icon": "tabler:book", + "href": "http://www.tacallbook.org/", + "values": ["tacallbook.org"] + } ] } diff --git a/src/data/home.json b/src/data/home.json index c6d6d9f..0f1cbd0 100644 --- a/src/data/home.json +++ b/src/data/home.json @@ -16,7 +16,12 @@ "title": "Amatör Telsizcilik Notları", "lead": "DMR, röleler, antenler ve cihazlar üzerine denemelerimi, öğrendiklerimi ve işe yarayan kaynakları paylaşıyorum.", "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" } ] }, @@ -65,9 +70,21 @@ "eyebrow": "Topluluk", "title": "Telegram Grupları", "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 Radyocular Derneği (YM1KTC)", "href": "https://t.me/ym1ktc", "icon": "tabler:brand-telegram" } + { + "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 Radyocular Derneği (YM1KTC)", + "href": "https://t.me/ym1ktc", + "icon": "tabler:brand-telegram" + } ] }, { @@ -85,9 +102,24 @@ "eyebrow": "Kaynaklar", "title": "Site Tanıtımları", "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ü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" } + { + "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ü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" + } ] } ] diff --git a/src/data/social.json b/src/data/social.json index e31e169..efebdfc 100644 --- a/src/data/social.json +++ b/src/data/social.json @@ -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": "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" } + { + "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": "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" + } ] diff --git a/src/lib/data.ts b/src/lib/data.ts index 55b719e..2891255 100644 --- a/src/lib/data.ts +++ b/src/lib/data.ts @@ -144,7 +144,7 @@ const blogSchema = z.object({ 'pageTitle', 'empty', ]), - card: strings(['readMore', 'readingTime']), + card: strings(['readMore', 'readingTime', 'views', 'viewsLabel']), dates: strings(['created', 'updated']), pager: strings(['label', 'first', 'prev', 'next', 'last', 'page']), sidebar: strings([ diff --git a/src/lib/views.ts b/src/lib/views.ts index fbb138d..94420ab 100644 --- a/src/lib/views.ts +++ b/src/lib/views.ts @@ -65,6 +65,15 @@ export function clientIp(request: Request, fallback: string): string { export const isBot = (ua: string | null) => !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 { + 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. */ export function dbHealthy(): boolean { try { diff --git a/src/pages/api/views/counts.ts b/src/pages/api/views/counts.ts new file mode 100644 index 0000000..22f6c9d --- /dev/null +++ b/src/pages/api/views/counts.ts @@ -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' } }); +}; diff --git a/src/pages/blog/[slug].astro b/src/pages/blog/[slug].astro index 33d941a..9014e5e 100644 --- a/src/pages/blog/[slug].astro +++ b/src/pages/blog/[slug].astro @@ -100,7 +100,12 @@ const jsonLd = [ {title}

{description}

- + {cover && (