Compare commits

...
3 Commits
Author SHA1 Message Date
SirBlob 7ca0003800 Update README 2026-10-31 23:07:12 -04:00
SirBlob dcf27d90b9 Add btop and pacman terminal commands 2026-10-31 23:07:12 -04:00
SirBlob 201a1e0914 Add floating windows and btop system monitor 2026-10-31 23:07:12 -04:00
27 changed files with 1204 additions and 37 deletions

No files matched your search

+5
View File
@@ -19,6 +19,8 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu
- **Tiling windows** - every page opens as a window that tiles like Hyprland's dwindle layout, with gradient borders. - **Tiling windows** - every page opens as a window that tiles like Hyprland's dwindle layout, with gradient borders.
- **Quickshell bar** - app launcher, workspaces, clock, music player, live Discord status, battery details and a theme picker. - **Quickshell bar** - app launcher, workspaces, clock, music player, live Discord status, battery details and a theme picker.
- **Music player** - plays a random song from a YouTube playlist, muted until you unmute it, with seek, skip, repeat, volume and a playlist view. - **Music player** - plays a random song from a YouTube playlist, muted until you unmute it, with seek, skip, repeat, volume and a playlist view.
- **Floating windows** - Alt + V or the title bar button pops a window out of the tiling layout so it can be dragged and resized.
- **btop** - a system monitor window with my real GitHub commit graph, repos as processes and some very scientific CPU and caffeine readings.
- **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe. - **Image viewer** - click any image to open it in an imv window with zoom, pan, rotate, pinch and swipe.
- **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`. - **Kitty terminals** - a real command line with history and tab completion. Try `fastfetch`, `whoami` or `cowsay`.
- **Desktop icons** - grouped into pages, terminal scripts and socials (GitHub, LinkedIn, Devpost, Discord). - **Desktop icons** - grouped into pages, terminal scripts and socials (GitHub, LinkedIn, Devpost, Discord).
@@ -35,6 +37,7 @@ The site recreates my [blomarchy](https://git.sirblob.co/SirBlob/blomarchy) setu
| Alt + Space | App launcher | | Alt + Space | App launcher |
| Alt + Q | Close window | | Alt + Q | Close window |
| Alt + F | Fullscreen page | | Alt + F | Fullscreen page |
| Alt + V | Float or tile window |
| Alt + J / K | Next / previous window | | Alt + J / K | Next / previous window |
| Alt + 1-9 | Switch workspace | | Alt + 1-9 | Switch workspace |
| Alt + T | Next theme | | Alt + T | Next theme |
@@ -65,6 +68,8 @@ Alt stands in for Super, since browsers can't use it.
| `ls` / `cd <page>` | browse and open pages | | `ls` / `cd <page>` | browse and open pages |
| `theme [name]` | list or switch themes | | `theme [name]` | list or switch themes |
| `cowsay <text>` | moo | | `cowsay <text>` | moo |
| `btop` | system monitor with my GitHub stats |
| `pacman -S <pkg>` | install things (not really) |
## Running ## Running
+2 -1
View File
@@ -29,7 +29,8 @@
{ "label": "fastfetch.sh", "icon": "ph:cpu-duotone", "open": { "terminal": "fastfetch" } }, { "label": "fastfetch.sh", "icon": "ph:cpu-duotone", "open": { "terminal": "fastfetch" } },
{ "label": "whoami.sh", "icon": "ph:identification-card-duotone", "open": { "terminal": "whoami" } }, { "label": "whoami.sh", "icon": "ph:identification-card-duotone", "open": { "terminal": "whoami" } },
{ "label": "skills.sh", "icon": "ph:lightning-duotone", "open": { "terminal": "skills" } }, { "label": "skills.sh", "icon": "ph:lightning-duotone", "open": { "terminal": "skills" } },
{ "label": "contact.sh", "icon": "ph:envelope-simple-duotone", "open": { "terminal": "contact" } } { "label": "contact.sh", "icon": "ph:envelope-simple-duotone", "open": { "terminal": "contact" } },
{ "label": "btop", "icon": "ph:chart-line-duotone", "open": { "app": "btop" } }
] ]
}, },
{ {
+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 Terminal from '#lib/components/terminal/Terminal.svelte';
import ImageViewer from '#lib/components/viewer/ImageViewer.svelte'; import ImageViewer from '#lib/components/viewer/ImageViewer.svelte';
import { imageFileName } from '#lib/desktop/imageViewer'; 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 DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte'; import Window from './Window.svelte';
@@ -25,6 +27,9 @@
const isNarrow = $derived(areaWidth < narrowWidth); const isNarrow = $derived(areaWidth < narrowWidth);
const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.extraWindowIds]); 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 layout = $derived.by(() => {
const rects = new Map<string, TileRect>(); const rects = new Map<string, TileRect>();
@@ -33,10 +38,16 @@
rects.set(soloId, fullscreenLayout()); rects.set(soloId, fullscreenLayout());
return rects; 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; return rects;
}); });
$effect(() => {
floatingIds.forEach((id, index) => {
if (!windowManager.floatingRects[id]) windowManager.setFloatingRect(id, defaultFloatingRect(area, index));
});
});
$effect(() => { $effect(() => {
if (openWindowIds.length && !openWindowIds.includes(windowManager.focusedId)) { if (openWindowIds.length && !openWindowIds.includes(windowManager.focusedId)) {
windowManager.focus(openWindowIds.at(-1)!); windowManager.focus(openWindowIds.at(-1)!);
@@ -44,9 +55,31 @@
}); });
function styleFor(id: string): string { 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); const rect = layout.get(id);
return rect ? tileStyle(rect, isNarrow ? 4 : 10) : 'display: none'; 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> </script>
<div class="desktop" class:narrow={isNarrow}> <div class="desktop" class:narrow={isNarrow}>
@@ -66,29 +99,13 @@
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}> <div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
{#if pageOpen} {#if pageOpen}
<Window <Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
title="kitty"
path={pageTitle}
icon={pageIcon}
focused={windowManager.focusedId === pageWindowId}
tileStyle={styleFor(pageWindowId)}
scrollKey={pageKey}
onfocus={() => windowManager.focus(pageWindowId)}
onclose={closePage}
>
{@render children()} {@render children()}
</Window> </Window>
{/if} {/if}
{#each windowManager.terminals as terminal (terminal.id)} {#each windowManager.terminals as terminal (terminal.id)}
<Window <Window title="kitty" path="~" {...windowProps(terminal.id)} onclose={() => windowManager.closeWindow(terminal.id)}>
title="kitty"
path="~"
focused={windowManager.focusedId === terminal.id}
tileStyle={styleFor(terminal.id)}
onfocus={() => windowManager.focus(terminal.id)}
onclose={() => windowManager.closeWindow(terminal.id)}
>
<Terminal <Terminal
startupCommand={terminal.startupCommand} startupCommand={terminal.startupCommand}
focused={windowManager.focusedId === terminal.id} focused={windowManager.focusedId === terminal.id}
@@ -102,9 +119,7 @@
title="imv" title="imv"
path={imageFileName(viewer.images[viewer.currentIndex]?.src ?? '')} path={imageFileName(viewer.images[viewer.currentIndex]?.src ?? '')}
icon="ph:image-duotone" icon="ph:image-duotone"
focused={windowManager.focusedId === viewer.id} {...windowProps(viewer.id)}
tileStyle={styleFor(viewer.id)}
onfocus={() => windowManager.focus(viewer.id)}
onclose={() => windowManager.closeWindow(viewer.id)} onclose={() => windowManager.closeWindow(viewer.id)}
> >
<ImageViewer <ImageViewer
@@ -114,6 +129,18 @@
/> />
</Window> </Window>
{/each} {/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>
</div> </div>
+8 -1
View File
@@ -24,7 +24,7 @@
.filter((item) => !('page' in item.open)) .filter((item) => !('page' in item.open))
.map((item) => ({ .map((item) => ({
label: item.label, 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, icon: item.icon,
open: item.open open: item.open
})); }));
@@ -66,6 +66,13 @@
selectedIndex = Math.max(0, selectedIndex - 1); selectedIndex = Math.max(0, selectedIndex - 1);
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]); } 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> </script>
<button class="backdrop" aria-label="Close launcher" onclick={close}></button> <button class="backdrop" aria-label="Close launcher" onclick={close}></button>
+101 -5
View File
@@ -9,8 +9,13 @@
focused, focused,
tileStyle, tileStyle,
scrollKey = '', scrollKey = '',
floating = false,
canFloat = false,
onfocus, onfocus,
onclose, onclose,
ontogglefloat,
ondrag,
onresize,
children children
}: { }: {
title: string; title: string;
@@ -19,12 +24,44 @@
focused: boolean; focused: boolean;
tileStyle: string; tileStyle: string;
scrollKey?: string; scrollKey?: string;
floating?: boolean;
canFloat?: boolean;
onfocus: () => void; onfocus: () => void;
onclose: () => void; onclose: () => void;
ontogglefloat?: () => void;
ondrag?: (deltaX: number, deltaY: number) => void;
onresize?: (deltaX: number, deltaY: number) => void;
children: Snippet; children: Snippet;
} = $props(); } = $props();
let contentElement = $state<HTMLDivElement>(); 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(() => { $effect(() => {
scrollKey; scrollKey;
@@ -35,27 +72,49 @@
<section <section
class="window" class="window"
class:focused class:focused
class:floating
class:dragging={isDragging}
style={tileStyle} style={tileStyle}
aria-label="{title} {path}" aria-label="{title} {path}"
onpointerdown={onfocus} onpointerdown={onfocus}
onfocusin={onfocus} onfocusin={onfocus}
> >
<div class="window-surface"> <div class="window-surface">
<header class="title-bar"> <header class="title-bar" onpointerdown={(event) => trackPointer(event, ondrag)}>
<span class="title"> <span class="title">
<span class="app-name"><Icon {icon} width="15" />{title}</span> <span class="app-name"><Icon {icon} width="15" />{title}</span>
{#if path} {#if path}
<span class="path">{path}</span> <span class="path">{path}</span>
{/if} {/if}
</span> </span>
<button class="close-button" title="Close (Alt+Q)" aria-label="Close {title} {path}" onclick={onclose}> <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" /> <Icon icon="mdi:close" width="15" />
</button> </button>
</span>
</header> </header>
<div class="window-content" bind:this={contentElement}> <div class="window-content" bind:this={contentElement}>
{@render children()} {@render children()}
</div> </div>
</div> </div>
{#if floating && onresize}
<span
class="resize-handle"
role="presentation"
title="Resize"
onpointerdown={(event) => trackPointer(event, onresize)}
></span>
{/if}
</section> </section>
<style> <style>
@@ -75,6 +134,33 @@
background: var(--border-active); 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 { .window-surface {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -124,9 +210,14 @@
color: var(--foreground); color: var(--foreground);
} }
.close-button { .title-buttons {
display: grid; display: flex;
flex-shrink: 0; flex-shrink: 0;
align-items: center;
}
.title-button {
display: grid;
place-items: center; place-items: center;
width: 22px; width: 22px;
height: 22px; height: 22px;
@@ -134,6 +225,11 @@
background: none; background: none;
border: none; border: none;
color: var(--muted); color: var(--muted);
cursor: pointer;
}
.title-button:hover {
color: var(--accent);
} }
.close-button:hover { .close-button:hover {
@@ -152,7 +248,7 @@
height: 36px; height: 36px;
} }
.close-button { .title-button {
width: 34px; width: 34px;
height: 34px; height: 34px;
} }
+7 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte'; import { onMount, tick } from 'svelte';
import { profile } from '#lib/content'; 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 { themeState } from '#lib/theme/theme.svelte';
import { runCommand, type CommandContext } from '#lib/terminal/commands'; import { runCommand, type CommandContext } from '#lib/terminal/commands';
import { complete } from '#lib/terminal/completion'; import { complete } from '#lib/terminal/completion';
@@ -29,7 +29,8 @@
openPage, openPage,
openUrl, openUrl,
setTheme: (themeId) => themeState.set(themeId), setTheme: (themeId) => themeState.set(themeId),
currentThemeId: () => themeState.currentId currentThemeId: () => themeState.currentId,
openApp
}; };
async function execute(command: string) { async function execute(command: string) {
@@ -99,6 +100,10 @@
if (focused) inputElement?.focus({ preventScroll: true }); if (focused) inputElement?.focus({ preventScroll: true });
}); });
$effect(() => {
if (blocks.length) tick().then(() => screenElement?.scrollTo({ top: screenElement.scrollHeight }));
});
onMount(() => { onMount(() => {
if (startupCommand) typeOut(startupCommand); if (startupCommand) typeOut(startupCommand);
}); });
+3 -1
View File
@@ -144,7 +144,9 @@ export interface AboutContent {
movies: RatedTitle[]; 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 { export interface DesktopIconGroup {
name: string; name: string;
+6 -1
View File
@@ -1,5 +1,5 @@
import { goto } from '$app/navigation'; 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'; import { pageWindowId, windowManager } from './windows.svelte';
export function isExternalUrl(url: string): boolean { export function isExternalUrl(url: string): boolean {
@@ -19,10 +19,15 @@ export function openPage(path: string) {
goto(path); goto(path);
} }
export function openApp(app: DesktopApp) {
if (app === 'btop') windowManager.openSystemMonitor();
}
export function runDesktopAction(action: DesktopAction) { export function runDesktopAction(action: DesktopAction) {
windowManager.launcherOpen = false; windowManager.launcherOpen = false;
if ('page' in action) openPage(action.page); if ('page' in action) openPage(action.page);
else if ('terminal' in action) windowManager.openTerminal(action.terminal); else if ('terminal' in action) windowManager.openTerminal(action.terminal);
else if ('app' in action) openApp(action.app);
else openUrl(action.url); 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', 'Space'], action: 'Open the app launcher' },
{ keys: ['Alt', 'Q'], action: 'Close the focused window' }, { keys: ['Alt', 'Q'], action: 'Close the focused window' },
{ keys: ['Alt', 'F'], action: 'Toggle fullscreen for the page 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', 'J'], action: 'Focus the next window' },
{ keys: ['Alt', 'K'], action: 'Focus the previous window' }, { keys: ['Alt', 'K'], action: 'Focus the previous window' },
{ keys: ['Alt', 'T'], action: 'Switch to the next theme' } { 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 === 'Space') windowManager.launcherOpen = true;
else if (event.code === 'KeyQ') closeFocusedWindow(); else if (event.code === 'KeyQ') closeFocusedWindow();
else if (event.code === 'KeyF' && windowManager.focusedId === pageWindowId) windowManager.toggleMaximize(pageWindowId); 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 === 'KeyJ') windowManager.cycleFocus(openWindowIds, 1);
else if (event.code === 'KeyK') windowManager.cycleFocus(openWindowIds, -1); else if (event.code === 'KeyK') windowManager.cycleFocus(openWindowIds, -1);
else if (event.code === 'KeyT') themeState.cycle(1); else if (event.code === 'KeyT') themeState.cycle(1);
+42 -1
View File
@@ -16,18 +16,40 @@ export interface ImageViewerWindow {
currentIndex: number; currentIndex: number;
} }
export interface SystemMonitorWindow {
id: string;
}
export interface FloatingRect {
x: number;
y: number;
width: number;
height: number;
}
class WindowManager { class WindowManager {
terminals = $state<TerminalWindow[]>([]); terminals = $state<TerminalWindow[]>([]);
imageViewers = $state<ImageViewerWindow[]>([]); imageViewers = $state<ImageViewerWindow[]>([]);
systemMonitors = $state<SystemMonitorWindow[]>([]);
floatingRects = $state<Record<string, FloatingRect | null>>({});
focusedId = $state<string>(pageWindowId); focusedId = $state<string>(pageWindowId);
maximizedId = $state<string | null>(null); maximizedId = $state<string | null>(null);
launcherOpen = $state(false); launcherOpen = $state(false);
private nextTerminalNumber = 1; private nextTerminalNumber = 1;
private nextImageViewerNumber = 1; private nextImageViewerNumber = 1;
private nextSystemMonitorNumber = 1;
get extraWindowIds(): string[] { 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 = '') { openTerminal(startupCommand = '') {
@@ -42,9 +64,17 @@ class WindowManager {
this.focus(viewer.id); this.focus(viewer.id);
} }
openSystemMonitor() {
const monitor = { id: `btop-${this.nextSystemMonitorNumber++}` };
this.systemMonitors.push(monitor);
this.focus(monitor.id);
}
closeWindow(id: string) { closeWindow(id: string) {
this.terminals = this.terminals.filter((terminal) => terminal.id !== id); this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
this.imageViewers = this.imageViewers.filter((viewer) => viewer.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.maximizedId === id) this.maximizedId = null;
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId; 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; 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) { toggleMaximize(id: string) {
this.maximizedId = this.maximizedId === id ? null : id; this.maximizedId = this.maximizedId === id ? null : id;
this.focusedId = 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 };
}
+9 -1
View File
@@ -3,6 +3,7 @@ import { allSitePages } from '#lib/site/pages';
import { blankLine, errorLine, line, span } from '../output'; import { blankLine, errorLine, line, span } from '../output';
import { infoCommands } from './info'; import { infoCommands } from './info';
import { systemCommands } from './system'; import { systemCommands } from './system';
import { pacmanCommand } from './pacman';
import type { Command, CommandContext } from './types'; import type { Command, CommandContext } from './types';
export type { Command, CommandContext } from './types'; export type { Command, CommandContext } from './types';
@@ -38,7 +39,14 @@ const socialShortcuts: Command[] = profile.socials.map((social) => ({
run: (_args, context) => context.openUrl(social.url) run: (_args, context) => context.openUrl(social.url)
})); }));
export const commands: Command[] = [helpCommand, ...infoCommands, ...systemCommands, ...pageShortcuts, ...socialShortcuts]; export const commands: Command[] = [
helpCommand,
...infoCommands,
...systemCommands,
pacmanCommand,
...pageShortcuts,
...socialShortcuts
];
export function findCommand(name: string): Command | undefined { export function findCommand(name: string): Command | undefined {
const lowerName = name.toLowerCase(); const lowerName = name.toLowerCase();
+132
View File
@@ -0,0 +1,132 @@
import { profile } from '#lib/content';
import { blankLine, errorLine, line, span, type OutputBlock } from '../output';
import type { Command, CommandContext } from './types';
const stepDelay = 350;
const progressBarWidth = 24;
const jokePackages: Record<string, OutputBlock[]> = {
girlfriend: [errorLine('target not found: girlfriend')],
money: [errorLine('target not found: money'), line(span('hint: ', 'yellow'), 'try the Ko-fi icon in the top bar')],
sleep: [
line(span(':: ', 'blue', { bold: true }), 'sleep and energy-drinks are in conflict. Remove energy-drinks? [y/N] n'),
errorLine('unresolvable package conflicts detected')
],
windows: [errorLine('windows is not available for Arch Linux. Nice try.')],
job: [errorLine('job requires entry-level-position>=5-years-experience, which cannot be satisfied')],
internship: [errorLine('internship requires internship-experience, which requires internship')]
};
function packageVersion(name: string): string {
const seed = [...name].reduce((sum, character) => sum + character.charCodeAt(0), 0);
return `${(seed % 9) + 1}.${seed % 13}.${seed % 7}-1`;
}
function packageSize(name: string): string {
return `${(name.length * 1.37 + 0.42).toFixed(2)} MiB`;
}
function progressLine(index: number, total: number, action: string, name: string): OutputBlock {
const counter = `(${index}/${total})`;
return line(
span(`${counter} ${action} ${name}`.padEnd(38), 'foreground'),
span(`[${'#'.repeat(progressBarWidth)}]`, 'green'),
span(' 100%', 'muted')
);
}
function printOverTime(context: CommandContext, blocks: OutputBlock[]) {
blocks.forEach((block, index) => setTimeout(() => context.print(block), index * stepDelay));
}
function installPackages(context: CommandContext, packages: string[]) {
const jokeName = packages.find((name) => name in jokePackages);
if (jokeName) {
printOverTime(context, [line(span('resolving dependencies...', 'muted')), ...jokePackages[jokeName]]);
return;
}
const total = packages.length;
printOverTime(context, [
line(span('resolving dependencies...', 'muted')),
line(span('looking for conflicting packages...', 'muted')),
blankLine,
line(span(`Packages (${total}) `, 'foreground', { bold: true }), packages.map((name) => `${name}-${packageVersion(name)}`).join(' ')),
blankLine,
line(span('Total Installed Size: ', 'foreground', { bold: true }), packageSize(packages.join(''))),
blankLine,
line(span(':: ', 'blue', { bold: true }), 'Proceed with installation? [Y/n] y'),
...packages.map((name, index) => progressLine(index + 1, total, 'installing', name)),
line(span(':: ', 'blue', { bold: true }), 'Running post-transaction hooks...'),
progressLine(1, 1, 'Reloading', profile.username),
blankLine,
line(span('(this is a website, nothing was actually installed, but it felt good right?)', 'muted'))
]);
}
function systemUpgrade(context: CommandContext) {
printOverTime(context, [
line(span(':: ', 'blue', { bold: true }), 'Synchronizing package databases...'),
line(' core is up to date'),
line(' extra is up to date'),
line(span(':: ', 'blue', { bold: true }), 'Starting full system upgrade...'),
line(' there is nothing to do'),
line(span('btw, I use Arch.', 'accent'))
]);
}
function listInstalled(context: CommandContext) {
const installed = Object.values(profile.skills)
.flat()
.map((skill) => skill.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''));
context.print(...installed.map((name) => line(span(name, 'foreground', { bold: true }), ' ', span(packageVersion(name), 'green'))));
}
function removePackages(context: CommandContext, packages: string[]) {
const name = packages[0];
context.print(line(span('checking dependencies...', 'muted')), errorLine(`failed to prepare transaction: ${name} is required by ${profile.username}`));
}
function printHelp(context: CommandContext) {
context.print(
line('usage: pacman <operation> [...]'),
line('operations:'),
line(' pacman -S <package> install packages'),
line(' pacman -Syu upgrade the system'),
line(' pacman -Q list installed packages'),
line(' pacman -R <package> remove packages')
);
}
function runPacman(args: string[], context: CommandContext) {
const [operation, ...packages] = args;
if (!operation) {
context.print(errorLine('no operation specified (use -h for help)'));
return;
}
if (operation === '-h' || operation === '--help') return printHelp(context);
if (operation.startsWith('-Sy') && packages.length === 0) return systemUpgrade(context);
if (operation.startsWith('-Q')) return listInstalled(context);
if (packages.length === 0) {
context.print(errorLine('no targets specified (use -h for help)'));
return;
}
if (operation.startsWith('-S')) return installPackages(context, packages.map((name) => name.toLowerCase()));
if (operation.startsWith('-R')) return removePackages(context, packages);
context.print(errorLine(`invalid option '${operation}'`));
}
export const pacmanCommand: Command = {
name: 'pacman',
hidden: true,
description: 'Arch Linux package manager',
run: runPacman
};
export function runAsRoot(args: string[], context: CommandContext): boolean {
if (args[0] !== 'pacman') return false;
context.print(line(span(`[sudo] password for ${profile.username}: `, 'muted'), '********'));
runPacman(args.slice(1), context);
return true;
}
+11 -2
View File
@@ -2,6 +2,7 @@ import { profile } from '#lib/content';
import { themes } from '#lib/theme/themes'; import { themes } from '#lib/theme/themes';
import { allSitePages, findSitePage, sitePages } from '#lib/site/pages'; import { allSitePages, findSitePage, sitePages } from '#lib/site/pages';
import { blankLine, errorLine, line, span } from '../output'; import { blankLine, errorLine, line, span } from '../output';
import { runAsRoot } from './pacman';
import type { Command } from './types'; import type { Command } from './types';
function cowsay(message: string): string[] { function cowsay(message: string): string[] {
@@ -122,8 +123,16 @@ export const systemCommands: Command[] = [
name: 'sudo', name: 'sudo',
hidden: true, hidden: true,
description: 'Elevated privileges', description: 'Elevated privileges',
run: (_args, context) => run: (args, context) => {
context.print(errorLine(`${profile.username} is not in the sudoers file. This incident will be reported.`)) if (runAsRoot(args, context)) return;
context.print(errorLine(`${profile.username} is not in the sudoers file. This incident will be reported.`));
}
},
{
name: 'btop',
aliases: ['htop', 'top'],
description: 'Open the system monitor',
run: (_args, context) => context.openApp('btop')
}, },
{ {
name: 'pwd', name: 'pwd',
+2
View File
@@ -1,3 +1,4 @@
import type { DesktopApp } from '#lib/content';
import type { OutputBlock } from '../output'; import type { OutputBlock } from '../output';
export interface CommandContext { export interface CommandContext {
@@ -8,6 +9,7 @@ export interface CommandContext {
openUrl: (url: string) => void; openUrl: (url: string) => void;
setTheme: (themeId: string) => boolean; setTheme: (themeId: string) => boolean;
currentThemeId: () => string; currentThemeId: () => string;
openApp: (app: DesktopApp) => void;
} }
export interface Command { 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' } });
};