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.

initial commit

Aly Raffauf (Jan 29, 2026, 12:23 PM EST) e535187a

+815
+34
.gitignore
··· 1 + # dependencies (bun install) 2 + node_modules 3 + 4 + # output 5 + out 6 + dist 7 + *.tgz 8 + 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 + .idea 32 + 33 + # Finder (MacOS) folder config 34 + .DS_Store
+106
CLAUDE.md
··· 1 + 2 + Default to using Bun instead of Node.js. 3 + 4 + - Use `bun <file>` instead of `node <file>` or `ts-node <file>` 5 + - Use `bun test` instead of `jest` or `vitest` 6 + - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild` 7 + - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` 8 + - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>` 9 + - Use `bunx <package> <command>` instead of `npx <package> <command>` 10 + - Bun automatically loads .env, so don't use dotenv. 11 + 12 + ## APIs 13 + 14 + - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`. 15 + - `bun:sqlite` for SQLite. Don't use `better-sqlite3`. 16 + - `Bun.redis` for Redis. Don't use `ioredis`. 17 + - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`. 18 + - `WebSocket` is built-in. Don't use `ws`. 19 + - Prefer `Bun.file` over `node:fs`'s readFile/writeFile 20 + - Bun.$`ls` instead of execa. 21 + 22 + ## Testing 23 + 24 + Use `bun test` to run tests. 25 + 26 + ```ts#index.test.ts 27 + import { test, expect } from "bun:test"; 28 + 29 + test("hello world", () => { 30 + expect(1).toBe(1); 31 + }); 32 + ``` 33 + 34 + ## Frontend 35 + 36 + Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind. 37 + 38 + Server: 39 + 40 + ```ts#index.ts 41 + import index from "./index.html" 42 + 43 + Bun.serve({ 44 + routes: { 45 + "/": index, 46 + "/api/users/:id": { 47 + GET: (req) => { 48 + return new Response(JSON.stringify({ id: req.params.id })); 49 + }, 50 + }, 51 + }, 52 + // optional websocket support 53 + websocket: { 54 + open: (ws) => { 55 + ws.send("Hello, world!"); 56 + }, 57 + message: (ws, message) => { 58 + ws.send(message); 59 + }, 60 + close: (ws) => { 61 + // handle close 62 + } 63 + }, 64 + development: { 65 + hmr: true, 66 + console: true, 67 + } 68 + }) 69 + ``` 70 + 71 + HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle. 72 + 73 + ```html#index.html 74 + <html> 75 + <body> 76 + <h1>Hello, world!</h1> 77 + <script type="module" src="./frontend.tsx"></script> 78 + </body> 79 + </html> 80 + ``` 81 + 82 + With the following `frontend.tsx`: 83 + 84 + ```tsx#frontend.tsx 85 + import React from "react"; 86 + import { createRoot } from "react-dom/client"; 87 + 88 + // import .css files directly and it works 89 + import './index.css'; 90 + 91 + const root = createRoot(document.body); 92 + 93 + export default function Frontend() { 94 + return <h1>Hello, world!</h1>; 95 + } 96 + 97 + root.render(<Frontend />); 98 + ``` 99 + 100 + Then, run index.ts 101 + 102 + ```sh 103 + bun --hot ./index.ts 104 + ``` 105 + 106 + For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
+21
README.md
··· 1 + # watsup 2 + 3 + To install dependencies: 4 + 5 + ```bash 6 + bun install 7 + ``` 8 + 9 + To start a development server: 10 + 11 + ```bash 12 + bun dev 13 + ``` 14 + 15 + To run for production: 16 + 17 + ```bash 18 + bun start 19 + ``` 20 + 21 + This project was created using `bun init` in bun v1.3.7. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+160
build.ts
··· 1 + #!/usr/bin/env bun 2 + import plugin from "bun-plugin-tailwind"; 3 + import { existsSync } from "fs"; 4 + import { rm } from "fs/promises"; 5 + import path from "path"; 6 + 7 + if (process.argv.includes("--help") || process.argv.includes("-h")) { 8 + console.log(` 9 + 🏗️ Bun Build Script 10 + 11 + Usage: bun run build.ts [options] 12 + 13 + Common Options: 14 + --outdir <path> Output directory (default: "dist") 15 + --minify Enable minification (or --minify.whitespace, --minify.syntax, etc) 16 + --sourcemap <type> Sourcemap type: none|linked|inline|external 17 + --target <target> Build target: browser|bun|node 18 + --format <format> Output format: esm|cjs|iife 19 + --splitting Enable code splitting 20 + --packages <type> Package handling: bundle|external 21 + --public-path <path> Public path for assets 22 + --env <mode> Environment handling: inline|disable|prefix* 23 + --conditions <list> Package.json export conditions (comma separated) 24 + --external <list> External packages (comma separated) 25 + --banner <text> Add banner text to output 26 + --footer <text> Add footer text to output 27 + --define <obj> Define global constants (e.g. --define.VERSION=1.0.0) 28 + --help, -h Show this help message 29 + 30 + Example: 31 + bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom 32 + `); 33 + process.exit(0); 34 + } 35 + 36 + const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); 37 + 38 + const parseValue = (value: string): any => { 39 + if (value === "true") return true; 40 + if (value === "false") return false; 41 + 42 + if (/^\d+$/.test(value)) return parseInt(value, 10); 43 + if (/^\d*\.\d+$/.test(value)) return parseFloat(value); 44 + 45 + if (value.includes(",")) return value.split(",").map(v => v.trim()); 46 + 47 + return value; 48 + }; 49 + 50 + function parseArgs(): Partial<Bun.BuildConfig> { 51 + const config: Record<string, unknown> = {}; 52 + const args = process.argv.slice(2); 53 + 54 + for (let i = 0; i < args.length; i++) { 55 + const arg = args[i]; 56 + if (arg === undefined) continue; 57 + if (!arg.startsWith("--")) continue; 58 + 59 + if (arg.startsWith("--no-")) { 60 + const key = toCamelCase(arg.slice(5)); 61 + config[key] = false; 62 + continue; 63 + } 64 + 65 + if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) { 66 + const key = toCamelCase(arg.slice(2)); 67 + config[key] = true; 68 + continue; 69 + } 70 + 71 + let key: string; 72 + let value: string; 73 + 74 + if (arg.includes("=")) { 75 + [key, value] = arg.slice(2).split("=", 2) as [string, string]; 76 + } else { 77 + key = arg.slice(2); 78 + value = args[++i] ?? ""; 79 + } 80 + 81 + key = toCamelCase(key); 82 + 83 + if (key.includes(".")) { 84 + const parts = key.split("."); 85 + if (parts.length > 2) { 86 + console.warn( 87 + `Warning: Deeply nested option "${key}" is not supported. Only single-level nesting (e.g., --minify.whitespace) is allowed.`, 88 + ); 89 + continue; 90 + } 91 + const parentKey = parts[0]!; 92 + const childKey = parts[1]!; 93 + const existing = config[parentKey]; 94 + if (typeof existing !== "object" || existing === null || Array.isArray(existing)) { 95 + config[parentKey] = {}; 96 + } 97 + (config[parentKey] as Record<string, unknown>)[childKey] = parseValue(value); 98 + } else { 99 + config[key] = parseValue(value); 100 + } 101 + } 102 + 103 + return config as Partial<Bun.BuildConfig>; 104 + } 105 + 106 + const formatFileSize = (bytes: number): string => { 107 + const units = ["B", "KB", "MB", "GB"]; 108 + let size = bytes; 109 + let unitIndex = 0; 110 + 111 + while (size >= 1024 && unitIndex < units.length - 1) { 112 + size /= 1024; 113 + unitIndex++; 114 + } 115 + 116 + return `${size.toFixed(2)} ${units[unitIndex]}`; 117 + }; 118 + 119 + console.log("\n🚀 Starting build process...\n"); 120 + 121 + const cliConfig = parseArgs(); 122 + const outdir = cliConfig.outdir || path.join(process.cwd(), "dist"); 123 + 124 + if (existsSync(outdir)) { 125 + console.log(`🗑️ Cleaning previous build at ${outdir}`); 126 + await rm(outdir, { recursive: true, force: true }); 127 + } 128 + 129 + const start = performance.now(); 130 + 131 + const entrypoints = [...new Bun.Glob("**.html").scanSync("src")] 132 + .map(a => path.resolve("src", a)) 133 + .filter(dir => !dir.includes("node_modules")); 134 + console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`); 135 + 136 + const result = await Bun.build({ 137 + entrypoints, 138 + outdir, 139 + plugins: [plugin], 140 + minify: true, 141 + target: "browser", 142 + sourcemap: "linked", 143 + define: { 144 + "process.env.NODE_ENV": JSON.stringify("production"), 145 + }, 146 + ...cliConfig, 147 + }); 148 + 149 + const end = performance.now(); 150 + 151 + const outputTable = result.outputs.map(output => ({ 152 + File: path.relative(process.cwd(), output.path), 153 + Type: output.kind, 154 + Size: formatFileSize(output.size), 155 + })); 156 + 157 + console.table(outputTable); 158 + const buildTime = (end - start).toFixed(2); 159 + 160 + console.log(`\n✅ Build completed in ${buildTime}ms\n`);
+17
bun-env.d.ts
··· 1 + // Generated by `bun init` 2 + 3 + declare module "*.svg" { 4 + /** 5 + * A path to the SVG file 6 + */ 7 + const path: `${string}.svg`; 8 + export = path; 9 + } 10 + 11 + declare module "*.module.css" { 12 + /** 13 + * A record of class names to their corresponding CSS module classes 14 + */ 15 + const classes: { readonly [key: string]: string }; 16 + export = classes; 17 + }
+72
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "bun-react-template", 7 + "dependencies": { 8 + "bun-plugin-tailwind": "^0.1.2", 9 + "lucide-react": "^0.563.0", 10 + "react": "^19", 11 + "react-dom": "^19", 12 + "tailwindcss": "^4.1.11", 13 + }, 14 + "devDependencies": { 15 + "@types/bun": "latest", 16 + "@types/react": "^19", 17 + "@types/react-dom": "^19", 18 + }, 19 + }, 20 + }, 21 + "packages": { 22 + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mh78f4B+vNTOhFpI7RWHRWDqSKTnFXj/MauRx7I/GmNwEfw56sUx98gWRwXyF4lkW+9VNU+33wuw6E+M22W66w=="], 23 + 24 + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-dFfKdSVz6Ois5zjEJboUC7igcYAVd+c//ajotd0L6WUQAKQrHMVq/+6LjOj/0zjC6VPFNGWzeF8erymNo1y0Jw=="], 25 + 26 + "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-bUND1aQoTCfIL+idALT7FWtuX59ltOIRo954c7p/JkESbSIJ01jY06BSNVbkGk8RQM19v/7qiqZZqi4NyO4Utw=="], 27 + 28 + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-m03OtzEs+/RkWtk6tBf8yw0GW4P8ajfzTXnTt984tQBgkMubGQYUyUnFasWgr3mD2820LhkVjhYeBf1rkz/biQ=="], 29 + 30 + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-QDxrROdUnC1d/uoilXtUeFHaLhYdRN7dRIzw/Iqj/vrrhnkA6VS+HYoCWtyyVvci/K+JrPmDwxOWlSRpmV4INA=="], 31 + 32 + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-uttKQ/eIRVGc4uBtLRqmQqXGf57/dmQaF0AEd37RQNRRRd1P/VYnFMiMcVaot3HJ6IFjHjGtcPO9ekT49LxBYQ=="], 33 + 34 + "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-Jlb/AcrIFU3QDeR3EL4UVT1CIKqnLJDgbU+R0k/+NaSWMrBEpZV+gJJT5L1cmEKTNhU/d+c7hudxkjtqA7XXqA=="], 35 + 36 + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-aK8fvkCosrHRG3CNdVqMom1C8Rj3XkqZp0ZFSBXgaXlKP22RkxlEE9tS7OmSq9yVgEk6euTB3dW4NFo/jlXqeg=="], 37 + 38 + "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.7", "", { "os": "linux", "cpu": "x64" }, "sha512-lySQQ7zJJsoa5hQH+PE5bQyQaTI8G2Erszhu4iQuDtsocwy3zSxjB6TxGWTd4HmetPl9aRvg3nb2KR8RVAd7ug=="], 39 + 40 + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.7", "", { "os": "win32", "cpu": "x64" }, "sha512-3QdIGdSn3fkssCq/vPjtPLAQxo+eMUzcwJedn1c5mXDy1AoisjhoxhWnbVl8+uk+wt9N6JUPdISoe0N4OdwXfg=="], 41 + 42 + "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.7", "", { "os": "win32", "cpu": "x64" }, "sha512-wMgELfW5vFceh4qEOYb5iV5TjrjjnBJzE383ixA3kqGKzaubksSxNc11eZhS0ptcJ5a0UjN5hfbMh6sYoh+cRQ=="], 43 + 44 + "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="], 45 + 46 + "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], 47 + 48 + "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="], 49 + 50 + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], 51 + 52 + "bun": ["bun@1.3.7", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.7", "@oven/bun-darwin-x64": "1.3.7", "@oven/bun-darwin-x64-baseline": "1.3.7", "@oven/bun-linux-aarch64": "1.3.7", "@oven/bun-linux-aarch64-musl": "1.3.7", "@oven/bun-linux-x64": "1.3.7", "@oven/bun-linux-x64-baseline": "1.3.7", "@oven/bun-linux-x64-musl": "1.3.7", "@oven/bun-linux-x64-musl-baseline": "1.3.7", "@oven/bun-windows-x64": "1.3.7", "@oven/bun-windows-x64-baseline": "1.3.7" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-ha86NG8WiAXYR7eQw/9S+7V7Lo8KfD36XutWJNS1VndzaipWS0QIen5n3K9MT3PpP/sdGmmHjhkrU0sCM2lGGQ=="], 53 + 54 + "bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="], 55 + 56 + "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="], 57 + 58 + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], 59 + 60 + "lucide-react": ["lucide-react@0.563.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA=="], 61 + 62 + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], 63 + 64 + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], 65 + 66 + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], 67 + 68 + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], 69 + 70 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 71 + } 72 + }
+4
bunfig.toml
··· 1 + 2 + [serve.static] 3 + plugins = ["bun-plugin-tailwind"] 4 + env = "BUN_PUBLIC_*"
+23
package.json
··· 1 + { 2 + "name": "bun-react-template", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "scripts": { 7 + "dev": "bun --hot src/index.ts", 8 + "start": "NODE_ENV=production bun src/index.ts", 9 + "build": "bun run build.ts" 10 + }, 11 + "dependencies": { 12 + "bun-plugin-tailwind": "^0.1.2", 13 + "lucide-react": "^0.563.0", 14 + "react": "^19", 15 + "react-dom": "^19", 16 + "tailwindcss": "^4.1.11" 17 + }, 18 + "devDependencies": { 19 + "@types/react": "^19", 20 + "@types/react-dom": "^19", 21 + "@types/bun": "latest" 22 + } 23 + }
+26
src/App.tsx
··· 1 + import Layout from "./Layout"; 2 + import ServiceGrid from "./components/ServiceGrid"; 3 + import { apps } from "./data/apps"; 4 + import { privateApps } from "./data/privateApps"; 5 + import { websites } from "./data/websites"; 6 + 7 + export function App() { 8 + return ( 9 + <Layout> 10 + <h2 className="text-2xl font-semibold text-zinc-400 mt-8 mb-4"> 11 + Websites 12 + </h2> 13 + <ServiceGrid services={websites} columns={2} /> 14 + <h2 className="text-2xl font-semibold text-zinc-400 mt-8 mb-4"> 15 + Public Apps 16 + </h2> 17 + <ServiceGrid services={apps} columns={4} /> 18 + <h2 className="text-2xl font-semibold text-zinc-400 mt-8 mb-4"> 19 + Tailnet Apps 20 + </h2> 21 + <ServiceGrid services={privateApps} columns={4} /> 22 + </Layout> 23 + ); 24 + } 25 + 26 + export default App;
+12
src/Layout.tsx
··· 1 + import "./index.css"; 2 + 3 + export default function Layout({ children }: { children: React.ReactNode }) { 4 + return ( 5 + <main className="min-h-screen p-8 max-w-5xl mx-auto"> 6 + {/*<header className="mb-12"> 7 + <h1 className="text-4xl font-bold">cute.haus</h1> 8 + </header>*/} 9 + {children} 10 + </main> 11 + ); 12 + }
+77
src/components/ServiceGrid.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { Circle, CircleCheck, CircleX } from "lucide-react"; 3 + import type { Service } from "../types"; 4 + 5 + type Props = { 6 + services: Service[]; 7 + columns?: 1 | 2 | 3 | 4; 8 + refreshInterval?: number; 9 + }; 10 + 11 + export default function ServiceGrid({ 12 + services, 13 + columns = 4, 14 + refreshInterval = 20000, 15 + }: Props) { 16 + const [statuses, setStatuses] = useState<Record<string, boolean | null>>({}); 17 + 18 + useEffect(() => { 19 + const fetchStatuses = () => { 20 + fetch("/api/check", { 21 + method: "POST", 22 + headers: { 23 + "Content-Type": "application/json", 24 + }, 25 + body: JSON.stringify(services), 26 + }) 27 + .then((response) => response.json()) 28 + .then((data) => { 29 + setStatuses(data); 30 + }); 31 + }; 32 + 33 + fetchStatuses(); 34 + const interval = setInterval(fetchStatuses, refreshInterval); 35 + 36 + return () => clearInterval(interval); 37 + }, [services, refreshInterval]); 38 + 39 + const gridCols = { 40 + 1: "md:grid-cols-1", 41 + 2: "md:grid-cols-2", 42 + 3: "md:grid-cols-3", 43 + 4: "md:grid-cols-4", 44 + }; 45 + 46 + return ( 47 + <div className={`grid gap-4 ${gridCols[columns]}`}> 48 + {services.map((service) => ( 49 + <a 50 + key={service.name} 51 + href={service.url} 52 + className="block p-4 bg-zinc-800 border border-zinc-700 rounded-lg hover:bg-zinc-750 hover:border-zinc-600 hover:scale-105 transition-all" 53 + > 54 + <div className="flex items-center gap-3"> 55 + {service.icon && ( 56 + <img src={service.icon} alt="" className="w-6 h-6 shrink-0" /> 57 + )} 58 + <h3 className="text-lg font-semibold flex-1 truncate"> 59 + {service.name} 60 + </h3> 61 + <span className="shrink-0"> 62 + {statuses[service.name] == null && ( 63 + <Circle className="text-zinc-500" /> 64 + )} 65 + {statuses[service.name] === true && ( 66 + <CircleCheck className="text-emerald-400" /> 67 + )} 68 + {statuses[service.name] === false && ( 69 + <CircleX className="text-rose-400" /> 70 + )} 71 + </span> 72 + </div> 73 + </a> 74 + ))} 75 + </div> 76 + ); 77 + }
+44
src/data/apps.ts
··· 1 + import type { Service } from "../types"; 2 + 3 + export const apps: Service[] = [ 4 + { 5 + name: "Plex", 6 + url: "https://plex.cute.haus/", 7 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/plex.png", 8 + }, 9 + { 10 + name: "Ombi", 11 + url: "https://ombi.cute.haus/", 12 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/ombi.png", 13 + }, 14 + { 15 + name: "Audiobookshelf", 16 + url: "https://audiobookshelf.cute.haus/", 17 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/audiobookshelf.png", 18 + }, 19 + { 20 + name: "Immich", 21 + url: "https://immich.cute.haus/", 22 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/immich.png", 23 + }, 24 + { 25 + name: "Forĝejo", 26 + url: "https://git.aly.codes/", 27 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/forgejo.png", 28 + }, 29 + { 30 + name: "Karakeep", 31 + url: "https://karakeep.cute.haus/", 32 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/karakeep.png", 33 + }, 34 + { 35 + name: "aly.social", 36 + url: "https://aly.social/", 37 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bluesky.png", 38 + }, 39 + { 40 + name: "Vaultwarden", 41 + url: "https://vault.cute.haus/", 42 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/vaultwarden.png", 43 + }, 44 + ];
+64
src/data/privateApps.ts
··· 1 + import type { Service } from "../types"; 2 + 3 + export const privateApps: Service[] = [ 4 + { 5 + name: "Jellyfin", 6 + url: "https://jellyfin.narwhal-snapper.ts.net/", 7 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/jellyfin.png", 8 + }, 9 + { 10 + name: "Photoprism", 11 + url: "https://photoprism.narwhal-snapper.ts.net/", 12 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/photoprism.png", 13 + }, 14 + { 15 + name: "Navidrome", 16 + url: "https://navidrome.narwhal-snapper.ts.net/", 17 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/navidrome.png", 18 + }, 19 + { 20 + name: "Sonarr", 21 + url: "https://sonarr.narwhal-snapper.ts.net/", 22 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/sonarr.png", 23 + }, 24 + { 25 + name: "Radarr", 26 + url: "https://radarr.narwhal-snapper.ts.net/", 27 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/radarr.png", 28 + }, 29 + { 30 + name: "Lidarr", 31 + url: "https://lidarr.narwhal-snapper.ts.net/", 32 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/lidarr.png", 33 + }, 34 + { 35 + name: "Prowlarr", 36 + url: "https://prowlarr.narwhal-snapper.ts.net/", 37 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/prowlarr.png", 38 + }, 39 + { 40 + name: "Bazarr", 41 + url: "https://bazarr.narwhal-snapper.ts.net/", 42 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/bazarr.png", 43 + }, 44 + { 45 + name: "Tautulli", 46 + url: "https://tautulli.narwhal-snapper.ts.net/", 47 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tautulli.png", 48 + }, 49 + { 50 + name: "qBittorrent", 51 + url: "https://qbittorrent.narwhal-snapper.ts.net/", 52 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/qbittorrent.png", 53 + }, 54 + { 55 + name: "Grafana", 56 + url: "https://grafana.narwhal-snapper.ts.net/", 57 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/grafana.png", 58 + }, 59 + { 60 + name: "Uptime Kuma", 61 + url: "https://uptime-kuma.narwhal-snapper.ts.net/", 62 + icon: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png", 63 + }, 64 + ];
+6
src/data/websites.ts
··· 1 + import type { Service } from "../types"; 2 + 3 + export const websites: Service[] = [ 4 + { name: "aly.codes", url: "https://aly.codes/" }, 5 + { name: "switchyard.aly.codes", url: "https://switchyard.aly.codes/" }, 6 + ];
+20
src/frontend.tsx
··· 1 + /** 2 + * This file is the entry point for the React app, it sets up the root 3 + * element and renders the App component to the DOM. 4 + * 5 + * It is included in `src/index.html`. 6 + */ 7 + 8 + import { createRoot } from "react-dom/client"; 9 + import { App } from "./App"; 10 + 11 + function start() { 12 + const root = createRoot(document.getElementById("root")!); 13 + root.render(<App />); 14 + } 15 + 16 + if (document.readyState === "loading") { 17 + document.addEventListener("DOMContentLoaded", start); 18 + } else { 19 + start(); 20 + }
+1
src/index.css
··· 1 + @import "tailwindcss";
+13
src/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <!--<link rel="icon" type="image/svg+xml" href="./logo.svg" />--> 7 + <title>watsup</title> 8 + </head> 9 + <body class="bg-zinc-900 text-zinc-100"> 10 + <div id="root"></div> 11 + <script type="module" src="./frontend.tsx"></script> 12 + </body> 13 + </html>
+78
src/index.ts
··· 1 + import { serve } from "bun"; 2 + import index from "./index.html"; 3 + import { privateApps } from "./data/privateApps"; 4 + import { websites } from "./data/websites"; 5 + 6 + async function checkStatuses(items: { name: string; url: string }[]) { 7 + const results = await Promise.all( 8 + items.map(async (item) => { 9 + try { 10 + const response = await fetch(item.url, { 11 + method: "HEAD", 12 + signal: AbortSignal.timeout(3000), 13 + }); 14 + return { name: item.name, online: response.status < 500 }; 15 + } catch { 16 + return { name: item.name, online: false }; 17 + } 18 + }), 19 + ); 20 + 21 + const statuses: Record<string, boolean> = {}; 22 + for (const result of results) { 23 + statuses[result.name] = result.online; 24 + } 25 + return statuses; 26 + } 27 + 28 + const server = serve({ 29 + routes: { 30 + // Serve index.html for all unmatched routes. 31 + "/*": index, 32 + 33 + "/api/check": { 34 + async POST(req) { 35 + const items = await req.json(); 36 + return Response.json(await checkStatuses(items)); 37 + }, 38 + }, 39 + 40 + "/api/websites": { 41 + async GET(req) { 42 + return Response.json(await checkStatuses(websites)); 43 + }, 44 + }, 45 + 46 + "/api/hello": { 47 + async GET(req) { 48 + return Response.json({ 49 + message: "Hello, world!", 50 + method: "GET", 51 + }); 52 + }, 53 + async PUT(req) { 54 + return Response.json({ 55 + message: "Hello, world!", 56 + method: "PUT", 57 + }); 58 + }, 59 + }, 60 + 61 + "/api/hello/:name": async (req) => { 62 + const name = req.params.name; 63 + return Response.json({ 64 + message: `Hello, ${name}!`, 65 + }); 66 + }, 67 + }, 68 + 69 + development: process.env.NODE_ENV !== "production" && { 70 + // Enable browser hot reloading in development 71 + hmr: true, 72 + 73 + // Echo console logs from the browser to the server 74 + console: true, 75 + }, 76 + }); 77 + 78 + console.log(`🚀 Server running at ${server.url}`);
+1
src/types.ts
··· 1 + export type Service = { name: string; url: string; icon?: string };
+36
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext", "DOM"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "react-jsx", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Best practices 18 + "strict": true, 19 + "skipLibCheck": true, 20 + "noFallthroughCasesInSwitch": true, 21 + "noUncheckedIndexedAccess": true, 22 + "noImplicitOverride": true, 23 + 24 + "baseUrl": ".", 25 + "paths": { 26 + "@/*": ["./src/*"] 27 + }, 28 + 29 + // Some stricter flags (disabled by default) 30 + "noUnusedLocals": false, 31 + "noUnusedParameters": false, 32 + "noPropertyAccessFromIndexSignature": false 33 + }, 34 + 35 + "exclude": ["dist", "node_modules"] 36 + }