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:
+5
-2
@@ -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`
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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}"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' } });
|
||||||
|
};
|
||||||
@@ -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 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user