Compare commits
4
Commits
70cd01019d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c52779cf6 | ||
|
|
46e2fe8d5a | ||
|
|
f315981cbc | ||
|
|
6cfce40e54 |
@@ -23,3 +23,5 @@ vite.config.js.timestamp-*
|
|||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
bun.lock
|
bun.lock
|
||||||
|
Update.md
|
||||||
|
blogs/
|
||||||
@@ -4,38 +4,75 @@ An Arch Linux terminal-themed portfolio website with Hyprland-style TUI componen
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🖥️ **Hyprland-style TUI** - Terminal interface inspired by Textual Python TUI
|
- Hyprland-style TUI - Terminal interface inspired by Textual Python TUI
|
||||||
- 🎨 **Theme Support** - Arch Linux and Catppuccin (Mocha/Latte) themes
|
- Interactive command line - Type commands directly into the terminal to navigate and run actions
|
||||||
- 🌓 **Dark/Light Mode** - Toggle between dark and light modes
|
- Theme Support - Arch, Catppuccin, Wintry, Rose, and Cerberus themes, each with dark/light variants
|
||||||
- ⌨️ **Keyboard Navigation** - Navigate with arrow keys or vim-style j/k
|
- Dark/Light Mode - Toggle between dark and light modes
|
||||||
- 🎮 **3D Model Viewer** - Interactive Three.js viewer for .glb models
|
- Keyboard Navigation - Navigate with arrow keys or vim-style j/k
|
||||||
- ⚡ **Configurable Speed** - Per-page typing animation speed
|
- Native Blog - Markdown posts with tags, authors, syntax highlighting, search, pagination, and an RSS feed
|
||||||
- 📱 **Responsive** - Works on desktop and mobile
|
- 3D Model Viewer - Interactive Three.js viewer for .glb models
|
||||||
- 🎨 **Rich Text Formatting** - Colors, backgrounds, and text decorations
|
- QR Links Page - Generate a scannable QR code for any of your links
|
||||||
|
- Configurable Speed - Per-page typing animation speed
|
||||||
|
- Responsive - Works on desktop and mobile
|
||||||
|
- Rich Text Formatting - Colors, backgrounds, text decorations, and sizeable inline icons
|
||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
- **Home** (`/`) - Neofetch-style intro with navigation
|
- Home (`/`) - Neofetch-style intro with navigation
|
||||||
- **Portfolio** (`/portfolio`) - Skills, projects, and contact info
|
- About (`/about`) - Background and details
|
||||||
- **Models** (`/models`) - 3D model gallery with interactive viewer
|
- Portfolio (`/portfolio`) - Profile, experience, skills, and contact info
|
||||||
- **Hackathons** (`/hackathons`) - Hackathon projects and achievements
|
- Projects (`/projects`) - Hub linking out to the three project categories below
|
||||||
- **Components** (`/components`) - Showcase of all TUI components
|
- Open Source (`/projects/opensource`) - Projects built and maintained in the open
|
||||||
|
- Packages (`/projects/packages`) - Published packages and CLI tools
|
||||||
|
- Hackathons (`/projects/hackathons`) - Hackathon projects, awards, and stats
|
||||||
|
- Blog (`/blog`) - Native markdown blog with tags, search, pagination, and an RSS feed at `/blog/rss.xml`
|
||||||
|
- Models (`/models`) - 3D model gallery with interactive viewer
|
||||||
|
- Components (`/components`) - Showcase of all TUI components
|
||||||
|
- Links (`/links`) - All links with on-demand QR codes
|
||||||
|
|
||||||
|
The navbar exposes Home, About, Projects, and Blog. Portfolio, Models, Components, and Links are reachable directly by URL or with the terminal `cd` command (for example, `cd links`).
|
||||||
|
|
||||||
|
## Terminal Commands
|
||||||
|
|
||||||
|
Every terminal page has an interactive command line at the bottom. Click the terminal (or just start typing once focused), enter a command, and press Enter. Output is printed back into the terminal.
|
||||||
|
|
||||||
|
Use the Up/Down arrows to cycle through command history.
|
||||||
|
|
||||||
|
| Command | Aliases | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `help` | `h`, `?` | List all available commands |
|
||||||
|
| `clear` | `cls` | Clear the terminal |
|
||||||
|
| `whoami` | | Show a short bio |
|
||||||
|
| `neofetch` | `banner` | Display system info with profile picture |
|
||||||
|
| `skills` | | List skills by category |
|
||||||
|
| `contact` | `socials` | Show social links as buttons |
|
||||||
|
| `email` | | Show contact email |
|
||||||
|
| `cd <page>` | `goto`, `open` | Navigate to a page, including subpages (e.g. `cd projects/opensource`) |
|
||||||
|
| `ls` | `dir` | List the pages you can visit as a tree, with project subpages nested under `projects/` |
|
||||||
|
| `theme [name]` | | List or set the color theme |
|
||||||
|
| `mode [dark\|light]` | | Toggle or set dark/light mode |
|
||||||
|
| `echo <text>` | | Print text back |
|
||||||
|
| `cowsay <text>` | | Make a cow say something |
|
||||||
|
| `date` | | Show the current date and time (Eastern) |
|
||||||
|
|
||||||
|
Command definitions live in `src/lib/components/tui/terminal-commands.ts`. Add a new entry to the `commands` list to extend the command set. Pages and their subpages are declared once in the `pageTree` structure in that file, which both `ls` (tree rendering) and `cd` (nested path resolution) read from.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The site configuration is now **modular** — split into focused files in `src/lib/config/` for easier maintenance. You can still import everything from `$lib/config` for backward compatibility.
|
The site configuration is modular - split into focused files in `src/lib/config/` for easier maintenance. You can still import everything from `$lib/config` for backward compatibility.
|
||||||
|
|
||||||
### Config File Structure
|
### Config File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/lib/config/
|
src/lib/config/
|
||||||
├── index.ts # Barrel export (re-exports all modules)
|
├── index.ts # Barrel export (re-exports all modules)
|
||||||
├── user.ts # User profile, socials, skills
|
├── user.ts # User profile, socials, links, skills
|
||||||
├── layout.ts # Layout dimensions, breakpoints, fonts, navbar, scrollbar
|
├── layout.ts # Layout dimensions, breakpoints, fonts, navbar, scrollbar
|
||||||
├── theme.ts # Colors, animations, effects, loading screen
|
├── theme.ts # Colors, animations, effects, loading screen
|
||||||
├── content.ts # Projects, 3D models, hackathon cards
|
├── content.ts # Projects, 3D models, experience, hackathon cards
|
||||||
├── terminal.ts # Terminal settings, TUI styling, speed presets, shortcuts
|
├── terminal.ts # Terminal settings, TUI styling, speed presets, shortcuts
|
||||||
└── navigation.ts # Navigation links, site metadata, page meta
|
├── navigation.ts # Navigation links, site metadata, page meta
|
||||||
|
└── blog.ts # Blog name and tagline shown on /blog
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import Examples
|
### Import Examples
|
||||||
@@ -45,9 +82,9 @@ src/lib/config/
|
|||||||
import { user, colorPalette, projects } from '$lib/config';
|
import { user, colorPalette, projects } from '$lib/config';
|
||||||
|
|
||||||
// Direct imports (smaller bundles, faster builds)
|
// Direct imports (smaller bundles, faster builds)
|
||||||
import { user, skills } from '$lib/config/user';
|
import { user, skills, links } from '$lib/config/user';
|
||||||
import { colorPalette, animations } from '$lib/config/theme';
|
import { colorPalette, animations } from '$lib/config/theme';
|
||||||
import { projects, models, cards } from '$lib/config/content';
|
import { openSourceProjects, cards, experience } from '$lib/config/content';
|
||||||
import { terminalSettings, keyboardShortcuts } from '$lib/config/terminal';
|
import { terminalSettings, keyboardShortcuts } from '$lib/config/terminal';
|
||||||
import { navigation, site, pageMeta } from '$lib/config/navigation';
|
import { navigation, site, pageMeta } from '$lib/config/navigation';
|
||||||
```
|
```
|
||||||
@@ -56,14 +93,16 @@ import { navigation, site, pageMeta } from '$lib/config/navigation';
|
|||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `user.ts` | `user`, `skills` |
|
| `user.ts` | `user`, `skills`, `links` |
|
||||||
| `layout.ts` | `layout`, `breakpoints`, `fonts`, `navbar`, `scrollbar` |
|
| `layout.ts` | `layout`, `breakpoints`, `fonts`, `navbar`, `scrollbar` |
|
||||||
| `theme.ts` | `colorPalette`, `terminalButtons`, `loadingScreen`, `effects`, `animations` |
|
| `theme.ts` | `colorPalette`, `terminalButtons`, `loadingScreen`, `effects`, `animations` |
|
||||||
| `content.ts` | `projects`, `models`, `cards`, `sortedCards` + types |
|
| `content.ts` | `openSourceProjects`, `packageProjects`, `models`, `experience`, `cards`, `sortedCards` + types |
|
||||||
| `terminal.ts` | `terminalSettings`, `tuiStyle`, `tuiText`, `pageSpeedSettings`, `pageAutoscrollSettings`, `speedPresets`, `modelViewer`, `particles`, `keyboardShortcuts` |
|
| `terminal.ts` | `terminalSettings`, `tuiStyle`, `tuiText`, `pageSpeedSettings`, `pageAutoscrollSettings`, `speedPresets`, `modelViewer`, `particles`, `keyboardShortcuts` |
|
||||||
| `navigation.ts` | `navigation`, `site`, `pageMeta` |
|
| `navigation.ts` | `navigation`, `site`, `pageMeta` |
|
||||||
|
| `blog.ts` | `blogName`, `blogTagline` |
|
||||||
|
|
||||||
### Example: Key config snippets
|
### Example: Key config snippets
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Toggle theme keys and other shortcuts
|
// Toggle theme keys and other shortcuts
|
||||||
export const keyboardShortcuts = {
|
export const keyboardShortcuts = {
|
||||||
@@ -83,7 +122,6 @@ export const terminalSettings = {
|
|||||||
lineDelay: 100,
|
lineDelay: 100,
|
||||||
showCursor: true,
|
showCursor: true,
|
||||||
promptStyle: 'full',
|
promptStyle: 'full',
|
||||||
icon: '🐧',
|
|
||||||
scrollMargin: 80,
|
scrollMargin: 80,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,21 +138,12 @@ export const colorPalette = {
|
|||||||
error: '#f38ba8',
|
error: '#f38ba8',
|
||||||
success: '#a6e3a1',
|
success: '#a6e3a1',
|
||||||
};
|
};
|
||||||
|
|
||||||
// TUI styling example
|
|
||||||
export const tuiStyle = {
|
|
||||||
borderRadius: 8,
|
|
||||||
borderWidth: 2,
|
|
||||||
width: '95%',
|
|
||||||
bodyPadding: '1rem 1.25rem 2rem 1.25rem',
|
|
||||||
buttonPadding: '0.5rem 0.75rem',
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### How to Customize
|
### How to Customize
|
||||||
|
|
||||||
- Edit `config/user.ts` to update your profile, socials, and skills
|
- Edit `config/user.ts` to update your profile, socials, links, and skills
|
||||||
- Edit `config/content.ts` to add projects, models, or hackathon entries
|
- Edit `config/content.ts` to add projects, models, experience, or hackathon entries
|
||||||
- Edit `config/theme.ts` to change colors, animations, or loading screen
|
- Edit `config/theme.ts` to change colors, animations, or loading screen
|
||||||
- Edit `config/terminal.ts` to adjust typing speed, TUI styling, or shortcuts
|
- Edit `config/terminal.ts` to adjust typing speed, TUI styling, or shortcuts
|
||||||
- Edit `config/layout.ts` to change dimensions, breakpoints, or navbar settings
|
- Edit `config/layout.ts` to change dimensions, breakpoints, or navbar settings
|
||||||
@@ -124,11 +153,64 @@ Changes take effect on next reload. Some values (fonts, CSS variables) may also
|
|||||||
|
|
||||||
### Where to Look for Types & Utilities
|
### Where to Look for Types & Utilities
|
||||||
|
|
||||||
- `src/lib/config/` — All configuration modules
|
- `src/lib/config/` - All configuration modules
|
||||||
- `src/lib/components/tui/types.ts` — TerminalLine types
|
- `src/lib/components/tui/types.ts` - TerminalLine types
|
||||||
- `src/lib/components/tui/utils.ts` — Parsing utilities and style helpers
|
- `src/lib/components/tui/utils.ts` - Parsing utilities and style helpers
|
||||||
- `src/lib/stores/theme.ts` — Theme store & `toggleMode()`
|
- `src/lib/components/tui/terminal-commands.ts` - Interactive command definitions
|
||||||
- `src/lib/index.ts` — Helper functions (barrel export)
|
- `src/lib/stores/theme.ts` - Theme store & `toggleMode()`
|
||||||
|
- `src/lib/index.ts` - Helper functions (barrel export)
|
||||||
|
|
||||||
|
## Links Page
|
||||||
|
|
||||||
|
The `/links` page lists everything in the `links` array (a dedicated list, separate from `user.socials`) and renders a scannable QR code for whichever link is selected, plus a clickable button to open it.
|
||||||
|
|
||||||
|
Add or remove entries in `src/lib/config/user.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const links: LinkItem[] = [
|
||||||
|
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
|
||||||
|
{ name: 'Email', icon: 'mdi:email', link: `mailto:${user.email}` },
|
||||||
|
// Add page-only links here without touching user.socials
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
QR codes are generated with the `svelte-qrcode` package, loaded on the client.
|
||||||
|
|
||||||
|
## Blog
|
||||||
|
|
||||||
|
The `/blog` page is a native markdown blog — no external blogging platform required.
|
||||||
|
|
||||||
|
### Writing a Post
|
||||||
|
|
||||||
|
Add a markdown file to the `blogs/` directory at the project root:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: My Post Title
|
||||||
|
author: Your Name
|
||||||
|
date: 2026-01-01
|
||||||
|
tags: meta, notes
|
||||||
|
excerpt: A one line summary shown on the blog list and in the RSS feed.
|
||||||
|
---
|
||||||
|
|
||||||
|
Post content goes here, written in markdown.
|
||||||
|
```
|
||||||
|
|
||||||
|
Posts are picked up automatically via `import.meta.glob` in `src/lib/blog/posts.ts` — no manual registration needed. `author` and `date` are optional; `author` falls back to `user.displayname`.
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
|
||||||
|
`src/lib/blog/markdown.ts` is a small hand-written markdown-to-HTML renderer (headings, bold/italic, links, images, lists, blockquotes, and fenced code blocks) — no external markdown dependency. Fenced code blocks with a language tag (` ```js `, ` ```python `, etc.) get lightweight syntax highlighting via `src/lib/blog/highlight.ts`, and every code block gets a copy-to-clipboard button.
|
||||||
|
|
||||||
|
Dates are parsed and formatted in Eastern time via `src/lib/blog/date.ts`, anchored at noon UTC to avoid off-by-one day shifts from timezone conversion.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Tags with click-to-filter on the blog list
|
||||||
|
- Full-text search across title, excerpt, and tags
|
||||||
|
- Pagination (5 posts per page)
|
||||||
|
- Previous/next post navigation on each post page
|
||||||
|
- RSS feed at `/blog/rss.xml` (`src/routes/blog/rss.xml/+server.ts`)
|
||||||
|
|
||||||
## Speed Presets
|
## Speed Presets
|
||||||
|
|
||||||
@@ -195,6 +277,13 @@ Add `bg-` prefix to any color:
|
|||||||
'(&overline)Overlined text(&)'
|
'(&overline)Overlined text(&)'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Inline Icons
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'(&icon, mdi:github) GitHub' // Inline icon, default size
|
||||||
|
'(&icon, mdi:trophy, 32) Winner' // Inline icon with a custom size in pixels
|
||||||
|
```
|
||||||
|
|
||||||
### Combining Styles
|
### Combining Styles
|
||||||
|
|
||||||
Combine multiple styles with commas:
|
Combine multiple styles with commas:
|
||||||
@@ -214,10 +303,10 @@ Combine multiple styles with commas:
|
|||||||
const lines: TerminalLine[] = [
|
const lines: TerminalLine[] = [
|
||||||
{ type: 'command', content: 'ls -la' }, // With prompt prefix
|
{ type: 'command', content: 'ls -la' }, // With prompt prefix
|
||||||
{ type: 'output', content: 'File listing...' }, // Muted text
|
{ type: 'output', content: 'File listing...' }, // Muted text
|
||||||
{ type: 'error', content: 'Error message' }, // Red with ✗ prefix
|
{ type: 'error', content: 'Error message' }, // Red with prefix
|
||||||
{ type: 'success', content: 'Success!' }, // Green with ✓ prefix
|
{ type: 'success', content: 'Success!' }, // Green with prefix
|
||||||
{ type: 'info', content: 'Information' }, // Primary with › prefix
|
{ type: 'info', content: 'Information' }, // Primary with prefix
|
||||||
{ type: 'header', content: 'Section Title' }, // Bold with # icon
|
{ type: 'header', content: 'Section Title' }, // Bold heading
|
||||||
{ type: 'blank', content: '' }, // Empty line
|
{ type: 'blank', content: '' }, // Empty line
|
||||||
{ type: 'divider', content: 'SECTION', id: 'section' }, // Horizontal divider with anchor ID
|
{ type: 'divider', content: 'SECTION', id: 'section' }, // Horizontal divider with anchor ID
|
||||||
];
|
];
|
||||||
@@ -251,6 +340,7 @@ All line types support these optional properties:
|
|||||||
// OR
|
// OR
|
||||||
action: () => doSomething(), // Custom action
|
action: () => doSomething(), // Custom action
|
||||||
border: false, // Disable default border (default: true)
|
border: false, // Disable default border (default: true)
|
||||||
|
flex: true, // Grow to fill available space in a row group (default: false)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -381,7 +471,6 @@ Groups allow you to arrange multiple elements together with custom layout:
|
|||||||
groupDirection: 'row', // row | column (default: row)
|
groupDirection: 'row', // row | column (default: row)
|
||||||
groupAlign: 'start', // start | center | end
|
groupAlign: 'start', // start | center | end
|
||||||
groupGap: '1rem', // CSS gap value
|
groupGap: '1rem', // CSS gap value
|
||||||
groupGap: '1rem', // CSS gap value
|
|
||||||
groupExpand: true, // Expand children to fill width (default: false)
|
groupExpand: true, // Expand children to fill width (default: false)
|
||||||
inline: true, // Render inline with other elements
|
inline: true, // Render inline with other elements
|
||||||
children: [
|
children: [
|
||||||
@@ -407,6 +496,7 @@ Main terminal component:
|
|||||||
interactive={true}
|
interactive={true}
|
||||||
speed="normal"
|
speed="normal"
|
||||||
autoscroll={true}
|
autoscroll={true}
|
||||||
|
enableCommands={true}
|
||||||
onComplete={() => console.log('Done!')}
|
onComplete={() => console.log('Done!')}
|
||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
@@ -417,6 +507,7 @@ Props:
|
|||||||
- `interactive` - Enable keyboard navigation
|
- `interactive` - Enable keyboard navigation
|
||||||
- `speed` - Typing speed preset or multiplier
|
- `speed` - Typing speed preset or multiplier
|
||||||
- `autoscroll` - Auto-scroll as content types (default: true)
|
- `autoscroll` - Auto-scroll as content types (default: true)
|
||||||
|
- `enableCommands` - Show the interactive command input (default: true)
|
||||||
- `onComplete` - Callback when typing animation finishes
|
- `onComplete` - Callback when typing animation finishes
|
||||||
|
|
||||||
### Anchor Scrolling
|
### Anchor Scrolling
|
||||||
@@ -429,38 +520,6 @@ Add `id` to any line to create an anchor that can be linked to:
|
|||||||
|
|
||||||
Then link to it with `/portfolio#skills` - the page will scroll to that section after typing completes.
|
Then link to it with `/portfolio#skills` - the page will scroll to that section after typing completes.
|
||||||
|
|
||||||
### Component Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/lib/components/
|
|
||||||
├── TerminalTUI.svelte # Main terminal container
|
|
||||||
└── tui/
|
|
||||||
├── types.ts # TypeScript types (TerminalLine, TerminalAPI, etc.)
|
|
||||||
├── utils.ts # Parsing & styling utilities
|
|
||||||
├── terminal-api.ts # Terminal API factory for reactive control
|
|
||||||
├── terminal-typing.ts # Typing animation engine
|
|
||||||
├── terminal-keyboard.ts# Keyboard navigation handler
|
|
||||||
├── TuiHeader.svelte # Top status bar
|
|
||||||
├── TuiBody.svelte # Scrollable content area (uses TuiLine)
|
|
||||||
├── TuiFooter.svelte # Bottom status bar
|
|
||||||
├── TuiLine.svelte # Unified line renderer for all types
|
|
||||||
├── TuiGroup.svelte # Container for grouped elements
|
|
||||||
├── TuiButton.svelte # Full-width button
|
|
||||||
├── TuiLink.svelte # Inline clickable link
|
|
||||||
├── TuiCard.svelte # Card with header/body/footer
|
|
||||||
├── TuiCardGrid.svelte # Grid layout for cards
|
|
||||||
├── TuiProgress.svelte # Animated progress bar
|
|
||||||
├── TuiAccordion.svelte # Collapsible sections
|
|
||||||
├── TuiTable.svelte # Data table with headers
|
|
||||||
├── TuiTooltip.svelte # Hover tooltip
|
|
||||||
├── TuiInput.svelte # Text input field
|
|
||||||
├── TuiTextarea.svelte # Multi-line text input
|
|
||||||
├── TuiCheckbox.svelte # Checkbox input
|
|
||||||
├── TuiRadio.svelte # Radio button group
|
|
||||||
├── TuiSelect.svelte # Dropdown select
|
|
||||||
└── TuiToggle.svelte # Toggle switch
|
|
||||||
```
|
|
||||||
|
|
||||||
## Terminal API
|
## Terminal API
|
||||||
|
|
||||||
The `TerminalTUI` component exposes a reactive API for programmatic control via the `terminal` bindable prop.
|
The `TerminalTUI` component exposes a reactive API for programmatic control via the `terminal` bindable prop.
|
||||||
@@ -540,7 +599,6 @@ The `TerminalTUI` component exposes a reactive API for programmatic control via
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
async function runProcess() {
|
async function runProcess() {
|
||||||
// Update existing line
|
|
||||||
terminal?.updateById('status', {
|
terminal?.updateById('status', {
|
||||||
type: 'info',
|
type: 'info',
|
||||||
content: 'Processing...'
|
content: 'Processing...'
|
||||||
@@ -548,7 +606,6 @@ The `TerminalTUI` component exposes a reactive API for programmatic control via
|
|||||||
|
|
||||||
await delay(1000);
|
await delay(1000);
|
||||||
|
|
||||||
// Add progress
|
|
||||||
terminal?.write({
|
terminal?.write({
|
||||||
type: 'progress',
|
type: 'progress',
|
||||||
content: '',
|
content: '',
|
||||||
@@ -558,7 +615,6 @@ The `TerminalTUI` component exposes a reactive API for programmatic control via
|
|||||||
|
|
||||||
await delay(1000);
|
await delay(1000);
|
||||||
|
|
||||||
// Complete
|
|
||||||
terminal?.updateById('status', {
|
terminal?.updateById('status', {
|
||||||
type: 'success',
|
type: 'success',
|
||||||
content: 'Complete!'
|
content: 'Complete!'
|
||||||
@@ -575,13 +631,14 @@ The `TerminalTUI` component exposes a reactive API for programmatic control via
|
|||||||
The `ModelViewer` component provides an interactive Three.js viewer for `.glb` models.
|
The `ModelViewer` component provides an interactive Three.js viewer for `.glb` models.
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- **Mouse Controls**: Drag to rotate, scroll to zoom
|
- Mouse Controls: Drag to rotate, scroll to zoom
|
||||||
- **Arrow Key Controls**: Use arrow keys to orbit the camera (click viewer to focus first)
|
- Arrow Key Controls: Use arrow keys to orbit the camera (click viewer to focus first)
|
||||||
- **Auto-rotate**: Toggle automatic rotation
|
- Auto-rotate: Toggle automatic rotation
|
||||||
- **Wireframe Mode**: View model wireframe
|
- Wireframe Mode: View model wireframe
|
||||||
- **Adjustable Lighting**: Increase/decrease scene brightness
|
- Adjustable Lighting: Increase/decrease scene brightness
|
||||||
- **Fullscreen Mode**: Expand to full viewport (press `Escape` to exit)
|
- Fullscreen Mode: Expand to full viewport (press Escape to exit)
|
||||||
- **Ground Plane**: Optional shadow-receiving ground
|
- Ground Plane: Optional shadow-receiving ground
|
||||||
|
- Graceful Fallback: Shows an error message when WebGL is unavailable instead of crashing
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
|
|
||||||
@@ -596,11 +653,13 @@ Place `.glb` files in `/static/models/` and they'll be accessible at `/models/fi
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Framework**: SvelteKit 2.x with Svelte 5 runes
|
- Framework: SvelteKit 2.x with Svelte 5 runes
|
||||||
- **Styling**: Tailwind CSS 4.x
|
- Styling: Tailwind CSS 4.x
|
||||||
- **3D**: Three.js with GLTFLoader
|
- 3D: Three.js with GLTFLoader
|
||||||
- **Icons**: @iconify/svelte
|
- QR codes: svelte-qrcode
|
||||||
- **Font**: JetBrains Mono
|
- Icons: @iconify/svelte
|
||||||
|
- Font: JetBrains Mono
|
||||||
|
- Runtime: Bun
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -614,40 +673,42 @@ bun run dev
|
|||||||
# Build for production
|
# Build for production
|
||||||
bun run build
|
bun run build
|
||||||
|
|
||||||
# Preview production build
|
# Type-check
|
||||||
bun run preview
|
bun run check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `↑` / `k` | Navigate up |
|
| Up / k | Navigate up |
|
||||||
| `↓` / `j` | Navigate down |
|
| Down / j | Navigate down |
|
||||||
| `Enter` | Activate button |
|
| Enter | Activate button |
|
||||||
| `Y` | Skip typing animation |
|
| Y | Skip typing animation |
|
||||||
| `T` | Toggle dark/light mode |
|
| T | Toggle dark/light mode |
|
||||||
|
|
||||||
|
When the command input is focused, these keys type normally instead of triggering navigation.
|
||||||
|
|
||||||
### 3D Model Viewer
|
### 3D Model Viewer
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `←` | Rotate camera left |
|
| Left | Rotate camera left |
|
||||||
| `→` | Rotate camera right |
|
| Right | Rotate camera right |
|
||||||
| `↑` | Rotate camera up |
|
| Up | Rotate camera up |
|
||||||
| `↓` | Rotate camera down |
|
| Down | Rotate camera down |
|
||||||
| `Escape` | Exit fullscreen |
|
| Escape | Exit fullscreen |
|
||||||
|
|
||||||
## Theme System
|
## Theme System
|
||||||
|
|
||||||
Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contains colors for both dark and light modes.
|
Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contains colors for both dark and light modes. Shipped themes: Arch, Catppuccin, Wintry, Rose, and Cerberus (`src/lib/stores/theme.ts`).
|
||||||
|
|
||||||
### Theme File Structure
|
### Theme File Structure
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "Theme Name",
|
"name": "Theme Name",
|
||||||
"icon": "🎨",
|
"icon": "arch",
|
||||||
"dark": {
|
"dark": {
|
||||||
"colors": {
|
"colors": {
|
||||||
"primary": "#89b4fa",
|
"primary": "#89b4fa",
|
||||||
@@ -673,8 +734,8 @@ Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contain
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"light": {
|
"light": {
|
||||||
"colors": { /* light mode colors */ },
|
"colors": { },
|
||||||
"colorMap": { /* light mode color map */ }
|
"colorMap": { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -696,10 +757,6 @@ Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contain
|
|||||||
```
|
```
|
||||||
4. Update the `ColorTheme` type to include your theme name
|
4. Update the `ColorTheme` type to include your theme name
|
||||||
|
|
||||||
### Available Themes
|
|
||||||
- **Arch Linux** (`arch`) - Classic terminal colors with Arch blue
|
|
||||||
- **Catppuccin** (`catppuccin`) - Soft, pastel Mocha/Latte colors
|
|
||||||
|
|
||||||
### Theme-Specific Colors
|
### Theme-Specific Colors
|
||||||
|
|
||||||
Beyond the basic colors, themes include:
|
Beyond the basic colors, themes include:
|
||||||
@@ -716,7 +773,6 @@ Beyond the basic colors, themes include:
|
|||||||
|
|
||||||
## Mobile Considerations
|
## Mobile Considerations
|
||||||
|
|
||||||
- **Viewport height**: Uses `100dvh` (dynamic viewport height) to properly handle mobile browser chrome
|
- Viewport height: Uses `100dvh` (dynamic viewport height) to properly handle mobile browser chrome
|
||||||
- **Background color**: A fallback dark background (`#1e1e2e`) is set on `html` and `body` to prevent white bars when the page content doesn't fill the viewport
|
- Background color: A fallback dark background (`#1e1e2e`) is set on `html` and `body` to prevent white bars when the page content doesn't fill the viewport
|
||||||
- **Overflow handling**: Hidden horizontal scrollbar to prevent accidental horizontal scroll on mobile
|
- Overflow handling: Hidden horizontal scrollbar to prevent accidental horizontal scroll on mobile
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -11,28 +11,28 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.68.0",
|
"@sveltejs/kit": "^2.70.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@tailwindcss/vite": "^4.3.2",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"svelte": "^5.56.4",
|
"svelte": "^5.56.8",
|
||||||
"svelte-check": "^4.7.1",
|
"svelte-check": "^4.7.4",
|
||||||
"tailwindcss": "^4.3.2",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/svelte": "^5.2.2",
|
"@iconify/svelte": "^5.2.2",
|
||||||
"@threlte/core": "^8.5.16",
|
"@threlte/core": "^8.5.16",
|
||||||
"@types/three": "^0.185.0",
|
"@types/three": "^0.185.1",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"discord.js": "^14.26.4",
|
"discord.js": "^14.27.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"hotkeys-js": "^4.0.4",
|
"hotkeys-js": "^4.0.4",
|
||||||
"play-dl": "^1.9.7",
|
"play-dl": "^1.9.7",
|
||||||
"svelte-qrcode": "^1.0.1",
|
"svelte-qrcode": "^1.0.1",
|
||||||
"three": "^0.185.0"
|
"three": "^0.185.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script defer src="https://umami.sirblob.co/script.js" data-website-id="5cf0aa1f-5585-4a8e-a7eb-b1348932e3aa"></script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
@@ -11,24 +11,12 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: calc(100vh - var(--navbar-height));
|
height: calc(100vh - var(--navbar-height));
|
||||||
max-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 {
|
.tui-terminal:focus-within .tui-border-glow {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tuiFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hyprland-style animated border glow */
|
/* Hyprland-style animated border glow */
|
||||||
.tui-border-glow {
|
.tui-border-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -20,6 +20,11 @@
|
|||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tui-button.flex-expand {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Inline button styles */
|
/* Inline button styles */
|
||||||
.tui-button.inline {
|
.tui-button.inline {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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() };
|
||||||
|
}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<TuiLine
|
<TuiLine
|
||||||
line={child}
|
line={child}
|
||||||
index={k}
|
index={k}
|
||||||
segments={parseColorText(child.content)}
|
segments={parseColorText(child.content, $themeColors.colorMap)}
|
||||||
complete={true}
|
complete={true}
|
||||||
showImage={true}
|
showImage={true}
|
||||||
selectedIndex={-1}
|
selectedIndex={-1}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
class:selected
|
class:selected
|
||||||
class:inline
|
class:inline
|
||||||
class:no-border={line.border === false}
|
class:no-border={line.border === false}
|
||||||
|
class:flex-expand={line.flex}
|
||||||
style="--btn-color: {getButtonStyle(line.style)}"
|
style="--btn-color: {getButtonStyle(line.style)}"
|
||||||
onclick={() => onClick(index, line)}
|
onclick={() => onClick(index, line)}
|
||||||
onmouseenter={() => onHover(index)}
|
onmouseenter={() => onHover(index)}
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
<span class="btn-text" style:text-align={line.textStyle || "left"}>
|
<span class="btn-text" style:text-align={line.textStyle || "left"}>
|
||||||
{#each segments as segment}
|
{#each segments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
getSegmentStyle,
|
getSegmentStyle,
|
||||||
parseDimension,
|
parseDimension,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
import { themeColors } from "$lib/stores/theme";
|
||||||
import type { CardLine, TerminalLine } from "./types";
|
import type { CardLine, TerminalLine } from "./types";
|
||||||
import TuiLine from "./TuiLine.svelte";
|
import TuiLine from "./TuiLine.svelte";
|
||||||
import "$lib/assets/css/tui-card.css";
|
import "$lib/assets/css/tui-card.css";
|
||||||
@@ -26,12 +27,12 @@
|
|||||||
onLinkClick = () => {},
|
onLinkClick = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const segments = $derived(parseColorText(line.content));
|
const segments = $derived(parseColorText(line.content, $themeColors.colorMap));
|
||||||
const titleSegments = $derived(
|
const titleSegments = $derived(
|
||||||
line.cardTitle ? parseColorText(line.cardTitle) : [],
|
line.cardTitle ? parseColorText(line.cardTitle, $themeColors.colorMap) : [],
|
||||||
);
|
);
|
||||||
const footerSegments = $derived(
|
const footerSegments = $derived(
|
||||||
line.cardFooter ? parseColorText(line.cardFooter) : [],
|
line.cardFooter ? parseColorText(line.cardFooter, $themeColors.colorMap) : [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const cardStyle = $derived(
|
const cardStyle = $derived(
|
||||||
@@ -146,7 +147,7 @@
|
|||||||
<TuiLine
|
<TuiLine
|
||||||
line={item.line}
|
line={item.line}
|
||||||
index={item.index}
|
index={item.index}
|
||||||
segments={parseColorText(item.line.content)}
|
segments={parseColorText(item.line.content, $themeColors.colorMap)}
|
||||||
complete={true}
|
complete={true}
|
||||||
showImage={true}
|
showImage={true}
|
||||||
selectedIndex={-1}
|
selectedIndex={-1}
|
||||||
@@ -161,7 +162,7 @@
|
|||||||
<TuiLine
|
<TuiLine
|
||||||
line={group.line}
|
line={group.line}
|
||||||
index={group.index}
|
index={group.index}
|
||||||
segments={parseColorText(group.line.content)}
|
segments={parseColorText(group.line.content, $themeColors.colorMap)}
|
||||||
complete={true}
|
complete={true}
|
||||||
showImage={true}
|
showImage={true}
|
||||||
selectedIndex={-1}
|
selectedIndex={-1}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
<span class="checkbox-label">
|
<span class="checkbox-label">
|
||||||
{#each labelSegments as segment}
|
{#each labelSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
<TuiLine
|
<TuiLine
|
||||||
line={item.line}
|
line={item.line}
|
||||||
index={item.index}
|
index={item.index}
|
||||||
segments={parseColorText(item.line.content)}
|
segments={parseColorText(item.line.content, colorMap)}
|
||||||
complete={true}
|
complete={true}
|
||||||
showImage={item.line.type === "image"}
|
showImage={item.line.type === "image"}
|
||||||
selectedIndex={-1}
|
selectedIndex={-1}
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
<TuiLine
|
<TuiLine
|
||||||
line={group.line}
|
line={group.line}
|
||||||
index={group.index}
|
index={group.index}
|
||||||
segments={parseColorText(group.line.content)}
|
segments={parseColorText(group.line.content, colorMap)}
|
||||||
complete={true}
|
complete={true}
|
||||||
showImage={group.line.type === "image"}
|
showImage={group.line.type === "image"}
|
||||||
selectedIndex={-1}
|
selectedIndex={-1}
|
||||||
@@ -167,10 +167,16 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-group.expand > :global(*) {
|
.tui-group.expand > :global(*),
|
||||||
|
.tui-group.expand > :global(*) > :global(*) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tui-group.expand :global(.tui-button) {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes lineSlideIn {
|
@keyframes lineSlideIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each labelSegments as segment}
|
{#each labelSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -76,12 +76,26 @@
|
|||||||
const isHeader = $derived(line.type === "header");
|
const isHeader = $derived(line.type === "header");
|
||||||
const isLink = $derived(line.type === "link");
|
const isLink = $derived(line.type === "link");
|
||||||
const isTooltip = $derived(line.type === "tooltip");
|
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>
|
</script>
|
||||||
|
|
||||||
<div class:mobile-hidden={line.mobile === false} style="display: contents">
|
<div class:mobile-hidden={line.mobile === false} style="display: contents">
|
||||||
{#if isBlank}
|
{#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"
|
class="tui-line blank"
|
||||||
|
style={blankHeight
|
||||||
|
? `height: ${blankHeight}; min-height: ${blankHeight};`
|
||||||
|
: undefined}
|
||||||
></div>{/if}
|
></div>{/if}
|
||||||
{:else if isDivider}
|
{:else if isDivider}
|
||||||
<div class="tui-divider" id={line.id}>
|
<div class="tui-divider" id={line.id}>
|
||||||
@@ -149,7 +163,7 @@
|
|||||||
<Icon icon="mdi:pound" width="20" class="header-icon" />
|
<Icon icon="mdi:pound" width="20" class="header-icon" />
|
||||||
{#each segments as seg}{#if seg.icon}<Icon
|
{#each segments as seg}{#if seg.icon}<Icon
|
||||||
icon={seg.icon}
|
icon={seg.icon}
|
||||||
width="20"
|
width={seg.iconSize ?? 20}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
/>{:else if getSegmentStyle(seg)}<span
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||||
@@ -161,7 +175,7 @@
|
|||||||
<Icon icon="mdi:pound" width="25" class="header-icon" />
|
<Icon icon="mdi:pound" width="25" class="header-icon" />
|
||||||
{#each segments as seg}{#if seg.icon}<Icon
|
{#each segments as seg}{#if seg.icon}<Icon
|
||||||
icon={seg.icon}
|
icon={seg.icon}
|
||||||
width="25"
|
width={seg.iconSize ?? 25}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
/>{:else if getSegmentStyle(seg)}<span
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||||
@@ -175,7 +189,7 @@
|
|||||||
<span class="inline-content {line.type}">
|
<span class="inline-content {line.type}">
|
||||||
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
||||||
icon={seg.icon}
|
icon={seg.icon}
|
||||||
width="14"
|
width={seg.iconSize ?? 14}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
/>{:else if getSegmentStyle(seg)}<span
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||||
@@ -196,7 +210,7 @@
|
|||||||
line.type,
|
line.type,
|
||||||
)}{#each segments as seg}{#if seg.icon}<Icon
|
)}{#each segments as seg}{#if seg.icon}<Icon
|
||||||
icon={seg.icon}
|
icon={seg.icon}
|
||||||
width="14"
|
width={seg.iconSize ?? 14}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
/>{:else if getSegmentStyle(seg)}<span
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||||
@@ -210,7 +224,7 @@
|
|||||||
<span class="inline-content {line.type}">
|
<span class="inline-content {line.type}">
|
||||||
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
||||||
icon={seg.icon}
|
icon={seg.icon}
|
||||||
width="14"
|
width={seg.iconSize ?? 14}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
/>{:else if getSegmentStyle(seg)}<span
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||||
@@ -224,7 +238,7 @@
|
|||||||
line.type,
|
line.type,
|
||||||
)}{#each segments as seg}{#if seg.icon}<Icon
|
)}{#each segments as seg}{#if seg.icon}<Icon
|
||||||
icon={seg.icon}
|
icon={seg.icon}
|
||||||
width="14"
|
width={seg.iconSize ?? 14}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
/>{:else if getSegmentStyle(seg)}<span
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<button class="link-text" onclick={onClick}>
|
<button class="link-text" onclick={onClick}>
|
||||||
{#each segments as segment}
|
{#each segments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
<div class="progress-label">
|
<div class="progress-label">
|
||||||
{#each contentSegments as segment}
|
{#each contentSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
<div class="progress-value">
|
<div class="progress-value">
|
||||||
{#each labelSegments as segment}
|
{#each labelSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each labelSegments as segment}
|
{#each labelSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
{#if segment.icon}
|
{#if segment.icon}
|
||||||
<Icon
|
<Icon
|
||||||
icon={segment.icon}
|
icon={segment.icon}
|
||||||
width="14"
|
width={segment.iconSize ?? 14}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>
|
/>
|
||||||
{:else if getSegmentStyle(segment)}
|
{:else if getSegmentStyle(segment)}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each labelSegments as segment}
|
{#each labelSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="select-options">
|
<div class="select-options">
|
||||||
{#each filteredOptions as option, i}
|
{#each filteredOptions as option, i}
|
||||||
{@const optionSegments = parseColorText(option.label)}
|
{@const optionSegments = parseColorText(option.label, $themeColors.colorMap)}
|
||||||
<div
|
<div
|
||||||
class="select-option"
|
class="select-option"
|
||||||
class:selected={value === option.value}
|
class:selected={value === option.value}
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
{#if segment.icon}
|
{#if segment.icon}
|
||||||
<Icon
|
<Icon
|
||||||
icon={segment.icon}
|
icon={segment.icon}
|
||||||
width="14"
|
width={segment.iconSize ?? 14}
|
||||||
class="inline-icon"
|
class="inline-icon"
|
||||||
/>
|
/>
|
||||||
{:else if getSegmentStyle(segment)}
|
{:else if getSegmentStyle(segment)}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each labelSegments as segment}
|
{#each labelSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<span class="toggle-label">
|
<span class="toggle-label">
|
||||||
{#each labelSegments as segment}
|
{#each labelSegments as segment}
|
||||||
{#if segment.icon}
|
{#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)}
|
{:else if getSegmentStyle(segment)}
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||||
|
import { themeColors } from "$lib/stores/theme";
|
||||||
import type { TooltipLine } from "./types";
|
import type { TooltipLine } from "./types";
|
||||||
import "$lib/assets/css/tui-tooltip.css";
|
import "$lib/assets/css/tui-tooltip.css";
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
let triggerEl: HTMLSpanElement | undefined = $state();
|
let triggerEl: HTMLSpanElement | undefined = $state();
|
||||||
let tooltipStyle = $state("");
|
let tooltipStyle = $state("");
|
||||||
|
|
||||||
const contentSegments = $derived(parseColorText(line.content));
|
const contentSegments = $derived(parseColorText(line.content, $themeColors.colorMap));
|
||||||
const position = $derived(line.tooltipPosition || "top");
|
const position = $derived(line.tooltipPosition || "top");
|
||||||
|
|
||||||
function updateTooltipPosition() {
|
function updateTooltipPosition() {
|
||||||
|
|||||||
@@ -29,15 +29,71 @@ const err = (content: string): TerminalLine => ({ type: 'error', content });
|
|||||||
const info = (content: string): TerminalLine => ({ type: 'info', content });
|
const info = (content: string): TerminalLine => ({ type: 'info', content });
|
||||||
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
|
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
|
||||||
|
|
||||||
const pageRoutes: Record<string, string> = {
|
interface PageNode {
|
||||||
home: '/',
|
name: string;
|
||||||
about: '/about',
|
path: string;
|
||||||
portfolio: '/portfolio',
|
children?: PageNode[];
|
||||||
projects: '/projects',
|
}
|
||||||
models: '/models',
|
|
||||||
components: '/components',
|
const pageTree: PageNode[] = [
|
||||||
links: '/links'
|
{ 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[] = [
|
const baseCommands: Command[] = [
|
||||||
{
|
{
|
||||||
@@ -59,7 +115,7 @@ const baseCommands: Command[] = [
|
|||||||
tableRows: rows,
|
tableRows: rows,
|
||||||
style: 'accent'
|
style: 'accent'
|
||||||
} as TerminalLine);
|
} 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());
|
ctx.terminal.write(blank());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -74,7 +130,7 @@ const baseCommands: Command[] = [
|
|||||||
description: 'Show a short bio',
|
description: 'Show a short bio',
|
||||||
run: (_args, ctx) => {
|
run: (_args, ctx) => {
|
||||||
ctx.terminal.writeLines([
|
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}(&)`),
|
out(`(&muted)${ctx.user.bio}(&)`),
|
||||||
blank()
|
blank()
|
||||||
]);
|
]);
|
||||||
@@ -85,14 +141,37 @@ const baseCommands: Command[] = [
|
|||||||
aliases: ['banner'],
|
aliases: ['banner'],
|
||||||
description: 'Display system info',
|
description: 'Display system info',
|
||||||
run: (_args, ctx) => {
|
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(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`),
|
||||||
out('(&muted)------------------------------(&)'),
|
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)Title(&): ${ctx.user.title}`),
|
||||||
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
|
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
|
||||||
out(`(&blue,bold)Shell(&): tui-terminal`),
|
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(`(&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()
|
blank()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -134,17 +213,16 @@ const baseCommands: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'email',
|
name: 'email',
|
||||||
description: 'Open a mail draft',
|
description: 'Show contact email',
|
||||||
run: (_args, ctx) => {
|
run: (_args, ctx) => {
|
||||||
ctx.terminal.write(ok(`Opening mail to (&accent)${ctx.user.email}(&)`));
|
ctx.terminal.write(out(`(&accent)${ctx.user.email}(&)`));
|
||||||
ctx.navigate(`mailto:${ctx.user.email}`, true);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'cd',
|
name: 'cd',
|
||||||
aliases: ['goto', 'open'],
|
aliases: ['goto', 'open'],
|
||||||
description: 'Navigate to a page',
|
description: 'Navigate to a page',
|
||||||
usage: 'cd <page>',
|
usage: 'cd <page> (e.g. projects/opensource)',
|
||||||
run: (args, ctx) => {
|
run: (args, ctx) => {
|
||||||
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
|
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||||
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
|
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
|
||||||
@@ -153,9 +231,10 @@ const baseCommands: Command[] = [
|
|||||||
ctx.navigate(navItem.path, navItem.external);
|
ctx.navigate(navItem.path, navItem.external);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pageRoutes[target] !== undefined) {
|
const resolvedPath = resolvePagePath(target);
|
||||||
|
if (resolvedPath !== undefined) {
|
||||||
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||||
ctx.navigate(pageRoutes[target]);
|
ctx.navigate(resolvedPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
|
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
|
||||||
@@ -163,13 +242,26 @@ const baseCommands: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'ls',
|
name: 'ls',
|
||||||
|
aliases: ['dir'],
|
||||||
description: 'List the pages you can visit',
|
description: 'List the pages you can visit',
|
||||||
run: (_args, ctx) => {
|
run: (_args, ctx) => {
|
||||||
const names = Object.keys(pageRoutes);
|
const lines: TerminalLine[] = [];
|
||||||
ctx.navigation.forEach((item) => {
|
|
||||||
if (item.external && !names.includes(item.name)) names.push(item.name);
|
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>',
|
usage: 'echo <text>',
|
||||||
run: (args, ctx) => ctx.terminal.write(out(args.join(' ')))
|
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',
|
name: 'date',
|
||||||
description: 'Show the current date and time',
|
description: 'Show the current date and time (Eastern)',
|
||||||
run: (_args, ctx) => ctx.terminal.write(out(new Date().toString()))
|
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',
|
name: 'sudo',
|
||||||
@@ -230,15 +360,6 @@ const baseCommands: Command[] = [
|
|||||||
ctx.terminal.write(
|
ctx.terminal.write(
|
||||||
err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`)
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface WarningLine extends BaseTerminalLine {
|
|||||||
|
|
||||||
export interface BlankLine extends BaseTerminalLine {
|
export interface BlankLine extends BaseTerminalLine {
|
||||||
type: 'blank';
|
type: 'blank';
|
||||||
|
height?: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DividerLine extends BaseTerminalLine {
|
export interface DividerLine extends BaseTerminalLine {
|
||||||
@@ -80,6 +81,7 @@ export interface ButtonLine extends BaseTerminalLine {
|
|||||||
external?: boolean;
|
external?: boolean;
|
||||||
textStyle?: 'start' | 'center' | 'end';
|
textStyle?: 'start' | 'center' | 'end';
|
||||||
border?: boolean;
|
border?: boolean;
|
||||||
|
flex?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LinkLine extends BaseTerminalLine {
|
export interface LinkLine extends BaseTerminalLine {
|
||||||
|
|||||||
@@ -85,10 +85,11 @@ export function parseColorText(text: string, colors: ThemeColorMap = colorMap):
|
|||||||
segments.push({ text: text.slice(lastIndex, match.index) });
|
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]) {
|
if (match[1]) {
|
||||||
const iconName = match[1].trim();
|
const [iconName, sizeArg] = match[1].split(',').map(part => part.trim());
|
||||||
segments.push({ text: '', icon: iconName });
|
const iconSize = sizeArg ? Number(sizeArg) : undefined;
|
||||||
|
segments.push({ text: '', icon: iconName, iconSize: iconSize && !isNaN(iconSize) ? iconSize : undefined });
|
||||||
lastIndex = match.index + match[0].length;
|
lastIndex = match.index + match[0].length;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const blogName = 'Finding Out';
|
||||||
|
export const blogTagline = 'My Engineering Blog';
|
||||||
@@ -18,6 +18,14 @@ export const openSourceProjects: OpenSourceProject[] = [
|
|||||||
description: 'TypstDrive is a self-hosted collaborative web editor for Typst.',
|
description: 'TypstDrive is a self-hosted collaborative web editor for Typst.',
|
||||||
tech: ['Typst', 'Self-Hosted', 'Collaborative'],
|
tech: ['Typst', 'Self-Hosted', 'Collaborative'],
|
||||||
github: 'https://github.com/SirBlobby/TypstDrive',
|
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',
|
name: 'Pkit',
|
||||||
@@ -33,13 +41,6 @@ export const openSourceProjects: OpenSourceProject[] = [
|
|||||||
tech: ['Python', 'LLM', 'AI', 'NotebookLLM'],
|
tech: ['Python', 'LLM', 'AI', 'NotebookLLM'],
|
||||||
github: 'https://github.com/SirBlobby/OBookLLM',
|
github: 'https://github.com/SirBlobby/OBookLLM',
|
||||||
image: 'https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg',
|
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
|
// HACKATHONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -193,6 +215,7 @@ export const cards: Card[] = [
|
|||||||
description:
|
description:
|
||||||
"Architected a real-time campus noise and occupancy monitoring system utilizing SvelteKit, FastAPI, and MongoDB to help students locate quiet study spaces.",
|
"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/",
|
link: "https://hushmap.study/",
|
||||||
|
devpost: "https://devpost.com/software/hushmap-193nia",
|
||||||
hackathonName: "Bitcamp 2026",
|
hackathonName: "Bitcamp 2026",
|
||||||
university: "University of Maryland",
|
university: "University of Maryland",
|
||||||
location: "College Park, MD",
|
location: "College Park, MD",
|
||||||
@@ -208,6 +231,7 @@ export const cards: Card[] = [
|
|||||||
title: "LearningBuddy",
|
title: "LearningBuddy",
|
||||||
description:
|
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.",
|
"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",
|
hackathonName: "HaxFax x PatriotHacks 2026",
|
||||||
university: "George Mason University",
|
university: "George Mason University",
|
||||||
location: "Fairfax, VA",
|
location: "Fairfax, VA",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// User profile and skills
|
// User profile and skills
|
||||||
export { user, skills } from './user';
|
export type { LinkItem } from './user';
|
||||||
|
export { user, skills, links } from './user';
|
||||||
|
|
||||||
// Layout, breakpoints, fonts, navbar, scrollbar
|
// Layout, breakpoints, fonts, navbar, scrollbar
|
||||||
export { layout, breakpoints, fonts, navbar, scrollbar } from './layout';
|
export { layout, breakpoints, fonts, navbar, scrollbar } from './layout';
|
||||||
@@ -25,9 +26,9 @@ export {
|
|||||||
animations
|
animations
|
||||||
} from './theme';
|
} from './theme';
|
||||||
|
|
||||||
// Content: projects, models, experience, hackathon cards
|
// Content: projects, models, experience, research, hackathon cards
|
||||||
export type { OpenSourceProject, PackageProject, Model3D, Experience, Card } from './content';
|
export type { OpenSourceProject, PackageProject, Model3D, Experience, ResearchItem, Card } from './content';
|
||||||
export { openSourceProjects, packageProjects, models, experience, cards, sortedCards } from './content';
|
export { openSourceProjects, packageProjects, models, experience, researchItems, cards, sortedCards } from './content';
|
||||||
|
|
||||||
// Terminal settings, TUI styling, speed presets, model viewer, particles, shortcuts
|
// Terminal settings, TUI styling, speed presets, model viewer, particles, shortcuts
|
||||||
export type { SpeedPreset, TerminalSettings } from './terminal';
|
export type { SpeedPreset, TerminalSettings } from './terminal';
|
||||||
@@ -52,6 +53,7 @@ export { navigation, site, pageMeta } from './navigation';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Use (&color)text(&) syntax in terminal content for colored text
|
// Use (&color)text(&) syntax in terminal content for colored text
|
||||||
// Use (&icon, iconName) syntax for inline icons
|
// Use (&icon, iconName) syntax for inline icons
|
||||||
|
// Use (&icon, iconName, size) to set a custom icon size in pixels
|
||||||
//
|
//
|
||||||
// Available colors:
|
// Available colors:
|
||||||
// red, green, yellow, blue, magenta, cyan, white, gray, orange, pink
|
// red, green, yellow, blue, magenta, cyan, white, gray, orange, pink
|
||||||
@@ -70,6 +72,7 @@ export { navigation, site, pageMeta } from './navigation';
|
|||||||
// "(&error)Failed(&) - (&success)Passed(&)" -> multiple colors
|
// "(&error)Failed(&) - (&success)Passed(&)" -> multiple colors
|
||||||
// "(&icon, mdi:github) GitHub" -> inline GitHub icon
|
// "(&icon, mdi:github) GitHub" -> inline GitHub icon
|
||||||
// "Check (&icon, mdi:check) Done" -> inline check icon
|
// "Check (&icon, mdi:check) Done" -> inline check icon
|
||||||
|
// "(&icon, mdi:trophy, 32) Winner" -> bigger trophy icon (32px)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import { user, skills } from './user';
|
|||||||
export const navigation = [
|
export const navigation = [
|
||||||
{ name: 'home', path: '/', icon: '~' },
|
{ name: 'home', path: '/', icon: '~' },
|
||||||
{ name: 'about', path: '/about', icon: 'mdi:account' },
|
{ name: 'about', path: '/about', icon: 'mdi:account' },
|
||||||
{ name: 'portfolio', path: '/portfolio', icon: '📁' },
|
// { name: 'portfolio', path: '/portfolio', icon: '📁' },
|
||||||
// { name: 'models', path: '/models', icon: '🎨' },
|
// { name: 'models', path: '/models', icon: '🎨' },
|
||||||
{ name: 'projects', path: '/projects', icon: '🏆' },
|
{ name: 'projects', path: '/projects', icon: '🏆' },
|
||||||
// { name: 'components', path: '/components', 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',
|
icon: 'mdi:trophy',
|
||||||
keywords: ['hackathon', 'projects', 'events']
|
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': {
|
'/components': {
|
||||||
title: `${user.displayname} — Components`,
|
title: `${user.displayname} — Components`,
|
||||||
description: 'Terminal UI components showcase and documentation.',
|
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.',
|
description: 'All my links with scannable QR codes.',
|
||||||
icon: 'mdi:link-variant',
|
icon: 'mdi:link-variant',
|
||||||
keywords: ['links', 'qr', 'social', 'contact']
|
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']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
|
|||||||
'portfolio': 'instant',
|
'portfolio': 'instant',
|
||||||
'models': 'fast',
|
'models': 'fast',
|
||||||
'projects': 'fast',
|
'projects': 'fast',
|
||||||
|
'opensource': 'fast',
|
||||||
|
'packages': 'fast',
|
||||||
|
'hackathons': 'fast',
|
||||||
|
'research': 'fast',
|
||||||
'components': 'fast'
|
'components': 'fast'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +156,10 @@ export const pageAutoscrollSettings: Record<string, boolean> = {
|
|||||||
'portfolio': false,
|
'portfolio': false,
|
||||||
'models': false,
|
'models': false,
|
||||||
'projects': false,
|
'projects': false,
|
||||||
|
'opensource': false,
|
||||||
|
'packages': false,
|
||||||
|
'hackathons': false,
|
||||||
|
'research': false,
|
||||||
'components': false
|
'components': false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -26,12 +26,31 @@ export const user = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LINKS PAGE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface LinkItem {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const links: LinkItem[] = [
|
||||||
|
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
|
||||||
|
{ name: 'LinkedIn', icon: 'mdi:linkedin', link: 'https://www.linkedin.com/in/gmanjunatha/' },
|
||||||
|
{ name: 'Devpost', icon: 'simple-icons:devpost', link: 'https://devpost.com/Sir_Blob_' },
|
||||||
|
{ name: 'Discord', icon: 'ic:baseline-discord', link: 'https://discord.com/users/sir_blob_' },
|
||||||
|
{ name: 'Email', icon: 'mdi:email', link: `mailto:${user.email}` },
|
||||||
|
// { name: 'Blog', icon: 'mdi:rss', link: 'https://blog.sirblob.co' }
|
||||||
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SKILLS
|
// SKILLS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const skills = {
|
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', ],
|
frameworks: ['React', 'Electron', 'Svelte', 'Bootstrap', 'TailwindCSS', 'Discord.js', ],
|
||||||
applications: ['IntelliJ', 'VS Code', 'Git', 'Blender', 'Godot'],
|
applications: ['IntelliJ', 'VS Code', 'Git', 'Blender', 'Godot'],
|
||||||
platforms: ['Windows', 'Linux', 'macOS', 'Arduino', 'Raspberry Pi'],
|
platforms: ['Windows', 'Linux', 'macOS', 'Arduino', 'Raspberry Pi'],
|
||||||
|
|||||||
@@ -65,6 +65,16 @@ const games = [
|
|||||||
name: "Risk",
|
name: "Risk",
|
||||||
image: '/img/risk.png',
|
image: '/img/risk.png',
|
||||||
url: 'https://store.steampowered.com/app/1128810/RISK_Global_Domination/'
|
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/'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
];
|
||||||
@@ -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
|
||||||
|
];
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
const tagPalette = ['blue', 'magenta', 'cyan', 'orange', 'green', 'pink'];
|
||||||
|
|
||||||
|
export function tagColor(index: number): string {
|
||||||
|
return tagPalette[index % tagPalette.length];
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
};
|
||||||
+60
-101
@@ -1,112 +1,71 @@
|
|||||||
import type { TerminalLine } from '$lib/components/tui/types';
|
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 totalHackathons = sortedCards.length;
|
||||||
const totalAwards = sortedCards.filter(c => c.awards && c.awards.length > 0).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[] = [
|
export const lines: TerminalLine[] = [
|
||||||
{ type: 'command' as const, content: 'ls ~/projects' },
|
{ 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: [
|
children: [
|
||||||
{ type: 'link' as const, href: "/projects#opensource", content: `(&bg-blue,black)Open Scourced(&)`, inline: true },
|
{ type: 'output' as const, content: `(&muted)Libraries and tools I build and maintain in the open.(&)` },
|
||||||
{ type: 'link' as const, href: "/projects#packages", content: `(&bg-orange,black)Packages(&)`, inline: true },
|
{ type: 'button' as const, content: 'Browse Open Source', href: '/projects/opensource', icon: 'mdi:arrow-right', style: 'accent' as const }
|
||||||
{ type: 'link' as const, href: "/projects#hackathons", content: `(&bg-green,black)Hackathons(&)`, inline: true },
|
],
|
||||||
]
|
inline: true,
|
||||||
|
display: 'flex' as const,
|
||||||
|
cardWidth
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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
|
||||||
},
|
},
|
||||||
{ 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! 🚀(&)` },
|
|
||||||
];
|
];
|
||||||
@@ -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
|
||||||
|
];
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getAllPosts, getAllTags } from '$lib/blog/posts';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = () => {
|
||||||
|
const posts = getAllPosts().map(({ slug, title, author, date, tags, excerpt, readingTime }) => ({
|
||||||
|
slug,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
date,
|
||||||
|
tags,
|
||||||
|
excerpt,
|
||||||
|
readingTime
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
tags: getAllTags()
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { user, pageMeta } from '$lib/config';
|
||||||
|
import { blogName, blogTagline } from '$lib/config/blog';
|
||||||
|
import { formatPostDate } from '$lib/blog/date';
|
||||||
|
import { themeColors } from '$lib/stores/theme';
|
||||||
|
import TuiHeader from '$lib/components/tui/TuiHeader.svelte';
|
||||||
|
import TuiFooter from '$lib/components/tui/TuiFooter.svelte';
|
||||||
|
import '$lib/assets/css/terminal-tui.css';
|
||||||
|
import '$lib/assets/css/tui-body.css';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let activeTag = $state<string | null>(null);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let currentPage = $state(1);
|
||||||
|
|
||||||
|
const POSTS_PER_PAGE = 5;
|
||||||
|
|
||||||
|
const filteredPosts = $derived.by(() => {
|
||||||
|
const query = searchQuery.trim().toLowerCase();
|
||||||
|
return data.posts.filter((post) => {
|
||||||
|
const matchesTag = !activeTag || post.tags.includes(activeTag);
|
||||||
|
const matchesQuery =
|
||||||
|
!query ||
|
||||||
|
post.title.toLowerCase().includes(query) ||
|
||||||
|
post.excerpt.toLowerCase().includes(query) ||
|
||||||
|
post.tags.some((tag) => tag.toLowerCase().includes(query));
|
||||||
|
return matchesTag && matchesQuery;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPages = $derived(Math.max(1, Math.ceil(filteredPosts.length / POSTS_PER_PAGE)));
|
||||||
|
|
||||||
|
const paginatedPosts = $derived(
|
||||||
|
filteredPosts.slice((currentPage - 1) * POSTS_PER_PAGE, currentPage * POSTS_PER_PAGE)
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
activeTag;
|
||||||
|
searchQuery;
|
||||||
|
currentPage = 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
function goToPage(page: number) {
|
||||||
|
currentPage = Math.min(Math.max(1, page), totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = pageMeta['/blog'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta?.title ?? `${user.displayname} — Blog`}</title>
|
||||||
|
<meta name="description" content={meta?.description ?? 'Blog posts'} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="blog-container">
|
||||||
|
<div
|
||||||
|
class="tui-terminal"
|
||||||
|
style="
|
||||||
|
--terminal-bg: {$themeColors.terminal};
|
||||||
|
--terminal-text: {$themeColors.text};
|
||||||
|
--terminal-muted: {$themeColors.textMuted};
|
||||||
|
--terminal-border: {$themeColors.border};
|
||||||
|
--terminal-prompt: {$themeColors.terminalPrompt};
|
||||||
|
--terminal-user: {$themeColors.terminalUser};
|
||||||
|
--terminal-path: {$themeColors.terminalPath};
|
||||||
|
--terminal-primary: {$themeColors.primary};
|
||||||
|
--terminal-secondary: {$themeColors.secondary};
|
||||||
|
--terminal-accent: {$themeColors.accent};
|
||||||
|
--terminal-bg-light: {$themeColors.backgroundLight};
|
||||||
|
"
|
||||||
|
role="region"
|
||||||
|
aria-label="Blog"
|
||||||
|
>
|
||||||
|
<div class="tui-border-glow"></div>
|
||||||
|
|
||||||
|
<div class="tui-content">
|
||||||
|
<TuiHeader title="~/blog" interactive={false} hasButtons={false} />
|
||||||
|
|
||||||
|
<div class="tui-body">
|
||||||
|
<div class="blog-body">
|
||||||
|
<div class="blog-hero">
|
||||||
|
<h1 class="blog-name">{blogName}</h1>
|
||||||
|
<p class="blog-tagline">{blogTagline}</p>
|
||||||
|
<div class="blog-links">
|
||||||
|
<a class="blog-link" href="/blog/rss.xml" target="_blank" rel="noopener noreferrer">RSS Feed</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input class="search-input" type="search" placeholder="Search posts..." bind:value={searchQuery} />
|
||||||
|
|
||||||
|
{#if data.tags.length}
|
||||||
|
<div class="tag-filter">
|
||||||
|
<button class="tag-chip" class:active={activeTag === null} onclick={() => (activeTag = null)}>
|
||||||
|
all
|
||||||
|
</button>
|
||||||
|
{#each data.tags as tag (tag)}
|
||||||
|
<button
|
||||||
|
class="tag-chip"
|
||||||
|
class:active={activeTag === tag}
|
||||||
|
onclick={() => (activeTag = activeTag === tag ? null : tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if filteredPosts.length === 0}
|
||||||
|
<p class="empty-state">
|
||||||
|
{data.posts.length === 0 ? 'No posts yet. Check back soon.' : 'No posts match your search.'}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="post-list">
|
||||||
|
{#each paginatedPosts as post (post.slug)}
|
||||||
|
<a class="post-card" href={`/blog/${post.slug}`}>
|
||||||
|
<div class="post-meta">
|
||||||
|
<span class="post-author">{post.author}</span>
|
||||||
|
<span class="post-date">{formatPostDate(post.date)}</span>
|
||||||
|
<span class="post-reading-time">{post.readingTime} min read</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="post-title">{post.title}</h2>
|
||||||
|
<p class="post-excerpt">{post.excerpt}</p>
|
||||||
|
{#if post.tags.length}
|
||||||
|
<div class="post-tags">
|
||||||
|
{#each post.tags as tag (tag)}
|
||||||
|
<span class="post-tag">{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if totalPages > 1}
|
||||||
|
<div class="pagination">
|
||||||
|
<button
|
||||||
|
class="page-btn"
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
onclick={() => goToPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
<span class="page-status">Page {currentPage} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
class="page-btn"
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
onclick={() => goToPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TuiFooter isTyping={false} linesCount={filteredPosts.length} skipAnimation={() => {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.blog-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - var(--navbar-height, 60px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-hero {
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--terminal-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-tagline {
|
||||||
|
margin: 0.25rem 0 0.75rem 0;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-link {
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-link:hover {
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-link-separator {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip:hover {
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
color: var(--terminal-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip.active {
|
||||||
|
border-color: var(--terminal-accent);
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card {
|
||||||
|
display: block;
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-card:hover {
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author {
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
margin: 0 0 0.4rem 0;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-excerpt {
|
||||||
|
margin: 0 0 0.6rem 0;
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tag {
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:hover:not(:disabled) {
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-status {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { getPostBySlug, getAdjacentPosts } from '$lib/blog/posts';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ params }) => {
|
||||||
|
const post = getPostBySlug(params.slug);
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
error(404, 'Post not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { previous, next } = getAdjacentPosts(params.slug);
|
||||||
|
|
||||||
|
return { post, previous, next };
|
||||||
|
};
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { user } from '$lib/config';
|
||||||
|
import { formatPostDate } from '$lib/blog/date';
|
||||||
|
import { themeColors } from '$lib/stores/theme';
|
||||||
|
import TuiHeader from '$lib/components/tui/TuiHeader.svelte';
|
||||||
|
import TuiFooter from '$lib/components/tui/TuiFooter.svelte';
|
||||||
|
import '$lib/assets/css/terminal-tui.css';
|
||||||
|
import '$lib/assets/css/tui-body.css';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let contentEl = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
function copyCode(code: string, button: HTMLButtonElement) {
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
const original = button.textContent;
|
||||||
|
button.textContent = 'Copied!';
|
||||||
|
button.classList.add('copied');
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = original;
|
||||||
|
button.classList.remove('copied');
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCopyButtons() {
|
||||||
|
if (!contentEl) return;
|
||||||
|
contentEl.querySelectorAll('pre').forEach((pre) => {
|
||||||
|
if (pre.querySelector('.copy-button')) return;
|
||||||
|
const code = pre.querySelector('code');
|
||||||
|
if (!code) return;
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'copy-button';
|
||||||
|
button.textContent = 'Copy';
|
||||||
|
button.addEventListener('click', () => copyCode(code.textContent || '', button));
|
||||||
|
pre.appendChild(button);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
data.post.html;
|
||||||
|
addCopyButtons();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.post.title} | {user.displayname}</title>
|
||||||
|
<meta name="description" content={data.post.excerpt} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="post-container">
|
||||||
|
<div
|
||||||
|
class="tui-terminal"
|
||||||
|
style="
|
||||||
|
--terminal-bg: {$themeColors.terminal};
|
||||||
|
--terminal-text: {$themeColors.text};
|
||||||
|
--terminal-muted: {$themeColors.textMuted};
|
||||||
|
--terminal-border: {$themeColors.border};
|
||||||
|
--terminal-prompt: {$themeColors.terminalPrompt};
|
||||||
|
--terminal-user: {$themeColors.terminalUser};
|
||||||
|
--terminal-path: {$themeColors.terminalPath};
|
||||||
|
--terminal-primary: {$themeColors.primary};
|
||||||
|
--terminal-secondary: {$themeColors.secondary};
|
||||||
|
--terminal-accent: {$themeColors.accent};
|
||||||
|
--terminal-bg-light: {$themeColors.backgroundLight};
|
||||||
|
"
|
||||||
|
role="region"
|
||||||
|
aria-label="Blog post"
|
||||||
|
>
|
||||||
|
<div class="tui-border-glow"></div>
|
||||||
|
|
||||||
|
<div class="tui-content">
|
||||||
|
<TuiHeader title={`~/blog/${data.post.slug}`} interactive={false} hasButtons={false} />
|
||||||
|
|
||||||
|
<div class="tui-body">
|
||||||
|
<article class="post-article">
|
||||||
|
<a class="back-link" href="/blog">← back to blog</a>
|
||||||
|
|
||||||
|
<h1 class="post-title">{data.post.title}</h1>
|
||||||
|
|
||||||
|
<div class="post-meta">
|
||||||
|
<span class="post-author">{data.post.author}</span>
|
||||||
|
<span>{formatPostDate(data.post.date)}</span>
|
||||||
|
<span>{data.post.readingTime} min read</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.post.tags.length}
|
||||||
|
<div class="post-tags">
|
||||||
|
{#each data.post.tags as tag (tag)}
|
||||||
|
<span class="post-tag">{tag}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="post-content" bind:this={contentEl}>
|
||||||
|
{@html data.post.html}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.previous || data.next}
|
||||||
|
<nav class="post-nav">
|
||||||
|
{#if data.previous}
|
||||||
|
<a class="post-nav-link prev" href={`/blog/${data.previous.slug}`}>
|
||||||
|
<span class="post-nav-label">← Previous</span>
|
||||||
|
<span class="post-nav-title">{data.previous.title}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="post-nav-spacer"></span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.next}
|
||||||
|
<a class="post-nav-link next" href={`/blog/${data.next.slug}`}>
|
||||||
|
<span class="post-nav-label">Next →</span>
|
||||||
|
<span class="post-nav-title">{data.next.title}</span>
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="post-nav-spacer"></span>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TuiFooter isTyping={false} linesCount={1} skipAnimation={() => {}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - var(--navbar-height, 60px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-article {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-author {
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tag {
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
line-height: 1.75;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(h1),
|
||||||
|
.post-content :global(h2),
|
||||||
|
.post-content :global(h3) {
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
margin: 1.75rem 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(p) {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(a) {
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(code) {
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(pre) {
|
||||||
|
position: relative;
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.copy-button) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background: var(--terminal-bg);
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
color 0.15s ease,
|
||||||
|
border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.copy-button:hover) {
|
||||||
|
color: var(--terminal-text);
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.copy-button.copied) {
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
border-color: var(--terminal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-keyword) {
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-string) {
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-comment) {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-number) {
|
||||||
|
color: var(--terminal-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(blockquote) {
|
||||||
|
border-left: 3px solid var(--terminal-primary);
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
padding: 0.25rem 0 0.25rem 1rem;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(ul),
|
||||||
|
.post-content :global(ol) {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--terminal-border);
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid var(--terminal-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
max-width: 48%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav-link:hover {
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav-link.next {
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav-label {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-nav-title {
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { getAllPosts } from '$lib/blog/posts';
|
||||||
|
import { blogName, blogTagline } from '$lib/config/blog';
|
||||||
|
import { formatRssDate } from '$lib/blog/date';
|
||||||
|
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
|
const items = getAllPosts()
|
||||||
|
.map((post) => {
|
||||||
|
const link = `${url.origin}/blog/${post.slug}`;
|
||||||
|
const pubDate = formatRssDate(post.date);
|
||||||
|
return `
|
||||||
|
<item>
|
||||||
|
<title>${escapeXml(post.title)}</title>
|
||||||
|
<link>${link}</link>
|
||||||
|
<guid>${link}</guid>
|
||||||
|
<pubDate>${pubDate}</pubDate>
|
||||||
|
<author>${escapeXml(post.author)}</author>
|
||||||
|
<description>${escapeXml(post.excerpt)}</description>
|
||||||
|
</item>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0">
|
||||||
|
<channel>
|
||||||
|
<title>${escapeXml(blogName)}</title>
|
||||||
|
<link>${url.origin}/blog</link>
|
||||||
|
<description>${escapeXml(blogTagline)}</description>${items}
|
||||||
|
</channel>
|
||||||
|
</rss>`;
|
||||||
|
|
||||||
|
return new Response(xml, {
|
||||||
|
headers: { 'Content-Type': 'application/xml' }
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,27 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import { user, navigation, pageMeta } from "$lib/config";
|
import { user, links, pageMeta, type LinkItem } from "$lib/config";
|
||||||
import { themeColors } from "$lib/stores/theme";
|
import { themeColors } from "$lib/stores/theme";
|
||||||
import TuiHeader from "$lib/components/tui/TuiHeader.svelte";
|
import TuiHeader from "$lib/components/tui/TuiHeader.svelte";
|
||||||
import TuiFooter from "$lib/components/tui/TuiFooter.svelte";
|
import TuiFooter from "$lib/components/tui/TuiFooter.svelte";
|
||||||
import "$lib/assets/css/terminal-tui.css";
|
import "$lib/assets/css/terminal-tui.css";
|
||||||
import "$lib/assets/css/tui-body.css";
|
import "$lib/assets/css/tui-body.css";
|
||||||
|
|
||||||
interface LinkItem {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
link: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const blogLink = navigation.find((item) => item.name === "blog")?.path;
|
|
||||||
|
|
||||||
const links: LinkItem[] = [
|
|
||||||
...user.socials,
|
|
||||||
{ name: "Email", icon: "mdi:email", link: `mailto:${user.email}` },
|
|
||||||
...(blogLink ? [{ name: "Blog", icon: "mdi:rss", link: blogLink }] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
let selected = $state<LinkItem | null>(null);
|
let selected = $state<LinkItem | null>(null);
|
||||||
let QrCode = $state<any>(null);
|
let QrCode = $state<any>(null);
|
||||||
let detailEl = $state<HTMLDivElement>();
|
let detailEl = $state<HTMLDivElement>();
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
|
||||||
|
import { user, pageMeta } from '$lib/config';
|
||||||
|
import { getPageSpeedMultiplier, getPageAutoscroll } from '$lib';
|
||||||
|
import { lines } from '$lib/pages/hackathons';
|
||||||
|
|
||||||
|
const speed = getPageSpeedMultiplier('hackathons');
|
||||||
|
const autoscroll = getPageAutoscroll('hackathons');
|
||||||
|
const meta = pageMeta['/projects/hackathons'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta?.title ?? `${user.displayname} — Hackathons`}</title>
|
||||||
|
<meta name="description" content={meta?.description ?? 'Hackathon projects and awards'} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="hackathons-container">
|
||||||
|
<TerminalTUI {lines} title="~/projects/hackathons" interactive={true} {speed} {autoscroll} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.hackathons-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
|
||||||
|
import { user, pageMeta } from '$lib/config';
|
||||||
|
import { getPageSpeedMultiplier, getPageAutoscroll } from '$lib';
|
||||||
|
import { lines } from '$lib/pages/opensource';
|
||||||
|
|
||||||
|
const speed = getPageSpeedMultiplier('opensource');
|
||||||
|
const autoscroll = getPageAutoscroll('opensource');
|
||||||
|
const meta = pageMeta['/projects/opensource'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta?.title ?? `${user.displayname} — Open Source`}</title>
|
||||||
|
<meta name="description" content={meta?.description ?? 'Open source projects'} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="opensource-container">
|
||||||
|
<TerminalTUI {lines} title="~/projects/opensource" interactive={true} {speed} {autoscroll} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.opensource-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
|
||||||
|
import { user, pageMeta } from '$lib/config';
|
||||||
|
import { getPageSpeedMultiplier, getPageAutoscroll } from '$lib';
|
||||||
|
import { lines } from '$lib/pages/packages';
|
||||||
|
|
||||||
|
const speed = getPageSpeedMultiplier('packages');
|
||||||
|
const autoscroll = getPageAutoscroll('packages');
|
||||||
|
const meta = pageMeta['/projects/packages'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta?.title ?? `${user.displayname} — Packages`}</title>
|
||||||
|
<meta name="description" content={meta?.description ?? 'Published packages'} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="packages-container">
|
||||||
|
<TerminalTUI {lines} title="~/projects/packages" interactive={true} {speed} {autoscroll} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.packages-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
|
||||||
|
import { user, pageMeta } from '$lib/config';
|
||||||
|
import { getPageSpeedMultiplier, getPageAutoscroll } from '$lib';
|
||||||
|
import { lines } from '$lib/pages/research';
|
||||||
|
|
||||||
|
const speed = getPageSpeedMultiplier('research');
|
||||||
|
const autoscroll = getPageAutoscroll('research');
|
||||||
|
const meta = pageMeta['/projects/research'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta?.title ?? `${user.displayname} — Research`}</title>
|
||||||
|
<meta name="description" content={meta?.description ?? 'Research work and publications'} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="research-container">
|
||||||
|
<TerminalTUI {lines} title="~/projects/research" interactive={true} {speed} {autoscroll} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.research-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - 60px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 706 KiB After Width: | Height: | Size: 620 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 589 KiB |
Reference in New Issue
Block a user