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:
2026-01-28 22:11:17 -06:00
parent 15256ff91c
commit 5fab8c216a
26 changed files with 1360 additions and 401 deletions
+6 -12
View File
@@ -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
View File
@@ -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>
+8
View File
@@ -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 };
};
+327
View File
@@ -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>
+7
View File
@@ -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% {