Skip to content
Merged
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
182 changes: 140 additions & 42 deletions src/routes/logs/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { ScrollArea } from "$lib/components/ui/scroll-area/index.js";
import { Input } from "$lib/components/ui/input/index.js";
import { Label } from "$lib/components/ui/label/index.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";

import { cn } from "$lib/utils.js";
import { CalendarDate, DateFormatter, getLocalTimeZone, today, type DateValue } from "@internationalized/date";
Expand All @@ -37,7 +38,7 @@
import { page } from "$app/state";
import { goto } from "$app/navigation";

import { LoaderCircleIcon, FileTextIcon, ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon, CalendarIcon, ExternalLinkIcon, FilterIcon, SearchIcon } from "@lucide/svelte";
import { LoaderCircleIcon, FileTextIcon, ArrowDownWideNarrowIcon, ArrowUpNarrowWideIcon, CalendarIcon, ExternalLinkIcon, FilterIcon, SearchIcon, ChartColumnIcon } from "@lucide/svelte";

import { dateTimeFormat, type TitleContext } from "$lib/common";

Expand All @@ -52,14 +53,19 @@
day?: string;
};

type StatsResponse = {
messageCount: number;
topChatters?: Array<{ userId: string; userLogin: string; messageCount: number }>;
};

getContext<TitleContext>("title").set("Logs");

const lineHeight = 20;

let error: string | null = $state(null);
let loading = $state(false);

let isPopoverOpen = $state(false);
let datePopoverOpen = $state(false);

let selectedIndex = $state(0); // Track selected item

Expand Down Expand Up @@ -196,6 +202,11 @@

let channelId = $state("");

// Channel Stats
let statsPopoverOpen = $state(false);
let channelStats = $state<StatsResponse | null>(null);
let statsError = $state<string | null>(null);

// Emotes
const channelEmotes = new SvelteMap<string, EmoteProps>();
const globalEmotes = new SvelteMap<string, EmoteProps>();
Expand Down Expand Up @@ -386,12 +397,38 @@
else return `${channelType}/${encodeURIComponent(channel)}${user ? `/${userType}/${encodeURIComponent(user)}` : ""}`;
};

const fetchChannelStats = async () => {
if (!channelName) return;

statsError = null;

try {
const res = await fetch(`https://logs.zonian.dev/${parseChannelUser(channelName, userName, false)}/stats`);
if (!res.ok) {
if (res.status === 404) statsError = "No stats found for this channel";
else statsError = `Error from server: ${res.status} ${res.statusText}`;
return;
}

channelStats = await res.json();
} catch (err) {
statsError = err instanceof Error ? err.message : "Failed to fetch channel stats";
}
};

$effect(() => {
if (statsPopoverOpen && !channelStats) {
fetchChannelStats();
}
});

$effect(() => {
// fetch available dates
if (!channelName) return;
untrack(async () => {
availableDates = [];
chatLogs = [];
channelStats = null;
loading = true;

const res = await fetch(`https://logs.zonian.dev/list?${parseChannelUser(channelName, userName, true)}`);
Expand Down Expand Up @@ -784,55 +821,116 @@
{/if}
</div>

<div class="my-2 flex min-h-0 flex-row justify-between">
<form class="relative flex gap-2 align-middle" onsubmit={formSubmit}>
<div class="flex gap-2">
<div class="relative flex w-1/2 flex-col">
<Label for="input-channel" class="text-base">
Channel<span class="text-red-500">*</span>
</Label>
<Input id="input-channel" maxlength={25} bind:value={inputChannelName} placeholder="channel or id:123" onkeydown={channelKeydown} autocomplete="off" autofocus />

{#if foundChannels.length && foundChannels[0].target !== inputChannelName.toLowerCase()}
<div class="absolute left-0 right-0 top-full z-10 mt-1">
<ScrollArea class="flex-1 rounded-md">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each foundChannels as c, index (c.target)}
<div class="my-2 flex min-h-0 flex-row justify-between gap-2">
<form class="relative flex w-full gap-2 align-middle" onsubmit={formSubmit}>
<div class="flex flex-1">
<div class="flex max-w-xl gap-2">
<div class="relative flex flex-col">
<Label for="input-channel" class="text-base">
Channel<span class="text-red-500">*</span>
</Label>
<Input id="input-channel" maxlength={25} bind:value={inputChannelName} placeholder="channel or id:123" onkeydown={channelKeydown} autocomplete="off" autofocus />

{#if foundChannels.length && foundChannels[0].target !== inputChannelName.toLowerCase()}
<div class="absolute left-0 right-0 top-full z-10 mt-1">
<ScrollArea class="flex-1 rounded-md">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex h-8 items-center text-sm hover:cursor-pointer
<!-- svelte-ignore a11y_no_static_element_interactions -->
{#each foundChannels as c, index (c.target)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="flex h-8 items-center text-sm hover:cursor-pointer
{index === selectedIndex ? 'bg-zinc-200 dark:bg-zinc-800' : 'bg-zinc-100 dark:bg-zinc-900'}"
onmouseenter={() => (selectedIndex = index)}
onclick={() => selectResult(index)}
>
<span class="mx-3">{c.target}</span>
</div>
{/each}
</ScrollArea>
</div>
{/if}
</div>
onmouseenter={() => (selectedIndex = index)}
onclick={() => selectResult(index)}
>
<span class="mx-3">{c.target}</span>
</div>
{/each}
</ScrollArea>
</div>
{/if}
</div>

<div class="flex w-1/2 flex-col">
<Label for="input-user" class="text-base">User</Label>
<Input id="input-user" maxlength={25} bind:value={inputUserName} placeholder="username or id:123" />
</div>
<div class="flex flex-col">
<Label for="input-user" class="text-base">User</Label>
<Input id="input-user" maxlength={25} bind:value={inputUserName} placeholder="username or id:123" />
</div>

<div class="flex flex-row items-center gap-1 self-end">
<Button type="submit" id="load-btn" class="sticky" disabled={loading}>Load</Button>
{#if loading}
<LoaderCircleIcon class="size-8 animate-spin" />
{/if}
<div class="flex flex-row items-center gap-1 self-end">
<Button type="submit" id="load-btn" class="sticky" disabled={loading}>Load</Button>
{#if loading}
<LoaderCircleIcon class="size-8 animate-spin" />
{/if}
</div>
</div>
</div>
</form>

<div class="flex self-end">
<Popover.Root bind:open={statsPopoverOpen}>
<Popover.Trigger class={cn(buttonVariants({ variant: "secondary" }), [!chatLogs.length && "hidden"])} title="Channel Stats" aria-label="Channel Stats">
<ChartColumnIcon />
<span class="hidden md:block">Stats</span>
</Popover.Trigger>
<Popover.Content class="w-80 max-w-[90vw] rounded-md border-0 p-0" align="end" sideOffset={8}>
<Card.Root class="rounded-md">
<Card.Content>
{#if statsError}
<p class="text-red-500">{statsError}</p>
{:else}
<div class="space-y-4">
<div>
<h3 class="text-lg font-semibold">
Total Messages
{#if userName}
<span class="font-light text-muted-foreground">by <span class="font-semibold">{userName}</span></span>
{/if}
</h3>
{#if channelStats}
<p class="text-2xl font-bold">{channelStats.messageCount.toLocaleString()}</p>
{:else}
<Skeleton class="h-8 w-28" />
{/if}
</div>

{#if !userName}
<div>
<h3 class="mb-2 text-lg font-semibold">Top Chatters</h3>
<div class="space-y-2">
{#if channelStats?.topChatters}
{#each channelStats.topChatters as chatter, index (chatter.userId)}
<div class="flex items-center justify-between border-b pb-2 last:border-0">
<div class="flex items-center gap-3">
<span class="w-8 text-right tabular-nums text-muted-foreground">{index + 1}.</span>
<span class="font-medium">{chatter.userLogin}</span>
</div>
<span class="tabular-nums text-muted-foreground">{chatter.messageCount.toLocaleString()}</span>
</div>
{/each}
{:else}
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
<Skeleton class="h-8 w-full" />
{/if}
</div>
</div>
{/if}
</div>
{/if}
</Card.Content>
</Card.Root>
</Popover.Content>
</Popover.Root>
</div>
</div>

<div class="mb-1 flex flex-row flex-wrap-reverse justify-between gap-1">
{#if dateContent}
{#if dateContent.day}
<Popover.Root bind:open={isPopoverOpen}>
<Popover.Root bind:open={datePopoverOpen}>
<Popover.Trigger
disabled={loading}
class={cn(
Expand All @@ -846,7 +944,7 @@
<CalendarIcon class="opacity-50" />
</Popover.Trigger>

<Popover.Content bind:ref={contentRef} class="w-auto p-0" align="start">
<Popover.Content bind:ref={contentRef} class="w-auto border-0 p-0" align="start">
<CalendarPrimitive.Root
type="single"
weekdayFormat="short"
Expand Down Expand Up @@ -931,7 +1029,7 @@
</Popover.Root>
{:else}
<div class="flex flex-row">
<Select.Root type="single" name="input-date" bind:open={isPopoverOpen} bind:value={dateValue} disabled={loading}>
<Select.Root type="single" name="input-date" bind:open={datePopoverOpen} bind:value={dateValue} disabled={loading}>
<Select.Trigger class="h-8 w-32 tabular-nums">
{dateContent.year}-{String(dateContent.month).padStart(2, "0")}{dateContent.day ? `-${String(dateContent.day).padStart(2, "0")}` : ""}
</Select.Trigger>
Expand Down Expand Up @@ -1065,7 +1163,7 @@
{/if}
</div>

{#if isPopoverOpen}
{#if datePopoverOpen || statsPopoverOpen}
<FocusTrap />
{/if}

Expand Down