Bug Fixes and Formatting Update

This commit is contained in:
2025-11-28 05:13:49 +00:00
parent c61cb39475
commit 88b068a2b5
18 changed files with 863 additions and 199 deletions

169
README.md
View File

@@ -21,6 +21,7 @@ An Arch Linux terminal-themed portfolio website with Hyprland-style TUI componen
- **Portfolio** (`/portfolio`) - Skills, projects, and contact info
- **Models** (`/models`) - 3D model gallery with interactive viewer
- **Hackathons** (`/hackathons`) - Hackathon projects and achievements
- **Components** (`/components`) - Showcase of all TUI components
## Configuration
@@ -198,10 +199,24 @@ const lines: TerminalLine[] = [
{ type: 'info', content: 'Information' }, // Primary with prefix
{ type: 'header', content: 'Section Title' }, // Bold with # icon
{ type: 'blank', content: '' }, // Empty line
{ type: 'divider', content: 'SECTION' }, // Horizontal divider
{ type: 'divider', content: 'SECTION', id: 'section' }, // Horizontal divider with anchor ID
];
```
### Line Properties
All line types support these optional properties:
```typescript
{
type: 'output',
content: 'Hello world',
id: 'my-section', // Anchor ID for URL hash scrolling (e.g., /page#my-section)
inline: true, // Render inline with adjacent inline elements
delay: 500, // Delay before this line appears (ms)
}
```
### Button (Full-width interactive)
```typescript
@@ -212,11 +227,28 @@ const lines: TerminalLine[] = [
style: 'primary', // primary | accent | warning | error
href: 'https://github.com', // URL to navigate to
external: true, // Open in new tab (auto-detected for http/https)
inline: true, // Render as compact inline button
// OR
action: () => doSomething(), // Custom action
}
```
### Inline Elements
Multiple elements can be rendered on the same line using `inline: true`:
```typescript
// These will appear on the same line
{ type: 'output', content: 'Status:', inline: true },
{ type: 'success', content: 'Online', inline: true },
{ type: 'button', content: 'Refresh', icon: 'mdi:refresh', inline: true },
// Next line without inline breaks the group
{ type: 'blank', content: '' },
```
Supported inline types: `button`, `link`, `tooltip`, `progress`, `output`, `info`, `success`, `error`, `warning`
### Link (Inline clickable text)
```typescript
@@ -322,10 +354,29 @@ Main terminal component:
title="~/directory"
interactive={true}
speed="normal"
autoscroll={true}
onComplete={() => console.log('Done!')}
/>
```
Props:
- `lines` - Array of TerminalLine objects
- `title` - Terminal window title
- `interactive` - Enable keyboard navigation
- `speed` - Typing speed preset or multiplier
- `autoscroll` - Auto-scroll as content types (default: true)
- `onComplete` - Callback when typing animation finishes
### Anchor Scrolling
Add `id` to any line to create an anchor that can be linked to:
```typescript
{ type: 'divider', content: 'SKILLS', id: 'skills' },
```
Then link to it with `/portfolio#skills` - the page will scroll to that section after typing completes.
### Component Structure
```
@@ -346,9 +397,29 @@ src/lib/components/
└── TuiTooltip.svelte # Hover tooltip
```
## 3D Models
## 3D Model Viewer
Place `.glb` files in `/static/models/` and update the models page to display them.
The `ModelViewer` component provides an interactive Three.js viewer for `.glb` models.
### Features
- **Mouse Controls**: Drag to rotate, scroll to zoom
- **Arrow Key Controls**: Use arrow keys to orbit the camera (click viewer to focus first)
- **Auto-rotate**: Toggle automatic rotation
- **Wireframe Mode**: View model wireframe
- **Adjustable Lighting**: Increase/decrease scene brightness
- **Fullscreen Mode**: Expand to full viewport (press `Escape` to exit)
- **Ground Plane**: Optional shadow-receiving ground
### Usage
```svelte
<ModelViewer
modelPath="/models/my-model.glb"
modelName="My Model"
/>
```
Place `.glb` files in `/static/models/` and they'll be accessible at `/models/filename.glb`.
## Tech Stack
@@ -384,3 +455,95 @@ bun run preview
| `Y` | Skip typing animation |
| `T` | Toggle dark/light mode |
### 3D Model Viewer
| Key | Action |
|-----|--------|
| `←` | Rotate camera left |
| `→` | Rotate camera right |
| `↑` | Rotate camera up |
| `↓` | Rotate camera down |
| `Escape` | Exit fullscreen |
## Theme System
Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contains colors for both dark and light modes.
### Theme File Structure
```json
{
"name": "Theme Name",
"icon": "🎨",
"dark": {
"colors": {
"primary": "#89b4fa",
"secondary": "#313244",
"accent": "#a6e3a1",
"background": "#1e1e2e",
"backgroundLight": "#313244",
"text": "#cdd6f4",
"textMuted": "#a6adc8",
"border": "#45475a",
"terminal": "#1e1e2e",
"terminalPrompt": "#cba6f7",
"terminalUser": "#a6e3a1",
"terminalPath": "#89b4fa"
},
"colorMap": {
"red": "#f38ba8",
"green": "#a6e3a1",
"blue": "#89b4fa",
"primary": "var(--terminal-primary)",
"accent": "var(--terminal-accent)",
"muted": "var(--terminal-muted)"
}
},
"light": {
"colors": { /* light mode colors */ },
"colorMap": { /* light mode color map */ }
}
}
```
### Adding a New Theme
1. Create a new file: `src/lib/assets/themes/mytheme.theme.json`
2. Import it in `src/lib/stores/theme.ts`:
```typescript
import myTheme from '$lib/assets/themes/mytheme.theme.json';
```
3. Add it to the themes object:
```typescript
const themes: Record<ColorTheme, ThemeJson> = {
arch: archTheme,
catppuccin: catppuccinTheme,
mytheme: myTheme as ThemeJson
};
```
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
Beyond the basic colors, themes include:
- `teal`, `sky`, `sapphire`, `lavender`
- `peach`, `maroon`, `mauve`
- `flamingo`, `rosewater`
```typescript
// These colors adapt to the current theme
'(&teal)Teal text(&)'
'(&lavender)Lavender text(&)'
'(&peach)Peach text(&)'
```
## Mobile Considerations
- **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
- **Overflow handling**: Hidden horizontal scrollbar to prevent accidental horizontal scroll on mobile

View File

@@ -0,0 +1,96 @@
{
"name": "Arch Linux",
"icon": "🐧",
"dark": {
"colors": {
"primary": "#1793d1",
"secondary": "#333333",
"accent": "#23d18b",
"background": "#0d1117",
"backgroundLight": "#161b22",
"text": "#c9d1d9",
"textMuted": "#8b949e",
"border": "#30363d",
"terminal": "#0d1117",
"terminalPrompt": "#1793d1",
"terminalUser": "#23d18b",
"terminalPath": "#1793d1"
},
"colorMap": {
"red": "#fa7970",
"green": "#23d18b",
"yellow": "#faa356",
"blue": "#1793d1",
"magenta": "#cea5fb",
"cyan": "#89dceb",
"white": "#c9d1d9",
"gray": "#6e7681",
"orange": "#ff9500",
"pink": "#ff79c6",
"black": "#0d1117",
"surface": "#161b22",
"teal": "#77dfd8",
"sky": "#00bfff",
"sapphire": "#0099cc",
"lavender": "#b4a7d6",
"peach": "#ff9966",
"maroon": "#fa7970",
"mauve": "#cea5fb",
"flamingo": "#e06c75",
"rosewater": "#e8b4b8",
"primary": "var(--terminal-primary)",
"accent": "var(--terminal-accent)",
"muted": "var(--terminal-muted)",
"error": "#fa7970",
"success": "#23d18b",
"warning": "#faa356",
"info": "#1793d1"
}
},
"light": {
"colors": {
"primary": "#1793d1",
"secondary": "#e0e0e0",
"accent": "#005a2b",
"background": "#f6f8fa",
"backgroundLight": "#ffffff",
"text": "#111111",
"textMuted": "#57606a",
"border": "#d0d7de",
"terminal": "#ffffff",
"terminalPrompt": "#005a87",
"terminalUser": "#005a2b",
"terminalPath": "#005a87"
},
"colorMap": {
"red": "#a6101d",
"green": "#005a2b",
"yellow": "#945b00",
"blue": "#0a5696",
"magenta": "#7820a0",
"cyan": "#006060",
"white": "#111111",
"gray": "#57606a",
"orange": "#c04500",
"pink": "#a01b58",
"black": "#ffffff",
"surface": "#eaeef2",
"teal": "#006060",
"sky": "#005a87",
"sapphire": "#004870",
"lavender": "#4a4ec0",
"peach": "#b05010",
"maroon": "#802020",
"mauve": "#602090",
"flamingo": "#901040",
"rosewater": "#a05040",
"primary": "var(--terminal-primary)",
"accent": "var(--terminal-accent)",
"muted": "var(--terminal-muted)",
"error": "#a6101d",
"success": "#005a2b",
"warning": "#945b00",
"info": "#0a5696"
}
}
}

View File

@@ -0,0 +1,96 @@
{
"name": "Catppuccin",
"icon": "🐱",
"dark": {
"colors": {
"primary": "#89b4fa",
"secondary": "#313244",
"accent": "#a6e3a1",
"background": "#1e1e2e",
"backgroundLight": "#313244",
"text": "#cdd6f4",
"textMuted": "#a6adc8",
"border": "#45475a",
"terminal": "#1e1e2e",
"terminalPrompt": "#cba6f7",
"terminalUser": "#a6e3a1",
"terminalPath": "#89b4fa"
},
"colorMap": {
"red": "#f38ba8",
"green": "#a6e3a1",
"yellow": "#f9e2af",
"blue": "#89b4fa",
"magenta": "#cba6f7",
"cyan": "#94e2d5",
"white": "#cdd6f4",
"gray": "#6c7086",
"orange": "#fab387",
"pink": "#f5c2e7",
"black": "#1e1e2e",
"surface": "#313244",
"teal": "#94e2d5",
"sky": "#89dceb",
"sapphire": "#74c7ec",
"lavender": "#b4befe",
"peach": "#fab387",
"maroon": "#eba0ac",
"mauve": "#cba6f7",
"flamingo": "#f2cdcd",
"rosewater": "#f5e0dc",
"primary": "var(--terminal-primary)",
"accent": "var(--terminal-accent)",
"muted": "var(--terminal-muted)",
"error": "#f38ba8",
"success": "#a6e3a1",
"warning": "#f9e2af",
"info": "#89b4fa"
}
},
"light": {
"colors": {
"primary": "#1e66f5",
"secondary": "#ccd0da",
"accent": "#40a02b",
"background": "#eff1f5",
"backgroundLight": "#dce0e8",
"text": "#4c4f69",
"textMuted": "#6c6f85",
"border": "#bcc0cc",
"terminal": "#eff1f5",
"terminalPrompt": "#8839ef",
"terminalUser": "#40a02b",
"terminalPath": "#1e66f5"
},
"colorMap": {
"red": "#d20f39",
"green": "#40a02b",
"yellow": "#df8e1d",
"blue": "#1e66f5",
"magenta": "#8839ef",
"cyan": "#179299",
"white": "#4c4f69",
"gray": "#9ca0b0",
"orange": "#fe640b",
"pink": "#ea76cb",
"black": "#eff1f5",
"surface": "#ccd0da",
"teal": "#179299",
"sky": "#04a5e5",
"sapphire": "#209fb5",
"lavender": "#7287fd",
"peach": "#fe640b",
"maroon": "#e64553",
"mauve": "#8839ef",
"flamingo": "#dd7878",
"rosewater": "#dc8a78",
"primary": "var(--terminal-primary)",
"accent": "var(--terminal-accent)",
"muted": "var(--terminal-muted)",
"error": "#d20f39",
"success": "#40a02b",
"warning": "#df8e1d",
"info": "#1e66f5"
}
}
}

View File

@@ -268,6 +268,67 @@
controls.update();
}
function rotateCamera(direction: 'left' | 'right' | 'up' | 'down') {
if (!controls) return;
const rotateSpeed = 0.1;
const spherical = new THREE.Spherical();
const offset = new THREE.Vector3();
// Get current camera position relative to target
offset.copy(camera.position).sub(controls.target);
spherical.setFromVector3(offset);
switch (direction) {
case 'left':
spherical.theta += rotateSpeed;
break;
case 'right':
spherical.theta -= rotateSpeed;
break;
case 'up':
spherical.phi = Math.max(0.1, spherical.phi - rotateSpeed);
break;
case 'down':
spherical.phi = Math.min(Math.PI - 0.1, spherical.phi + rotateSpeed);
break;
}
offset.setFromSpherical(spherical);
camera.position.copy(controls.target).add(offset);
controls.update();
}
function handleKeydown(event: KeyboardEvent) {
// Only handle if this component is focused or in fullscreen
if (!container?.contains(document.activeElement) && !isFullscreen) return;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
rotateCamera('left');
break;
case 'ArrowRight':
event.preventDefault();
rotateCamera('right');
break;
case 'ArrowUp':
event.preventDefault();
rotateCamera('up');
break;
case 'ArrowDown':
event.preventDefault();
rotateCamera('down');
break;
case 'Escape':
if (isFullscreen) {
isFullscreen = false;
setTimeout(handleResize, 100);
}
break;
}
}
function toggleControlsPanel() {
showControls = !showControls;
}
@@ -277,6 +338,7 @@
initScene();
animate();
window.addEventListener('resize', handleResize);
window.addEventListener('keydown', handleKeydown);
}
});
@@ -288,6 +350,7 @@
renderer.dispose();
}
window.removeEventListener('resize', handleResize);
window.removeEventListener('keydown', handleKeydown);
});
</script>
@@ -410,7 +473,8 @@
{/if}
<!-- Canvas container -->
<div class="canvas-container" bind:this={container}>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="canvas-container" bind:this={container} tabindex="0" role="application" aria-label="3D model viewer - use arrow keys to rotate">
{#if isLoading}
<div class="loading-overlay">
<Icon icon="mdi:loading" width="32" class="spin" />
@@ -435,6 +499,10 @@
<Icon icon="mdi:magnify-plus-minus" width="14" />
Scroll to zoom
</span>
<span class="hint">
<Icon icon="mdi:arrow-all" width="14" />
Arrow keys
</span>
</div>
</div>
@@ -557,6 +625,11 @@
color-mix(in srgb, var(--viewer-primary) 5%, transparent),
transparent 70%
);
outline: none;
}
.canvas-container:focus {
box-shadow: inset 0 0 0 2px var(--viewer-primary);
}
.canvas-container :global(canvas) {

View File

@@ -142,7 +142,7 @@
<Icon icon="mdi:folder-multiple" width="14" />
{:else if currentPath === '/models'}
<Icon icon="mdi:cube-outline" width="14" />
{:else if currentPath === '/hackathons'}
{:else if currentPath === '/projects'}
<Icon icon="mdi:trophy" width="14" />
{:else}
<Icon icon="mdi:file" width="14" />

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { themeColors } from '$lib/stores/theme';
import { toggleMode } from '$lib/stores/theme';
import { terminalSettings, type SpeedPreset, speedPresets } from '$lib/config';
@@ -65,6 +66,24 @@
});
}
// Scroll to hash target (anchor link like #skills)
function scrollToHash() {
if (!browser || !bodyElement) return;
const hash = window.location.hash;
if (!hash) return;
const targetId = hash.slice(1); // Remove the #
const targetElement = bodyElement.querySelector(`#${CSS.escape(targetId)}`);
if (targetElement) {
// Small delay to ensure layout is complete
setTimeout(() => {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
// Get all interactive button indices
let buttonIndices = $derived(
displayedLines
@@ -167,6 +186,9 @@
selectedIndex = buttonIndices[0];
}
// Scroll to hash anchor if present in URL
scrollToHash();
onComplete?.();
}

View File

@@ -22,79 +22,137 @@
export let onLinkClick: (idx: number) => void;
export let terminalSettings: any;
// Group displayed lines into regular items and inline groups
type GroupedItem =
| { type: 'single'; index: number; displayed: DisplayedLine }
| { type: 'inline-group'; indices: number[]; items: DisplayedLine[] };
$: groupedLines = (() => {
const result: GroupedItem[] = [];
let i = 0;
while (i < displayedLines.length) {
const displayed = displayedLines[i];
const line = displayed.parsed.line;
if (line.inline) {
// Start an inline group
const group: GroupedItem = { type: 'inline-group', indices: [], items: [] };
while (i < displayedLines.length && displayedLines[i].parsed.line.inline) {
group.indices.push(i);
group.items.push(displayedLines[i]);
i++;
}
result.push(group);
} else {
result.push({ type: 'single', index: i, displayed });
i++;
}
}
return result;
})();
</script>
<div class="tui-body" bind:this={ref}>
{#each displayedLines as { parsed, charIndex, complete, showImage }, i}
{@const line = parsed.line}
{@const visibleSegments = getSegmentsUpToChar(parsed.segments, charIndex)}
{#if line.type === 'divider'}
<div class="tui-divider">
<span class="divider-line"></span>
{#if line.content}
<span class="divider-text">{line.content}</span>
{/if}
<span class="divider-line"></span>
{#each groupedLines as group}
{#if group.type === 'inline-group'}
<!-- Inline group container -->
<div class="tui-inline-group">
{#each group.items as displayed, j}
{@const line = displayed.parsed.line}
{@const idx = group.indices[j]}
{@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
{#if line.type === 'button'}
<TuiButton {line} index={idx} selected={selectedIndex === idx} onClick={onButtonClick} onHover={onHoverButton} inline={true} />
{:else if line.type === 'link'}
<TuiLink {line} onClick={() => onLinkClick(idx)} />
{:else if line.type === 'tooltip'}
<TuiTooltip {line} />
{:else if line.type === 'progress'}
<TuiProgress {line} inline={true} />
{:else}
<span class="inline-content {line.type}">
{getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
</span>
{/if}
{/each}
</div>
{:else if line.type === 'button'}
<TuiButton {line} index={i} selected={selectedIndex === i} onClick={onButtonClick} onHover={onHoverButton} />
{:else if line.type === 'link'}
<div class="tui-line link">
<TuiLink {line} onClick={() => onLinkClick(i)} />
</div>
{:else if line.type === 'card'}
<TuiCard {line} />
{:else if line.type === 'cardgrid'}
<TuiCardGrid {line} />
{:else if line.type === 'progress'}
<TuiProgress {line} />
{:else if line.type === 'accordion'}
<TuiAccordion {line} />
{:else if line.type === 'table'}
<TuiTable {line} />
{:else if line.type === 'tooltip'}
<div class="tui-line">
<TuiTooltip {line} />
</div>
{:else}
<div class="tui-line {line.type}" class:complete>
{#if line.type === 'command' || line.type === 'prompt'}
<span class="prompt">
<span class="user">{user.username}</span><span class="at">@</span><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
{/if}
{:else if group.type === 'single'}
{@const i = group.index}
{@const displayed = group.displayed}
{@const line = displayed.parsed.line}
{@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
{@const complete = displayed.complete}
{@const showImage = displayed.showImage}
{#if line.type === 'divider'}
<div class="tui-divider" id={line.id}>
<span class="divider-line"></span>
{#if line.content}
<span class="divider-text">{line.content}</span>
{/if}
<span class="divider-line"></span>
</div>
{:else if line.type === 'button'}
<TuiButton {line} index={i} selected={selectedIndex === i} onClick={onButtonClick} onHover={onHoverButton} />
{:else if line.type === 'link'}
<div class="tui-line link">
<TuiLink {line} onClick={() => onLinkClick(i)} />
</div>
{:else if line.type === 'card'}
<TuiCard {line} />
{:else if line.type === 'cardgrid'}
<TuiCardGrid {line} />
{:else if line.type === 'progress'}
<TuiProgress {line} />
{:else if line.type === 'accordion'}
<TuiAccordion {line} />
{:else if line.type === 'table'}
<TuiTable {line} />
{:else if line.type === 'tooltip'}
<div class="tui-line">
<TuiTooltip {line} />
</div>
{:else}
<div class="tui-line {line.type}" class:complete id={line.id}>
{#if line.type === 'command' || line.type === 'prompt'}
<span class="prompt">
<span class="user">{user.username}</span><span class="at">@</span><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
{/if}
{#if line.type === 'image' && showImage}
<div class="tui-image">
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
{#if line.content}
<span class="image-caption">{line.content}</span>
{/if}
</div>
{:else if line.type === 'header'}
<span class="content header-text">
<Icon icon="mdi:pound" width="14" class="header-icon" />
{#each visibleSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="16" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{#if line.type === 'image' && showImage}
<div class="tui-image">
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
{#if line.content}
<span class="image-caption">{line.content}</span>
{/if}
{/each}
</span>
{:else if line.type !== 'blank'}
<span class="content">
{getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
</span>
{/if}
</div>
{:else if line.type === 'header'}
<span class="content header-text">
<Icon icon="mdi:pound" width="14" class="header-icon" />
{#each visibleSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="16" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
{:else if line.type !== 'blank'}
<span class="content">
{getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
</span>
{/if}
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
<span class="cursor"></span>
{/if}
</div>
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
<span class="cursor"></span>
{/if}
</div>
{/if}
{/if}
{/each}
@@ -120,6 +178,40 @@
min-height: 0;
}
/* Inline group wrapper - groups consecutive inline elements */
.tui-inline-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.2rem;
animation: lineSlideIn 0.15s ease-out;
}
.inline-content {
display: inline;
white-space: pre-wrap;
}
.inline-content.output {
color: var(--terminal-muted);
}
.inline-content.info {
color: var(--terminal-primary);
}
.inline-content.success {
color: #a6e3a1;
}
.inline-content.error {
color: #f38ba8;
}
.inline-content.warning {
color: #f9e2af;
}
/* Lines */
.tui-line {
display: flex;

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle } from './utils';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
@@ -8,14 +8,19 @@
export let selected: boolean;
export let onClick: (idx: number) => void;
export let onHover: (idx: number) => void;
export let inline: boolean = false;
// Determine if this is an external link
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
// Parse color formatting in content
$: segments = parseColorText(line.content);
</script>
<button
class="tui-button"
class:selected={selected}
class:inline={inline}
style="--btn-color: {getButtonStyle(line.style)}"
on:click={() => onClick(index)}
on:mouseenter={() => onHover(index)}
@@ -24,7 +29,17 @@
{#if line.icon}
<Icon icon={line.icon} width="16" />
{/if}
<span class="btn-text">{line.content}</span>
<span class="btn-text">
{#each segments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
{#if line.href}
{#if isExternal}
<Icon icon="mdi:open-in-new" width="14" class="btn-arrow" />
@@ -53,6 +68,19 @@
transition: all 0.15s ease;
}
/* Inline button styles */
.tui-button.inline {
width: auto;
display: inline-flex;
margin: 0;
padding: 0.35rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--btn-color) 40%, transparent);
}
.tui-button.inline .btn-indicator {
display: none;
}
.tui-button:hover,
.tui-button.selected {
background: color-mix(in srgb, var(--btn-color) 15%, transparent);
@@ -76,4 +104,10 @@
.tui-button.selected :global(.btn-arrow) {
opacity: 1;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle } from './utils';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
@@ -8,6 +8,9 @@
// Determine if this is an external link
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
// Parse color formatting in content
$: segments = parseColorText(line.content);
</script>
<span class="tui-link" style="--link-color: {getButtonStyle(line.style)}">
@@ -15,7 +18,15 @@
<Icon icon={line.icon} width="14" class="link-icon" />
{/if}
<button class="link-text" on:click={onClick}>
{line.content}
{#each segments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</button>
{#if isExternal}
<Icon icon="mdi:open-in-new" width="12" class="link-external" />
@@ -62,4 +73,10 @@
.tui-link:hover :global(.link-external) {
opacity: 0.8;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>

View File

@@ -1,16 +1,30 @@
<script lang="ts">
import { getButtonStyle } from './utils';
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
export let inline: boolean = false;
$: progress = Math.min(100, Math.max(0, line.progress ?? 0));
$: label = line.progressLabel || `${progress}%`;
$: contentSegments = parseColorText(line.content);
$: labelSegments = parseColorText(label);
</script>
<div class="tui-progress" style="--progress-color: {getButtonStyle(line.style)}">
<div class="tui-progress" class:inline={inline} style="--progress-color: {getButtonStyle(line.style)}">
{#if line.content}
<div class="progress-label">{line.content}</div>
<div class="progress-label">
{#each contentSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="14" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
{/if}
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%">
@@ -22,7 +36,17 @@
{/each}
</div>
</div>
<div class="progress-value">{label}</div>
<div class="progress-value">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="12" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
</div>
<style>
@@ -31,6 +55,31 @@
font-size: 0.85rem;
}
/* Inline progress styles */
.tui-progress.inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 0;
min-width: 120px;
}
.tui-progress.inline .progress-label {
margin-bottom: 0;
white-space: nowrap;
}
.tui-progress.inline .progress-bar {
flex: 1;
min-width: 80px;
height: 0.8rem;
}
.tui-progress.inline .progress-value {
margin-top: 0;
white-space: nowrap;
}
.progress-label {
color: var(--terminal-text);
margin-bottom: 0.25rem;
@@ -99,4 +148,10 @@
margin-top: 0.25rem;
font-weight: 600;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>

View File

@@ -13,6 +13,10 @@ export interface TerminalLine {
image?: string;
imageAlt?: string;
imageWidth?: number;
// For anchor scrolling (e.g., #skills)
id?: string;
// For inline rendering (multiple elements on same line)
inline?: boolean;
// For button and link types
action?: () => void;
href?: string;

View File

@@ -18,9 +18,11 @@ export interface TextSegment {
action?: () => void;
}
// Color map for text and background colors
export const colorMap: Record<string, string> = {
// Basic colors
// Color maps for each theme
export type ThemeColorMap = Record<string, string>;
// Default color map (fallback)
export const defaultColorMap: ThemeColorMap = {
'red': '#f38ba8',
'green': '#a6e3a1',
'yellow': '#f9e2af',
@@ -33,7 +35,6 @@ export const colorMap: Record<string, string> = {
'pink': '#f5c2e7',
'black': '#1e1e2e',
'surface': '#313244',
// Semantic colors
'primary': 'var(--terminal-primary)',
'accent': 'var(--terminal-accent)',
'muted': 'var(--terminal-muted)',
@@ -43,10 +44,13 @@ export const colorMap: Record<string, string> = {
'info': '#89b4fa',
};
// Legacy alias for backwards compatibility
export const colorMap = defaultColorMap;
// Text style keywords
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
export function parseColorText(text: string): TextSegment[] {
export function parseColorText(text: string, colors: ThemeColorMap = colorMap): TextSegment[] {
const segments: TextSegment[] = [];
// Match both (&specs)content(&) and (&icon, iconName) patterns
const regex = /\(&([^)]+)\)(.*?)\(&\)|\(&icon,\s*([^)]+)\)/g;
@@ -81,15 +85,15 @@ export function parseColorText(text: string): TextSegment[] {
// Background color (bg-colorname or bg-#hex)
else if (spec.startsWith('bg-')) {
const bgColor = spec.slice(3);
if (colorMap[bgColor]) {
segment.background = colorMap[bgColor];
if (colors[bgColor]) {
segment.background = colors[bgColor];
} else if (bgColor.startsWith('#')) {
segment.background = bgColor;
}
}
// Foreground color
else if (colorMap[spec] && !textStyles.includes(spec)) {
segment.color = colorMap[spec];
else if (colors[spec] && !textStyles.includes(spec)) {
segment.color = colors[spec];
} else if (spec.startsWith('#')) {
segment.color = spec;
}

View File

@@ -11,11 +11,11 @@ export const user = {
title: 'Engineering Student',
email: 'sirblob0@gmail.com',
location: 'Washington DC-Baltimore Area',
bio: `Hi, I am Sir Blob — a engineer who loves making things.` +
bio: `Hi, I am Sir Blob — a engineer who loves making things. ` +
`I build fun coding projects, participate in game jams and hackathons, and enjoy games like Minecraft and Pokémon TCG Live.`,
// Prefer an absolute avatar URL if you want to pull directly from GitHub
avatar: 'https://avatars.githubusercontent.com/u/76974209?v=4',
avatar: '/blob_nerd.png',
// Social links - array of { name, icon (Iconify), link }
socials: [
@@ -113,12 +113,11 @@ export const terminalButtons = {
export const skills = {
languages: ['Python', 'JavaScript', 'TypeScript', 'C', 'C++', 'Java', 'Node.js'],
frameworks: ['Arduino', 'Bootstrap', 'TailwindCSS', 'Discord.js', 'React', 'Electron', 'Svelte', 'MongoDB'],
// Applications / IDEs and major platforms
frameworks: ['Arduino', 'Bootstrap', 'TailwindCSS', 'Discord.js', 'React', 'Electron', 'Svelte'],
applications: ['Windows', 'Linux', 'macOS', 'IntelliJ', 'VS Code', 'Git', 'Blender', 'Godot'],
tools: ['Git', 'Docker', 'Neovim', 'VS Code', 'Figma'],
databases: ['PostgreSQL', 'MongoDB', 'Redis', 'SQLite'],
cloud: ['AWS', 'Vercel', 'Cloudflare', 'DigitalOcean'],
platforms: ['Windows', 'Linux', 'macOS', 'Arduino', 'Raspberry Pi'],
tools: ['Git', 'Docker', 'Neovim', 'VS Code'],
databases: ['MongoDB', 'Redis', 'SQLite'],
interests: ['Open Source', '3D Graphics', 'CLI Tools', 'Game Dev']
};
@@ -485,7 +484,7 @@ export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
'home': 'fast',
'portfolio': 'fast',
'models': 'fast',
'hackathons': 'normal'
'projects': 'normal'
};
// Per-page autoscroll settings (whether to auto-scroll as content types)
@@ -496,18 +495,18 @@ export const pageAutoscrollSettings: Record<string, boolean> = {
// 'portfolio': false, // Disable autoscroll
'home': true,
'portfolio': false,
'models': true,
'hackathons': false,
'components': true
'models': false,
'projects': false,
'components': false
};
// Speed preset multipliers (lower = faster)
export const speedPresets: Record<SpeedPreset, number> = {
'instant': 0, // No delay, instant display
'fast': 0.3, // 3x faster
'normal': 0.8, // Default speed
'slow': 2, // 2x slower
'typewriter': 3 // 3x slower, classic typewriter feel
'fast': 0.1, // 3x faster
'normal': 0.5, // Default speed
'slow': 1.5, // 2x slower
'typewriter': 2 // 3x slower, classic typewriter feel
};
// ============================================================================
@@ -685,7 +684,7 @@ export const loadingScreen = {
// ============================================================================
export const site = {
title: `${user.username} | Portfolio`,
title: `${user.displayname} | Portfolio`,
description: `${user.title} - ${user.bio}`,
keywords: ['developer', 'portfolio', 'programming', ...skills.languages, ...skills.frameworks],
ogImage: '/og-image.png',
@@ -706,31 +705,31 @@ export interface PageMeta {
// Map route => meta. Use route paths as keys (e.g. '/', '/portfolio')
export const pageMeta: Record<string, PageMeta> = {
'/': {
title: `${user.username} — Home`,
title: `${user.displayname} — Home`,
description: `Home — ${user.title} portfolio and projects.`,
icon: 'mdi:home',
keywords: ['home', 'portfolio', 'about']
},
'/portfolio': {
title: `${user.username} — Portfolio`,
title: `${user.displayname} — Portfolio`,
description: 'Selected projects, highlights and case studies.',
icon: 'mdi:folder-multiple',
keywords: ['projects', 'portfolio', 'showcase']
},
'/models': {
title: `${user.username} — 3D Models`,
title: `${user.displayname} — 3D Models`,
description: 'A curated collection of 3D models and previews.',
icon: 'mdi:cube-outline',
keywords: ['3d', 'models', 'glb', 'gltf']
},
'/hackathons': {
title: `${user.username}Hackathons`,
'/projects': {
title: `${user.displayname}Projects`,
description: 'Hackathon projects, demos and awards.',
icon: 'mdi:trophy',
keywords: ['hackathon', 'projects', 'events']
},
'/components': {
title: `${user.username} — Components`,
title: `${user.displayname} — Components`,
description: 'Terminal UI components showcase and documentation.',
icon: 'mdi:puzzle',
keywords: ['components', 'ui', 'terminal', 'tui']
@@ -757,7 +756,7 @@ export const navigation = [
{ name: 'home', path: '/', icon: '~' },
{ name: 'portfolio', path: '/portfolio', icon: '📁' },
{ name: 'models', path: '/models', icon: '🎨' },
{ name: 'hackathons', path: '/hackathons', icon: '🏆' },
{ name: 'projects', path: '/projects', icon: '🏆' },
// { name: 'components', path: '/components', icon: '🧩' },
{ name: 'blog', path: 'https://blog.sirblob.co', icon: '📝', external: true }
];

View File

@@ -1,9 +1,28 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
import { type ThemeColorMap, defaultColorMap } from '$lib/components/tui/utils';
// Import theme JSON files
import archTheme from '$lib/assets/themes/arch.theme.json';
import catppuccinTheme from '$lib/assets/themes/catppuccin.theme.json';
export type ColorTheme = 'arch' | 'catppuccin';
export type Mode = 'dark' | 'light';
// Theme JSON structure
export interface ThemeJson {
name: string;
icon: string;
dark: {
colors: Record<string, string>;
colorMap: ThemeColorMap;
};
light: {
colors: Record<string, string>;
colorMap: ThemeColorMap;
};
}
export interface ThemeColors {
primary: string;
secondary: string;
@@ -17,73 +36,50 @@ export interface ThemeColors {
terminalPrompt: string;
terminalUser: string;
terminalPath: string;
colorMap: ThemeColorMap;
}
const themeColorsMap: Record<ColorTheme, { dark: ThemeColors; light: ThemeColors }> = {
arch: {
dark: {
primary: '#1793d1',
secondary: '#333333',
accent: '#00ff00',
background: '#0d1117',
backgroundLight: '#161b22',
text: '#c9d1d9',
textMuted: '#8b949e',
border: '#30363d',
terminal: '#0d1117',
terminalPrompt: '#1793d1',
terminalUser: '#00ff00',
terminalPath: '#1793d1'
},
light: {
primary: '#1793d1',
secondary: '#e0e0e0',
accent: '#006600',
background: '#f6f8fa',
backgroundLight: '#ffffff',
text: '#1a1a1a',
textMuted: '#4a5568',
border: '#d0d7de',
terminal: '#ffffff',
terminalPrompt: '#0f6ca0',
terminalUser: '#006600',
terminalPath: '#0f6ca0'
}
},
catppuccin: {
// Catppuccin Mocha (dark)
dark: {
primary: '#89b4fa', // Blue
secondary: '#313244', // Surface 0
accent: '#a6e3a1', // Green
background: '#1e1e2e', // Base
backgroundLight: '#313244', // Surface 0
text: '#cdd6f4', // Text
textMuted: '#a6adc8', // Subtext 0
border: '#45475a', // Surface 1
terminal: '#1e1e2e', // Base
terminalPrompt: '#cba6f7', // Mauve
terminalUser: '#a6e3a1', // Green
terminalPath: '#89b4fa' // Blue
},
// Catppuccin Latte (light)
light: {
primary: '#1e66f5', // Blue
secondary: '#ccd0da', // Surface 0
accent: '#40a02b', // Green
background: '#eff1f5', // Base
backgroundLight: '#dce0e8', // Crust
text: '#4c4f69', // Text
textMuted: '#6c6f85', // Subtext 0
border: '#bcc0cc', // Surface 1
terminal: '#eff1f5', // Base
terminalPrompt: '#8839ef', // Mauve
terminalUser: '#40a02b', // Green
terminalPath: '#1e66f5' // Blue
}
}
// Load themes from JSON files
const themes: Record<ColorTheme, ThemeJson> = {
arch: archTheme as ThemeJson,
catppuccin: catppuccinTheme as ThemeJson
};
// Export themes for external access
export { themes };
// Build theme colors from JSON
function buildThemeColors(theme: ThemeJson, mode: Mode): ThemeColors {
const modeData = theme[mode];
return {
primary: modeData.colors.primary,
secondary: modeData.colors.secondary,
accent: modeData.colors.accent,
background: modeData.colors.background,
backgroundLight: modeData.colors.backgroundLight,
text: modeData.colors.text,
textMuted: modeData.colors.textMuted,
border: modeData.colors.border,
terminal: modeData.colors.terminal,
terminalPrompt: modeData.colors.terminalPrompt,
terminalUser: modeData.colors.terminalUser,
terminalPath: modeData.colors.terminalPath,
colorMap: modeData.colorMap ?? defaultColorMap
};
}
// Build the theme colors map from JSON themes
const themeColorsMap: Record<ColorTheme, { dark: ThemeColors; light: ThemeColors }> =
Object.fromEntries(
Object.entries(themes).map(([key, theme]) => [
key,
{
dark: buildThemeColors(theme, 'dark'),
light: buildThemeColors(theme, 'light')
}
])
) as Record<ColorTheme, { dark: ThemeColors; light: ThemeColors }>;
function getInitialMode(): Mode {
if (browser) {
const stored = localStorage.getItem('mode');
@@ -129,7 +125,10 @@ export function setColorTheme(theme: ColorTheme) {
colorTheme.set(theme);
}
export const themeOptions: { value: ColorTheme; label: string; icon: string }[] = [
{ value: 'arch', label: 'Arch Linux', icon: '🐧' },
{ value: 'catppuccin', label: 'Catppuccin', icon: '🐱' }
];
// Generate theme options from loaded themes
export const themeOptions: { value: ColorTheme; label: string; icon: string }[] =
Object.entries(themes).map(([key, theme]) => ({
value: key as ColorTheme,
label: theme.name,
icon: theme.icon
}));

View File

@@ -12,17 +12,18 @@
const lines: TerminalLine[] = [
// neofetch style intro
{ type: 'command', content: 'bash ~/startup.sh', delay: 300 },
{ type: 'blank', content: '' },
{ type: 'header', content: `Welcome to ${user.displayname}'s Portfolio` },
{ type: 'blank', content: '' },
{ type: 'output', content: `(&muted)${user.bio}(&)` },
{ type: 'blank', content: '' },
// Color palette (moved below the art/stats block)
{ type: 'blank', content: '' },
{ type: 'output', content: '(&red)███(&)(&orange)███(&)(&yellow)███(&)(&green)███(&)(&cyan)███(&)(&blue)███(&)(&magenta)███(&)(&pink)███(&)' },
{ type: 'output', content: '(&dim,red)███(&)(&dim,orange)███(&)(&dim,yellow)███(&)(&dim,green)███(&)(&dim,cyan)███(&)(&dim,blue)███(&)(&dim,magenta)███(&)(&dim,pink)███(&)' },
{ type: 'blank', content: '' },
{ type: 'header', content: `Welcome to ${user.displayname}'s Portfolio` },
{ type: 'blank', content: '' },
{ type: 'output', content: `(&muted)${user.bio}(&)` },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'NAVIGATION' },
{ type: 'blank', content: '' },

View File

@@ -48,6 +48,7 @@ code, pre, kbd, samp, .terminal, .mono, .prompt, .prompt-mini, .hero-title {
html {
scroll-behavior: smooth;
background: #1e1e2e; /* Fallback dark bg to prevent white flash on mobile */
}
body {
@@ -58,6 +59,8 @@ body {
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
min-height: 100vh;
min-height: 100dvh; /* Dynamic viewport height for mobile */
background: #1e1e2e; /* Fallback dark bg to prevent white flash on mobile */
}
/* Terminal font for code elements */

View File

@@ -22,17 +22,23 @@
{ type: 'info', content: `(&accent)${user.title}(&)` },
{ type: 'output', content: `(&muted)${user.bio}(&)` },
{ type: 'blank', content: '' },
{ type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
{ type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true },
{ type: 'link', href: "/portfolio#skills", content: `(&bg-orange,black)Skills(&)`, inline: true },
{ type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'CONTACT' },
{ type: 'divider', content: 'CONTACT', id: 'contact' },
{ type: 'blank', content: '' },
// Contact buttons - dynamically generated from socials array
...user.socials.map(social => ({
type: 'button' as const,
content: `${social.name}: ${social.link}`,
content: `${social.name}`,
icon: social.icon,
style: 'primary' as const,
href: social.link
href: social.link,
inline: true
})),
// {
// type: 'button',
@@ -43,7 +49,7 @@
// },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'SKILLS' },
{ type: 'divider', content: 'SKILLS', id: 'skills' },
{ type: 'blank', content: '' },
// Skills as TUI sections
@@ -59,14 +65,11 @@
{ type: 'info', content: '(&cyan,bold)▸ Databases(&)' },
{ type: 'output', content: ' ' + skills.databases.map(s => `(&cyan)${s}(&)`).join(' (&muted)•(&) ') },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&yellow,bold)▸ Cloud(&)' },
{ type: 'output', content: ' ' + skills.cloud.map(s => `(&orange)${s}(&)`).join(' (&muted)•(&) ') },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&accent,bold)▸ Interests(&)' },
{ type: 'output', content: ' ' + skills.interests.map(s => `(&muted)${s}(&)`).join(' (&muted)•(&) ') },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'PROJECTS' },
{ type: 'divider', content: 'PROJECTS', id: 'projects' },
{ type: 'blank', content: '' },
// Featured projects with buttons
@@ -119,7 +122,7 @@
</script>
<svelte:head>
<title>Portfolio | {user.name}</title>
<title>Portfolio | {user.displayname}</title>
<meta name="description" content={site.description} />
</svelte:head>

View File

@@ -4,8 +4,8 @@
import { user, sortedCards } from '$lib/config';
import { getPageSpeedMultiplier, getPageAutoscroll } from '$lib';
const speed = getPageSpeedMultiplier('hackathons');
const autoscroll = getPageAutoscroll('hackathons');
const speed = getPageSpeedMultiplier('projects');
const autoscroll = getPageAutoscroll('projects');
// Count stats
const totalHackathons = sortedCards.length;
@@ -14,12 +14,15 @@
// Build the terminal lines with card grid
const lines: TerminalLine[] = [
{ type: 'divider', content: 'PROJECTS' },
{ type: 'command', content: 'ls ~/projects --grid' },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'HACKATHONS' },
{ type: 'command', content: 'ls ~/hackathons --grid' },
{ type: 'blank', content: '' },
{ type: 'header', content: `Hackathon Journey` },
{ type: 'output', content: `(&muted)Total:(&) (&primary)${totalHackathons}(&) (&muted)| Awards:(&) (&yellow)${totalAwards}(&) (&muted)| Featured:(&) (&accent)${featuredCount}(&)` },
{ type: 'blank', content: '' },
// { type: 'divider', content: 'PROJECTS' },
{ type: 'blank', content: '' },
{ type: 'cardgrid', content: '', cards: sortedCards },
{ type: 'blank', content: '' },
@@ -28,16 +31,16 @@
</script>
<svelte:head>
<title>Hackathons | {user.name}</title>
<title>Projects | {user.name}</title>
<meta name="description" content="Hackathon projects and achievements" />
</svelte:head>
<div class="hackathons-container">
<TerminalTUI {lines} title="~/hackathons" interactive={true} {speed} {autoscroll} />
<div class="projects-container">
<TerminalTUI {lines} title="~/projects" interactive={true} {speed} {autoscroll} />
</div>
<style>
.hackathons-container {
.projects-container {
padding: 2rem 1rem;
min-height: calc(100vh - 60px);
}