Custom app launcher, browser home page, and service monitor. cute.haus
homelab react typescript
0

Configure Feed

Select the types of activity you want to include in your feed.

add hacker news

Aly Raffauf (Jan 29, 2026, 7:42 PM EST) 906188c2 c89ea483

+131 -5
+8
src/App.tsx
··· 1 1 import Layout from "./Layout"; 2 2 import ServiceGrid from "./components/ServiceGrid"; 3 + import { HackerNews } from "./components/HackerNews"; 3 4 import { Weather } from "./components/Weather"; 4 5 import { apps } from "./data/apps"; 5 6 import { privateApps } from "./data/privateApps"; ··· 13 14 {" "} 14 15 <h2 className="text-2xl font-semibold text-zinc-400 mb-4">Weather</h2> 15 16 <Weather /> 17 + </div> 18 + 19 + <div> 20 + <h2 className="text-2xl font-semibold text-zinc-400 mb-4"> 21 + Hacker News 22 + </h2> 23 + <HackerNews /> 16 24 </div> 17 25 </div> 18 26 <h2 className="text-2xl font-semibold text-zinc-400 mt-8 mb-4">
+51
src/components/HackerNews.tsx
··· 1 + import { useState, useEffect } from "react"; 2 + 3 + function timeAgo(timestamp: number): string { 4 + const seconds = Math.floor(Date.now() / 1000 - timestamp); 5 + 6 + if (seconds < 60) return "1m"; 7 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; 8 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; 9 + return `${Math.floor(seconds / 86400)}d`; 10 + } 11 + 12 + export function HackerNews() { 13 + const [stories, setStories] = useState<any[]>([]); 14 + 15 + const [error, setError] = useState<string | null>(null); 16 + 17 + useEffect(() => { 18 + const fetchHackerNews = () => { 19 + fetch(`/api/hackernews`) 20 + .then((response) => response.json()) 21 + .then((data) => setStories(data)); 22 + }; 23 + 24 + fetchHackerNews(); 25 + }, []); 26 + 27 + if (stories.length == 0) { 28 + return <div>Loading stories...</div>; 29 + } 30 + 31 + return ( 32 + <div className="p-4 bg-zinc-800 border border-zinc-700 rounded-lg"> 33 + <ul className="space-y-2"> 34 + {stories.map((story) => ( 35 + <li key={story.id} className="flex gap-3 text-sm"> 36 + <span className="text-zinc-500 w-8 shrink-0">{story.score}↑</span> 37 + 38 + <a 39 + href={story.url} 40 + target="_blank" 41 + rel="noopener noreferrer" 42 + className="text-zinc-200 hover:text-white hover:underline truncate" 43 + > 44 + {story.title} 45 + </a> 46 + </li> 47 + ))} 48 + </ul> 49 + </div> 50 + ); 51 + }
+1 -1
src/components/ServiceGrid.tsx
··· 55 55 {service.icon && ( 56 56 <img src={service.icon} alt="" className="w-6 h-6 shrink-0" /> 57 57 )} 58 - <h3 className="text-lg font-semibold flex-1 truncate"> 58 + <h3 className="text-md font-semibold flex-1 truncate"> 59 59 {service.name} 60 60 </h3> 61 61 <span className="shrink-0">
+43 -4
src/components/Weather.tsx
··· 1 1 import { useState, useEffect } from "react"; 2 + import { 3 + Sun, 4 + Moon, 5 + Cloud, 6 + CloudRain, 7 + CloudSnow, 8 + CloudLightning, 9 + CloudFog, 10 + CloudSun, 11 + CloudMoon, 12 + } from "lucide-react"; 13 + 14 + function getWeatherIcon(forecast: string, className?: string) { 15 + const f = forecast.toLowerCase(); 16 + if (f.includes("thunder") || f.includes("lightning")) 17 + return <CloudLightning className={className} />; 18 + if (f.includes("snow") || f.includes("flurr")) 19 + return <CloudSnow className={className} />; 20 + if (f.includes("rain") || f.includes("shower") || f.includes("drizzle")) 21 + return <CloudRain className={className} />; 22 + if (f.includes("fog") || f.includes("mist") || f.includes("haze")) 23 + return <CloudFog className={className} />; 24 + if (f.includes("partly cloudy")) return <CloudSun className={className} />; 25 + if ( 26 + f.includes("mostly cloudy") || 27 + f.includes("cloud") || 28 + f.includes("overcast") 29 + ) 30 + return <Cloud className={className} />; 31 + if (f.includes("sunny") || f.includes("clear")) 32 + return <Sun className={className} />; 33 + return <Sun className={className} />; 34 + } 2 35 3 36 export function Weather() { 4 37 const [weather, setWeather] = useState<{ ··· 32 65 } 33 66 34 67 return ( 35 - <div className="block p-4 bg-zinc-800 border border-zinc-700 rounded-lg"> 36 - <div> 37 - {weather.temperature}°{weather.temperatureUnit} 68 + <div className="p-4 bg-zinc-800 border border-zinc-700 rounded-lg"> 69 + <div className="flex items-center gap-4"> 70 + {getWeatherIcon(weather.shortForecast, "w-12 h-12 text-zinc-300")} 71 + <div> 72 + <div className="flex items-baseline gap-2"> 73 + <span className="text-4xl font-light">{weather.temperature}°</span> 74 + <span className="text-zinc-500">{weather.temperatureUnit}</span> 75 + </div> 76 + <div className="text-zinc-400">{weather.shortForecast}</div> 77 + </div> 38 78 </div> 39 - <div>{weather.shortForecast}</div> 40 79 </div> 41 80 ); 42 81 }
+28
src/index.ts
··· 3 3 import { privateApps } from "./data/privateApps"; 4 4 import { websites } from "./data/websites"; 5 5 6 + let hnCache: { stories: any[]; fetchedAt: number } | null = null; 7 + 6 8 async function checkStatuses(items: { name: string; url: string }[]) { 7 9 const results = await Promise.all( 8 10 items.map(async (item) => { ··· 36 38 async POST(req) { 37 39 const items = await req.json(); 38 40 return Response.json(await checkStatuses(items)); 41 + }, 42 + }, 43 + 44 + "/api/hackernews": { 45 + async GET(req) { 46 + if (hnCache && Date.now() - hnCache.fetchedAt < 5 * 60 * 1000) { 47 + return Response.json(hnCache.stories); 48 + } 49 + 50 + const ids = await fetch( 51 + "https://hacker-news.firebaseio.com/v0/topstories.json", 52 + ).then((res) => res.json()); 53 + 54 + const stories = await Promise.all( 55 + ids 56 + .slice(0, 5) 57 + .map((id: number) => 58 + fetch( 59 + `https://hacker-news.firebaseio.com/v0/item/${id}.json`, 60 + ).then((res) => res.json()), 61 + ), 62 + ); 63 + 64 + hnCache = { stories, fetchedAt: Date.now() }; 65 + 66 + return Response.json(stories); 39 67 }, 40 68 }, 41 69