Add Native Blog and Info Update

This commit is contained in:
SirBlob committed 2026-07-19 22:01:57 -04:00
1 parent 6cfce40e54
commit f315981cbc
55 files changed
+1969 -247

No files matched your search

-12
View File
@@ -11,24 +11,12 @@
margin: 0 auto;
height: calc(100vh - var(--navbar-height));
max-height: calc(100vh - var(--navbar-height));
animation: tuiFadeIn 0.4s ease-out;
}
.tui-terminal:focus-within .tui-border-glow {
opacity: 1;
}
@keyframes tuiFadeIn {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Hyprland-style animated border glow */
.tui-border-glow {
position: absolute;
+5
View File
@@ -20,6 +20,11 @@
border-color: transparent;
}
.tui-button.flex-expand {
flex: 1;
width: auto;
}
/* Inline button styles */
.tui-button.inline {
width: auto;
+47
View File
@@ -0,0 +1,47 @@
export const EASTERN_TIME_ZONE = 'America/New_York';
function parseCalendarDate(dateStr: string): Date {
const [year, month, day] = dateStr.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day, 12));
}
function easternOffset(date: Date): string {
const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
const eastern = new Date(date.toLocaleString('en-US', { timeZone: EASTERN_TIME_ZONE }));
const diffMinutes = Math.round((eastern.getTime() - utc.getTime()) / 60000);
const sign = diffMinutes <= 0 ? '-' : '+';
const abs = Math.abs(diffMinutes);
const hours = String(Math.floor(abs / 60)).padStart(2, '0');
const minutes = String(abs % 60).padStart(2, '0');
return `${sign}${hours}${minutes}`;
}
export function formatPostDate(dateStr: string): string {
if (!dateStr) return '';
return parseCalendarDate(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: EASTERN_TIME_ZONE
});
}
export function formatRssDate(dateStr: string): string {
if (!dateStr) return '';
const anchored = parseCalendarDate(dateStr);
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: EASTERN_TIME_ZONE,
weekday: 'short',
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).formatToParts(anchored);
const get = (type: string) => parts.find((part) => part.type === type)?.value ?? '';
return `${get('weekday')}, ${get('day')} ${get('month')} ${get('year')} ${get('hour')}:${get('minute')}:${get('second')} ${easternOffset(anchored)}`;
}
+41
View File
@@ -0,0 +1,41 @@
export interface FrontmatterResult {
data: Record<string, string | string[]>;
content: string;
}
function parseScalar(raw: string): string {
const trimmed = raw.trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function parseList(raw: string): string[] {
const trimmed = raw.trim();
const inner = trimmed.startsWith('[') && trimmed.endsWith(']') ? trimmed.slice(1, -1) : trimmed;
return inner
.split(',')
.map((item) => parseScalar(item))
.filter(Boolean);
}
export function parseFrontmatter(raw: string): FrontmatterResult {
const match = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
if (!match) {
return { data: {}, content: raw.trim() };
}
const [, block, content] = match;
const data: Record<string, string | string[]> = {};
for (const line of block.split('\n')) {
const separator = line.indexOf(':');
if (separator === -1) continue;
const key = line.slice(0, separator).trim();
const value = line.slice(separator + 1).trim();
data[key] = key === 'tags' ? parseList(value) : parseScalar(value);
}
return { data, content: content.trim() };
}
+83
View File
@@ -0,0 +1,83 @@
const keywordsByLanguage: Record<string, string[]> = {
js: [
'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'while', 'class',
'extends', 'new', 'import', 'from', 'export', 'default', 'async', 'await', 'try',
'catch', 'finally', 'throw', 'typeof', 'instanceof', 'in', 'of', 'switch', 'case',
'break', 'continue', 'do', 'yield', 'static', 'get', 'set', 'this', 'super', 'null',
'undefined', 'true', 'false'
],
python: [
'def', 'return', 'if', 'elif', 'else', 'for', 'while', 'class', 'import', 'from', 'as',
'try', 'except', 'finally', 'raise', 'with', 'lambda', 'yield', 'pass', 'break',
'continue', 'and', 'or', 'not', 'in', 'is', 'None', 'True', 'False', 'self', 'async',
'await', 'global', 'nonlocal'
],
bash: [
'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'function', 'return',
'local', 'export', 'case', 'esac', 'in', 'echo'
],
go: [
'func', 'return', 'if', 'else', 'for', 'range', 'switch', 'case', 'break', 'continue',
'package', 'import', 'var', 'const', 'type', 'struct', 'interface', 'map', 'chan', 'go',
'defer', 'nil', 'true', 'false'
],
rust: [
'fn', 'let', 'mut', 'return', 'if', 'else', 'for', 'while', 'loop', 'match', 'struct',
'enum', 'impl', 'trait', 'pub', 'use', 'mod', 'const', 'static', 'self', 'Self', 'true',
'false'
]
};
keywordsByLanguage.ts = [
...keywordsByLanguage.js,
'interface', 'type', 'enum', 'implements', 'public', 'private', 'protected', 'readonly',
'namespace', 'declare', 'as'
];
keywordsByLanguage.jsx = keywordsByLanguage.js;
keywordsByLanguage.tsx = keywordsByLanguage.ts;
keywordsByLanguage.sh = keywordsByLanguage.bash;
keywordsByLanguage.py = keywordsByLanguage.python;
const defaultKeywords = keywordsByLanguage.js;
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
const tokenPattern =
/(\/\/.*$)|(#.*$)|(\/\*[\s\S]*?\*\/)|("(?:[^"\\]|\\.)*")|('(?:[^'\\]|\\.)*')|(`(?:[^`\\]|\\.)*`)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_$][A-Za-z0-9_$]*)/gm;
export function highlightCode(code: string, lang: string): string {
const keywords = new Set(keywordsByLanguage[lang.toLowerCase()] ?? defaultKeywords);
let result = '';
let lastIndex = 0;
let match: RegExpExecArray | null;
tokenPattern.lastIndex = 0;
while ((match = tokenPattern.exec(code)) !== null) {
result += escapeHtml(code.slice(lastIndex, match.index));
const [full, lineComment, hashComment, blockComment, dquote, squote, backtick, number, word] = match;
if (lineComment || hashComment || blockComment) {
result += `<span class="tok-comment">${escapeHtml(full)}</span>`;
} else if (dquote || squote || backtick) {
result += `<span class="tok-string">${escapeHtml(full)}</span>`;
} else if (number) {
result += `<span class="tok-number">${escapeHtml(full)}</span>`;
} else if (word) {
result += keywords.has(word)
? `<span class="tok-keyword">${word}</span>`
: escapeHtml(word);
}
lastIndex = match.index + full.length;
}
result += escapeHtml(code.slice(lastIndex));
return result;
}
+124
View File
@@ -0,0 +1,124 @@
import { escapeHtml, highlightCode } from './highlight';
function renderInline(text: string): string {
let result = escapeHtml(text);
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" loading="lazy" />');
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
result = result.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
result = result.replace(/__([^_]+)__/g, '<strong>$1</strong>');
result = result.replace(/\*([^*]+)\*/g, '<em>$1</em>');
result = result.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<em>$1</em>');
return result;
}
export function renderMarkdown(markdown: string): string {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const blocks: string[] = [];
let paragraph: string[] = [];
let list: { ordered: boolean; items: string[] } | null = null;
function flushParagraph() {
if (paragraph.length) {
blocks.push(`<p>${renderInline(paragraph.join(' '))}</p>`);
paragraph = [];
}
}
function flushList() {
if (list) {
const tag = list.ordered ? 'ol' : 'ul';
const items = list.items.map((item) => `<li>${renderInline(item)}</li>`).join('');
blocks.push(`<${tag}>${items}</${tag}>`);
list = null;
}
}
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) {
flushParagraph();
flushList();
i++;
continue;
}
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length;
blocks.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
i++;
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
flushParagraph();
flushList();
blocks.push('<hr />');
i++;
continue;
}
if (line.trim().startsWith('```')) {
flushParagraph();
flushList();
const lang = line.trim().slice(3).trim();
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].trim().startsWith('```')) {
codeLines.push(lines[i]);
i++;
}
i++;
const codeClass = lang ? ` class="language-${lang}"` : '';
const codeText = codeLines.join('\n');
const codeHtml = lang ? highlightCode(codeText, lang) : escapeHtml(codeText);
blocks.push(`<pre><code${codeClass}>${codeHtml}</code></pre>`);
continue;
}
const quote = line.match(/^>\s?(.*)$/);
if (quote) {
flushParagraph();
flushList();
const quoteLines = [quote[1]];
i++;
while (i < lines.length && lines[i].match(/^>\s?(.*)$/)) {
quoteLines.push(lines[i].match(/^>\s?(.*)$/)![1]);
i++;
}
blocks.push(`<blockquote><p>${renderInline(quoteLines.join(' '))}</p></blockquote>`);
continue;
}
const unordered = line.match(/^[-*+]\s+(.*)$/);
const ordered = line.match(/^\d+\.\s+(.*)$/);
if (unordered || ordered) {
const isOrdered = !!ordered;
const itemText = (unordered ?? ordered)![1];
if (!list || list.ordered !== isOrdered) {
flushList();
list = { ordered: isOrdered, items: [] };
}
list.items.push(itemText);
i++;
continue;
}
flushList();
paragraph.push(line.trim());
i++;
}
flushParagraph();
flushList();
return blocks.join('\n');
}
+88
View File
@@ -0,0 +1,88 @@
import { user } from '$lib/config';
import { parseFrontmatter } from './frontmatter';
import { renderMarkdown } from './markdown';
export interface BlogPost {
slug: string;
title: string;
author: string;
date: string;
tags: string[];
excerpt: string;
html: string;
readingTime: number;
}
const postFiles = import.meta.glob('/blogs/*.md', {
eager: true,
query: '?raw',
import: 'default'
}) as Record<string, string>;
function slugFromPath(path: string): string {
return path.split('/').pop()!.replace(/\.md$/, '');
}
function estimateReadingTime(content: string): number {
const words = content.trim().split(/\s+/).filter(Boolean).length;
return Math.max(1, Math.round(words / 200));
}
function buildPost(path: string, raw: string): BlogPost {
const { data, content } = parseFrontmatter(raw);
const slug = slugFromPath(path);
return {
slug,
title: (data.title as string) || slug,
author: (data.author as string) || user.displayname,
date: (data.date as string) || '',
tags: (data.tags as string[]) || [],
excerpt: (data.excerpt as string) || '',
html: renderMarkdown(content),
readingTime: estimateReadingTime(content)
};
}
const allPosts: BlogPost[] = Object.entries(postFiles)
.map(([path, raw]) => buildPost(path, raw))
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
export function getAllPosts(): BlogPost[] {
return allPosts;
}
export function getPostBySlug(slug: string): BlogPost | undefined {
return allPosts.find((post) => post.slug === slug);
}
export interface BlogPostNav {
slug: string;
title: string;
}
export interface AdjacentPosts {
previous: BlogPostNav | null;
next: BlogPostNav | null;
}
export function getAdjacentPosts(slug: string): AdjacentPosts {
const index = allPosts.findIndex((post) => post.slug === slug);
if (index === -1) return { previous: null, next: null };
const older = allPosts[index + 1];
const newer = allPosts[index - 1];
return {
previous: older ? { slug: older.slug, title: older.title } : null,
next: newer ? { slug: newer.slug, title: newer.title } : null
};
}
export function getAllTags(): string[] {
const tags = new Set<string>();
for (const post of allPosts) {
for (const tag of post.tags) tags.add(tag);
}
return Array.from(tags).sort();
}
+1 -1
View File
@@ -79,7 +79,7 @@
<TuiLine
line={child}
index={k}
segments={parseColorText(child.content)}
segments={parseColorText(child.content, $themeColors.colorMap)}
complete={true}
showImage={true}
selectedIndex={-1}
+2 -1
View File
@@ -42,6 +42,7 @@
class:selected
class:inline
class:no-border={line.border === false}
class:flex-expand={line.flex}
style="--btn-color: {getButtonStyle(line.style)}"
onclick={() => onClick(index, line)}
onmouseenter={() => onHover(index)}
@@ -55,7 +56,7 @@
<span class="btn-text" style:text-align={line.textStyle || "left"}>
{#each segments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
+6 -5
View File
@@ -6,6 +6,7 @@
getSegmentStyle,
parseDimension,
} from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { CardLine, TerminalLine } from "./types";
import TuiLine from "./TuiLine.svelte";
import "$lib/assets/css/tui-card.css";
@@ -26,12 +27,12 @@
onLinkClick = () => {},
}: Props = $props();
const segments = $derived(parseColorText(line.content));
const segments = $derived(parseColorText(line.content, $themeColors.colorMap));
const titleSegments = $derived(
line.cardTitle ? parseColorText(line.cardTitle) : [],
line.cardTitle ? parseColorText(line.cardTitle, $themeColors.colorMap) : [],
);
const footerSegments = $derived(
line.cardFooter ? parseColorText(line.cardFooter) : [],
line.cardFooter ? parseColorText(line.cardFooter, $themeColors.colorMap) : [],
);
const cardStyle = $derived(
@@ -146,7 +147,7 @@
<TuiLine
line={item.line}
index={item.index}
segments={parseColorText(item.line.content)}
segments={parseColorText(item.line.content, $themeColors.colorMap)}
complete={true}
showImage={true}
selectedIndex={-1}
@@ -161,7 +162,7 @@
<TuiLine
line={group.line}
index={group.index}
segments={parseColorText(group.line.content)}
segments={parseColorText(group.line.content, $themeColors.colorMap)}
complete={true}
showImage={true}
selectedIndex={-1}
+1 -1
View File
@@ -72,7 +72,7 @@
<span class="checkbox-label">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
+9 -3
View File
@@ -112,7 +112,7 @@
<TuiLine
line={item.line}
index={item.index}
segments={parseColorText(item.line.content)}
segments={parseColorText(item.line.content, colorMap)}
complete={true}
showImage={item.line.type === "image"}
selectedIndex={-1}
@@ -127,7 +127,7 @@
<TuiLine
line={group.line}
index={group.index}
segments={parseColorText(group.line.content)}
segments={parseColorText(group.line.content, colorMap)}
complete={true}
showImage={group.line.type === "image"}
selectedIndex={-1}
@@ -167,10 +167,16 @@
width: 100%;
}
.tui-group.expand > :global(*) {
.tui-group.expand > :global(*),
.tui-group.expand > :global(*) > :global(*) {
flex: 1;
}
.tui-group.expand :global(.tui-button) {
flex: 1;
width: auto;
}
@keyframes lineSlideIn {
from {
opacity: 0;
+1 -1
View File
@@ -70,7 +70,7 @@
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
+21 -7
View File
@@ -76,12 +76,26 @@
const isHeader = $derived(line.type === "header");
const isLink = $derived(line.type === "link");
const isTooltip = $derived(line.type === "tooltip");
const blankHeight = $derived(
line.type === "blank" && line.height !== undefined
? typeof line.height === "number"
? `${line.height}px`
: line.height
: undefined,
);
</script>
<div class:mobile-hidden={line.mobile === false} style="display: contents">
{#if isBlank}
{#if inline}<span class="inline-blank"></span>{:else}<div
{#if inline}<span
class="inline-blank"
style={blankHeight ? `width: ${blankHeight}` : undefined}
></span>{:else}<div
class="tui-line blank"
style={blankHeight
? `height: ${blankHeight}; min-height: ${blankHeight};`
: undefined}
></div>{/if}
{:else if isDivider}
<div class="tui-divider" id={line.id}>
@@ -149,7 +163,7 @@
<Icon icon="mdi:pound" width="20" class="header-icon" />
{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width="20"
width={seg.iconSize ?? 20}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
@@ -161,7 +175,7 @@
<Icon icon="mdi:pound" width="25" class="header-icon" />
{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width="25"
width={seg.iconSize ?? 25}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
@@ -175,7 +189,7 @@
<span class="inline-content {line.type}">
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width="14"
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
@@ -196,7 +210,7 @@
line.type,
)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width="14"
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
@@ -210,7 +224,7 @@
<span class="inline-content {line.type}">
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width="14"
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
@@ -224,7 +238,7 @@
line.type,
)}{#each segments as seg}{#if seg.icon}<Icon
icon={seg.icon}
width="14"
width={seg.iconSize ?? 14}
class="inline-icon"
/>{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span
+1 -1
View File
@@ -33,7 +33,7 @@
<button class="link-text" onclick={onClick}>
{#each segments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
+2 -2
View File
@@ -41,7 +41,7 @@
<div class="progress-label">
{#each contentSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
@@ -89,7 +89,7 @@
<div class="progress-value">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="12" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 12} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
+2 -2
View File
@@ -58,7 +58,7 @@
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
@@ -98,7 +98,7 @@
{#if segment.icon}
<Icon
icon={segment.icon}
width="14"
width={segment.iconSize ?? 14}
class="inline-icon"
/>
{:else if getSegmentStyle(segment)}
+3 -3
View File
@@ -158,7 +158,7 @@
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
@@ -202,7 +202,7 @@
{/if}
<div class="select-options">
{#each filteredOptions as option, i}
{@const optionSegments = parseColorText(option.label)}
{@const optionSegments = parseColorText(option.label, $themeColors.colorMap)}
<div
class="select-option"
class:selected={value === option.value}
@@ -232,7 +232,7 @@
{#if segment.icon}
<Icon
icon={segment.icon}
width="14"
width={segment.iconSize ?? 14}
class="inline-icon"
/>
{:else if getSegmentStyle(segment)}
+1 -1
View File
@@ -76,7 +76,7 @@
{/if}
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
+1 -1
View File
@@ -62,7 +62,7 @@
<span class="toggle-label">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from "$lib/stores/theme";
import type { TooltipLine } from "./types";
import "$lib/assets/css/tui-tooltip.css";
@@ -13,7 +14,7 @@
let triggerEl: HTMLSpanElement | undefined = $state();
let tooltipStyle = $state("");
const contentSegments = $derived(parseColorText(line.content));
const contentSegments = $derived(parseColorText(line.content, $themeColors.colorMap));
const position = $derived(line.tooltipPosition || "top");
function updateTooltipPosition() {
+155 -34
View File
@@ -29,15 +29,71 @@ const err = (content: string): TerminalLine => ({ type: 'error', content });
const info = (content: string): TerminalLine => ({ type: 'info', content });
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
const pageRoutes: Record<string, string> = {
home: '/',
about: '/about',
portfolio: '/portfolio',
projects: '/projects',
models: '/models',
components: '/components',
links: '/links'
};
interface PageNode {
name: string;
path: string;
children?: PageNode[];
}
const pageTree: PageNode[] = [
{ name: 'home', path: '/' },
{ name: 'about', path: '/about' },
{ name: 'portfolio', path: '/portfolio' },
{
name: 'projects',
path: '/projects',
children: [
{ name: 'opensource', path: '/projects/opensource' },
{ name: 'packages', path: '/projects/packages' },
{ name: 'hackathons', path: '/projects/hackathons' },
{ name: 'research', path: '/projects/research' }
]
},
{ name: 'models', path: '/models' },
{ name: 'components', path: '/components' },
{ name: 'links', path: '/links' },
{ name: 'blog', path: '/blog' }
];
const pageRoutes: Record<string, string> = pageTree.reduce((routes, node) => {
routes[node.name] = node.path;
node.children?.forEach((child) => {
routes[child.name] = child.path;
});
return routes;
}, {} as Record<string, string>);
function resolvePagePath(target: string): string | undefined {
if (pageRoutes[target] !== undefined) return pageRoutes[target];
const segments = target.split('/').filter(Boolean);
if (segments.length < 2) return undefined;
let nodes: PageNode[] = pageTree;
let path: string | undefined;
for (const segment of segments) {
const node = nodes.find((candidate) => candidate.name === segment);
if (!node) return undefined;
path = node.path;
nodes = node.children ?? [];
}
return path;
}
function cowsay(message: string): string[] {
const text = message || 'Moo?';
const border = '-'.repeat(text.length + 2);
return [
` ${border}`,
`< ${text} >`,
` ${border}`,
' \\ ^__^',
' \\ (oo)\\_______',
' (__)\\ )\\/\\',
' ||----w |',
' || ||'
];
}
const baseCommands: Command[] = [
{
@@ -59,7 +115,7 @@ const baseCommands: Command[] = [
tableRows: rows,
style: 'accent'
} as TerminalLine);
ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cd projects(&), or (&accent)theme(&)'));
ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cowsay(&), or (&accent)cd projects(&)'));
ctx.terminal.write(blank());
}
},
@@ -74,7 +130,7 @@ const baseCommands: Command[] = [
description: 'Show a short bio',
run: (_args, ctx) => {
ctx.terminal.writeLines([
out(`(&primary,bold)${ctx.user.name}(&) (&muted)— ${ctx.user.title}(&)`),
out(`(&primary,bold)${ctx.user.displayname}(&) (&muted)— ${ctx.user.title}(&)`),
out(`(&muted)${ctx.user.bio}(&)`),
blank()
]);
@@ -85,14 +141,37 @@ const baseCommands: Command[] = [
aliases: ['banner'],
description: 'Display system info',
run: (_args, ctx) => {
ctx.terminal.writeLines([
const launched = new Date('2020-01-01T00:00:00Z');
const days = Math.floor((Date.now() - launched.getTime()) / 86400000);
const infoLines: TerminalLine[] = [
out(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`),
out('(&muted)------------------------------(&)'),
out(`(&blue,bold)Name(&): ${ctx.user.name}`),
out(`(&blue,bold)Name(&): ${ctx.user.displayname}`),
out(`(&blue,bold)Title(&): ${ctx.user.title}`),
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
out(`(&blue,bold)Shell(&): tui-terminal`),
out(`(&blue,bold)Theme(&): ${ctx.getColorTheme()} (${ctx.getMode()})`),
out(`(&blue,bold)Uptime(&): ${days} days since launch`),
out(`(&blue,bold)Languages(&): ${ctx.skills.languages.slice(0, 6).join(', ')}`),
out('(&red)███(&)(&yellow)███(&)(&green)███(&)(&cyan)███(&)(&blue)███(&)(&magenta)███(&)')
];
ctx.terminal.writeLines([
{
type: 'image',
content: '',
image: ctx.user.avatar,
imageAlt: ctx.user.displayname,
imageWidth: 325,
inline: true
},
{
type: 'group',
content: '',
inline: true,
groupDirection: 'column',
groupGap: '0',
children: infoLines
},
blank()
]);
}
@@ -134,17 +213,16 @@ const baseCommands: Command[] = [
},
{
name: 'email',
description: 'Open a mail draft',
description: 'Show contact email',
run: (_args, ctx) => {
ctx.terminal.write(ok(`Opening mail to (&accent)${ctx.user.email}(&)`));
ctx.navigate(`mailto:${ctx.user.email}`, true);
ctx.terminal.write(out(`(&accent)${ctx.user.email}(&)`));
}
},
{
name: 'cd',
aliases: ['goto', 'open'],
description: 'Navigate to a page',
usage: 'cd <page>',
usage: 'cd <page> (e.g. projects/opensource)',
run: (args, ctx) => {
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
@@ -153,9 +231,10 @@ const baseCommands: Command[] = [
ctx.navigate(navItem.path, navItem.external);
return;
}
if (pageRoutes[target] !== undefined) {
const resolvedPath = resolvePagePath(target);
if (resolvedPath !== undefined) {
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
ctx.navigate(pageRoutes[target]);
ctx.navigate(resolvedPath);
return;
}
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
@@ -163,13 +242,26 @@ const baseCommands: Command[] = [
},
{
name: 'ls',
aliases: ['dir'],
description: 'List the pages you can visit',
run: (_args, ctx) => {
const names = Object.keys(pageRoutes);
ctx.navigation.forEach((item) => {
if (item.external && !names.includes(item.name)) names.push(item.name);
const lines: TerminalLine[] = [];
pageTree.forEach((node) => {
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${node.name}(&)/`));
node.children?.forEach((child, i) => {
const branch = i === node.children!.length - 1 ? '└──' : '├──';
lines.push(out(`(&muted) ${branch}(&) (&primary,bold)${child.name}(&)/`));
});
});
ctx.terminal.write(out(names.map((name) => `(&blue)${name}(&)`).join(' ')));
ctx.navigation.forEach((item) => {
if (item.external && pageRoutes[item.name] === undefined) {
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${item.name}(&)/`));
}
});
ctx.terminal.writeLines(lines);
}
},
{
@@ -217,10 +309,48 @@ const baseCommands: Command[] = [
usage: 'echo <text>',
run: (args, ctx) => ctx.terminal.write(out(args.join(' ')))
},
{
name: 'cowsay',
description: 'Make a cow say something',
usage: 'cowsay <text>',
run: (args, ctx) => {
const lines = cowsay(args.join(' '));
ctx.terminal.writeLines(lines.map((line) => out(`(&accent)${line}(&)`)));
}
},
{
name: 'date',
description: 'Show the current date and time',
run: (_args, ctx) => ctx.terminal.write(out(new Date().toString()))
description: 'Show the current date and time (Eastern)',
run: (_args, ctx) => {
const now = new Date();
const easternString = now.toLocaleString('en-US', {
timeZone: 'America/New_York',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
});
const easternParts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/New_York',
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(now);
const part = (type: string) => Number(easternParts.find((p) => p.type === type)?.value);
const easternDate = new Date(Date.UTC(part('year'), part('month') - 1, part('day')));
const startOfYear = new Date(Date.UTC(part('year'), 0, 1));
const dayOfYear = Math.floor((easternDate.getTime() - startOfYear.getTime()) / 86400000) + 1;
ctx.terminal.writeLines([
out(easternString),
info(`(&muted)Day ${dayOfYear} of ${part('year')}(&)`)
]);
}
},
{
name: 'sudo',
@@ -230,15 +360,6 @@ const baseCommands: Command[] = [
ctx.terminal.write(
err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`)
)
},
{
name: 'blog',
hidden: true,
description: 'Open the blog',
run: (_args, ctx) => {
const blogItem = ctx.navigation.find((item) => item.name.toLowerCase() === 'blog');
if (blogItem) ctx.navigate(blogItem.path, blogItem.external);
}
}
];
+2
View File
@@ -62,6 +62,7 @@ export interface WarningLine extends BaseTerminalLine {
export interface BlankLine extends BaseTerminalLine {
type: 'blank';
height?: string | number;
}
export interface DividerLine extends BaseTerminalLine {
@@ -80,6 +81,7 @@ export interface ButtonLine extends BaseTerminalLine {
external?: boolean;
textStyle?: 'start' | 'center' | 'end';
border?: boolean;
flex?: boolean;
}
export interface LinkLine extends BaseTerminalLine {
+4 -3
View File
@@ -85,10 +85,11 @@ export function parseColorText(text: string, colors: ThemeColorMap = colorMap):
segments.push({ text: text.slice(lastIndex, match.index) });
}
// Check if this is an icon match (Group 1 is the icon name)
// Check if this is an icon match (Group 1 is the icon name, optionally followed by a size)
if (match[1]) {
const iconName = match[1].trim();
segments.push({ text: '', icon: iconName });
const [iconName, sizeArg] = match[1].split(',').map(part => part.trim());
const iconSize = sizeArg ? Number(sizeArg) : undefined;
segments.push({ text: '', icon: iconName, iconSize: iconSize && !isNaN(iconSize) ? iconSize : undefined });
lastIndex = match.index + match[0].length;
continue;
}
+2
View File
@@ -0,0 +1,2 @@
export const blogName = 'Finding Out';
export const blogTagline = 'My Engineering Blog';
+31 -7
View File
@@ -18,6 +18,14 @@ export const openSourceProjects: OpenSourceProject[] = [
description: 'TypstDrive is a self-hosted collaborative web editor for Typst.',
tech: ['Typst', 'Self-Hosted', 'Collaborative'],
github: 'https://github.com/SirBlobby/TypstDrive',
image: '/projects/typstdrive.png',
},
{
name: 'Typst Desktop',
description: 'Typst Desktop is a native desktop app for writing and compiling Typst documents offline.',
tech: ['Typst', 'Desktop', 'Offline'],
github: 'https://github.com/SirBlobby/typst-desktop',
image: '/projects/typstdesktop.png',
},
{
name: 'Pkit',
@@ -33,13 +41,6 @@ export const openSourceProjects: OpenSourceProject[] = [
tech: ['Python', 'LLM', 'AI', 'NotebookLLM'],
github: 'https://github.com/SirBlobby/OBookLLM',
image: 'https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg',
},
{
name: 'Filaprint',
description: 'Filaprint is a web application to help 3D printing enthusiasts manage their filament inventory, track print jobs, view 3D models, and calculate costs and energy usage.',
tech: ['Sveltekit', 'Management', '3D Printing'],
github: 'https://github.com/SirBlobby/Filaprint',
image: 'https://repository-images.githubusercontent.com/354583933/72c58c80-9727-11eb-98b2-f352fded32b9',
}
];
@@ -165,6 +166,27 @@ export const experience: Experience[] = [
}
];
// ============================================================================
// RESEARCH
// ============================================================================
export interface ResearchItem {
title: string;
organization?: string;
period?: string;
description?: string;
tech?: string[];
link?: string;
}
export const researchItems: ResearchItem[] = [
{
title: 'Research Assistant',
organization: 'College of Engineering and Computing',
period: 'May 2026 – Present · 3 mos'
}
];
// ============================================================================
// HACKATHONS
// ============================================================================
@@ -193,6 +215,7 @@ export const cards: Card[] = [
description:
"Architected a real-time campus noise and occupancy monitoring system utilizing SvelteKit, FastAPI, and MongoDB to help students locate quiet study spaces.",
link: "https://hushmap.study/",
devpost: "https://devpost.com/software/hushmap-193nia",
hackathonName: "Bitcamp 2026",
university: "University of Maryland",
location: "College Park, MD",
@@ -208,6 +231,7 @@ export const cards: Card[] = [
title: "LearningBuddy",
description:
"Engineered an ESP32-S3 IoT study companion in C++ with secure Wi-Fi provisioning, utilizing WebSockets and Socket.IO to stream low-latency raw audio for live lecture capture.",
devpost: "https://devpost.com/software/studybuddy-bc0i3j",
hackathonName: "HaxFax x PatriotHacks 2026",
university: "George Mason University",
location: "Fairfax, VA",
+5 -3
View File
@@ -26,9 +26,9 @@ export {
animations
} from './theme';
// Content: projects, models, experience, hackathon cards
export type { OpenSourceProject, PackageProject, Model3D, Experience, Card } from './content';
export { openSourceProjects, packageProjects, models, experience, cards, sortedCards } from './content';
// Content: projects, models, experience, research, hackathon cards
export type { OpenSourceProject, PackageProject, Model3D, Experience, ResearchItem, Card } from './content';
export { openSourceProjects, packageProjects, models, experience, researchItems, cards, sortedCards } from './content';
// Terminal settings, TUI styling, speed presets, model viewer, particles, shortcuts
export type { SpeedPreset, TerminalSettings } from './terminal';
@@ -53,6 +53,7 @@ export { navigation, site, pageMeta } from './navigation';
// ============================================================================
// Use (&color)text(&) syntax in terminal content for colored text
// Use (&icon, iconName) syntax for inline icons
// Use (&icon, iconName, size) to set a custom icon size in pixels
//
// Available colors:
// red, green, yellow, blue, magenta, cyan, white, gray, orange, pink
@@ -71,6 +72,7 @@ export { navigation, site, pageMeta } from './navigation';
// "(&error)Failed(&) - (&success)Passed(&)" -> multiple colors
// "(&icon, mdi:github) GitHub" -> inline GitHub icon
// "Check (&icon, mdi:check) Done" -> inline check icon
// "(&icon, mdi:trophy, 32) Winner" -> bigger trophy icon (32px)
// ============================================================================
// HELPER FUNCTIONS
+32 -2
View File
@@ -7,11 +7,11 @@ import { user, skills } from './user';
export const navigation = [
{ name: 'home', path: '/', icon: '~' },
{ name: 'about', path: '/about', icon: 'mdi:account' },
{ name: 'portfolio', path: '/portfolio', icon: '📁' },
// { name: 'portfolio', path: '/portfolio', icon: '📁' },
// { name: 'models', path: '/models', icon: '🎨' },
{ name: 'projects', path: '/projects', icon: '🏆' },
// { name: 'components', path: '/components', icon: '🧩' },
{ name: 'blog', path: 'https://blog.sirblob.co', icon: '📝', external: true }
{ name: 'blog', path: '/blog', icon: '📝' }
];
// ============================================================================
@@ -70,6 +70,30 @@ export const pageMeta: Record<string, PageMeta> = {
icon: 'mdi:trophy',
keywords: ['hackathon', 'projects', 'events']
},
'/projects/opensource': {
title: `${user.displayname} — Open Source`,
description: 'Libraries and tools built and maintained in the open.',
icon: 'mdi:source-branch',
keywords: ['open source', 'projects', 'github']
},
'/projects/packages': {
title: `${user.displayname} — Packages`,
description: 'Published packages and CLI tools.',
icon: 'mdi:package-variant',
keywords: ['packages', 'npm', 'cli']
},
'/projects/hackathons': {
title: `${user.displayname} — Hackathons`,
description: 'Hackathon projects, demos and awards.',
icon: 'mdi:trophy',
keywords: ['hackathon', 'projects', 'events']
},
'/projects/research': {
title: `${user.displayname} — Research`,
description: 'Research roles, projects, and publications.',
icon: 'mdi:microscope',
keywords: ['research', 'academic', 'publications']
},
'/components': {
title: `${user.displayname} — Components`,
description: 'Terminal UI components showcase and documentation.',
@@ -81,5 +105,11 @@ export const pageMeta: Record<string, PageMeta> = {
description: 'All my links with scannable QR codes.',
icon: 'mdi:link-variant',
keywords: ['links', 'qr', 'social', 'contact']
},
'/blog': {
title: `${user.displayname} — Blog`,
description: 'Notes and write-ups from my projects.',
icon: 'mdi:post-outline',
keywords: ['blog', 'writing', 'notes']
}
};
+8
View File
@@ -138,6 +138,10 @@ export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
'portfolio': 'instant',
'models': 'fast',
'projects': 'fast',
'opensource': 'fast',
'packages': 'fast',
'hackathons': 'fast',
'research': 'fast',
'components': 'fast'
};
@@ -152,6 +156,10 @@ export const pageAutoscrollSettings: Record<string, boolean> = {
'portfolio': false,
'models': false,
'projects': false,
'opensource': false,
'packages': false,
'hackathons': false,
'research': false,
'components': false
};
+1 -1
View File
@@ -50,7 +50,7 @@ export const links: LinkItem[] = [
// ============================================================================
export const skills = {
languages: ['Python', 'JavaScript', 'TypeScript', 'C', 'C++', 'Java', 'Node.js'],
languages: ['Python', 'JavaScript', 'TypeScript', 'C', 'C++', 'Java', 'Node.js', 'Rust', 'Go'],
frameworks: ['React', 'Electron', 'Svelte', 'Bootstrap', 'TailwindCSS', 'Discord.js', ],
applications: ['IntelliJ', 'VS Code', 'Git', 'Blender', 'Godot'],
platforms: ['Windows', 'Linux', 'macOS', 'Arduino', 'Raspberry Pi'],
+10
View File
@@ -65,6 +65,16 @@ const games = [
name: "Risk",
image: '/img/risk.png',
url: 'https://store.steampowered.com/app/1128810/RISK_Global_Domination/'
},
{
name: "Slay the Spire",
image: '/img/slay_the_spire.png',
url: 'https://store.steampowered.com/app/646570/Slay_the_Spire/'
},
{
name: "Slay the Spire 2",
image: '/img/slay_the_spire_2.png',
url: 'https://store.steampowered.com/app/2868840/Slay_the_Spire_2/'
}
]
+73
View File
@@ -0,0 +1,73 @@
import type { TerminalLine } from '$lib/components/tui/types';
import { sortedCards } from '$lib/config';
import { projectNavLinks } from './projectNav';
import { tagColor } from './palette';
const totalHackathons = sortedCards.length;
const totalAwards = sortedCards.filter(c => c.awards && c.awards.length > 0).length;
const featuredCount = sortedCards.filter(c => c.featured).length;
const hackathonSections: TerminalLine[] = sortedCards.flatMap(card => {
const hasImage = !!card.image;
const hasAwards = !!card.awards?.length;
const icon = hasAwards ? 'mdi:trophy' : 'mdi:rocket-launch';
const infoChildren: TerminalLine[] = [
{
type: 'output' as const,
content: `(&icon, ${icon}) (&bg-green,black) HACKATHON (&)${card.featured ? ' (&bg-orange,black) FEATURED (&)' : ''}`
},
...(card.hackathonName
? [{ type: 'info' as const, content: `(&primary)${card.hackathonName}(&)${card.year ? ` (&muted)(${card.year})(&)` : ''}` }]
: []),
...(card.university
? [{ type: 'output' as const, content: `(&muted)${card.university}${card.location ? `, ${card.location}` : ''}(&)` }]
: []),
{ type: 'output' as const, content: `(&text)${card.description}(&)` },
...(card.awards?.length
? card.awards.map(award => ({ type: 'success' as const, content: `(&success)🏆 ${award.place} — ${award.track}(&)` }))
: []),
...(card.tags?.length
? [{ type: 'output' as const, content: card.tags.map((tag, i) => `(&bg-${tagColor(i)},black) ${tag} (&)`).join(' ') }]
: []),
...(card.liveWarning ? [{ type: 'warning' as const, content: `(&warning)Demo may be unavailable(&)` }] : []),
{
type: 'group' as const,
content: '',
groupDirection: 'row' as const,
groupGap: '0.75rem',
groupExpand: true,
children: [
...(card.link
? [{ type: 'button' as const, content: 'View Demo', href: card.link, icon: 'mdi:open-in-new', style: 'primary' as const, flex: true }]
: []),
...(card.repo
? [{ type: 'button' as const, content: 'View Code', href: card.repo, icon: 'mdi:github', style: 'accent' as const, flex: true }]
: []),
...(card.devpost
? [{ type: 'button' as const, content: 'View on Devpost', href: card.devpost, icon: 'mdi:rocket-launch', style: 'secondary' as const, flex: true }]
: [])
]
}
];
return [
{ type: 'divider' as const, content: card.title.toUpperCase() },
...(hasImage
? [{ type: 'image' as const, content: '', image: card.image as string, imageAlt: card.title, imageWidth: 350, inline: true }]
: []),
{ type: 'group' as const, content: '', inline: hasImage, groupDirection: 'column' as const, groupAlign: 'stretch' as const, children: infoChildren },
{ type: 'blank' as const, content: '' }
];
});
export const lines: TerminalLine[] = [
{ type: 'command' as const, content: 'ls ~/projects/hackathons' },
{ type: 'blank' as const, content: '', height: '1rem' },
projectNavLinks,
{ type: 'blank' as const, content: '', height: '1rem' },
{ type: 'header' as const, content: 'Hackathon Journey' },
{ type: 'output' as const, content: `(&muted)Total:(&) (&primary)${totalHackathons}(&) (&muted)| Awards:(&) (&yellow)${totalAwards}(&) (&muted)| Featured:(&) (&accent)${featuredCount}(&)` },
{ type: 'blank' as const, content: '' },
...hackathonSections
];
+52
View File
@@ -0,0 +1,52 @@
import type { TerminalLine } from '$lib/components/tui/types';
import { openSourceProjects } from '$lib/config';
import { projectNavLinks } from './projectNav';
import { tagColor } from './palette';
const projectSections: TerminalLine[] = openSourceProjects.flatMap(project => {
const hasImage = !!project.image;
const infoChildren: TerminalLine[] = [
{ type: 'output' as const, content: `(&icon, mdi:source-branch) (&bg-blue,black) OPEN SOURCE (&)` },
{ type: 'output' as const, content: `(&text)${project.description}(&)` },
{
type: 'output' as const,
content: project.tech.map((tech, i) => `(&bg-${tagColor(i)},black) ${tech} (&)`).join(' ')
},
{
type: 'group' as const,
content: '',
groupDirection: 'row' as const,
groupGap: '0.75rem',
groupExpand: true,
children: [
...(project.github
? [{ type: 'button' as const, content: 'View on GitHub', href: project.github, icon: 'mdi:github', style: 'accent' as const, flex: true }]
: []),
...(project.live
? [{ type: 'button' as const, content: 'View Live Demo', href: project.live, icon: 'mdi:open-in-new', style: 'primary' as const, flex: true }]
: [])
]
}
];
return [
{ type: 'divider' as const, content: project.name.toUpperCase() },
...(hasImage
? [{ type: 'image' as const, content: '', image: project.image as string, imageAlt: project.name, imageWidth: 350, inline: true }]
: []),
{ type: 'group' as const, content: '', inline: hasImage, groupDirection: 'column' as const, groupAlign: 'stretch' as const, children: infoChildren },
{ type: 'blank' as const, content: '' }
];
});
export const lines: TerminalLine[] = [
{ type: 'command' as const, content: 'ls ~/projects/opensource' },
{ type: 'blank' as const, content: '', height: '1rem' },
projectNavLinks,
{ type: 'blank' as const, content: '', height: '1rem' },
{ type: 'header' as const, content: 'Open Source' },
{ type: 'output' as const, content: `(&muted)Libraries and tools I build and maintain in the open.(&)` },
{ type: 'blank' as const, content: '' },
...projectSections
];
+52
View File
@@ -0,0 +1,52 @@
import type { TerminalLine } from '$lib/components/tui/types';
import { packageProjects } from '$lib/config';
import { projectNavLinks } from './projectNav';
import { tagColor } from './palette';
const orderedPackages = [...packageProjects].sort((a, b) => Number(b.featured) - Number(a.featured));
const packageSections: TerminalLine[] = orderedPackages.flatMap(pkg => {
const infoChildren: TerminalLine[] = [
{
type: 'output' as const,
content: `(&icon, mdi:package-variant, 32) (&bg-orange,black) PACKAGE (&)${pkg.featured ? ' (&bg-accent,black) FEATURED (&)' : ''}`
},
{ type: 'output' as const, content: `(&text)${pkg.description}(&)` },
{
type: 'output' as const,
content: pkg.tech.map((tech, i) => `(&bg-${tagColor(i)},black) ${tech} (&)`).join(' ')
},
{
type: 'group' as const,
content: '',
groupDirection: 'row' as const,
groupGap: '0.75rem',
groupExpand: true,
children: [
...(pkg.github
? [{ type: 'button' as const, content: 'View on GitHub', href: pkg.github, icon: 'mdi:github', style: 'accent' as const, flex: true }]
: []),
...(pkg.live
? [{ type: 'button' as const, content: 'View Package', href: pkg.live, icon: 'mdi:package-variant', style: 'primary' as const, flex: true }]
: [])
]
}
];
return [
{ type: 'divider' as const, content: pkg.name.toUpperCase() },
{ type: 'group' as const, content: '', groupDirection: 'column' as const, groupAlign: 'stretch' as const, children: infoChildren },
{ type: 'blank' as const, content: '' }
];
});
export const lines: TerminalLine[] = [
{ type: 'command' as const, content: 'ls ~/projects/packages' },
{ type: 'blank' as const, content: '', height: '1rem' },
projectNavLinks,
{ type: 'blank' as const, content: '', height: '1rem' },
{ type: 'header' as const, content: 'Packages' },
{ type: 'output' as const, content: `(&muted)Published packages and CLI tools you can install directly.(&)` },
{ type: 'blank' as const, content: '' },
...packageSections
];
+5
View File
@@ -0,0 +1,5 @@
const tagPalette = ['blue', 'magenta', 'cyan', 'orange', 'green', 'pink'];
export function tagColor(index: number): string {
return tagPalette[index % tagPalette.length];
}
+21
View File
@@ -0,0 +1,21 @@
import type { TerminalLine } from '$lib/components/tui/types';
const spacer: TerminalLine = { type: 'output' as const, content: '  ', inline: true };
export const projectNavLinks: TerminalLine = {
type: 'group' as const,
content: '',
groupAlign: 'start',
groupGap: '1rem',
children: [
{ type: 'link' as const, href: '/projects', content: `(&bg-primary,black) ← Projects (&)`, inline: true },
spacer,
{ type: 'link' as const, href: '/projects/opensource', content: `(&bg-pink,black) Open Source (&)`, inline: true },
spacer,
{ type: 'link' as const, href: '/projects/packages', content: `(&bg-orange,black) Packages (&)`, inline: true },
spacer,
{ type: 'link' as const, href: '/projects/hackathons', content: `(&bg-green,black) Hackathons (&)`, inline: true },
spacer,
{ type: 'link' as const, href: '/projects/research', content: `(&bg-cyan,black) Research (&)`, inline: true }
]
};
+61 -102
View File
@@ -1,112 +1,71 @@
import type { TerminalLine } from '$lib/components/tui/types';
import { sortedCards, packageProjects, openSourceProjects } from '$lib/config';
import { sortedCards, packageProjects, openSourceProjects, researchItems } from '$lib/config';
const totalHackathons = sortedCards.length;
const totalAwards = sortedCards.filter(c => c.awards && c.awards.length > 0).length;
const featuredCount = sortedCards.filter(c => c.featured).length;
const cardWidth = 'calc((100% - 1.5rem) / 4)';
const openSourceElements:TerminalLine[] = [];
openSourceProjects.forEach(project => {
openSourceElements.push(
{
type: 'card',
content: '',
cardTitle: project.name,
// cardFooter: '' + (project.github ? `(&muted)GitHub:(&) (&link)${project.github}(&)` : ''),
image: project.image || '/images/placeholder.png',
imageAlt: 'Placeholder image',
children: [
...project.tech.map(tech => ({
type: 'output' as const,
content: `(&bg-blue, black)${tech}(&)`,
inline: true
})),
{
type: 'button',
content: 'View on GitHub',
href: project.github || 'https://github.com/',
icon: 'mdi:github',
style: 'accent',
},
project.live ? {
type: 'button',
content: 'View Live Demo',
href: project.live,
icon: 'mdi:open-in-new',
style: 'primary'
} : { type: 'blank', content: '' }
],
inline: true,
display: 'flex',
cardWidth: '275px'
},
);
});
// Build the terminal lines with card grid
export const lines: TerminalLine[] = [
{ type: 'command' as const, content: 'ls ~/projects' },
{ type: 'header' as const, content: 'My Project Universe' },
{ type: 'output' as const, content: `(&muted)Everything I build lives in one of four buckets. Pick a door.(&)` },
{ type: 'blank' as const, content: '' },
{ type: 'output' as const, content: `(&muted)Open Source:(&) (&primary)${openSourceProjects.length}(&) (&muted)Packages:(&) (&orange)${packageProjects.length}(&) (&muted)Hackathons:(&) (&green)${totalHackathons}(&) (&muted)(${totalAwards} with awards)(&) (&muted)Research:(&) (&cyan)${researchItems.length}(&)` },
{ type: 'blank' as const, content: '' },
{
type: 'group' as const, content: '', groupAlign: 'start', groupGap: '1rem',
type: 'card' as const,
content: '',
cardTitle: 'Open Source',
icon: 'mdi:source-branch',
cardFooter: `${openSourceProjects.length} projects`,
children: [
{ type: 'link' as const, href: "/projects#opensource", content: `(&bg-blue,black)Open Scourced(&)`, inline: true },
{ type: 'link' as const, href: "/projects#packages", content: `(&bg-orange,black)Packages(&)`, inline: true },
{ type: 'link' as const, href: "/projects#hackathons", content: `(&bg-green,black)Hackathons(&)`, inline: true },
]
{ type: 'output' as const, content: `(&muted)Libraries and tools I build and maintain in the open.(&)` },
{ type: 'button' as const, content: 'Browse Open Source', href: '/projects/opensource', icon: 'mdi:arrow-right', style: 'accent' as const }
],
inline: true,
display: 'flex' as const,
cardWidth
},
{ type: 'divider' as const, content: 'OPEN SOURCED', id: 'opensource' },
...openSourceElements,
{ type: 'divider' as const, content: 'PACKAGES', id: 'packages' },
...packageProjects.filter(p => p.featured).flatMap(project => [
{ type: 'header' as const, content: `(&orange)${project.name}(&)` },
{ type: 'output' as const, content: `(&muted)${project.description}(&)` },
{ type: 'info' as const, content: `(&info)TechStack:(&) (&magenta)${project.tech.join(', ')}(&)` },
...(project.github ? [{
type: 'button' as const,
content: 'View on GitHub',
icon: 'mdi:github',
style: 'accent' as const,
href: project.github
}] : []),
...(project.live ? [{
type: 'button' as const,
content: 'View Live Demo',
icon: 'mdi:open-in-new',
style: 'accent' as const,
href: project.live
}] : []),
{ type: 'blank' as const, content: '' }
]),
...packageProjects.filter(p => !p.featured).flatMap(project => [
{ type: 'header' as const, content: `${project.name}` },
{ type: 'output' as const, content: `(&muted)${project.description}(&)` },
{ type: 'info' as const, content: `(&info)TechStack:(&) (&magenta)${project.tech.join(', ')}(&)` },
...(project.github ? [{
type: 'button' as const,
content: 'View on GitHub',
icon: 'mdi:github',
style: 'accent' as const,
href: project.github
}] : []),
...(project.live ? [{
type: 'button' as const,
content: 'View Live',
icon: 'mdi:open-in-new',
style: 'accent' as const,
href: project.live
}] : []),
{ type: 'blank' as const, content: '' }
]),
{ type: 'divider' as const, content: 'HACKATHONS', id: 'hackathons' },
// { type: 'command', content: 'ls ~/hackathons --grid' },
{ type: 'blank' as const, content: '' },
{ type: 'header' as const, content: `Hackathon Journey` },
{ type: 'output' as const, content: `(&muted)Total:(&) (&primary)${totalHackathons}(&) (&muted)| Awards:(&) (&yellow)${totalAwards}(&) (&muted)| Featured:(&) (&accent)${featuredCount}(&)` },
{ type: 'blank' as const, content: '' },
{ type: 'cardgrid' as const, content: '', cards: sortedCards },
{ type: 'blank' as const, content: '' },
{ type: 'success' as const, content: `(&success)Ready for the next hackathon! 🚀(&)` },
];
{
type: 'card' as const,
content: '',
cardTitle: 'Packages',
icon: 'mdi:package-variant',
cardFooter: `${packageProjects.length} published`,
children: [
{ type: 'output' as const, content: `(&muted)CLI tools and packages published for others to install.(&)` },
{ type: 'button' as const, content: 'Browse Packages', href: '/projects/packages', icon: 'mdi:arrow-right', style: 'accent' as const }
],
inline: true,
display: 'flex' as const,
cardWidth
},
{
type: 'card' as const,
content: '',
cardTitle: 'Hackathons',
icon: 'mdi:trophy',
cardFooter: `${totalHackathons} events · ${totalAwards} awards`,
children: [
{ type: 'output' as const, content: `(&muted)Weekend builds, demos, and the awards they picked up.(&)` },
{ type: 'button' as const, content: 'Browse Hackathons', href: '/projects/hackathons', icon: 'mdi:arrow-right', style: 'accent' as const }
],
inline: true,
display: 'flex' as const,
cardWidth
},
{
type: 'card' as const,
content: '',
cardTitle: 'Research',
icon: 'mdi:microscope',
cardFooter: `${researchItems.length} items`,
children: [
{ type: 'output' as const, content: `(&muted)My Research Roles, Projects, and Publications.(&)` },
{ type: 'button' as const, content: 'Browse Research', href: '/projects/research', icon: 'mdi:arrow-right', style: 'accent' as const }
],
inline: true,
display: 'flex' as const,
cardWidth
},
];
+53
View File
@@ -0,0 +1,53 @@
import type { TerminalLine } from '$lib/components/tui/types';
import { researchItems } from '$lib/config';
import { projectNavLinks } from './projectNav';
import { tagColor } from './palette';
const researchSections: TerminalLine[] = researchItems.flatMap(item => {
const infoChildren: TerminalLine[] = [
{
type: 'output' as const,
content: `(&icon, mdi:microscope, 32) (&bg-cyan,black) RESEARCH (&)`
},
...(item.organization
? [{ type: 'info' as const, content: `(&primary)${item.organization}(&)` }]
: []),
...(item.period
? [{ type: 'output' as const, content: `(&muted)${item.period}(&)` }]
: []),
...(item.description
? [{ type: 'output' as const, content: `(&text)${item.description}(&)` }]
: []),
...(item.tech?.length
? [{ type: 'output' as const, content: item.tech.map((tech, i) => `(&bg-${tagColor(i)},black) ${tech} (&)`).join(' ') }]
: []),
...(item.link
? [{
type: 'group' as const,
content: '',
groupDirection: 'row' as const,
groupGap: '0.75rem',
groupExpand: true,
children: [
{ type: 'button' as const, content: 'View', href: item.link, icon: 'mdi:open-in-new', style: 'primary' as const, flex: true }
]
}]
: [])
];
return [
{ type: 'divider' as const, content: item.title.toUpperCase() },
{ type: 'group' as const, content: '', groupDirection: 'column' as const, groupAlign: 'stretch' as const, children: infoChildren },
{ type: 'blank' as const, content: '' }
];
});
export const lines: TerminalLine[] = [
{ type: 'command' as const, content: 'ls ~/projects/research' },
projectNavLinks,
{ type: 'blank' as const, content: '', height: '2rem' },
{ type: 'header' as const, content: 'Research' },
{ type: 'output' as const, content: `(&muted)Research roles, projects, and publications.(&)` },
{ type: 'blank' as const, content: '' },
...researchSections
];