mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 12:23:33 -06:00
feat: add course search UI with ts-rs type bindings
Integrate ts-rs for Rust-to-TypeScript type generation, build course search page with filters, pagination, and expandable detail rows, and refactor theme toggle into a reactive store with view transition animation.
This commit is contained in:
@@ -1,22 +1,16 @@
|
||||
<script lang="ts">
|
||||
import "./layout.css";
|
||||
import { onMount } from "svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{@html `<script>
|
||||
(function() {
|
||||
const stored = localStorage.getItem("theme");
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
if (stored === "dark" || (!stored && prefersDark) || (stored === "system" && prefersDark)) {
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
})();
|
||||
</script>`}
|
||||
</svelte:head>
|
||||
onMount(() => {
|
||||
themeStore.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<div class="fixed top-5 right-5 z-50">
|
||||
|
||||
+146
-304
@@ -1,327 +1,169 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
Globe,
|
||||
Hourglass,
|
||||
MessageCircle,
|
||||
WifiOff,
|
||||
XCircle,
|
||||
} from "@lucide/svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import { type Status, type ServiceInfo, type StatusResponse, client } from "$lib/api";
|
||||
import { relativeTime } from "$lib/time";
|
||||
import { untrack } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { type Subject, type SearchResponse, client } from "$lib/api";
|
||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||
import Pagination from "$lib/components/Pagination.svelte";
|
||||
|
||||
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
|
||||
const REQUEST_TIMEOUT = 10000;
|
||||
let { data } = $props();
|
||||
|
||||
const SERVICE_ICONS: Record<string, typeof Bot> = {
|
||||
bot: Bot,
|
||||
banner: Globe,
|
||||
discord: MessageCircle,
|
||||
database: Activity,
|
||||
web: Globe,
|
||||
scraper: Clock,
|
||||
};
|
||||
// Read initial state from URL params (intentionally captured once)
|
||||
const initialParams = untrack(() => new URLSearchParams(data.url.search));
|
||||
|
||||
interface ResponseTiming {
|
||||
health: number | null;
|
||||
status: number | null;
|
||||
}
|
||||
// Filter state
|
||||
let selectedTerm = $state(untrack(() => initialParams.get("term") ?? data.terms[0]?.code ?? ""));
|
||||
let selectedSubject = $state(initialParams.get("subject") ?? "");
|
||||
let query = $state(initialParams.get("q") ?? "");
|
||||
let openOnly = $state(initialParams.get("open") === "true");
|
||||
let offset = $state(Number(initialParams.get("offset")) || 0);
|
||||
const limit = 25;
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
status: Status;
|
||||
icon: typeof Bot;
|
||||
}
|
||||
// Data state
|
||||
let subjects: Subject[] = $state([]);
|
||||
let searchResult: SearchResponse | null = $state(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
type StatusState =
|
||||
| { mode: "loading" }
|
||||
| { mode: "response"; timing: ResponseTiming; lastFetch: Date; status: StatusResponse }
|
||||
| { mode: "error"; lastFetch: Date }
|
||||
| { mode: "timeout"; lastFetch: Date };
|
||||
|
||||
const STATUS_ICONS: Record<Status | "Unreachable", { icon: typeof CheckCircle; color: string }> = {
|
||||
active: { icon: CheckCircle, color: "var(--status-green)" },
|
||||
connected: { icon: CheckCircle, color: "var(--status-green)" },
|
||||
starting: { icon: Hourglass, color: "var(--status-orange)" },
|
||||
disabled: { icon: Circle, color: "var(--status-gray)" },
|
||||
error: { icon: XCircle, color: "var(--status-red)" },
|
||||
Unreachable: { icon: WifiOff, color: "var(--status-red)" },
|
||||
};
|
||||
|
||||
let statusState = $state({ mode: "loading" } as StatusState);
|
||||
let now = $state(new Date());
|
||||
|
||||
const isLoading = $derived(statusState.mode === "loading");
|
||||
const hasResponse = $derived(statusState.mode === "response");
|
||||
const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error");
|
||||
|
||||
const overallHealth: Status | "Unreachable" = $derived(
|
||||
statusState.mode === "timeout"
|
||||
? "Unreachable"
|
||||
: statusState.mode === "error"
|
||||
? "error"
|
||||
: statusState.mode === "response"
|
||||
? statusState.status.status
|
||||
: "error"
|
||||
);
|
||||
|
||||
const overallIcon = $derived(STATUS_ICONS[overallHealth]);
|
||||
|
||||
const services: Service[] = $derived(
|
||||
statusState.mode === "response"
|
||||
? (Object.entries(statusState.status.services) as [string, ServiceInfo][]).map(
|
||||
([id, info]) => ({
|
||||
name: info.name,
|
||||
status: info.status,
|
||||
icon: SERVICE_ICONS[id] ?? Bot,
|
||||
})
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
const shouldShowTiming = $derived(
|
||||
statusState.mode === "response" && statusState.timing.health !== null
|
||||
);
|
||||
|
||||
const shouldShowLastFetch = $derived(
|
||||
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
||||
);
|
||||
|
||||
const lastFetch = $derived(
|
||||
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
||||
? statusState.lastFetch
|
||||
: null
|
||||
);
|
||||
|
||||
const relativeLastFetchResult = $derived(lastFetch ? relativeTime(lastFetch, now) : null);
|
||||
const relativeLastFetch = $derived(relativeLastFetchResult?.text ?? "");
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let requestTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Adaptive tick: schedules the next `now` update based on when the
|
||||
// relative time text would actually change (every ~1s for recent
|
||||
// timestamps, every ~1m for minute-level, etc.)
|
||||
function scheduleNowTick() {
|
||||
const delay = relativeLastFetchResult?.nextUpdateMs ?? 1000;
|
||||
nowTimeoutId = setTimeout(() => {
|
||||
now = new Date();
|
||||
scheduleNowTick();
|
||||
}, delay);
|
||||
}
|
||||
scheduleNowTick();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
requestTimeoutId = setTimeout(() => {
|
||||
reject(new Error("Request timeout"));
|
||||
}, REQUEST_TIMEOUT);
|
||||
});
|
||||
|
||||
const statusData = await Promise.race([client.getStatus(), timeoutPromise]);
|
||||
|
||||
if (requestTimeoutId) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
requestTimeoutId = null;
|
||||
// Fetch subjects when term changes
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
if (!term) return;
|
||||
client.getSubjects(term).then((s) => {
|
||||
subjects = s;
|
||||
if (selectedSubject && !s.some((sub) => sub.code === selectedSubject)) {
|
||||
selectedSubject = "";
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
// Debounced search
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
$effect(() => {
|
||||
const term = selectedTerm;
|
||||
const subject = selectedSubject;
|
||||
const q = query;
|
||||
const open = openOnly;
|
||||
const off = offset;
|
||||
|
||||
statusState = {
|
||||
mode: "response",
|
||||
status: statusData,
|
||||
timing: { health: responseTime, status: responseTime },
|
||||
lastFetch: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
if (requestTimeoutId) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
requestTimeoutId = null;
|
||||
}
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
performSearch(term, subject, q, open, off);
|
||||
}, 300);
|
||||
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
return () => clearTimeout(searchTimeout);
|
||||
});
|
||||
|
||||
if (message === "Request timeout") {
|
||||
statusState = { mode: "timeout", lastFetch: new Date() };
|
||||
} else {
|
||||
statusState = { mode: "error", lastFetch: new Date() };
|
||||
}
|
||||
// Reset offset when filters change (not offset itself)
|
||||
let prevFilters = $state("");
|
||||
$effect(() => {
|
||||
const key = `${selectedTerm}|${selectedSubject}|${query}|${openOnly}`;
|
||||
if (prevFilters && key !== prevFilters) {
|
||||
offset = 0;
|
||||
}
|
||||
prevFilters = key;
|
||||
});
|
||||
|
||||
timeoutId = setTimeout(() => void fetchData(), REFRESH_INTERVAL);
|
||||
};
|
||||
async function performSearch(
|
||||
term: string,
|
||||
subject: string,
|
||||
q: string,
|
||||
open: boolean,
|
||||
off: number,
|
||||
) {
|
||||
if (!term) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
void fetchData();
|
||||
// Sync URL
|
||||
const params = new URLSearchParams();
|
||||
params.set("term", term);
|
||||
if (subject) params.set("subject", subject);
|
||||
if (q) params.set("q", q);
|
||||
if (open) params.set("open", "true");
|
||||
if (off > 0) params.set("offset", String(off));
|
||||
goto(`?${params.toString()}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (requestTimeoutId) clearTimeout(requestTimeoutId);
|
||||
if (nowTimeoutId) clearTimeout(nowTimeoutId);
|
||||
};
|
||||
});
|
||||
try {
|
||||
searchResult = await client.searchCourses({
|
||||
term,
|
||||
subject: subject || undefined,
|
||||
q: q || undefined,
|
||||
open_only: open || undefined,
|
||||
limit,
|
||||
offset: off,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : "Search failed";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(newOffset: number) {
|
||||
offset = newOffset;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col items-center justify-center p-5">
|
||||
<div
|
||||
class="bg-card text-card-foreground rounded-xl border border-border p-6 w-full max-w-[400px] shadow-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Overall Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Activity
|
||||
size={18}
|
||||
color={isLoading ? undefined : overallIcon.color}
|
||||
class={isLoading ? "animate-pulse" : ""}
|
||||
style="opacity: {isLoading ? 0.3 : 1}; transition: opacity 2s ease-in-out, color 2s ease-in-out;"
|
||||
/>
|
||||
<span class="text-base font-medium text-foreground">System Status</span>
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="h-5 w-20 bg-muted rounded animate-pulse"></div>
|
||||
{:else}
|
||||
{#if overallIcon}
|
||||
{@const OverallIconComponent = overallIcon.icon}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="text-sm"
|
||||
class:text-muted-foreground={overallHealth === "disabled"}
|
||||
class:opacity-70={overallHealth === "disabled"}
|
||||
>
|
||||
{overallHealth}
|
||||
</span>
|
||||
<OverallIconComponent size={16} color={overallIcon.color} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-h-screen flex flex-col items-center p-5">
|
||||
<div class="w-full max-w-4xl flex flex-col gap-6">
|
||||
<!-- Title -->
|
||||
<div class="text-center pt-8 pb-2">
|
||||
<h1 class="text-2xl font-semibold text-foreground">UTSA Course Search</h1>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="flex flex-col gap-3 mt-4">
|
||||
{#if shouldShowSkeleton}
|
||||
{#each Array(3) as _}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-[18px] bg-muted rounded animate-pulse"></div>
|
||||
<div class="h-6 w-[60px] bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-5 w-[50px] bg-muted rounded animate-pulse"></div>
|
||||
<div class="h-5 w-4 bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each services as service (service.name)}
|
||||
{@const statusInfo = STATUS_ICONS[service.status]}
|
||||
{@const ServiceIcon = service.icon}
|
||||
{@const StatusIconComponent = statusInfo.icon}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ServiceIcon size={18} />
|
||||
<span class="text-muted-foreground">{service.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="text-sm"
|
||||
class:text-muted-foreground={service.status === "disabled"}
|
||||
class:opacity-70={service.status === "disabled"}
|
||||
>
|
||||
{service.status}
|
||||
</span>
|
||||
<StatusIconComponent size={16} color={statusInfo.color} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Filters -->
|
||||
<SearchFilters
|
||||
terms={data.terms}
|
||||
{subjects}
|
||||
bind:selectedTerm
|
||||
bind:selectedSubject
|
||||
bind:query
|
||||
bind:openOnly
|
||||
/>
|
||||
|
||||
<!-- Timing & Last Updated -->
|
||||
<div class="flex flex-col gap-2 mt-4 pt-4 border-t border-border">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Hourglass size={13} />
|
||||
<span class="text-sm text-muted-foreground">Response Time</span>
|
||||
</div>
|
||||
<div class="h-[18px] w-[50px] bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
{:else if shouldShowTiming && statusState.mode === "response"}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Hourglass size={13} />
|
||||
<span class="text-sm text-muted-foreground">Response Time</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{formatNumber(statusState.timing.health!)}ms
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock size={13} />
|
||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground pb-0.5">Loading...</span>
|
||||
</div>
|
||||
{:else if shouldShowLastFetch && lastFetch}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock size={13} />
|
||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<abbr
|
||||
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
|
||||
</abbr>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
|
||||
>
|
||||
as of {lastFetch.toLocaleTimeString()}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Results -->
|
||||
{#if error}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-status-red">{error}</p>
|
||||
<button
|
||||
onclick={() => performSearch(selectedTerm, selectedSubject, query, openOnly, offset)}
|
||||
class="mt-2 text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<CourseTable courses={searchResult?.courses ?? []} {loading} />
|
||||
|
||||
{#if searchResult}
|
||||
<Pagination
|
||||
totalCount={searchResult.totalCount}
|
||||
offset={searchResult.offset}
|
||||
{limit}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-center items-center gap-2 mt-auto pt-6 pb-4">
|
||||
{#if __APP_VERSION__}
|
||||
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
|
||||
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
||||
{/if}
|
||||
<a
|
||||
href="https://github.com/Xevion/banner"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-muted-foreground no-underline hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
||||
<a href="/health" class="text-xs text-muted-foreground no-underline hover:underline">
|
||||
Status
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-center items-center gap-2 mt-3">
|
||||
{#if __APP_VERSION__}
|
||||
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
|
||||
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
||||
{/if}
|
||||
<a
|
||||
href={hasResponse && statusState.mode === "response" && statusState.status.commit
|
||||
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
|
||||
: "https://github.com/Xevion/banner"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-muted-foreground no-underline hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import { BannerApiClient } from "$lib/api";
|
||||
|
||||
export const load: PageLoad = async ({ url, fetch }) => {
|
||||
const client = new BannerApiClient(undefined, fetch);
|
||||
const terms = await client.getTerms();
|
||||
return { terms, url };
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
Globe,
|
||||
Hourglass,
|
||||
MessageCircle,
|
||||
WifiOff,
|
||||
XCircle,
|
||||
} from "@lucide/svelte";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
|
||||
import { relativeTime } from "$lib/time";
|
||||
|
||||
const REFRESH_INTERVAL = import.meta.env.DEV ? 3000 : 30000;
|
||||
const REQUEST_TIMEOUT = 10000;
|
||||
|
||||
const SERVICE_ICONS: Record<string, typeof Bot> = {
|
||||
bot: Bot,
|
||||
banner: Globe,
|
||||
discord: MessageCircle,
|
||||
database: Activity,
|
||||
web: Globe,
|
||||
scraper: Clock,
|
||||
};
|
||||
|
||||
interface ResponseTiming {
|
||||
health: number | null;
|
||||
status: number | null;
|
||||
}
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
status: ServiceStatus;
|
||||
icon: typeof Bot;
|
||||
}
|
||||
|
||||
type StatusState =
|
||||
| { mode: "loading" }
|
||||
| { mode: "response"; timing: ResponseTiming; lastFetch: Date; status: StatusResponse }
|
||||
| { mode: "error"; lastFetch: Date }
|
||||
| { mode: "timeout"; lastFetch: Date };
|
||||
|
||||
const STATUS_ICONS: Record<ServiceStatus | "Unreachable", { icon: typeof CheckCircle; color: string }> = {
|
||||
active: { icon: CheckCircle, color: "var(--status-green)" },
|
||||
connected: { icon: CheckCircle, color: "var(--status-green)" },
|
||||
starting: { icon: Hourglass, color: "var(--status-orange)" },
|
||||
disabled: { icon: Circle, color: "var(--status-gray)" },
|
||||
error: { icon: XCircle, color: "var(--status-red)" },
|
||||
Unreachable: { icon: WifiOff, color: "var(--status-red)" },
|
||||
};
|
||||
|
||||
let statusState = $state({ mode: "loading" } as StatusState);
|
||||
let now = $state(new Date());
|
||||
|
||||
const isLoading = $derived(statusState.mode === "loading");
|
||||
const hasResponse = $derived(statusState.mode === "response");
|
||||
const shouldShowSkeleton = $derived(statusState.mode === "loading" || statusState.mode === "error");
|
||||
|
||||
const overallHealth: ServiceStatus | "Unreachable" = $derived(
|
||||
statusState.mode === "timeout"
|
||||
? "Unreachable"
|
||||
: statusState.mode === "error"
|
||||
? "error"
|
||||
: statusState.mode === "response"
|
||||
? statusState.status.status
|
||||
: "error"
|
||||
);
|
||||
|
||||
const overallIcon = $derived(STATUS_ICONS[overallHealth]);
|
||||
|
||||
const services: Service[] = $derived(
|
||||
statusState.mode === "response"
|
||||
? (Object.entries(statusState.status.services) as [string, ServiceInfo][]).map(
|
||||
([id, info]) => ({
|
||||
name: info.name,
|
||||
status: info.status,
|
||||
icon: SERVICE_ICONS[id] ?? Bot,
|
||||
})
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
const shouldShowTiming = $derived(
|
||||
statusState.mode === "response" && statusState.timing.health !== null
|
||||
);
|
||||
|
||||
const shouldShowLastFetch = $derived(
|
||||
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
||||
);
|
||||
|
||||
const lastFetch = $derived(
|
||||
statusState.mode === "response" || statusState.mode === "error" || statusState.mode === "timeout"
|
||||
? statusState.lastFetch
|
||||
: null
|
||||
);
|
||||
|
||||
const relativeLastFetchResult = $derived(lastFetch ? relativeTime(lastFetch, now) : null);
|
||||
const relativeLastFetch = $derived(relativeLastFetchResult?.text ?? "");
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let requestTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let nowTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Adaptive tick: schedules the next `now` update based on when the
|
||||
// relative time text would actually change (every ~1s for recent
|
||||
// timestamps, every ~1m for minute-level, etc.)
|
||||
function scheduleNowTick() {
|
||||
const delay = relativeLastFetchResult?.nextUpdateMs ?? 1000;
|
||||
nowTimeoutId = setTimeout(() => {
|
||||
now = new Date();
|
||||
scheduleNowTick();
|
||||
}, delay);
|
||||
}
|
||||
scheduleNowTick();
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
requestTimeoutId = setTimeout(() => {
|
||||
reject(new Error("Request timeout"));
|
||||
}, REQUEST_TIMEOUT);
|
||||
});
|
||||
|
||||
const statusData = await Promise.race([client.getStatus(), timeoutPromise]);
|
||||
|
||||
if (requestTimeoutId) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
requestTimeoutId = null;
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
statusState = {
|
||||
mode: "response",
|
||||
status: statusData,
|
||||
timing: { health: responseTime, status: responseTime },
|
||||
lastFetch: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
if (requestTimeoutId) {
|
||||
clearTimeout(requestTimeoutId);
|
||||
requestTimeoutId = null;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : "";
|
||||
|
||||
if (message === "Request timeout") {
|
||||
statusState = { mode: "timeout", lastFetch: new Date() };
|
||||
} else {
|
||||
statusState = { mode: "error", lastFetch: new Date() };
|
||||
}
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => void fetchData(), REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
void fetchData();
|
||||
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (requestTimeoutId) clearTimeout(requestTimeoutId);
|
||||
if (nowTimeoutId) clearTimeout(nowTimeoutId);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col items-center justify-center p-5">
|
||||
<div
|
||||
class="bg-card text-card-foreground rounded-xl border border-border p-6 w-full max-w-[400px] shadow-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Overall Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Activity
|
||||
size={18}
|
||||
color={isLoading ? undefined : overallIcon.color}
|
||||
class={isLoading ? "animate-pulse" : ""}
|
||||
style="opacity: {isLoading ? 0.3 : 1}; transition: opacity 2s ease-in-out, color 2s ease-in-out;"
|
||||
/>
|
||||
<span class="text-base font-medium text-foreground">System Status</span>
|
||||
</div>
|
||||
{#if isLoading}
|
||||
<div class="h-5 w-20 bg-muted rounded animate-pulse"></div>
|
||||
{:else}
|
||||
{#if overallIcon}
|
||||
{@const OverallIconComponent = overallIcon.icon}
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="text-sm"
|
||||
class:text-muted-foreground={overallHealth === "disabled"}
|
||||
class:opacity-70={overallHealth === "disabled"}
|
||||
>
|
||||
{overallHealth}
|
||||
</span>
|
||||
<OverallIconComponent size={16} color={overallIcon.color} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="flex flex-col gap-3 mt-4">
|
||||
{#if shouldShowSkeleton}
|
||||
{#each Array(3) as _}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-6 w-[18px] bg-muted rounded animate-pulse"></div>
|
||||
<div class="h-6 w-[60px] bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-5 w-[50px] bg-muted rounded animate-pulse"></div>
|
||||
<div class="h-5 w-4 bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
{#each services as service (service.name)}
|
||||
{@const statusInfo = STATUS_ICONS[service.status]}
|
||||
{@const ServiceIcon = service.icon}
|
||||
{@const StatusIconComponent = statusInfo.icon}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<ServiceIcon size={18} />
|
||||
<span class="text-muted-foreground">{service.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="text-sm"
|
||||
class:text-muted-foreground={service.status === "disabled"}
|
||||
class:opacity-70={service.status === "disabled"}
|
||||
>
|
||||
{service.status}
|
||||
</span>
|
||||
<StatusIconComponent size={16} color={statusInfo.color} />
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Timing & Last Updated -->
|
||||
<div class="flex flex-col gap-2 mt-4 pt-4 border-t border-border">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Hourglass size={13} />
|
||||
<span class="text-sm text-muted-foreground">Response Time</span>
|
||||
</div>
|
||||
<div class="h-[18px] w-[50px] bg-muted rounded animate-pulse"></div>
|
||||
</div>
|
||||
{:else if shouldShowTiming && statusState.mode === "response"}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Hourglass size={13} />
|
||||
<span class="text-sm text-muted-foreground">Response Time</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{formatNumber(statusState.timing.health!)}ms
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock size={13} />
|
||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
||||
</div>
|
||||
<span class="text-sm text-muted-foreground pb-0.5">Loading...</span>
|
||||
</div>
|
||||
{:else if shouldShowLastFetch && lastFetch}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock size={13} />
|
||||
<span class="text-sm text-muted-foreground">Last Updated</span>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<abbr
|
||||
class="cursor-pointer underline decoration-dotted decoration-border underline-offset-[6px]"
|
||||
>
|
||||
<span class="text-sm text-muted-foreground">{relativeLastFetch}</span>
|
||||
</abbr>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
class="bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md"
|
||||
>
|
||||
as of {lastFetch.toLocaleTimeString()}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex justify-center items-center gap-2 mt-3">
|
||||
{#if __APP_VERSION__}
|
||||
<span class="text-xs text-muted-foreground">v{__APP_VERSION__}</span>
|
||||
<div class="w-px h-3 bg-muted-foreground opacity-30"></div>
|
||||
{/if}
|
||||
<a
|
||||
href={hasResponse && statusState.mode === "response" && statusState.status.commit
|
||||
? `https://github.com/Xevion/banner/commit/${statusState.status.commit}`
|
||||
: "https://github.com/Xevion/banner"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-muted-foreground no-underline hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,6 +69,13 @@ body * {
|
||||
transition: background-color 300ms, color 300ms, border-color 300ms, fill 300ms;
|
||||
}
|
||||
|
||||
/* View Transitions API - disable default cross-fade so JS can animate clip-path */
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
Reference in New Issue
Block a user