Implement Astro features: React Islands, Server Endpoints, i18n, Partytown

Features added:
- React integration with @astrojs/react
- React components: DMRDashboard, MemberSearch
- API endpoints: /api/repeaters.json, /api/dmr-contacts.json, /api/feed/[category].xml
- i18n support for TR/EN locales with LanguageSwitcher component
- Partytown enabled for third-party script optimization
- Admin panel logo fixed (moved to public/images/)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Buğra Canata
2026-01-17 17:30:58 +03:00
parent 214e9733d0
commit 2467d5555f
14 changed files with 1022 additions and 143 deletions
+27
View File
@@ -0,0 +1,27 @@
---
const { currentLocale } = Astro.params;
const locales = ['tr', 'en'];
const path = Astro.url.pathname;
function getLocalePath(locale: string) {
// Remove locale prefix from current path
const cleanPath = path.replace(/^\/(tr|en)\//, '/');
return locale === 'tr' ? cleanPath : `/en${cleanPath}`;
}
---
<div class="flex items-center gap-2">
{locales.map(locale => (
<a
href={getLocalePath(locale)}
class={`px-3 py-1 rounded-lg text-sm font-medium transition-colors ${
currentLocale === locale
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
aria-label={`Switch to ${locale === 'tr' ? 'Turkish' : 'English'}`}
>
{locale === 'tr' ? 'TR' : 'EN'}
</a>
))}
</div>
+59
View File
@@ -0,0 +1,59 @@
export const translations = {
tr: {
nav: {
home: 'Ana Sayfa',
about: 'Hakkımızda',
blog: 'Blog',
categories: 'Kategoriler',
tags: 'Etiketler',
contact: 'İletişim',
},
hero: {
title: 'Amatör Radyocular Derneği',
subtitle: 'Amatör telsizciliği yaygınlaştırmak, eğitim vermek ve röle-beacon altyapıları işletmek amacıyla kurulmuştur.',
},
repeaters: {
title: 'DMR Röle Durumu',
subtitle: 'DMR röle sistemlerimizin anlık durumunu buradan takip edebilirsiniz.',
online: 'Çevrimiçi',
offline: 'Çevrimdışı',
lastSeen: 'Son duyulum',
},
search: {
placeholder: 'Çağrı işareti veya isim ara...',
search: 'Ara',
searching: 'Aranıyor...',
noResults: 'Sonuç bulunamadı. Lütfen farklı bir arama deneyin.',
},
},
en: {
nav: {
home: 'Home',
about: 'About Us',
blog: 'Blog',
categories: 'Categories',
tags: 'Tags',
contact: 'Contact',
},
hero: {
title: 'Amateur Radio Association',
subtitle: 'Established to promote amateur radio, provide education, and operate repeater-beacon infrastructure.',
},
repeaters: {
title: 'DMR Repeater Status',
subtitle: 'Monitor our DMR repeater systems in real-time.',
online: 'Online',
offline: 'Offline',
lastSeen: 'Last heard',
},
search: {
placeholder: 'Search by callsign or name...',
search: 'Search',
searching: 'Searching...',
noResults: 'No results found. Please try a different search.',
},
},
} as const;
export type Locale = keyof typeof translations;
export type TranslationKey = keyof typeof translations.tr;
+11
View File
@@ -0,0 +1,11 @@
export async function GET() {
const contacts = [
{ id: '2867241', callsign: 'YM0KTC', name: 'ARC Merkez', location: 'Bölge 0 - İstanbul', type: 'repeater' },
{ id: '2866808', callsign: 'YM1KTC', name: 'İstanbul Röle', location: 'Bölge 1 - İstanbul', type: 'repeater' },
{ id: '2867887', callsign: 'YM2KTR', name: 'Ankara Röle', location: 'Bölge 2 - Ankara', type: 'repeater' },
{ id: '2867234', callsign: 'YM3KTC', name: 'İzmir Röle', location: 'Bölge 3 - İzmir', type: 'repeater' },
{ id: '2867235', callsign: 'YM4KTC', name: 'Antalya Röle', location: 'Bölge 4 - Antalya', type: 'repeater' },
];
return Response.json(contacts);
}
+74
View File
@@ -0,0 +1,74 @@
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
// Helper to slugify Turkish categories
function slugify(str: string): string {
return str
.toLowerCase()
.replace(/ş/g, 's')
.replace(/ı/g, 'i')
.replace(/ğ/g, 'g')
.replace(/ü/g, 'u')
.replace(/ö/g, 'o')
.replace(/ç/g, 'c')
.replace(/[^a-z0-9]+/g, '-');
}
export async function getStaticPaths() {
const posts = await getCollection('post');
// Extract all unique categories and create slugified paths
const categoryMap = new Map<string, string>();
posts.forEach(post => {
if (post.data.categories) {
post.data.categories.forEach(cat => {
if (!categoryMap.has(cat)) {
categoryMap.set(cat, slugify(cat));
}
});
}
if (post.data.category) {
if (!categoryMap.has(post.data.category)) {
categoryMap.set(post.data.category, slugify(post.data.category));
}
}
});
// Return paths with both original category name and slug
return Array.from(categoryMap.entries()).map(([category, slug]) => ({
params: { category: slug },
props: { originalCategory: category },
}));
}
export async function GET(context) {
const posts = await getCollection('post');
// Get the original category name from props
const originalCategory = context.props.originalCategory as string;
// Filter posts by category
const filteredPosts = posts
.filter(post =>
post.data.categories?.includes(originalCategory) || post.data.category === originalCategory
)
.filter(post => post.data.publishDate) // Only include posts with publishDate
.sort((a, b) => {
const aDate = a.data.publishDate || new Date(0);
const bDate = b.data.publishDate || new Date(0);
return bDate.valueOf() - aDate.valueOf();
})
.slice(0, 20); // Limit to 20 most recent posts
return rss({
title: `ARC - ${originalCategory} Blog Yazıları`,
description: `Amatör Radyocular Derneği - ${originalCategory} kategorisindeki blog yazıları`,
site: context.site?.toString() || 'https://radio.org.tr',
items: filteredPosts.map(post => ({
title: post.data.title,
pubDate: post.data.publishDate!,
description: post.data.excerpt || '',
link: `/blog/${post.slug}/`,
})),
});
}
+36
View File
@@ -0,0 +1,36 @@
export async function GET() {
const repeaters = [
{
id: 'YM1KTC',
name: 'İstanbul Röle',
frequency: '439.4125+',
offset: '+7.6',
tone: '88.5',
colorCode: '1',
status: 'online',
lastheard: new Date().toISOString(),
},
{
id: 'YM2KTR',
name: 'Ankara Röle',
frequency: '439.4750-',
offset: '-5.0',
tone: '88.5',
colorCode: '1',
status: 'online',
lastheard: new Date(Date.now() - 3600000).toISOString(),
},
{
id: 'YM3KTC',
name: 'İzmir Röle',
frequency: '439.5500+',
offset: '+7.6',
tone: '88.5',
colorCode: '1',
status: 'offline',
lastheard: new Date(Date.now() - 86400000).toISOString(),
},
];
return Response.json(repeaters);
}
+30
View File
@@ -0,0 +1,30 @@
---
import Layout from '~/layouts/PageLayout.astro';
import Hero from '~/components/widgets/Hero.astro';
import DMRDashboard from '~/react/DMRDashboard';
import MemberSearch from '~/react/MemberSearch';
const metadata = {
title: 'DMR Röle Durumu - ARC',
};
---
<Layout metadata={metadata}>
<!-- Hero Widget -->
<Hero
title="📡 DMR Röle Durumu"
subtitle="DMR röle sistemlerimizin anlık durumunu buradan takip edebilirsiniz."
/>
<!-- Member Search -->
<section class="mx-auto max-w-4xl px-4 py-8">
<h2 class="text-2xl font-bold mb-4">🔍 Üye Ara</h2>
<MemberSearch client:load />
</section>
<!-- DMR Dashboard -->
<section class="mx-auto max-w-6xl px-4 py-8">
<h2 class="text-2xl font-bold mb-4">📻 Röle Durumları</h2>
<DMRDashboard client:load />
</section>
</Layout>
+109
View File
@@ -0,0 +1,109 @@
import { useState, useEffect } from 'react';
interface Repeater {
id: string;
name: string;
frequency: string;
offset: string;
tone: string;
colorCode: string;
status: 'online' | 'offline';
lastheard: string;
}
export default function DMRDashboard() {
const [repeaters, setRepeaters] = useState<Repeater[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/repeaters.json')
.then(r => {
if (!r.ok) throw new Error('Failed to fetch');
return r.json();
})
.then(data => {
setRepeaters(data);
setLoading(false);
})
.catch(err => {
setError(err.message);
setLoading(false);
});
}, []);
const formatLastSeen = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Şimdi';
if (diffMins < 60) return `${diffMins} dakika önce`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours} saat önce`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} gün önce`;
};
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
if (error) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">Hata: {error}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Yeniden Dene
</button>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{repeaters.map(r => (
<div
key={r.id}
className={`p-4 rounded-lg border-2 transition-all ${
r.status === 'online'
? 'bg-green-50 border-green-200 hover:shadow-md'
: 'bg-red-50 border-red-200'
}`}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-lg">{r.name}</h3>
<span className={`px-2 py-1 text-xs rounded-full ${
r.status === 'online' ? 'bg-green-500 text-white' : 'bg-red-500 text-white'
}`}>
{r.status === 'online' ? 'Çevrimiçi' : 'Çevrimdışı'}
</span>
</div>
<p className="text-sm text-gray-700">
<span className="font-medium">Frekans:</span> {r.frequency}
</p>
<p className="text-sm text-gray-700">
<span className="font-medium">Offset:</span> {r.offset}
</p>
<p className="text-sm text-gray-700">
<span className="font-medium">CTCSS:</span> {r.tone} Hz
</p>
<p className="text-sm text-gray-700">
<span className="font-medium">Color Code:</span> {r.colorCode}
</p>
<p className="text-xs text-gray-500 mt-2">
Son duyulum: {formatLastSeen(r.lastheard)}
</p>
</div>
))}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react';
interface Member {
id: string;
callsign: string;
name: string;
location?: string;
}
export default function MemberSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Member[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const handleSearch = async () => {
if (!query.trim()) return;
setLoading(true);
setSearched(true);
try {
// For demo, we'll use the DMR contacts API
// In production, you'd have a dedicated members API
const res = await fetch(`/api/dmr-contacts.json`);
const data = await res.json();
// Filter by query (callsign or name)
const filtered = data.filter((member: Member) =>
member.callsign.toLowerCase().includes(query.toLowerCase()) ||
member.name.toLowerCase().includes(query.toLowerCase())
);
setResults(filtered);
} catch (err) {
console.error('Search failed:', err);
setResults([]);
} finally {
setLoading(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSearch();
}
};
return (
<div>
<div className="flex gap-2">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Çağrı işareti veya isim ara..."
className="flex-1 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
onClick={handleSearch}
disabled={loading || !query.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Aranıyor...' : 'Ara'}
</button>
</div>
{searched && results.length === 0 && !loading && (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800">Sonuç bulunamadı. Lütfen farklı bir arama deneyin.</p>
</div>
)}
{results.length > 0 && (
<ul className="mt-4 space-y-2">
{results.map(member => (
<li
key={member.id}
className="p-3 bg-white border border-gray-200 rounded-lg hover:shadow-md transition-shadow"
>
<div className="flex items-center justify-between">
<div>
<h4 className="font-bold text-lg text-blue-600">{member.callsign}</h4>
<p className="text-gray-700">{member.name}</p>
{member.location && (
<p className="text-sm text-gray-500">{member.location}</p>
)}
</div>
<span className="text-2xl">📻</span>
</div>
</li>
))}
</ul>
)}
</div>
);
}