Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ It replaces legacy heavy dashboards (like Homepage) with a fast, modern static S
## Key Capabilities

- **Fleet Design System**: Uses `@kleinbem/web-config` themes (dark/light, system preference, zero-FOUC).
- **Fleet Inventory Integration**: Reads services dynamically from `inventory.nix` data.
- **Service catalogue**: hand-maintained `FLEET_SERVICES` in `src/lib/data/services.ts` and `FLEET_HOSTS` in `src/lib/data/hosts.ts` (not generated from `inventory.nix`).
- **Network Reachability Indicators**: Highlights Public (Cloudflare Tunnel), Mesh-Only (NetBird), and LAN endpoints.
- **Gatus Live Health**: Queries live service statuses from `status.kleinbem.dev`.
- **Command Palette (`⌘K`)**: Instant fuzzy search across services, hosts, and tools.
Expand All @@ -24,3 +24,9 @@ just check # Run svelte-check and TypeScript checks
just build # Build static production assets
just preview # Preview production build
```

## TypeScript

`typescript` is aliased to `@typescript/typescript6` and TypeScript 7 sits beside it as
`@typescript/native`: svelte-check and the Svelte tooling need the TS 6 JavaScript API, which the
native TS 7 compiler does not ship. Same setup in every fleet Svelte repo.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Foyer serves as the front door to [`home.kleinbem.dev`](https://home.kleinbem.de
- **Network Reachability Awareness**: Clear visual badges for **Public** (Cloudflare Tunnel), **Mesh-Only** (NetBird), and **LAN-Only** services.
- **Real-Time Health & Uptime**: Direct live status integration with the fleet's [Gatus](https://status.kleinbem.dev) instance.
- **Command Palette (`⌘K` / `/`)**: Fast keyboard-driven fuzzy search across all services, host nodes, and quick links.
- **Host Node Filtering**: Filter services dynamically by node (`core-pi`, `nixos-nvme`, `mac-mini`, `nasbook`, `bpi-r4`).
- **Host Node Filtering**: Filter services dynamically by node (`core-pi`, `nixos-nvme`, `mac-mini`, `nasbook`, `orin-nano`, `bpi-r4`).
- **Live Weather & Telemetry**: Built-in weather widget (Open-Meteo) and system clocks.

## 🛠️ Development
Expand Down
10 changes: 10 additions & 0 deletions src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
@import "@kleinbem/web-config/base.css";
@import "@kleinbem/web-config/badges.css";

/* App-level names for web-config's tokens; without these the *-brand, bg-bg-surface and
text-text-primary utilities generate no CSS. */
@theme inline {
--color-bg-surface: var(--color-bg);
--color-bg-elevated: var(--color-surface);
--color-text-primary: var(--color-heading);
--color-border-subtle: var(--color-line);
--color-brand: var(--color-accent);
}

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *, .dark, .dark *));

/* Glassmorphism utility for modern card and navbar styling */
Expand Down
60 changes: 53 additions & 7 deletions src/lib/components/CommandPalette.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte';
import type { ServiceItem } from '../types';

let {
Expand All @@ -12,6 +13,7 @@
let query = $state('');
let selectedIndex = $state(0);
let inputEl = $state<HTMLInputElement | null>(null);
let dialogEl = $state<HTMLElement | null>(null);
let copyNotice = $state<string | null>(null);

let filtered = $derived.by(() => {
Expand Down Expand Up @@ -64,14 +66,42 @@
});

$effect(() => {
if (open) {
query = '';
selectedIndex = 0;
copyNotice = null;
setTimeout(() => inputEl?.focus(), 50);
}
if (!open) return;
const previous = document.activeElement as HTMLElement | null;
query = '';
selectedIndex = 0;
copyNotice = null;
inputEl?.focus();
return () => previous?.focus?.();
});

// When a result link has focus, move focus with the highlight so Enter opens the highlighted one.
async function followSelection() {
const active = document.activeElement;
if (!(active instanceof HTMLAnchorElement) || !dialogEl?.contains(active)) return;
await tick();
dialogEl.querySelectorAll<HTMLAnchorElement>('a[data-result]')[selectedIndex]?.focus();
}

// Keep Tab inside the open dialog.
function trapTab(e: KeyboardEvent) {
if (!dialogEl) return;
const focusable = Array.from(
dialogEl.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])')
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === first || !dialogEl.contains(active))) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || !dialogEl.contains(active))) {
e.preventDefault();
first.focus();
}
}

$effect(() => {
// Reset selected index when query changes
query;
Expand Down Expand Up @@ -103,7 +133,12 @@
e.preventDefault();
open = true;
}
if (e.key === '/' && document.activeElement?.tagName !== 'INPUT') {
if (
e.key === '/' &&
!(e.metaKey || e.ctrlKey || e.altKey) &&
!['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName ?? '') &&
!(document.activeElement as HTMLElement | null)?.isContentEditable
) {
e.preventDefault();
open = true;
}
Expand All @@ -113,6 +148,8 @@
if (e.key === 'Escape') {
e.preventDefault();
open = false;
} else if (e.key === 'Tab') {
trapTab(e);
} else if ((e.metaKey || e.ctrlKey) && e.key === 'c') {
// Copy highlighted URL if input text isn't selected
if (
Expand All @@ -127,10 +164,14 @@
} else if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % Math.max(1, filtered.length);
followSelection();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filtered.length) % Math.max(1, filtered.length);
followSelection();
} else if (e.key === 'Enter') {
// A focused result link or button activates itself natively.
if (e.target instanceof HTMLAnchorElement || e.target instanceof HTMLButtonElement) return;
Comment thread
kleinbem marked this conversation as resolved.
Resolved
e.preventDefault();
if (query.trim().startsWith('>')) {
webSearch(query.trim().slice(1));
Expand All @@ -152,9 +193,11 @@
{#if open}
<div
class="fixed inset-0 z-50 flex items-start justify-center pt-20 sm:pt-28 px-4 bg-zinc-950/60 backdrop-blur-xs transition-opacity animate-in fade-in duration-150"
bind:this={dialogEl}
role="dialog"
tabindex="-1"
aria-modal="true"
aria-label="Search services"
onclick={(e) => {
if (e.target === e.currentTarget) open = false;
}}
Expand All @@ -173,6 +216,7 @@
bind:this={inputEl}
bind:value={query}
type="text"
aria-label="Search services"
placeholder="Search services (#host, @category, !public, >web)..."
class="w-full py-3.5 px-3 text-sm bg-transparent outline-none text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400"
/>
Expand Down Expand Up @@ -222,7 +266,9 @@
href={service.url}
target="_blank"
rel="noopener noreferrer"
data-result
onmouseenter={() => (selectedIndex = index)}
onfocus={() => (selectedIndex = index)}
onclick={() => (open = false)}
class="flex items-center justify-between p-2.5 rounded-xl text-sm transition-colors {isSelected
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100'
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/HostBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
onclick={() => {
selectedHost = isSelected ? 'all' : host.id;
}}
class="flex-1 text-left cursor-pointer focus:outline-hidden"
class="flex-1 text-left cursor-pointer"
>
<div class="flex items-center justify-between gap-1 mb-1">
<span class="font-semibold text-xs text-zinc-900 dark:text-zinc-100 group-hover:text-brand transition-colors font-mono truncate">
Expand Down
16 changes: 8 additions & 8 deletions src/lib/components/ServiceCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2.5 min-w-0 flex-1 mr-2 focus:outline-hidden"
class="flex items-center gap-2.5 min-w-0 flex-1 mr-2"
title={service.description}
>
<span class="text-xl select-none group-hover:scale-110 transition-transform">
Expand All @@ -74,7 +74,7 @@
type="button"
onclick={copyUrl}
aria-label="Copy {service.name} URL"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 p-1 rounded transition-opacity cursor-pointer focus:outline-hidden"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 p-1 rounded transition-opacity cursor-pointer"
title="Copy URL"
>
<span class="text-[10px] font-mono">{copied ? '✓' : '📋'}</span>
Expand All @@ -90,7 +90,7 @@
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 min-w-0 flex-1 mr-4 focus:outline-hidden"
class="flex items-center gap-3 min-w-0 flex-1 mr-4"
>
<span class="text-xl select-none group-hover:scale-110 transition-transform shrink-0">
{service.icon}
Expand Down Expand Up @@ -138,7 +138,7 @@
type="button"
onclick={copyUrl}
aria-label="Copy {service.name} URL"
class="text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 p-1 rounded transition-colors cursor-pointer text-xs font-mono focus:outline-hidden"
class="text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 p-1 rounded transition-colors cursor-pointer text-xs font-mono"
title="Copy URL"
>
{copied ? '✓' : '📋'}
Expand All @@ -148,7 +148,7 @@
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 p-1 focus:outline-hidden"
class="text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 p-1"
title="Open link"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
Expand All @@ -168,7 +168,7 @@
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2.5 flex-1 min-w-0 focus:outline-hidden"
class="flex items-center gap-2.5 flex-1 min-w-0"
>
<span class="text-2xl select-none group-hover:scale-110 transition-transform duration-150">
{service.icon}
Expand Down Expand Up @@ -205,7 +205,7 @@
href={service.url}
target="_blank"
rel="noopener noreferrer"
class="truncate max-w-[200px] hover:text-brand transition-colors focus:outline-hidden"
class="truncate max-w-[200px] hover:text-brand transition-colors"
>
{service.url.replace(/^https?:\/\//, '')}
</a>
Expand All @@ -214,7 +214,7 @@
type="button"
onclick={copyUrl}
aria-label="Copy {service.name} URL"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-zinc-800 dark:hover:text-zinc-200 transition-opacity p-0.5 cursor-pointer focus:outline-hidden"
class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-zinc-800 dark:hover:text-zinc-200 transition-opacity p-0.5 cursor-pointer"
title="Copy URL"
>
<span>{copied ? '✓ Copied' : 'Copy'}</span>
Expand Down
Loading