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:
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user