Initial commit
This commit is contained in:
70
components/widgets/GlobeCard.tsx
Normal file
70
components/widgets/GlobeCard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import createGlobe from 'cobe';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
export function GlobeCard() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let phi = 0;
|
||||
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const globe = createGlobe(canvasRef.current, {
|
||||
devicePixelRatio: 2,
|
||||
width: 600 * 2,
|
||||
height: 600 * 2,
|
||||
phi: 0,
|
||||
theta: 0,
|
||||
dark: 1,
|
||||
diffuse: 1.2,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 6,
|
||||
baseColor: [0.3, 0.3, 0.3],
|
||||
markerColor: [0.1, 0.8, 1],
|
||||
glowColor: [0.1, 0.1, 0.2],
|
||||
markers: [
|
||||
// Mock data points
|
||||
{ location: [40.7128, -74.0060], size: 0.05 }, // NY
|
||||
{ location: [51.5074, -0.1278], size: 0.05 }, // London
|
||||
{ location: [35.6762, 139.6503], size: 0.05 }, // Tokyo
|
||||
{ location: [22.3193, 114.1694], size: 0.05 }, // HK
|
||||
{ location: [-33.8688, 151.2093], size: 0.05 }, // Sydney
|
||||
],
|
||||
onRender: (state) => {
|
||||
// Called on every animation frame.
|
||||
// `state` will be an empty object, return updated params.
|
||||
state.phi = phi;
|
||||
phi += 0.01;
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
globe.destroy();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 bg-neutral-900 border border-neutral-800 rounded-xl relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
||||
<div className="absolute top-6 left-6 z-10 pointer-events-none">
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Globe size={18} />
|
||||
<span className="text-sm font-medium">Active Visitors</span>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<span className="text-2xl font-bold text-white tracking-tight">1,240</span>
|
||||
<span className="text-xs text-neutral-500 ml-2 font-mono">LIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center opacity-80 mt-10">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width: 600, height: 600, maxWidth: '100%', aspectRatio: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
components/widgets/NewsFeed.tsx
Normal file
58
components/widgets/NewsFeed.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Rss, ExternalLink } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
export function NewsFeed() {
|
||||
const [news, setNews] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchNews() {
|
||||
try {
|
||||
const res = await fetch('/api/rss');
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
setNews(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchNews();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col hover:border-neutral-700 transition-colors">
|
||||
<div className="flex items-center gap-2 text-neutral-400 mb-4">
|
||||
<Rss size={18} />
|
||||
<span className="text-sm font-medium">Hacker News</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto pr-2 space-y-4 scrollbar-thin scrollbar-thumb-neutral-800 max-h-[300px]">
|
||||
{loading ? (
|
||||
[1, 2, 3].map(i => (
|
||||
<div key={i} className="space-y-2 animate-pulse">
|
||||
<div className="h-4 bg-neutral-800 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-neutral-800 rounded w-1/2"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
news.map((item) => (
|
||||
<a key={item.link} href={item.link} target="_blank" rel="noopener noreferrer" className="block group cursor-pointer border-b border-neutral-800/50 pb-3 last:border-0 last:pb-0">
|
||||
<h3 className="text-sm text-neutral-300 font-medium group-hover:text-amber-400 transition-colors line-clamp-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<div className="flex gap-3 mt-1 text-xs text-neutral-500">
|
||||
<span className="font-mono">{item.pubDate ? formatDistanceToNow(new Date(item.pubDate), { addSuffix: true }) : ''}</span>
|
||||
</div>
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
components/widgets/UptimeCard.tsx
Normal file
90
components/widgets/UptimeCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { Activity, RefreshCcw, AlertCircle } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string;
|
||||
url: string;
|
||||
status: 'up' | 'down';
|
||||
latency: number;
|
||||
}
|
||||
|
||||
export function UptimeCard() {
|
||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/status');
|
||||
if (!res.ok) throw new Error('Failed to fetch');
|
||||
const data = await res.json();
|
||||
setServices(data);
|
||||
setError(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
// Poll every 30 seconds
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Calculate overall health
|
||||
const upCount = services.filter(s => s.status === 'up').length;
|
||||
const healthPercentage = services.length > 0 ? Math.round((upCount / services.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col justify-between hover:border-neutral-700 transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Activity size={18} />
|
||||
<span className="text-sm font-medium">Uptime Monitor</span>
|
||||
{loading && <RefreshCcw size={12} className="animate-spin ml-2 opacity-50" />}
|
||||
</div>
|
||||
{!loading && !error && (
|
||||
<span className={`text-xs px-2 py-1 rounded-full font-mono ${healthPercentage === 100 ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'}`}>
|
||||
{healthPercentage}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-red-400 gap-2">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-xs">Failed to load status</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 mt-4">
|
||||
{services.length === 0 && loading ? (
|
||||
// Skeleton loader
|
||||
[1, 2, 3].map(i => (
|
||||
<div key={i} className="flex items-center justify-between animate-pulse">
|
||||
<div className="h-4 w-20 bg-neutral-800 rounded"></div>
|
||||
<div className="h-2 w-2 bg-neutral-800 rounded-full"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<div key={service.name} className="flex items-center justify-between group">
|
||||
<span className="text-sm text-neutral-300 font-medium group-hover:text-white transition-colors">{service.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-500 font-mono">{service.status === 'up' ? `${service.latency}ms` : 'DOWN'}</span>
|
||||
<div className={`w-2 h-2 rounded-full shadow-[0_0_8px_rgba(0,0,0,0.4)] ${service.status === 'up' ? 'bg-emerald-500 shadow-emerald-500/40' : 'bg-red-500 shadow-red-500/40'}`} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
components/widgets/WeatherCard.tsx
Normal file
84
components/widgets/WeatherCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { CloudRain, Sun, Cloud, Thermometer } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// Default to New York, user can change this
|
||||
const LAT = 40.7128;
|
||||
const LNG = -74.0060;
|
||||
|
||||
export function WeatherCard() {
|
||||
const [weather, setWeather] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchWeather() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${LAT}&longitude=${LNG}¤t=temperature_2m,relative_humidity_2m,weather_code&daily=temperature_2m_max,temperature_2m_min&temperature_unit=fahrenheit&timezone=auto`
|
||||
);
|
||||
const data = await res.json();
|
||||
setWeather(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchWeather();
|
||||
const interval = setInterval(fetchWeather, 600000); // 10 mins
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 relative overflow-hidden animate-pulse">
|
||||
<div className="h-4 w-24 bg-neutral-800 rounded mb-4" />
|
||||
<div className="h-10 w-16 bg-neutral-800 rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!weather?.current) {
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex items-center justify-center text-red-500">
|
||||
Failed to load weather
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const current = weather.current;
|
||||
const today = weather.daily;
|
||||
|
||||
return (
|
||||
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900 border border-neutral-800 rounded-xl p-6 relative overflow-hidden group hover:border-neutral-700 transition-colors">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2 text-neutral-400">
|
||||
<Cloud size={18} />
|
||||
<span className="text-sm font-medium">Local Weather</span>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500 font-mono">New York, US</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-4 mt-2">
|
||||
<div className="text-4xl font-bold text-white tracking-tighter">{Math.round(current.temperature_2m)}°F</div>
|
||||
<div className="pb-1 text-sm text-neutral-400 font-medium">{getWeatherDescription(current.weather_code)}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-4 text-xs font-mono text-neutral-500">
|
||||
<div>H: {Math.round(today.temperature_2m_max[0])}° L: {Math.round(today.temperature_2m_min[0])}°</div>
|
||||
<div>Humidity: {current.relative_humidity_2m}%</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getWeatherDescription(code: number) {
|
||||
if (code === 0) return 'Clear';
|
||||
if (code <= 3) return 'Partly Cloudy';
|
||||
if (code <= 48) return 'Foggy';
|
||||
if (code <= 67) return 'Rainy';
|
||||
if (code <= 77) return 'Snowy';
|
||||
return 'Cloudy';
|
||||
}
|
||||
Reference in New Issue
Block a user