Add floating windows and btop system monitor

This commit is contained in:
SirBlob committed 2026-10-31 23:07:12 -04:00
1 parent fd0b3b20a2
commit 201a1e0914
23 files changed
+1049 -36

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
export function randomWalk(value: number, step: number, minimum = 0, maximum = 100): number {
const next = value + (Math.random() * 2 - 1) * step;
return Math.min(maximum, Math.max(minimum, next));
}
export function usageTone(percent: number): string {
if (percent >= 80) return 'red';
if (percent >= 50) return 'yellow';
return 'green';
}
export function relativeTime(isoDate: string): string {
const elapsedMinutes = Math.floor((Date.now() - new Date(isoDate).getTime()) / 60000);
if (elapsedMinutes < 60) return `${elapsedMinutes}m`;
const elapsedHours = Math.floor(elapsedMinutes / 60);
if (elapsedHours < 24) return `${elapsedHours}h`;
const elapsedDays = Math.floor(elapsedHours / 24);
if (elapsedDays < 60) return `${elapsedDays}d`;
return `${Math.floor(elapsedDays / 30)}mo`;
}
export function formatSize(sizeKb: number): string {
if (sizeKb >= 1024 * 1024) return `${(sizeKb / 1024 / 1024).toFixed(1)}G`;
if (sizeKb >= 1024) return `${(sizeKb / 1024).toFixed(1)}M`;
return `${sizeKb}K`;
}
+35
View File
@@ -0,0 +1,35 @@
export interface ContributionDay {
date: string;
level: number;
count: number;
}
export interface ContributionCalendar {
total: number;
weeks: ContributionDay[][];
currentStreak: number;
bestDay: ContributionDay | null;
}
export interface RepoProcess {
name: string;
url: string;
language: string | null;
stars: number;
forks: number;
sizeKb: number;
pushedAt: string;
}
export interface GithubSummary {
username: string;
profileUrl: string;
publicRepos: number;
followers: number;
}
export interface BtopData {
github: GithubSummary;
contributions: ContributionCalendar;
repos: RepoProcess[];
}
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
number,
color = 'accent',
children
}: { title: string; number: number; color?: string; children: Snippet } = $props();
</script>
<section class="btop-box" style:--box-color="var(--{color})">
<span class="box-title"><sup>{number}</sup>{title}</span>
{@render children()}
</section>
<style>
.btop-box {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 0.9rem 0.75rem 0.6rem;
border: 1px solid var(--box-color);
border-radius: 8px;
}
.box-title {
position: absolute;
top: -0.65em;
left: 0.9rem;
padding: 0 0.35rem;
background: var(--background);
color: var(--box-color);
font-size: 0.8rem;
font-weight: 700;
line-height: 1.2;
}
sup {
margin-right: 0.15rem;
color: var(--foreground);
font-size: 0.6rem;
}
</style>
@@ -0,0 +1,104 @@
<script lang="ts">
import type { BtopData } from '#lib/btop/types';
import BtopBox from './BtopBox.svelte';
let { data }: { data: BtopData | null } = $props();
const levelOpacity = [0, 0.3, 0.55, 0.8, 1];
const calendar = $derived(data?.contributions ?? null);
</script>
<BtopBox title="net · commits" number={3} color="blue">
{#if calendar && data}
<div class="stats">
<span><b class="value">{calendar.total.toLocaleString('en-US')}</b> contributions in the last year</span>
<span class="muted">streak <b class="value">{calendar.currentStreak}d</b></span>
{#if calendar.bestDay}
<span class="muted">peak <b class="value">{calendar.bestDay.count}</b> on {calendar.bestDay.date}</span>
{/if}
</div>
<a class="heatmap" href={data.github.profileUrl} target="_blank" rel="noopener noreferrer" aria-label="GitHub contribution graph">
{#each calendar.weeks as week, weekIndex (weekIndex)}
<span class="week">
{#each week as day (day.date)}
<span
class="day"
style:opacity={day.level === 0 ? 1 : levelOpacity[day.level]}
class:empty={day.level === 0}
title="{day.count} contributions on {day.date}"
></span>
{/each}
</span>
{/each}
</a>
<div class="legend muted">
less
{#each levelOpacity as opacity, level (level)}
<span class="day" class:empty={level === 0} style:opacity={level === 0 ? 1 : opacity}></span>
{/each}
more
</div>
{:else}
<p class="muted loading">connecting to github...</p>
{/if}
</BtopBox>
<style>
.stats {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 1rem;
margin-bottom: 0.6rem;
font-size: 0.75rem;
}
.value {
color: var(--blue);
}
.heatmap {
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
gap: 2px;
}
.week {
display: grid;
grid-template-rows: repeat(7, auto);
gap: 2px;
}
.day {
display: block;
aspect-ratio: 1;
min-width: 4px;
background: var(--green);
border-radius: 1px;
}
.day.empty {
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.legend {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 3px;
margin-top: 0.4rem;
font-size: 0.7rem;
}
.legend .day {
width: 9px;
}
.loading {
margin: 0;
font-size: 0.8rem;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import { profile } from '#lib/content';
import { randomWalk, usageTone } from '#lib/btop/simulate';
import BtopBox from './BtopBox.svelte';
const historyLength = 48;
const updateInterval = 1000;
const coreNames = (profile.skills.Languages ?? []).slice(0, 6);
let history = $state<number[]>(Array.from({ length: historyLength }, () => 20 + Math.random() * 30));
let cores = $state(coreNames.map((name) => ({ name, usage: 20 + Math.random() * 60 })));
const totalUsage = $derived(Math.round(history.at(-1) ?? 0));
onMount(() => {
const timer = setInterval(() => {
history = [...history.slice(1), randomWalk(history.at(-1) ?? 40, 18, 5, 98)];
cores = cores.map((core) => ({ ...core, usage: randomWalk(core.usage, 15, 3, 100) }));
}, updateInterval);
return () => clearInterval(timer);
});
</script>
<BtopBox title="cpu" number={1} color="green">
<div class="cpu-header">
<span class="model">Brain 9000X @ 4.2 GHz (caffeinated)</span>
<span class="total" style:color="var(--{usageTone(totalUsage)})">{totalUsage}%</span>
</div>
<div class="graph" aria-hidden="true">
{#each history as value, index (index)}
<span class="bar" style:height="{value}%" style:background="var(--{usageTone(value)})"></span>
{/each}
</div>
<div class="cores">
{#each cores as core (core.name)}
<div class="core">
<span class="core-name">{core.name}</span>
<span class="core-meter"><span style:width="{core.usage}%" style:background="var(--{usageTone(core.usage)})"></span></span>
<span class="core-value">{Math.round(core.usage)}%</span>
</div>
{/each}
</div>
</BtopBox>
<style>
.cpu-header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.75rem;
}
.model {
overflow: hidden;
color: var(--muted);
white-space: nowrap;
text-overflow: ellipsis;
}
.total {
font-weight: 700;
}
.graph {
display: flex;
align-items: flex-end;
gap: 1px;
height: 70px;
margin: 0.4rem 0 0.6rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.bar {
flex: 1;
min-height: 1px;
opacity: 0.85;
transition: height 0.4s ease;
}
.cores {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 0.2rem 1rem;
font-size: 0.72rem;
}
.core {
display: grid;
grid-template-columns: 6.5rem 1fr 2.5rem;
align-items: center;
gap: 0.4rem;
}
.core-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.core-meter {
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.core-meter span {
display: block;
height: 100%;
transition: width 0.4s ease;
}
.core-value {
color: var(--muted);
text-align: right;
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { BtopData } from '#lib/btop/types';
import { randomWalk, usageTone } from '#lib/btop/simulate';
import BtopBox from './BtopBox.svelte';
let { data }: { data: BtopData | null } = $props();
const updateInterval = 2000;
let meters = $state([
{ label: 'Caffeine', value: 82 },
{ label: 'Sleep debt', value: 64 },
{ label: 'Side projects', value: 91 },
{ label: 'Free time', value: 12 }
]);
const disks = $derived(
data
? [
{ label: 'Public repos', value: `${data.github.publicRepos}` },
{ label: 'Followers', value: `${data.github.followers}` },
{ label: 'Total stars', value: `${data.repos.reduce((sum, repo) => sum + repo.stars, 0)}` },
{ label: 'Total forks', value: `${data.repos.reduce((sum, repo) => sum + repo.forks, 0)}` }
]
: []
);
onMount(() => {
const timer = setInterval(() => {
meters = meters.map((meter) => ({ ...meter, value: randomWalk(meter.value, 4, 2, 99) }));
}, updateInterval);
return () => clearInterval(timer);
});
</script>
<BtopBox title="mem" number={2} color="yellow">
<div class="meters">
{#each meters as meter (meter.label)}
<div class="meter">
<div class="meter-label">
<span>{meter.label}</span>
<span style:color="var(--{usageTone(meter.value)})">{Math.round(meter.value)}%</span>
</div>
<span class="meter-track"><span style:width="{meter.value}%" style:background="var(--{usageTone(meter.value)})"></span></span>
</div>
{/each}
</div>
{#if disks.length}
<div class="disks">
<span class="disks-title">disks</span>
{#each disks as disk (disk.label)}
<div class="disk"><span class="muted">{disk.label}</span><span>{disk.value}</span></div>
{/each}
</div>
{/if}
</BtopBox>
<style>
.meters {
display: flex;
flex-direction: column;
gap: 0.45rem;
font-size: 0.75rem;
}
.meter-label {
display: flex;
justify-content: space-between;
margin-bottom: 0.15rem;
}
.meter-track {
display: block;
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.meter-track span {
display: block;
height: 100%;
transition: width 0.6s ease;
}
.disks {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.7rem;
padding-top: 0.5rem;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 20%, transparent);
font-size: 0.75rem;
}
.disks-title {
color: var(--yellow);
font-weight: 700;
}
.disk {
display: flex;
justify-content: space-between;
}
</style>
@@ -0,0 +1,99 @@
<script lang="ts">
import type { BtopData } from '#lib/btop/types';
import { formatSize, relativeTime } from '#lib/btop/simulate';
import BtopBox from './BtopBox.svelte';
let { data }: { data: BtopData | null } = $props();
const firstProcessId = 1337;
</script>
<BtopBox title="proc · repos" number={4} color="magenta">
{#if data}
<div class="table-wrapper">
<table>
<thead>
<tr>
<th class="number">Pid</th>
<th>Program</th>
<th>Lang</th>
<th class="number">Stars</th>
<th class="number">Forks</th>
<th class="number">Mem</th>
<th class="number">Pushed</th>
</tr>
</thead>
<tbody>
{#each data.repos as repo, index (repo.name)}
<tr>
<td class="number muted">{firstProcessId + index}</td>
<td><a href={repo.url} target="_blank" rel="noopener noreferrer">{repo.name}</a></td>
<td class="muted">{repo.language ?? '-'}</td>
<td class="number stars">{repo.stars}</td>
<td class="number forks">{repo.forks}</td>
<td class="number muted">{formatSize(repo.sizeKb)}</td>
<td class="number muted">{relativeTime(repo.pushedAt)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="muted loading">reading /proc...</p>
{/if}
</BtopBox>
<style>
.table-wrapper {
flex: 1;
min-height: 0;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.75rem;
white-space: nowrap;
}
th {
position: sticky;
top: 0;
padding: 0.15rem 0.4rem;
background: var(--background);
color: var(--magenta);
font-weight: 700;
text-align: left;
}
td {
padding: 0.1rem 0.4rem;
}
tbody tr:hover {
background: color-mix(in srgb, var(--magenta) 12%, transparent);
}
.number {
text-align: right;
}
a:hover {
color: var(--accent);
text-decoration: underline;
}
.stars {
color: var(--yellow);
}
.forks {
color: var(--cyan);
}
.loading {
margin: 0;
font-size: 0.8rem;
}
</style>
@@ -0,0 +1,112 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { BtopData } from '#lib/btop/types';
import CpuPanel from './CpuPanel.svelte';
import MemoryPanel from './MemoryPanel.svelte';
import ContributionPanel from './ContributionPanel.svelte';
import ProcessPanel from './ProcessPanel.svelte';
let data = $state<BtopData | null>(null);
let clock = $state(new Date());
const clockLabel = $derived(clock.toLocaleTimeString('en-US', { hour12: false }));
onMount(() => {
fetch('/api/btop')
.then((response) => (response.ok ? response.json() : null))
.then((result) => (data = result))
.catch(() => (data = null));
const timer = setInterval(() => (clock = new Date()), 1000);
return () => clearInterval(timer);
});
</script>
<div class="system-monitor">
<header class="top-line">
<span class="accent">btop</span>
<span class="muted">github: {data?.github.username ?? '...'}</span>
<span>{clockLabel}</span>
</header>
<div class="panels">
<div class="cpu"><CpuPanel /></div>
<div class="net"><ContributionPanel {data} /></div>
<div class="mem"><MemoryPanel {data} /></div>
<div class="proc"><ProcessPanel {data} /></div>
</div>
</div>
<style>
.system-monitor {
display: flex;
flex-direction: column;
gap: 0.6rem;
height: 100%;
min-height: 0;
padding: 0.5rem 0.75rem 0.75rem;
container-type: inline-size;
}
.top-line {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.8rem;
}
.accent {
color: var(--accent);
font-weight: 700;
}
.panels {
display: grid;
flex: 1;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
grid-template-rows: auto minmax(0, 1fr);
grid-template-areas:
'cpu net'
'proc mem';
gap: 1rem 0.75rem;
min-height: 0;
}
.cpu {
grid-area: cpu;
}
.net {
grid-area: net;
}
.mem {
grid-area: mem;
}
.proc {
grid-area: proc;
display: flex;
min-height: 0;
}
.proc > :global(.btop-box) {
flex: 1;
}
@container (max-width: 760px) {
.panels {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: none;
grid-template-areas:
'cpu'
'net'
'mem'
'proc';
}
.proc {
min-height: 220px;
}
}
</style>
+49 -22
View File
@@ -6,6 +6,8 @@
import Terminal from '#lib/components/terminal/Terminal.svelte';
import ImageViewer from '#lib/components/viewer/ImageViewer.svelte';
import { imageFileName } from '#lib/desktop/imageViewer';
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
import SystemMonitor from '../btop/SystemMonitor.svelte';
import DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte';
@@ -25,6 +27,9 @@
const isNarrow = $derived(areaWidth < narrowWidth);
const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.extraWindowIds]);
const floatingIds = $derived(isNarrow ? [] : openWindowIds.filter((id) => windowManager.isFloating(id)));
const tiledIds = $derived(openWindowIds.filter((id) => !floatingIds.includes(id)));
const area = $derived({ width: areaWidth, height: areaHeight });
const layout = $derived.by(() => {
const rects = new Map<string, TileRect>();
@@ -33,10 +38,16 @@
rects.set(soloId, fullscreenLayout());
return rects;
}
dwindleLayout(openWindowIds.length, areaWidth, areaHeight).forEach((rect, index) => rects.set(openWindowIds[index], rect));
dwindleLayout(tiledIds.length, areaWidth, areaHeight).forEach((rect, index) => rects.set(tiledIds[index], rect));
return rects;
});
$effect(() => {
floatingIds.forEach((id, index) => {
if (!windowManager.floatingRects[id]) windowManager.setFloatingRect(id, defaultFloatingRect(area, index));
});
});
$effect(() => {
if (openWindowIds.length && !openWindowIds.includes(windowManager.focusedId)) {
windowManager.focus(openWindowIds.at(-1)!);
@@ -44,9 +55,31 @@
});
function styleFor(id: string): string {
const floatingRect = windowManager.floatingRects[id];
if (floatingIds.includes(id) && !layout.has(id)) {
return floatingRect ? floatingStyle(floatingRect, windowManager.focusedId === id) : 'display: none';
}
const rect = layout.get(id);
return rect ? tileStyle(rect, isNarrow ? 4 : 10) : 'display: none';
}
function updateFloatingRect(id: string, change: typeof moveRect, deltaX: number, deltaY: number) {
const rect = windowManager.floatingRects[id];
if (rect) windowManager.setFloatingRect(id, change(rect, deltaX, deltaY, area));
}
function windowProps(id: string) {
return {
focused: windowManager.focusedId === id,
tileStyle: styleFor(id),
floating: floatingIds.includes(id) && !layout.has(id),
canFloat: !isNarrow,
onfocus: () => windowManager.focus(id),
ontogglefloat: () => windowManager.toggleFloating(id),
ondrag: (deltaX: number, deltaY: number) => updateFloatingRect(id, moveRect, deltaX, deltaY),
onresize: (deltaX: number, deltaY: number) => updateFloatingRect(id, resizeRect, deltaX, deltaY)
};
}
</script>
<div class="desktop" class:narrow={isNarrow}>
@@ -66,29 +99,13 @@
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
{#if pageOpen}
<Window
title="kitty"
path={pageTitle}
icon={pageIcon}
focused={windowManager.focusedId === pageWindowId}
tileStyle={styleFor(pageWindowId)}
scrollKey={pageKey}
onfocus={() => windowManager.focus(pageWindowId)}
onclose={closePage}
>
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
{@render children()}
</Window>
{/if}
{#each windowManager.terminals as terminal (terminal.id)}
<Window
title="kitty"
path="~"
focused={windowManager.focusedId === terminal.id}
tileStyle={styleFor(terminal.id)}
onfocus={() => windowManager.focus(terminal.id)}
onclose={() => windowManager.closeWindow(terminal.id)}
>
<Window title="kitty" path="~" {...windowProps(terminal.id)} onclose={() => windowManager.closeWindow(terminal.id)}>
<Terminal
startupCommand={terminal.startupCommand}
focused={windowManager.focusedId === terminal.id}
@@ -102,9 +119,7 @@
title="imv"
path={imageFileName(viewer.images[viewer.currentIndex]?.src ?? '')}
icon="ph:image-duotone"
focused={windowManager.focusedId === viewer.id}
tileStyle={styleFor(viewer.id)}
onfocus={() => windowManager.focus(viewer.id)}
{...windowProps(viewer.id)}
onclose={() => windowManager.closeWindow(viewer.id)}
>
<ImageViewer
@@ -114,6 +129,18 @@
/>
</Window>
{/each}
{#each windowManager.systemMonitors as monitor (monitor.id)}
<Window
title="btop"
path="system monitor"
icon="ph:chart-line-duotone"
{...windowProps(monitor.id)}
onclose={() => windowManager.closeWindow(monitor.id)}
>
<SystemMonitor />
</Window>
{/each}
</div>
</div>
+8 -1
View File
@@ -24,7 +24,7 @@
.filter((item) => !('page' in item.open))
.map((item) => ({
label: item.label,
detail: 'terminal' in item.open ? `kitty ${item.open.terminal}`.trim() : 'url' in item.open ? item.open.url : '',
detail: launcherDetail(item.open),
icon: item.icon,
open: item.open
}));
@@ -66,6 +66,13 @@
selectedIndex = Math.max(0, selectedIndex - 1);
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]);
}
function launcherDetail(action: DesktopAction): string {
if ('terminal' in action) return `kitty ${action.terminal}`.trim();
if ('url' in action) return action.url;
if ('app' in action) return action.app;
return '';
}
</script>
<button class="backdrop" aria-label="Close launcher" onclick={close}></button>
+103 -7
View File
@@ -9,8 +9,13 @@
focused,
tileStyle,
scrollKey = '',
floating = false,
canFloat = false,
onfocus,
onclose,
ontogglefloat,
ondrag,
onresize,
children
}: {
title: string;
@@ -19,12 +24,44 @@
focused: boolean;
tileStyle: string;
scrollKey?: string;
floating?: boolean;
canFloat?: boolean;
onfocus: () => void;
onclose: () => void;
ontogglefloat?: () => void;
ondrag?: (deltaX: number, deltaY: number) => void;
onresize?: (deltaX: number, deltaY: number) => void;
children: Snippet;
} = $props();
let contentElement = $state<HTMLDivElement>();
let isDragging = $state(false);
function trackPointer(event: PointerEvent, onmove?: (deltaX: number, deltaY: number) => void) {
if (!floating || !onmove || event.button !== 0) return;
if (event.target instanceof Element && event.target.closest('button')) return;
event.preventDefault();
let lastX = event.clientX;
let lastY = event.clientY;
isDragging = true;
const handleMove = (moveEvent: PointerEvent) => {
onmove(moveEvent.clientX - lastX, moveEvent.clientY - lastY);
lastX = moveEvent.clientX;
lastY = moveEvent.clientY;
};
const handleEnd = () => {
isDragging = false;
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
}
$effect(() => {
scrollKey;
@@ -35,27 +72,49 @@
<section
class="window"
class:focused
class:floating
class:dragging={isDragging}
style={tileStyle}
aria-label="{title} {path}"
onpointerdown={onfocus}
onfocusin={onfocus}
>
<div class="window-surface">
<header class="title-bar">
<header class="title-bar" onpointerdown={(event) => trackPointer(event, ondrag)}>
<span class="title">
<span class="app-name"><Icon {icon} width="15" />{title}</span>
{#if path}
<span class="path">{path}</span>
{/if}
</span>
<button class="close-button" title="Close (Alt+Q)" aria-label="Close {title} {path}" onclick={onclose}>
<Icon icon="mdi:close" width="15" />
</button>
<span class="title-buttons">
{#if canFloat && ontogglefloat}
<button
class="title-button"
title={floating ? 'Tile window (Alt+V)' : 'Float window (Alt+V)'}
aria-label={floating ? 'Tile window' : 'Float window'}
onclick={ontogglefloat}
>
<Icon icon={floating ? 'mdi:view-dashboard-outline' : 'mdi:dock-window'} width="14" />
</button>
{/if}
<button class="title-button close-button" title="Close (Alt+Q)" aria-label="Close {title} {path}" onclick={onclose}>
<Icon icon="mdi:close" width="15" />
</button>
</span>
</header>
<div class="window-content" bind:this={contentElement}>
{@render children()}
</div>
</div>
{#if floating && onresize}
<span
class="resize-handle"
role="presentation"
title="Resize"
onpointerdown={(event) => trackPointer(event, onresize)}
></span>
{/if}
</section>
<style>
@@ -75,6 +134,33 @@
background: var(--border-active);
}
.window.floating {
box-shadow: 0 12px 32px rgb(0 0 0 / 0.45);
}
.window.floating .title-bar {
cursor: grab;
}
.window.dragging {
transition: none;
user-select: none;
}
.window.dragging .title-bar {
cursor: grabbing;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
}
.window-surface {
display: flex;
flex-direction: column;
@@ -124,9 +210,14 @@
color: var(--foreground);
}
.close-button {
display: grid;
.title-buttons {
display: flex;
flex-shrink: 0;
align-items: center;
}
.title-button {
display: grid;
place-items: center;
width: 22px;
height: 22px;
@@ -134,6 +225,11 @@
background: none;
border: none;
color: var(--muted);
cursor: pointer;
}
.title-button:hover {
color: var(--accent);
}
.close-button:hover {
@@ -152,7 +248,7 @@
height: 36px;
}
.close-button {
.title-button {
width: 34px;
height: 34px;
}
+7 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { profile } from '#lib/content';
import { openPage, openUrl } from '#lib/desktop/actions';
import { openApp, openPage, openUrl } from '#lib/desktop/actions';
import { themeState } from '#lib/theme/theme.svelte';
import { runCommand, type CommandContext } from '#lib/terminal/commands';
import { complete } from '#lib/terminal/completion';
@@ -29,7 +29,8 @@
openPage,
openUrl,
setTheme: (themeId) => themeState.set(themeId),
currentThemeId: () => themeState.currentId
currentThemeId: () => themeState.currentId,
openApp
};
async function execute(command: string) {
@@ -99,6 +100,10 @@
if (focused) inputElement?.focus({ preventScroll: true });
});
$effect(() => {
if (blocks.length) tick().then(() => screenElement?.scrollTo({ top: screenElement.scrollHeight }));
});
onMount(() => {
if (startupCommand) typeOut(startupCommand);
});
+3 -1
View File
@@ -144,7 +144,9 @@ export interface AboutContent {
movies: RatedTitle[];
}
export type DesktopAction = { page: string } | { terminal: string } | { url: string };
export type DesktopApp = 'btop';
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
export interface DesktopIconGroup {
name: string;
+6 -1
View File
@@ -1,5 +1,5 @@
import { goto } from '$app/navigation';
import { desktop, type DesktopAction, type Workspace } from '#lib/content';
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content';
import { pageWindowId, windowManager } from './windows.svelte';
export function isExternalUrl(url: string): boolean {
@@ -19,10 +19,15 @@ export function openPage(path: string) {
goto(path);
}
export function openApp(app: DesktopApp) {
if (app === 'btop') windowManager.openSystemMonitor();
}
export function runDesktopAction(action: DesktopAction) {
windowManager.launcherOpen = false;
if ('page' in action) openPage(action.page);
else if ('terminal' in action) windowManager.openTerminal(action.terminal);
else if ('app' in action) openApp(action.app);
else openUrl(action.url);
}
+49
View File
@@ -0,0 +1,49 @@
import type { FloatingRect } from './windows.svelte';
const defaultSizeRatio = 0.6;
const cascadeOffset = 28;
const minimumWidth = 280;
const minimumHeight = 180;
export interface AreaSize {
width: number;
height: number;
}
export function defaultFloatingRect(area: AreaSize, floatingCount: number): FloatingRect {
const width = Math.max(minimumWidth, Math.round(area.width * defaultSizeRatio));
const height = Math.max(minimumHeight, Math.round(area.height * defaultSizeRatio));
const offset = (floatingCount % 6) * cascadeOffset;
return clampRect({ x: (area.width - width) / 2 + offset, y: (area.height - height) / 2 + offset, width, height }, area);
}
export function clampRect(rect: FloatingRect, area: AreaSize): FloatingRect {
const width = Math.min(Math.max(rect.width, minimumWidth), area.width);
const height = Math.min(Math.max(rect.height, minimumHeight), area.height);
return {
x: Math.min(Math.max(rect.x, 0), area.width - width),
y: Math.min(Math.max(rect.y, 0), area.height - height),
width,
height
};
}
export function moveRect(rect: FloatingRect, deltaX: number, deltaY: number, area: AreaSize): FloatingRect {
return clampRect({ ...rect, x: rect.x + deltaX, y: rect.y + deltaY }, area);
}
export function resizeRect(rect: FloatingRect, deltaX: number, deltaY: number, area: AreaSize): FloatingRect {
const width = Math.min(rect.width + deltaX, area.width - rect.x);
const height = Math.min(rect.height + deltaY, area.height - rect.y);
return clampRect({ ...rect, width, height }, area);
}
export function floatingStyle(rect: FloatingRect, focused: boolean): string {
return [
`left: ${rect.x}px`,
`top: ${rect.y}px`,
`width: ${rect.width}px`,
`height: ${rect.height}px`,
`z-index: ${focused ? 21 : 20}`
].join('; ');
}
+1
View File
@@ -16,6 +16,7 @@ export const desktopKeybinds: KeybindEntry[] = [
{ keys: ['Alt', 'Space'], action: 'Open the app launcher' },
{ keys: ['Alt', 'Q'], action: 'Close the focused window' },
{ keys: ['Alt', 'F'], action: 'Toggle fullscreen for the page window' },
{ keys: ['Alt', 'V'], action: 'Float or tile the focused window' },
{ keys: ['Alt', 'J'], action: 'Focus the next window' },
{ keys: ['Alt', 'K'], action: 'Focus the previous window' },
{ keys: ['Alt', 'T'], action: 'Switch to the next theme' }
+1
View File
@@ -19,6 +19,7 @@ export function handleKeybind(event: KeyboardEvent, openWindowIds: string[]) {
else if (event.code === 'Space') windowManager.launcherOpen = true;
else if (event.code === 'KeyQ') closeFocusedWindow();
else if (event.code === 'KeyF' && windowManager.focusedId === pageWindowId) windowManager.toggleMaximize(pageWindowId);
else if (event.code === 'KeyV' && openWindowIds.includes(windowManager.focusedId)) windowManager.toggleFloating(windowManager.focusedId);
else if (event.code === 'KeyJ') windowManager.cycleFocus(openWindowIds, 1);
else if (event.code === 'KeyK') windowManager.cycleFocus(openWindowIds, -1);
else if (event.code === 'KeyT') themeState.cycle(1);
+42 -1
View File
@@ -16,18 +16,40 @@ export interface ImageViewerWindow {
currentIndex: number;
}
export interface SystemMonitorWindow {
id: string;
}
export interface FloatingRect {
x: number;
y: number;
width: number;
height: number;
}
class WindowManager {
terminals = $state<TerminalWindow[]>([]);
imageViewers = $state<ImageViewerWindow[]>([]);
systemMonitors = $state<SystemMonitorWindow[]>([]);
floatingRects = $state<Record<string, FloatingRect | null>>({});
focusedId = $state<string>(pageWindowId);
maximizedId = $state<string | null>(null);
launcherOpen = $state(false);
private nextTerminalNumber = 1;
private nextImageViewerNumber = 1;
private nextSystemMonitorNumber = 1;
get extraWindowIds(): string[] {
return [...this.terminals.map((terminal) => terminal.id), ...this.imageViewers.map((viewer) => viewer.id)];
return [
...this.terminals.map((terminal) => terminal.id),
...this.imageViewers.map((viewer) => viewer.id),
...this.systemMonitors.map((monitor) => monitor.id)
];
}
isFloating(id: string): boolean {
return id in this.floatingRects;
}
openTerminal(startupCommand = '') {
@@ -42,9 +64,17 @@ class WindowManager {
this.focus(viewer.id);
}
openSystemMonitor() {
const monitor = { id: `btop-${this.nextSystemMonitorNumber++}` };
this.systemMonitors.push(monitor);
this.focus(monitor.id);
}
closeWindow(id: string) {
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id);
delete this.floatingRects[id];
if (this.maximizedId === id) this.maximizedId = null;
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
}
@@ -60,6 +90,17 @@ class WindowManager {
if (this.maximizedId && this.maximizedId !== id) this.maximizedId = null;
}
toggleFloating(id: string) {
if (this.isFloating(id)) delete this.floatingRects[id];
else this.floatingRects[id] = null;
if (this.maximizedId === id) this.maximizedId = null;
this.focusedId = id;
}
setFloatingRect(id: string, rect: FloatingRect) {
if (this.isFloating(id)) this.floatingRects[id] = rect;
}
toggleMaximize(id: string) {
this.maximizedId = this.maximizedId === id ? null : id;
this.focusedId = id;
+73
View File
@@ -0,0 +1,73 @@
import { profile } from '#lib/content';
import type { BtopData, GithubSummary, RepoProcess } from '#lib/btop/types';
import { fetchContributionCalendar } from './contributions';
const cacheDuration = 60 * 60 * 1000;
const githubHeaders = { Accept: 'application/vnd.github+json', 'User-Agent': 'portfolio-website' };
interface GithubRepo {
name: string;
html_url: string;
language: string | null;
stargazers_count: number;
forks_count: number;
size: number;
pushed_at: string;
fork: boolean;
}
let cached: { data: BtopData; fetchedAt: number } | null = null;
export function githubUsername(): string | null {
const githubUrl = profile.socials.find((social) => social.name === 'GitHub')?.url;
return githubUrl?.match(/github\.com\/([^/]+)/)?.[1] ?? null;
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, { headers: githubHeaders });
if (!response.ok) throw new Error(`GitHub returned ${response.status} for ${url}`);
return response.json();
}
async function fetchSummary(username: string): Promise<GithubSummary> {
const user = await fetchJson<{ html_url: string; public_repos: number; followers: number }>(
`https://api.github.com/users/${username}`
);
return { username, profileUrl: user.html_url, publicRepos: user.public_repos, followers: user.followers };
}
async function fetchRepos(username: string): Promise<RepoProcess[]> {
const repos = await fetchJson<GithubRepo[]>(`https://api.github.com/users/${username}/repos?per_page=100&sort=pushed`);
return repos
.filter((repo) => !repo.fork)
.map((repo) => ({
name: repo.name,
url: repo.html_url,
language: repo.language,
stars: repo.stargazers_count,
forks: repo.forks_count,
sizeKb: repo.size,
pushedAt: repo.pushed_at
}));
}
export async function getBtopData(): Promise<BtopData | null> {
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.data;
const username = githubUsername();
if (!username) return null;
try {
const [github, contributions, repos] = await Promise.all([
fetchSummary(username),
fetchContributionCalendar(username),
fetchRepos(username)
]);
const data = { github, contributions, repos };
cached = { data, fetchedAt: Date.now() };
return data;
} catch (error) {
console.error('btop data lookup failed:', error);
return cached?.data ?? null;
}
}
+49
View File
@@ -0,0 +1,49 @@
import type { ContributionCalendar, ContributionDay } from '#lib/btop/types';
const dayCellPattern = /data-date="(\d{4}-\d{2}-\d{2})" id="contribution-day-component-(\d+)-(\d+)" data-level="(\d)"/g;
const tooltipPattern = /for="contribution-day-component-(\d+-\d+)"[^>]*>(\d+|No) contributions? on/g;
const totalPattern = /([\d,]+)\s+contributions?\s+in the last year/;
function parseCounts(html: string): Map<string, number> {
const counts = new Map<string, number>();
for (const [, cellId, count] of html.matchAll(tooltipPattern)) {
counts.set(cellId, count === 'No' ? 0 : Number(count));
}
return counts;
}
function parseWeeks(html: string): ContributionDay[][] {
const counts = parseCounts(html);
const weeks: ContributionDay[][] = [];
for (const [, date, weekday, week, level] of html.matchAll(dayCellPattern)) {
const weekIndex = Number(week);
weeks[weekIndex] ??= [];
weeks[weekIndex][Number(weekday)] = { date, level: Number(level), count: counts.get(`${weekday}-${week}`) ?? 0 };
}
return weeks.map((days) => days.filter(Boolean));
}
function currentStreak(days: ContributionDay[]): number {
const today = new Date().toISOString().slice(0, 10);
const pastDays = days.filter((day) => day.date <= today);
if (pastDays.at(-1)?.count === 0) pastDays.pop();
let streak = 0;
for (let index = pastDays.length - 1; index >= 0 && pastDays[index].count > 0; index--) streak++;
return streak;
}
export async function fetchContributionCalendar(username: string): Promise<ContributionCalendar> {
const response = await fetch(`https://github.com/users/${username}/contributions`, {
headers: { 'User-Agent': 'portfolio-website' }
});
if (!response.ok) throw new Error(`GitHub contributions returned ${response.status}`);
const html = await response.text();
const weeks = parseWeeks(html);
const days = weeks.flat().sort((first, second) => first.date.localeCompare(second.date));
const total = Number(html.match(totalPattern)?.[1].replace(/,/g, '') ?? days.reduce((sum, day) => sum + day.count, 0));
const bestDay = days.reduce<ContributionDay | null>((best, day) => (!best || day.count > best.count ? day : best), null);
return { total, weeks, currentStreak: currentStreak(days), bestDay };
}
+2
View File
@@ -1,3 +1,4 @@
import type { DesktopApp } from '#lib/content';
import type { OutputBlock } from '../output';
export interface CommandContext {
@@ -8,6 +9,7 @@ export interface CommandContext {
openUrl: (url: string) => void;
setTheme: (themeId: string) => boolean;
currentThemeId: () => string;
openApp: (app: DesktopApp) => void;
}
export interface Command {
+9
View File
@@ -0,0 +1,9 @@
import { error, json } from '@sveltejs/kit';
import { getBtopData } from '#lib/server/btop';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () => {
const data = await getBtopData();
if (!data) error(503, 'System monitor data is unavailable');
return json(data, { headers: { 'Cache-Control': 'public, max-age=600' } });
};