mirror of
https://github.com/Xevion/banner.git
synced 2026-01-31 08:23:35 -06:00
refactor: consolidate query logic and eliminate N+1 instructor loads
This commit is contained in:
+1
-18
@@ -11,23 +11,6 @@ describe("BannerApiClient", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch health data", async () => {
|
||||
const mockHealth = {
|
||||
status: "healthy",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockHealth),
|
||||
} as Response);
|
||||
|
||||
const result = await apiClient.getHealth();
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/health");
|
||||
expect(result).toEqual(mockHealth);
|
||||
});
|
||||
|
||||
it("should fetch status data", async () => {
|
||||
const mockStatus = {
|
||||
status: "active" as const,
|
||||
@@ -57,7 +40,7 @@ describe("BannerApiClient", () => {
|
||||
statusText: "Internal Server Error",
|
||||
} as Response);
|
||||
|
||||
await expect(apiClient.getHealth()).rejects.toThrow(
|
||||
await expect(apiClient.getStatus()).rejects.toThrow(
|
||||
"API request failed: 500 Internal Server Error"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,19 +30,6 @@ export type ReferenceEntry = CodeDescription;
|
||||
// SearchResponse re-exported (aliased to strip the "Generated" suffix)
|
||||
export type SearchResponse = SearchResponseGenerated;
|
||||
|
||||
// Health/metrics endpoints return ad-hoc JSON — keep manual types
|
||||
export interface HealthResponse {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface MetricsResponse {
|
||||
banner_api: {
|
||||
status: string;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Client-side only — not generated from Rust
|
||||
export type SortColumn = "course_code" | "title" | "instructor" | "time" | "seats";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
@@ -77,18 +64,10 @@ export class BannerApiClient {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async getHealth(): Promise<HealthResponse> {
|
||||
return this.request<HealthResponse>("/health");
|
||||
}
|
||||
|
||||
async getStatus(): Promise<StatusResponse> {
|
||||
return this.request<StatusResponse>("/status");
|
||||
}
|
||||
|
||||
async getMetrics(): Promise<MetricsResponse> {
|
||||
return this.request<MetricsResponse>("/metrics");
|
||||
}
|
||||
|
||||
async searchCourses(params: SearchParams): Promise<SearchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("term", params.term);
|
||||
|
||||
@@ -7,27 +7,17 @@ import {
|
||||
formatMeetingDaysLong,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
ratingColor,
|
||||
} from "$lib/course";
|
||||
import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
||||
import { cn, tooltipContentClass } from "$lib/utils";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import SimpleTooltip from "./SimpleTooltip.svelte";
|
||||
import { Info, Copy, Check } from "@lucide/svelte";
|
||||
|
||||
let { course }: { course: CourseResponse } = $props();
|
||||
|
||||
let copiedEmail: string | null = $state(null);
|
||||
|
||||
async function copyEmail(email: string, event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(email);
|
||||
copiedEmail = email;
|
||||
setTimeout(() => {
|
||||
copiedEmail = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy email:", err);
|
||||
}
|
||||
}
|
||||
const clipboard = useClipboard();
|
||||
</script>
|
||||
|
||||
<div class="bg-muted/60 p-5 text-sm border-b border-border">
|
||||
@@ -49,14 +39,14 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
{#if instructor.rmpRating != null}
|
||||
{@const rating = instructor.rmpRating}
|
||||
<span
|
||||
class="text-[10px] font-semibold {rating >= 4.0 ? 'text-status-green' : rating >= 3.0 ? 'text-yellow-500' : 'text-status-red'}"
|
||||
class="text-[10px] font-semibold {ratingColor(rating)}"
|
||||
>{rating.toFixed(1)}★</span>
|
||||
{/if}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
sideOffset={6}
|
||||
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-3 py-2 shadow-md max-w-72"
|
||||
class={cn(tooltipContentClass, "px-3 py-2")}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<div class="font-medium">{instructor.displayName}</div>
|
||||
@@ -70,10 +60,10 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
{/if}
|
||||
{#if instructor.email}
|
||||
<button
|
||||
onclick={(e) => copyEmail(instructor.email!, e)}
|
||||
onclick={(e) => clipboard.copy(instructor.email!, e)}
|
||||
class="inline-flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||
>
|
||||
{#if copiedEmail === instructor.email}
|
||||
{#if clipboard.copiedValue === instructor.email}
|
||||
<Check class="size-3" />
|
||||
<span>Copied!</span>
|
||||
{:else}
|
||||
@@ -212,7 +202,7 @@ async function copyEmail(email: string, event: MouseEvent) {
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content
|
||||
sideOffset={6}
|
||||
class="z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72"
|
||||
class={tooltipContentClass}
|
||||
>
|
||||
Group <span class="font-mono font-medium">{course.crossList}</span>
|
||||
{#if course.crossListCount != null && course.crossListCapacity != null}
|
||||
|
||||
@@ -12,13 +12,16 @@ import {
|
||||
getPrimaryInstructor,
|
||||
isMeetingTimeTBA,
|
||||
isTimeTBA,
|
||||
openSeats,
|
||||
seatsColor,
|
||||
seatsDotColor,
|
||||
ratingColor,
|
||||
} from "$lib/course";
|
||||
import { useClipboard } from "$lib/composables/useClipboard.svelte";
|
||||
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
||||
import CourseDetail from "./CourseDetail.svelte";
|
||||
import { fade, fly, slide } from "svelte/transition";
|
||||
import { flip } from "svelte/animate";
|
||||
import { onMount } from "svelte";
|
||||
import { OverlayScrollbars } from "overlayscrollbars";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
@@ -50,8 +53,7 @@ let {
|
||||
|
||||
let expandedCrn: string | null = $state(null);
|
||||
let tableWrapper: HTMLDivElement = undefined!;
|
||||
let copiedCrn: string | null = $state(null);
|
||||
let copyTimeoutId: number | undefined;
|
||||
const clipboard = useClipboard(1000);
|
||||
|
||||
// Collapse expanded row when the dataset changes to avoid stale detail rows
|
||||
// and FLIP position calculation glitches from lingering expanded content
|
||||
@@ -60,30 +62,9 @@ $effect(() => {
|
||||
expandedCrn = null;
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
const osInstance = OverlayScrollbars(tableWrapper, {
|
||||
overflow: { x: "scroll", y: "hidden" },
|
||||
scrollbars: {
|
||||
autoHide: "never",
|
||||
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
|
||||
},
|
||||
});
|
||||
|
||||
// React to theme changes
|
||||
const unwatch = $effect.root(() => {
|
||||
$effect(() => {
|
||||
osInstance.options({
|
||||
scrollbars: {
|
||||
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unwatch();
|
||||
osInstance.destroy();
|
||||
};
|
||||
useOverlayScrollbars(() => tableWrapper, {
|
||||
overflow: { x: "scroll", y: "hidden" },
|
||||
scrollbars: { autoHide: "never" },
|
||||
});
|
||||
|
||||
// Column visibility state
|
||||
@@ -104,63 +85,12 @@ function toggleRow(crn: string) {
|
||||
expandedCrn = expandedCrn === crn ? null : crn;
|
||||
}
|
||||
|
||||
async function handleCopyCrn(event: MouseEvent | KeyboardEvent, crn: string) {
|
||||
event.stopPropagation();
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(crn);
|
||||
|
||||
if (copyTimeoutId !== undefined) {
|
||||
clearTimeout(copyTimeoutId);
|
||||
}
|
||||
|
||||
copiedCrn = crn;
|
||||
copyTimeoutId = window.setTimeout(() => {
|
||||
copiedCrn = null;
|
||||
copyTimeoutId = undefined;
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy CRN:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCrnKeydown(event: KeyboardEvent, crn: string) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleCopyCrn(event, crn);
|
||||
}
|
||||
}
|
||||
|
||||
function openSeats(course: CourseResponse): number {
|
||||
return Math.max(0, course.maxEnrollment - course.enrollment);
|
||||
}
|
||||
|
||||
function seatsColor(course: CourseResponse): string {
|
||||
const open = openSeats(course);
|
||||
if (open === 0) return "text-status-red";
|
||||
if (open <= 5) return "text-yellow-500";
|
||||
return "text-status-green";
|
||||
}
|
||||
|
||||
function seatsDotColor(course: CourseResponse): string {
|
||||
const open = openSeats(course);
|
||||
if (open === 0) return "bg-red-500";
|
||||
if (open <= 5) return "bg-yellow-500";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
function primaryInstructorDisplay(course: CourseResponse): string {
|
||||
const primary = getPrimaryInstructor(course.instructors);
|
||||
if (!primary) return "Staff";
|
||||
return abbreviateInstructor(primary.displayName);
|
||||
}
|
||||
|
||||
function ratingColor(rating: number): string {
|
||||
if (rating >= 4.0) return "text-status-green";
|
||||
if (rating >= 3.0) return "text-yellow-500";
|
||||
return "text-status-red";
|
||||
}
|
||||
|
||||
function primaryRating(course: CourseResponse): { rating: number; count: number } | null {
|
||||
const primary = getPrimaryInstructor(course.instructors);
|
||||
if (!primary?.rmpRating) return null;
|
||||
@@ -470,19 +400,20 @@ const table = createSvelteTable({
|
||||
<button
|
||||
class="relative inline-flex items-center rounded-full px-2 py-0.5 border border-border/50 bg-muted/20 hover:bg-muted/40 hover:border-foreground/30 transition-colors duration-150 cursor-copy focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring font-mono text-xs text-muted-foreground/70"
|
||||
onclick={(e) =>
|
||||
handleCopyCrn(
|
||||
e,
|
||||
clipboard.copy(
|
||||
course.crn,
|
||||
)}
|
||||
onkeydown={(e) =>
|
||||
handleCrnKeydown(
|
||||
e,
|
||||
course.crn,
|
||||
)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
clipboard.copy(course.crn, e);
|
||||
}
|
||||
}}
|
||||
aria-label="Copy CRN {course.crn} to clipboard"
|
||||
>
|
||||
{course.crn}
|
||||
{#if copiedCrn === course.crn}
|
||||
{#if clipboard.copiedValue === course.crn}
|
||||
<span
|
||||
class="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap text-xs px-2 py-1 rounded-md bg-green-500/10 border border-green-500/20 text-green-700 dark:text-green-300 pointer-events-none z-10"
|
||||
in:fade={{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { cn } from "$lib/utils";
|
||||
|
||||
let {
|
||||
commitHash,
|
||||
showStatusLink = true,
|
||||
class: className,
|
||||
}: {
|
||||
commitHash?: string | null;
|
||||
showStatusLink?: boolean;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn("flex justify-center items-center gap-2 mt-auto pt-6 pb-4", className)}>
|
||||
{#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={commitHash
|
||||
? `https://github.com/Xevion/banner/commit/${commitHash}`
|
||||
: "https://github.com/Xevion/banner"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-xs text-muted-foreground no-underline hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
{#if showStatusLink}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Reactive clipboard copy with automatic "copied" state reset.
|
||||
*
|
||||
* Returns a `copiedValue` that is non-null while the copied feedback
|
||||
* should be displayed, and a `copy()` function to trigger a copy.
|
||||
*/
|
||||
export function useClipboard(resetMs = 2000) {
|
||||
let copiedValue = $state<string | null>(null);
|
||||
let timeoutId: number | undefined;
|
||||
|
||||
async function copy(text: string, event?: MouseEvent | KeyboardEvent) {
|
||||
event?.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
clearTimeout(timeoutId);
|
||||
copiedValue = text;
|
||||
timeoutId = window.setTimeout(() => {
|
||||
copiedValue = null;
|
||||
timeoutId = undefined;
|
||||
}, resetMs);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy to clipboard:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get copiedValue() {
|
||||
return copiedValue;
|
||||
},
|
||||
copy,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { onMount } from "svelte";
|
||||
import { OverlayScrollbars, type PartialOptions } from "overlayscrollbars";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
|
||||
/**
|
||||
* Set up OverlayScrollbars on an element with automatic theme reactivity.
|
||||
*
|
||||
* Must be called during component initialization (uses `onMount` internally).
|
||||
* The scrollbar theme automatically syncs with `themeStore.isDark`.
|
||||
*/
|
||||
export function useOverlayScrollbars(getElement: () => HTMLElement, options: PartialOptions = {}) {
|
||||
onMount(() => {
|
||||
const element = getElement();
|
||||
const osInstance = OverlayScrollbars(element, {
|
||||
...options,
|
||||
scrollbars: {
|
||||
...options.scrollbars,
|
||||
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
|
||||
},
|
||||
});
|
||||
|
||||
const unwatch = $effect.root(() => {
|
||||
$effect(() => {
|
||||
osInstance.options({
|
||||
scrollbars: {
|
||||
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unwatch();
|
||||
osInstance.destroy();
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -341,6 +341,34 @@ export function formatLocationTooltip(course: CourseResponse): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Number of open seats in a course section */
|
||||
export function openSeats(course: CourseResponse): number {
|
||||
return Math.max(0, course.maxEnrollment - course.enrollment);
|
||||
}
|
||||
|
||||
/** Text color class for seat availability: red (full), yellow (low), green (open) */
|
||||
export function seatsColor(course: CourseResponse): string {
|
||||
const open = openSeats(course);
|
||||
if (open === 0) return "text-status-red";
|
||||
if (open <= 5) return "text-yellow-500";
|
||||
return "text-status-green";
|
||||
}
|
||||
|
||||
/** Background dot color class for seat availability */
|
||||
export function seatsDotColor(course: CourseResponse): string {
|
||||
const open = openSeats(course);
|
||||
if (open === 0) return "bg-red-500";
|
||||
if (open <= 5) return "bg-yellow-500";
|
||||
return "bg-green-500";
|
||||
}
|
||||
|
||||
/** Text color class for a RateMyProfessors rating */
|
||||
export function ratingColor(rating: number): string {
|
||||
if (rating >= 4.0) return "text-status-green";
|
||||
if (rating >= 3.0) return "text-yellow-500";
|
||||
return "text-status-red";
|
||||
}
|
||||
|
||||
/** Format credit hours display */
|
||||
export function formatCreditHours(course: CourseResponse): string {
|
||||
if (course.creditHours != null) return String(course.creditHours);
|
||||
|
||||
@@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Shared tooltip content styling for bits-ui Tooltip.Content */
|
||||
export const tooltipContentClass =
|
||||
"z-50 bg-card text-card-foreground text-xs border border-border rounded-md px-2.5 py-1.5 shadow-md max-w-72";
|
||||
|
||||
@@ -2,44 +2,26 @@
|
||||
import "overlayscrollbars/overlayscrollbars.css";
|
||||
import "./layout.css";
|
||||
import { onMount } from "svelte";
|
||||
import { OverlayScrollbars } from "overlayscrollbars";
|
||||
import { Tooltip } from "bits-ui";
|
||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte";
|
||||
import { themeStore } from "$lib/stores/theme.svelte";
|
||||
import { useOverlayScrollbars } from "$lib/composables/useOverlayScrollbars.svelte";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
useOverlayScrollbars(() => document.body, {
|
||||
scrollbars: {
|
||||
autoHide: "leave",
|
||||
autoHideDelay: 800,
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
themeStore.init();
|
||||
|
||||
// Enable theme transitions now that the page has rendered with the correct theme.
|
||||
// Without this delay, the initial paint would animate from light to dark colors.
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.classList.remove("no-transition");
|
||||
});
|
||||
|
||||
const osInstance = OverlayScrollbars(document.body, {
|
||||
scrollbars: {
|
||||
autoHide: "leave",
|
||||
autoHideDelay: 800,
|
||||
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
|
||||
},
|
||||
});
|
||||
|
||||
const unwatch = $effect.root(() => {
|
||||
$effect(() => {
|
||||
osInstance.options({
|
||||
scrollbars: {
|
||||
theme: themeStore.isDark ? "os-theme-dark" : "os-theme-light",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unwatch();
|
||||
osInstance.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { SortingState } from "@tanstack/table-core";
|
||||
import SearchFilters from "$lib/components/SearchFilters.svelte";
|
||||
import CourseTable from "$lib/components/CourseTable.svelte";
|
||||
import Pagination from "$lib/components/Pagination.svelte";
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -240,23 +241,6 @@ function handlePageChange(newOffset: number) {
|
||||
{/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>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
XCircle,
|
||||
} from "@lucide/svelte";
|
||||
import SimpleTooltip from "$lib/components/SimpleTooltip.svelte";
|
||||
import Footer from "$lib/components/Footer.svelte";
|
||||
import { type ServiceStatus, type ServiceInfo, type StatusResponse, client } from "$lib/api";
|
||||
import { relativeTime } from "$lib/time";
|
||||
|
||||
@@ -61,7 +62,6 @@ 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(
|
||||
@@ -304,20 +304,9 @@ onMount(() => {
|
||||
</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>
|
||||
<Footer
|
||||
commitHash={statusState.mode === "response" ? statusState.status.commit : undefined}
|
||||
showStatusLink={false}
|
||||
class="mt-3 pt-0 pb-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user