mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 22:15:14 +00:00
* make leaderboards go vrooom * goog * Make leaderboards great again * Bit o' cleanup? * goog * goog * Greptile
311 lines
11 KiB
Svelte
311 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { Deferred, Link } from "@inertiajs/svelte";
|
|
import CountryFlag from "../../components/CountryFlag.svelte";
|
|
import Button from "../../components/Button.svelte";
|
|
import type {
|
|
LeaderboardMeta,
|
|
LeaderboardCountry,
|
|
LeaderboardEntriesPayload,
|
|
} from "../../types";
|
|
import {
|
|
secondsToDetailedDisplay,
|
|
timeAgo,
|
|
rankDisplay,
|
|
streakTheme,
|
|
streakLabel,
|
|
tabClass,
|
|
} from "./utils";
|
|
|
|
let {
|
|
period_type,
|
|
scope,
|
|
country,
|
|
leaderboard,
|
|
is_logged_in,
|
|
github_uid_blank,
|
|
github_auth_path,
|
|
settings_path,
|
|
entries,
|
|
}: {
|
|
period_type: string;
|
|
scope: string;
|
|
country: LeaderboardCountry;
|
|
leaderboard: LeaderboardMeta | null;
|
|
is_logged_in: boolean;
|
|
github_uid_blank: boolean;
|
|
github_auth_path: string;
|
|
settings_path: string;
|
|
entries?: LeaderboardEntriesPayload;
|
|
} = $props();
|
|
|
|
const dateRangeText = $derived(
|
|
leaderboard?.date_range_text ??
|
|
(period_type === "last_7_days"
|
|
? (() => {
|
|
const end = new Date();
|
|
const start = new Date(end);
|
|
start.setDate(start.getDate() - 6);
|
|
return `${start.toLocaleDateString("en-US", { month: "long", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}`;
|
|
})()
|
|
: new Date().toLocaleDateString("en-US", {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})),
|
|
);
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Leaderboards | Hackatime</title>
|
|
</svelte:head>
|
|
|
|
<div class="max-w-6xl mx-auto px-3 py-4 sm:p-6">
|
|
<div class="mb-8 space-y-4">
|
|
<h1 class="text-3xl font-bold text-surface-content">Leaderboards</h1>
|
|
|
|
<div
|
|
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
>
|
|
<!-- Scope tabs -->
|
|
<div
|
|
class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1"
|
|
>
|
|
<Link
|
|
href={`/leaderboards?period_type=${period_type}&scope=global`}
|
|
class={tabClass(scope === "global")}
|
|
preserveState
|
|
>
|
|
Global
|
|
</Link>
|
|
|
|
{#if country.available}
|
|
<Link
|
|
href={`/leaderboards?period_type=${period_type}&scope=country`}
|
|
class={`${tabClass(scope === "country")} inline-flex items-center justify-center gap-2`}
|
|
preserveState
|
|
>
|
|
<CountryFlag
|
|
countryCode={country.code}
|
|
class="inline-block w-5 h-5"
|
|
/>
|
|
<span class="max-w-48 truncate">{country.name}</span>
|
|
</Link>
|
|
{:else}
|
|
<span
|
|
class="text-center px-4 py-2 rounded-full text-sm font-medium text-muted/60 bg-darker cursor-not-allowed whitespace-nowrap"
|
|
>
|
|
Country
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Period tabs -->
|
|
<div
|
|
class="inline-flex max-w-full overflow-x-auto rounded-full bg-darkless p-1 gap-1"
|
|
>
|
|
<Link
|
|
href={`/leaderboards?period_type=daily&scope=${scope}`}
|
|
class={tabClass(period_type === "daily")}
|
|
preserveState
|
|
>
|
|
Last 24 Hours
|
|
</Link>
|
|
<Link
|
|
href={`/leaderboards?period_type=last_7_days&scope=${scope}`}
|
|
class={tabClass(period_type === "last_7_days")}
|
|
preserveState
|
|
>
|
|
Last 7 Days
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{#if is_logged_in && !country.available}
|
|
<p class="text-xs text-muted">
|
|
Set your country in
|
|
<Link
|
|
href={settings_path}
|
|
class="text-accent hover:text-cyan transition-colors">settings</Link
|
|
>
|
|
to unlock regional leaderboards.
|
|
</p>
|
|
{/if}
|
|
|
|
{#if github_uid_blank}
|
|
<div
|
|
class="bg-darker border border-primary rounded-lg p-4 flex flex-col sm:flex-row sm:items-center gap-3"
|
|
>
|
|
<span class="text-surface-content"
|
|
>Connect your GitHub to qualify for the leaderboard.</span
|
|
>
|
|
<Button href={github_auth_path} native size="md">Connect GitHub</Button>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="text-muted text-sm flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
{dateRangeText}
|
|
{#if leaderboard?.finished_generating && leaderboard?.updated_at}
|
|
<span class="italic">• Updated {timeAgo(leaderboard.updated_at)}.</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-elevated rounded-xl border border-primary overflow-hidden">
|
|
{#if leaderboard}
|
|
<Deferred data="entries">
|
|
{#snippet fallback()}
|
|
<div class="divide-y divide-gray-800">
|
|
{#each Array(20) as _}
|
|
<div class="flex items-center p-2 animate-pulse">
|
|
<div class="w-12 h-6 bg-darkless rounded shrink-0"></div>
|
|
<div class="w-8 h-8 bg-darkless rounded-full mx-4"></div>
|
|
<div class="flex-1">
|
|
<div class="h-4 w-32 bg-darkless rounded"></div>
|
|
</div>
|
|
<div class="h-4 w-16 bg-darkless rounded shrink-0"></div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet children()}
|
|
{#if entries && entries.total > 0}
|
|
<div class="divide-y divide-gray-800">
|
|
{#each entries.entries as entry, i}
|
|
{@const theme = streakTheme(entry.streak_count)}
|
|
<div
|
|
class="flex items-center p-2 sm:p-3 hover:bg-dark transition-colors duration-200 gap-2 sm:gap-0 {entry.is_current_user
|
|
? 'bg-dark border-l-4 border-l-primary'
|
|
: ''} {entry.user.red ? 'opacity-40 hover:opacity-60' : ''}"
|
|
>
|
|
<!-- Rank -->
|
|
<div
|
|
class="w-8 sm:w-12 shrink-0 text-center font-medium text-muted"
|
|
>
|
|
{#if i <= 2}
|
|
<span class="text-xl sm:text-2xl">{rankDisplay(i)}</span>
|
|
{:else}
|
|
<span class="text-base sm:text-lg">{i + 1}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- User info -->
|
|
<div class="flex-1 mx-1 sm:mx-4 min-w-0">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<div class="user-info flex items-center gap-2">
|
|
{#if entry.user.avatar_url}
|
|
<img
|
|
src={entry.user.avatar_url}
|
|
alt="{entry.user.display_name}'s avatar"
|
|
class="w-8 h-8 rounded-full aspect-square border border-surface-200"
|
|
loading="lazy"
|
|
/>
|
|
{/if}
|
|
<span class="inline-flex items-center gap-1">
|
|
{#if entry.user.profile_path}
|
|
<Link
|
|
href={entry.user.profile_path}
|
|
class="text-blue hover:underline"
|
|
>
|
|
{entry.user.display_name}
|
|
</Link>
|
|
{:else}
|
|
{entry.user.display_name}
|
|
{/if}
|
|
</span>
|
|
{#if entry.user.country_code}
|
|
<CountryFlag countryCode={entry.user.country_code} />
|
|
{/if}
|
|
</div>
|
|
|
|
{#if entry.active_project}
|
|
<span
|
|
class="text-xs italic text-muted truncate max-w-37.5 sm:max-w-none"
|
|
>
|
|
working on
|
|
<a
|
|
href={entry.active_project.repo_url}
|
|
target="_blank"
|
|
class="text-accent hover:text-cyan transition-colors"
|
|
>
|
|
{entry.active_project.name}
|
|
</a>
|
|
</span>
|
|
{/if}
|
|
|
|
{#if entry.streak_count > 0}
|
|
<div
|
|
class="inline-flex items-center gap-1 px-2 py-1 bg-linear-to-r {theme.bg} border {theme.bc} rounded-lg transition-all duration-200 {theme.hbg} group"
|
|
title={entry.streak_count > 30
|
|
? "30+ daily streak"
|
|
: `${entry.streak_count} day streak`}
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
class="{theme.ic} transition-colors duration-200 group-hover:animate-pulse"
|
|
>
|
|
<path
|
|
fill="currentColor"
|
|
d="M10 2c0-.88 1.056-1.331 1.692-.722c1.958 1.876 3.096 5.995 1.75 9.12l-.08.174l.012.003c.625.133 1.203-.43 2.303-2.173l.14-.224a1 1 0 0 1 1.582-.153C18.733 9.46 20 12.402 20 14.295C20 18.56 16.409 22 12 22s-8-3.44-8-7.706c0-2.252 1.022-4.716 2.632-6.301l.605-.589c.241-.236.434-.43.618-.624C9.285 5.268 10 3.856 10 2"
|
|
/>
|
|
</svg>
|
|
<span
|
|
class="text-md font-semibold {theme.tc} transition-colors duration-200"
|
|
>
|
|
{streakLabel(entry.streak_count)}
|
|
</span>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Duration -->
|
|
<div
|
|
class="shrink-0 font-mono text-xs sm:text-sm text-surface-content font-medium whitespace-nowrap"
|
|
>
|
|
{secondsToDetailedDisplay(entry.total_seconds)}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if leaderboard?.finished_generating && leaderboard?.generation_duration_seconds != null}
|
|
<div
|
|
class="px-4 py-2 text-xs italic text-muted border-t border-primary"
|
|
>
|
|
Generated in {leaderboard.generation_duration_seconds} seconds
|
|
</div>
|
|
{/if}
|
|
{:else}
|
|
<div class="py-16 text-center px-3">
|
|
<h3 class="text-xl font-medium text-surface-content mb-2">
|
|
No data available
|
|
</h3>
|
|
<p class="text-muted">
|
|
Check back later for {period_type === "last_7_days"
|
|
? "last 7 days"
|
|
: "last 24 hours"} results!
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
</Deferred>
|
|
{:else}
|
|
<div class="py-16 text-center px-3">
|
|
<h3 class="text-xl font-medium text-surface-content mb-2">
|
|
Leaderboard is being generated...
|
|
</h3>
|
|
<p class="text-muted">
|
|
Check back in a moment for {scope === "country" && country.name
|
|
? `${country.name} `
|
|
: ""}{period_type === "last_7_days"
|
|
? "last 7 days"
|
|
: "last 24 hours"} results!
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|