Add hobby stats and graphs to btop
This commit is contained in:
1 parent
4a52320d4e
commit
5f5d24cb2d
17 files changed
+571
-13
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import type { Book, RatedTitle } from '#lib/content';
|
||||
import type { AniListProfile } from '#lib/anilist/types';
|
||||
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||
import type { AniListStats, BookStats, CountStat, ListeningStats, ScreenStats } from './types';
|
||||
|
||||
const recentDays = 30;
|
||||
const topLimit = 5;
|
||||
const statusColors: Record<string, string> = {
|
||||
CURRENT: 'blue',
|
||||
REPEATING: 'cyan',
|
||||
COMPLETED: 'green',
|
||||
PAUSED: 'yellow',
|
||||
DROPPED: 'red',
|
||||
PLANNING: 'magenta'
|
||||
};
|
||||
|
||||
function average(values: number[]): string {
|
||||
if (!values.length) return '-';
|
||||
return `${(values.reduce((total, value) => total + value, 0) / values.length).toFixed(1)}/10`;
|
||||
}
|
||||
|
||||
function ratingCounts(ratings: number[]): number[] {
|
||||
const counts = Array<number>(11).fill(0);
|
||||
ratings.forEach((rating) => counts[Math.round(rating)]++);
|
||||
return counts;
|
||||
}
|
||||
|
||||
function topCounts(values: string[], color: string): CountStat[] {
|
||||
const counts = new Map<string, number>();
|
||||
values.forEach((value) => counts.set(value, (counts.get(value) ?? 0) + 1));
|
||||
return [...counts.entries()]
|
||||
.sort((first, second) => second[1] - first[1])
|
||||
.slice(0, topLimit)
|
||||
.map(([label, count]) => ({ label, count, color }));
|
||||
}
|
||||
|
||||
export function bookStats(books: Book[]): BookStats | null {
|
||||
if (!books.length) return null;
|
||||
const read = books.filter((book) => book.shelf === 'read');
|
||||
const ratings = read.flatMap((book) => (book.rating === undefined ? [] : [book.rating]));
|
||||
const seriesNames = new Set(books.flatMap((book) => (book.series ? [book.series.name] : [])));
|
||||
const pagesRead = read.reduce((total, book) => total + (book.pages ?? 0), 0);
|
||||
|
||||
return {
|
||||
shelves: [
|
||||
{ label: 'read', count: read.length, color: 'green' },
|
||||
{ label: 'reading', count: books.filter((book) => book.shelf === 'reading').length, color: 'blue' },
|
||||
{ label: 'want to read', count: books.filter((book) => book.shelf === 'want-to-read').length, color: 'magenta' }
|
||||
],
|
||||
rows: [
|
||||
{ label: 'Pages read', value: pagesRead.toLocaleString('en-US') },
|
||||
{ label: 'Authors', value: `${new Set(read.flatMap((book) => book.authors)).size}` },
|
||||
{ label: 'Series', value: `${seriesNames.size}` },
|
||||
{ label: 'Avg rating', value: average(ratings) }
|
||||
],
|
||||
topGenres: topCounts(read.flatMap((book) => book.genres), 'cyan'),
|
||||
ratingCounts: ratingCounts(ratings)
|
||||
};
|
||||
}
|
||||
|
||||
export function screenStats(titles: RatedTitle[]): ScreenStats {
|
||||
const ratings = titles.flatMap((title) => (title.rating === undefined ? [] : [title.rating]));
|
||||
const watching = titles.filter((title) => title.status === 'watching').map((title) => title.name);
|
||||
const groups = new Set(titles.flatMap((title) => (title.collection ? [title.collection] : [])));
|
||||
|
||||
return {
|
||||
rows: [
|
||||
{ label: 'Titles', value: `${titles.length}` },
|
||||
{ label: 'Watching', value: `${watching.length}` },
|
||||
{ label: 'Groups', value: `${groups.size}` },
|
||||
{ label: 'Avg rating', value: average(ratings) }
|
||||
],
|
||||
ratingCounts: ratingCounts(ratings),
|
||||
watching
|
||||
};
|
||||
}
|
||||
|
||||
export function listeningStats(profile: ListeningProfile): ListeningStats {
|
||||
const artists = profile.top.month.artists.length ? profile.top.month.artists : profile.top.all.artists;
|
||||
return {
|
||||
profileUrl: profile.profileUrl,
|
||||
rows: [
|
||||
{ label: 'Scrobbles', value: profile.totals.scrobbles.toLocaleString('en-US') },
|
||||
{ label: 'Artists', value: profile.totals.artists.toLocaleString('en-US') },
|
||||
{ label: 'Per day', value: `${profile.averagePerDay}` },
|
||||
{ label: 'Streak', value: `${profile.currentStreak}d (best ${profile.longestStreak}d)` }
|
||||
],
|
||||
daily: profile.daily.slice(-recentDays).map((bucket) => bucket.count),
|
||||
hourly: profile.hourly.map((bucket) => bucket.count),
|
||||
topArtists: artists.slice(0, topLimit).map((artist) => ({ label: artist.name, count: artist.count, color: 'red' }))
|
||||
};
|
||||
}
|
||||
|
||||
export function aniListStats(profile: AniListProfile): AniListStats {
|
||||
return {
|
||||
profileUrl: profile.listUrl,
|
||||
rows: profile.stats,
|
||||
statuses: profile.statusCounts
|
||||
.filter((status) => status.count > 0)
|
||||
.map((status) => ({ label: status.label, count: status.count, color: statusColors[status.status] ?? 'foreground' }))
|
||||
};
|
||||
}
|
||||
@@ -38,3 +38,50 @@ export interface BtopData {
|
||||
contributions: ContributionCalendar;
|
||||
repos: RepoProcess[];
|
||||
}
|
||||
|
||||
export interface CountStat {
|
||||
label: string;
|
||||
count: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface StatRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface BookStats {
|
||||
shelves: CountStat[];
|
||||
rows: StatRow[];
|
||||
topGenres: CountStat[];
|
||||
ratingCounts: number[];
|
||||
}
|
||||
|
||||
export interface ScreenStats {
|
||||
rows: StatRow[];
|
||||
ratingCounts: number[];
|
||||
watching: string[];
|
||||
}
|
||||
|
||||
export interface ListeningStats {
|
||||
profileUrl: string;
|
||||
rows: StatRow[];
|
||||
daily: number[];
|
||||
hourly: number[];
|
||||
topArtists: CountStat[];
|
||||
}
|
||||
|
||||
export interface AniListStats {
|
||||
profileUrl: string;
|
||||
rows: StatRow[];
|
||||
statuses: CountStat[];
|
||||
}
|
||||
|
||||
export interface HobbyStats {
|
||||
listening: ListeningStats | null;
|
||||
books: BookStats | null;
|
||||
shows: ScreenStats;
|
||||
movies: ScreenStats;
|
||||
anime: AniListStats | null;
|
||||
manga: AniListStats | null;
|
||||
}
|
||||
@@ -5,11 +5,12 @@
|
||||
title,
|
||||
number,
|
||||
color = 'accent',
|
||||
tall = false,
|
||||
children
|
||||
}: { title: string; number: number; color?: string; children: Snippet } = $props();
|
||||
}: { title: string; number: number; color?: string; tall?: boolean; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<section class="btop-box" style:--box-color="var(--{color})">
|
||||
<section class="btop-box" class:tall style:--box-color="var(--{color})">
|
||||
<span class="box-title"><sup>{number}</sup>{title}</span>
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
@@ -52,9 +52,10 @@
|
||||
|
||||
.graph {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 70px;
|
||||
min-height: 70px;
|
||||
margin-top: 0.4rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { HobbyStats } from '#lib/btop/types';
|
||||
import ListeningPanel from './hobbies/ListeningPanel.svelte';
|
||||
import BooksPanel from './hobbies/BooksPanel.svelte';
|
||||
import ScreenPanel from './hobbies/ScreenPanel.svelte';
|
||||
import AniListPanel from './hobbies/AniListPanel.svelte';
|
||||
|
||||
let stats = $state<HobbyStats | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
fetch('/api/btop/hobbies')
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((result) => (stats = result))
|
||||
.catch(() => (stats = null));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if stats}
|
||||
<div class="hobby-panels">
|
||||
{#if stats.listening}
|
||||
<ListeningPanel stats={stats.listening} number={5} />
|
||||
{/if}
|
||||
{#if stats.books}
|
||||
<BooksPanel stats={stats.books} number={6} />
|
||||
{/if}
|
||||
<ScreenPanel stats={stats.shows} title="tv" number={7} color="yellow" />
|
||||
<ScreenPanel stats={stats.movies} title="movies" number={8} color="green" />
|
||||
{#if stats.anime}
|
||||
<AniListPanel stats={stats.anime} title="anime" number={9} color="magenta" />
|
||||
{/if}
|
||||
{#if stats.manga}
|
||||
<AniListPanel stats={stats.manga} title="manga" number={10} color="blue" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.hobby-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, max(240px, 25% - 0.75rem)), 1fr));
|
||||
grid-auto-flow: dense;
|
||||
gap: 1rem 0.75rem;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
|
||||
.hobby-panels > :global(.btop-box.tall) {
|
||||
grid-row: span 2;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
import MemoryPanel from './MemoryPanel.svelte';
|
||||
import ContributionPanel from './ContributionPanel.svelte';
|
||||
import ProcessPanel from './ProcessPanel.svelte';
|
||||
import HobbyPanels from './HobbyPanels.svelte';
|
||||
|
||||
let data = $state<BtopData | null>(null);
|
||||
let clock = $state(new Date());
|
||||
@@ -35,6 +36,8 @@
|
||||
<div class="mem"><MemoryPanel {data} /></div>
|
||||
<div class="proc"><ProcessPanel {data} /></div>
|
||||
</div>
|
||||
|
||||
<HobbyPanels />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -45,6 +48,7 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
overflow-y: auto;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
@@ -62,14 +66,28 @@
|
||||
|
||||
.panels {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
flex: none;
|
||||
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-areas:
|
||||
'cpu net'
|
||||
'proc mem';
|
||||
gap: 1rem 0.75rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cpu,
|
||||
.net,
|
||||
.mem,
|
||||
.proc {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cpu > :global(.btop-box),
|
||||
.net > :global(.btop-box),
|
||||
.mem > :global(.btop-box),
|
||||
.proc > :global(.btop-box) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cpu {
|
||||
@@ -86,12 +104,8 @@
|
||||
|
||||
.proc {
|
||||
grid-area: proc;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.proc > :global(.btop-box) {
|
||||
flex: 1;
|
||||
min-height: 220px;
|
||||
contain: size;
|
||||
}
|
||||
|
||||
@container (max-width: 760px) {
|
||||
@@ -106,7 +120,7 @@
|
||||
}
|
||||
|
||||
.proc {
|
||||
min-height: 220px;
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
values,
|
||||
color,
|
||||
height = 56,
|
||||
labels = [],
|
||||
grow = false
|
||||
}: { values: number[]; color: string; height?: number; labels?: string[]; grow?: boolean } = $props();
|
||||
|
||||
const minimumScale = 0.02;
|
||||
|
||||
const highest = $derived(Math.max(1, ...(values ?? [])));
|
||||
|
||||
function barScale(value: number): number {
|
||||
return Math.max(minimumScale, value / highest);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bar-graph" class:grow style:min-height="{height}px" style:height={grow ? null : `${height}px`} aria-hidden="true">
|
||||
{#each values ?? [] as value, index (index)}
|
||||
<span
|
||||
class="bar"
|
||||
class:empty={value === 0}
|
||||
style:transform="scaleY({barScale(value)})"
|
||||
style:background="var(--{color})"
|
||||
title={labels[index] ? `${labels[index]}: ${value}` : `${value}`}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar-graph {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
.bar-graph.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
opacity: 0.85;
|
||||
transform-origin: bottom;
|
||||
}
|
||||
|
||||
.bar.empty {
|
||||
opacity: 0.25;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { CountStat } from '#lib/btop/types';
|
||||
|
||||
let { items }: { items: CountStat[] } = $props();
|
||||
|
||||
const highest = $derived(Math.max(1, ...items.map((item) => item.count)));
|
||||
</script>
|
||||
|
||||
<div class="count-bars">
|
||||
{#each items as item (item.label)}
|
||||
<span class="label" title={item.label}>{item.label}</span>
|
||||
<span class="track"><span style:width="{(item.count / highest) * 100}%" style:background="var(--{item.color})"></span></span>
|
||||
<span class="count" style:color="var(--{item.color})">{item.count}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.count-bars {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 7.5rem) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: block;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.count {
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
let { text, color }: { text: string; color: string } = $props();
|
||||
</script>
|
||||
|
||||
<span class="section-label" style:color="var(--{color})">{text}</span>
|
||||
|
||||
<style>
|
||||
.section-label {
|
||||
display: block;
|
||||
margin: 0.6rem 0 0.3rem;
|
||||
padding-top: 0.45rem;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { CountStat } from '#lib/btop/types';
|
||||
|
||||
let { items }: { items: CountStat[] } = $props();
|
||||
|
||||
const shown = $derived(items.filter((item) => item.count > 0));
|
||||
</script>
|
||||
|
||||
<div class="stacked">
|
||||
<div class="bar" role="img" aria-label={shown.map((item) => `${item.label}: ${item.count}`).join(', ')}>
|
||||
{#each shown as item (item.label)}
|
||||
<span style:flex-grow={item.count} style:background="var(--{item.color})"></span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="legend">
|
||||
{#each shown as item (item.label)}
|
||||
<span style:color="var(--{item.color})">{item.count} {item.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.bar span {
|
||||
flex-basis: 0;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { StatRow } from '#lib/btop/types';
|
||||
|
||||
let { rows }: { rows: StatRow[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="stat-rows">
|
||||
{#each rows as row (row.label)}
|
||||
<div class="row"><span class="muted">{row.label}</span><span>{row.value}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.stat-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { AniListStats } from '#lib/btop/types';
|
||||
import BtopBox from '../BtopBox.svelte';
|
||||
import CountBars from '../charts/CountBars.svelte';
|
||||
import StatRows from '../charts/StatRows.svelte';
|
||||
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||
|
||||
let { stats, title, number, color }: { stats: AniListStats; title: string; number: number; color: string } = $props();
|
||||
</script>
|
||||
|
||||
<BtopBox {title} {number} {color}>
|
||||
<StatRows rows={stats.rows} />
|
||||
|
||||
{#if stats.statuses.length}
|
||||
<SectionLabel text="lists" {color} />
|
||||
<CountBars items={stats.statuses} />
|
||||
{/if}
|
||||
</BtopBox>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { BookStats } from '#lib/btop/types';
|
||||
import BtopBox from '../BtopBox.svelte';
|
||||
import BarGraph from '../charts/BarGraph.svelte';
|
||||
import CountBars from '../charts/CountBars.svelte';
|
||||
import StackedBar from '../charts/StackedBar.svelte';
|
||||
import StatRows from '../charts/StatRows.svelte';
|
||||
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||
|
||||
let { stats, number }: { stats: BookStats; number: number } = $props();
|
||||
|
||||
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
|
||||
</script>
|
||||
|
||||
<BtopBox title="books" {number} color="cyan" tall>
|
||||
<StackedBar items={stats.shelves} />
|
||||
|
||||
<SectionLabel text="reading" color="cyan" />
|
||||
<StatRows rows={stats.rows} />
|
||||
|
||||
{#if stats.topGenres.length}
|
||||
<SectionLabel text="top genres" color="cyan" />
|
||||
<CountBars items={stats.topGenres} />
|
||||
{/if}
|
||||
|
||||
<SectionLabel text="ratings" color="cyan" />
|
||||
<BarGraph values={stats.ratingCounts} labels={ratingLabels} color="cyan" height={44} grow />
|
||||
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
|
||||
</BtopBox>
|
||||
|
||||
<style>
|
||||
.axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import type { ListeningStats } from '#lib/btop/types';
|
||||
import BtopBox from '../BtopBox.svelte';
|
||||
import BarGraph from '../charts/BarGraph.svelte';
|
||||
import CountBars from '../charts/CountBars.svelte';
|
||||
import StatRows from '../charts/StatRows.svelte';
|
||||
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||
|
||||
let { stats, number }: { stats: ListeningStats; number: number } = $props();
|
||||
|
||||
const hourLabels = Array.from({ length: 24 }, (_, hour) => `${hour % 12 || 12}${hour < 12 ? 'am' : 'pm'}`);
|
||||
</script>
|
||||
|
||||
<BtopBox title="music" {number} color="red" tall>
|
||||
<StatRows rows={stats.rows} />
|
||||
|
||||
<SectionLabel text="scrobbles, last {stats.daily.length} days" color="red" />
|
||||
<BarGraph values={stats.daily} color="red" grow />
|
||||
|
||||
<SectionLabel text="by hour of day" color="red" />
|
||||
<BarGraph values={stats.hourly} labels={hourLabels} color="magenta" height={36} />
|
||||
|
||||
{#if stats.topArtists.length}
|
||||
<SectionLabel text="top artists" color="red" />
|
||||
<CountBars items={stats.topArtists} />
|
||||
{/if}
|
||||
</BtopBox>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { ScreenStats } from '#lib/btop/types';
|
||||
import BtopBox from '../BtopBox.svelte';
|
||||
import BarGraph from '../charts/BarGraph.svelte';
|
||||
import StatRows from '../charts/StatRows.svelte';
|
||||
import SectionLabel from '../charts/SectionLabel.svelte';
|
||||
|
||||
let { stats, title, number, color }: { stats: ScreenStats; title: string; number: number; color: string } = $props();
|
||||
|
||||
const ratingLabels = Array.from({ length: 11 }, (_, rating) => `${rating}/10`);
|
||||
</script>
|
||||
|
||||
<BtopBox {title} {number} {color}>
|
||||
<StatRows rows={stats.rows} />
|
||||
|
||||
<SectionLabel text="ratings" {color} />
|
||||
<BarGraph values={stats.ratingCounts} labels={ratingLabels} {color} height={44} grow />
|
||||
<div class="axis muted"><span>0</span><span>5</span><span>10</span></div>
|
||||
|
||||
{#if stats.watching.length}
|
||||
<SectionLabel text="watching now" {color} />
|
||||
<ul class="watching">
|
||||
{#each stats.watching as name (name)}
|
||||
<li><span style:color="var(--blue)">></span> {name}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</BtopBox>
|
||||
|
||||
<style>
|
||||
.axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.watching {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.watching li {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { about, books } from '#lib/content';
|
||||
import type { HobbyStats } from '#lib/btop/types';
|
||||
import { aniListStats, bookStats, listeningStats, screenStats } from '#lib/btop/hobbies';
|
||||
import { getAniListProfile } from './anilist';
|
||||
import { getListeningProfile } from './lastfm/profile';
|
||||
|
||||
export async function getHobbyStats(): Promise<HobbyStats> {
|
||||
const anilistUsername = about.anilistUsername;
|
||||
const [listening, anime, manga] = await Promise.all([
|
||||
about.lastfmUsername ? getListeningProfile(about.lastfmUsername).catch(() => null) : null,
|
||||
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME').catch(() => null) : null,
|
||||
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA').catch(() => null) : null
|
||||
]);
|
||||
const hasManga = !!manga?.statusCounts.some((status) => status.count > 0);
|
||||
|
||||
return {
|
||||
listening: listening ? listeningStats(listening) : null,
|
||||
books: bookStats(books),
|
||||
shows: screenStats(about.shows),
|
||||
movies: screenStats(about.movies),
|
||||
anime: anime ? aniListStats(anime) : null,
|
||||
manga: manga && hasManga ? aniListStats(manga) : null
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { getHobbyStats } from '#lib/server/btopHobbies';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return json(await getHobbyStats(), { headers: { 'Cache-Control': 'no-cache' } });
|
||||
};
|
||||
Reference in new issue
Block a user