Add floating windows and btop system monitor
This commit is contained in:
1 parent
fd0b3b20a2
commit
201a1e0914
23 files changed
+1049
-36
No files matched your search
@@ -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" } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
<Icon icon="mdi:close" width="15" />
|
{#if canFloat && ontogglefloat}
|
||||||
</button>
|
<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>
|
</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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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('; ');
|
||||||
|
}
|
||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' } });
|
||||||
|
};
|
||||||
Reference in new issue
Block a user