Element Bug Fixes

This commit is contained in:
2025-12-01 05:18:27 +00:00
parent 1167c686e2
commit e0bcba74d5
24 changed files with 875 additions and 228 deletions

View File

@@ -2,8 +2,6 @@
An Arch Linux terminal-themed portfolio website with Hyprland-style TUI components, built with SvelteKit, Tailwind CSS, and Three.js. An Arch Linux terminal-themed portfolio website with Hyprland-style TUI components, built with SvelteKit, Tailwind CSS, and Three.js.
![Terminal Portfolio](./static/og-image.png)
## Features ## Features
- 🖥️ **Hyprland-style TUI** - Terminal interface inspired by Textual Python TUI - 🖥️ **Hyprland-style TUI** - Terminal interface inspired by Textual Python TUI
@@ -252,6 +250,7 @@ All line types support these optional properties:
inline: true, // Render as compact inline button inline: true, // Render as compact inline button
// OR // OR
action: () => doSomething(), // Custom action action: () => doSomething(), // Custom action
border: false, // Disable default border (default: true)
} }
``` ```
@@ -310,7 +309,10 @@ Supported inline types: `button`, `link`, `tooltip`, `progress`, `output`, `info
display: 'flex', // flex | grid | block (controls children layout) display: 'flex', // flex | grid | block (controls children layout)
children: [ // Optional nested elements children: [ // Optional nested elements
{ type: 'button', content: 'Action', style: 'primary' } { type: 'button', content: 'Action', style: 'primary' }
] ],
cardWidth: '1/2', // Width (string fraction, decimal, or px)
cardHeight: 300, // Height (string fraction, decimal, or px)
cardFloat: 'center', // start | center | end
} }
``` ```
@@ -379,6 +381,8 @@ 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)
inline: true, // Render inline with other elements inline: true, // Render inline with other elements
children: [ children: [
{ type: 'output', content: 'Label:', inline: true }, { type: 'output', content: 'Label:', inline: true },

View File

@@ -31,6 +31,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"hotkeys-js": "^4.0.0-beta.7", "hotkeys-js": "^4.0.0-beta.7",
"three": "^0.181.2" "three": "^0.181.2",
"ytpl": "^2.3.0"
} }
} }

View File

@@ -4,9 +4,9 @@
gap: 0.5rem; gap: 0.5rem;
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
margin: 0.2rem 0; margin: 0.75rem 0;
background: transparent; background: transparent;
border: 1px solid transparent; border: 1px solid var(--btn-color);
border-radius: 4px; border-radius: 4px;
color: var(--btn-color); color: var(--btn-color);
font-family: inherit; font-family: inherit;
@@ -16,6 +16,10 @@
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.tui-button.no-border {
border-color: transparent;
}
/* Inline button styles */ /* Inline button styles */
.tui-button.inline { .tui-button.inline {
width: auto; width: auto;

View File

@@ -3,9 +3,9 @@
border-left: 3px solid var(--card-color); border-left: 3px solid var(--card-color);
background: color-mix(in srgb, var(--terminal-bg) 50%, var(--terminal-bg-light)); background: color-mix(in srgb, var(--terminal-bg) 50%, var(--terminal-bg-light));
border-radius: 4px; border-radius: 4px;
padding: 0.75rem 1rem; padding: 0;
margin: 0.5rem 0; margin: 0.5rem 0;
max-width: 600px; width: 100%;
} }
.tui-card.inline { .tui-card.inline {

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from "svelte";
import { themeColors } from '$lib/stores/theme'; import { themeColors } from "$lib/stores/theme";
import '$lib/assets/css/model-viewer.css'; import "$lib/assets/css/model-viewer.css";
import * as THREE from 'three'; import * as THREE from "three";
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import Icon from '@iconify/svelte'; import Icon from "@iconify/svelte";
interface Props { interface Props {
modelPath: string; modelPath: string;
@@ -13,7 +13,11 @@
class?: string; class?: string;
} }
let { modelPath, modelName = 'Model', class: className = '' }: Props = $props(); let {
modelPath,
modelName = "Model",
class: className = "",
}: Props = $props();
let container: HTMLDivElement; let container: HTMLDivElement;
let scene: THREE.Scene; let scene: THREE.Scene;
@@ -30,10 +34,15 @@
let autoRotate = $state(true); let autoRotate = $state(true);
let wireframe = $state(false); let wireframe = $state(false);
let showGround = $state(true); let showGround = $state(true);
let showGrid = $state(false);
let showAxes = $state(false);
let lightIntensity = $state(1); let lightIntensity = $state(1);
let rotationSpeed = $state(2);
let showControls = $state(false); let showControls = $state(false);
let ground: THREE.Mesh | null = null; let ground: THREE.Mesh | null = null;
let gridHelper: THREE.GridHelper | null = null;
let axesHelper: THREE.AxesHelper | null = null;
// Watch for modelPath changes // Watch for modelPath changes
$effect(() => { $effect(() => {
@@ -45,7 +54,7 @@
if (child instanceof THREE.Mesh) { if (child instanceof THREE.Mesh) {
child.geometry.dispose(); child.geometry.dispose();
if (Array.isArray(child.material)) { if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose()); child.material.forEach((m) => m.dispose());
} else { } else {
child.material.dispose(); child.material.dispose();
} }
@@ -75,7 +84,7 @@
renderer = new THREE.WebGLRenderer({ renderer = new THREE.WebGLRenderer({
antialias: true, antialias: true,
alpha: true, alpha: true,
powerPreference: 'high-performance' powerPreference: "high-performance",
}); });
renderer.setSize(width, height); renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -122,14 +131,26 @@
const groundMaterial = new THREE.MeshStandardMaterial({ const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x222222, color: 0x222222,
transparent: true, transparent: true,
opacity: 0.3 opacity: 0.3,
}); });
ground = new THREE.Mesh(groundGeometry, groundMaterial); ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.5; ground.position.y = -0.5;
ground.receiveShadow = true; ground.receiveShadow = true;
ground.receiveShadow = true;
scene.add(ground); scene.add(ground);
// Grid Helper
gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
gridHelper.position.y = -0.49; // Slightly above ground to avoid z-fighting
gridHelper.visible = showGrid;
scene.add(gridHelper);
// Axes Helper
axesHelper = new THREE.AxesHelper(2);
axesHelper.visible = showAxes;
scene.add(axesHelper);
sceneInitialized = true; sceneInitialized = true;
loadModel(); loadModel();
} }
@@ -175,10 +196,10 @@
// Loading progress // Loading progress
}, },
(error) => { (error) => {
console.error('Error loading model:', error); console.error("Error loading model:", error);
loadError = 'Failed to load model'; loadError = "Failed to load model";
isLoading = false; isLoading = false;
} },
); );
} }
@@ -224,7 +245,9 @@
model.traverse((child) => { model.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) { if (child instanceof THREE.Mesh && child.material) {
if (Array.isArray(child.material)) { if (Array.isArray(child.material)) {
child.material.forEach(m => m.wireframe = wireframe); child.material.forEach(
(m) => (m.wireframe = wireframe),
);
} else { } else {
child.material.wireframe = wireframe; child.material.wireframe = wireframe;
} }
@@ -233,6 +256,27 @@
} }
} }
function toggleGrid() {
showGrid = !showGrid;
if (gridHelper) {
gridHelper.visible = showGrid;
}
}
function toggleAxes() {
showAxes = !showAxes;
if (axesHelper) {
axesHelper.visible = showAxes;
}
}
function adjustRotationSpeed(delta: number) {
rotationSpeed = Math.max(0, Math.min(10, rotationSpeed + delta));
if (controls) {
controls.autoRotateSpeed = rotationSpeed;
}
}
function toggleGround() { function toggleGround() {
showGround = !showGround; showGround = !showGround;
if (ground) { if (ground) {
@@ -245,7 +289,8 @@
if (scene) { if (scene) {
scene.traverse((child) => { scene.traverse((child) => {
if (child instanceof THREE.DirectionalLight) { if (child instanceof THREE.DirectionalLight) {
child.intensity = child.userData.baseIntensity * lightIntensity; child.intensity =
child.userData.baseIntensity * lightIntensity;
} else if (child instanceof THREE.AmbientLight) { } else if (child instanceof THREE.AmbientLight) {
child.intensity = 0.4 * lightIntensity; child.intensity = 0.4 * lightIntensity;
} }
@@ -269,7 +314,7 @@
controls.update(); controls.update();
} }
function rotateCamera(direction: 'left' | 'right' | 'up' | 'down') { function rotateCamera(direction: "left" | "right" | "up" | "down") {
if (!controls) return; if (!controls) return;
const rotateSpeed = 0.1; const rotateSpeed = 0.1;
@@ -281,17 +326,20 @@
spherical.setFromVector3(offset); spherical.setFromVector3(offset);
switch (direction) { switch (direction) {
case 'left': case "left":
spherical.theta += rotateSpeed; spherical.theta += rotateSpeed;
break; break;
case 'right': case "right":
spherical.theta -= rotateSpeed; spherical.theta -= rotateSpeed;
break; break;
case 'up': case "up":
spherical.phi = Math.max(0.1, spherical.phi - rotateSpeed); spherical.phi = Math.max(0.1, spherical.phi - rotateSpeed);
break; break;
case 'down': case "down":
spherical.phi = Math.min(Math.PI - 0.1, spherical.phi + rotateSpeed); spherical.phi = Math.min(
Math.PI - 0.1,
spherical.phi + rotateSpeed,
);
break; break;
} }
@@ -302,26 +350,27 @@
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
// Only handle if this component is focused or in fullscreen // Only handle if this component is focused or in fullscreen
if (!container?.contains(document.activeElement) && !isFullscreen) return; if (!container?.contains(document.activeElement) && !isFullscreen)
return;
switch (event.key) { switch (event.key) {
case 'ArrowLeft': case "ArrowLeft":
event.preventDefault(); event.preventDefault();
rotateCamera('left'); rotateCamera("left");
break; break;
case 'ArrowRight': case "ArrowRight":
event.preventDefault(); event.preventDefault();
rotateCamera('right'); rotateCamera("right");
break; break;
case 'ArrowUp': case "ArrowUp":
event.preventDefault(); event.preventDefault();
rotateCamera('up'); rotateCamera("up");
break; break;
case 'ArrowDown': case "ArrowDown":
event.preventDefault(); event.preventDefault();
rotateCamera('down'); rotateCamera("down");
break; break;
case 'Escape': case "Escape":
if (isFullscreen) { if (isFullscreen) {
isFullscreen = false; isFullscreen = false;
setTimeout(handleResize, 100); setTimeout(handleResize, 100);
@@ -338,8 +387,8 @@
if (container) { if (container) {
initScene(); initScene();
animate(); animate();
window.addEventListener('resize', handleResize); window.addEventListener("resize", handleResize);
window.addEventListener('keydown', handleKeydown); window.addEventListener("keydown", handleKeydown);
} }
}); });
@@ -350,8 +399,8 @@
if (renderer) { if (renderer) {
renderer.dispose(); renderer.dispose();
} }
window.removeEventListener('resize', handleResize); window.removeEventListener("resize", handleResize);
window.removeEventListener('keydown', handleKeydown); window.removeEventListener("keydown", handleKeydown);
}); });
</script> </script>
@@ -376,11 +425,16 @@
<div class="header-controls"> <div class="header-controls">
<button <button
class="control-btn" class="control-btn"
title={autoRotate ? 'Stop rotation' : 'Auto rotate'} title={autoRotate ? "Stop rotation" : "Auto rotate"}
onclick={toggleAutoRotate} onclick={toggleAutoRotate}
class:active={autoRotate} class:active={autoRotate}
> >
<Icon icon={autoRotate ? 'mdi:rotate-3d' : 'mdi:rotate-3d-variant'} width="16" /> <Icon
icon={autoRotate
? "mdi:rotate-3d"
: "mdi:rotate-3d-variant"}
width="16"
/>
</button> </button>
<button <button
class="control-btn" class="control-btn"
@@ -399,10 +453,15 @@
</button> </button>
<button <button
class="control-btn" class="control-btn"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'} title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
onclick={toggleFullscreen} onclick={toggleFullscreen}
> >
<Icon icon={isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'} width="16" /> <Icon
icon={isFullscreen
? "mdi:fullscreen-exit"
: "mdi:fullscreen"}
width="16"
/>
</button> </button>
</div> </div>
</div> </div>
@@ -439,7 +498,9 @@
> >
<Icon icon="mdi:brightness-5" width="14" /> <Icon icon="mdi:brightness-5" width="14" />
</button> </button>
<span class="control-value">{Math.round(lightIntensity * 100)}%</span> <span class="control-value"
>{Math.round(lightIntensity * 100)}%</span
>
<button <button
class="control-btn small" class="control-btn small"
title="Increase brightness" title="Increase brightness"
@@ -454,20 +515,62 @@
<div class="control-buttons"> <div class="control-buttons">
<button <button
class="control-btn small" class="control-btn small"
title={wireframe ? 'Solid view' : 'Wireframe view'} title={wireframe ? "Solid view" : "Wireframe view"}
onclick={toggleWireframe} onclick={toggleWireframe}
class:active={wireframe} class:active={wireframe}
> >
<Icon icon={wireframe ? 'mdi:cube' : 'mdi:cube-outline'} width="14" /> <Icon
icon={wireframe ? "mdi:cube" : "mdi:cube-outline"}
width="14"
/>
</button> </button>
<button <button
class="control-btn small" class="control-btn small"
title={showGround ? 'Hide ground' : 'Show ground'} title={showGround ? "Hide ground" : "Show ground"}
onclick={toggleGround} onclick={toggleGround}
class:active={showGround} class:active={showGround}
> >
<Icon icon="mdi:checkerboard" width="14" /> <Icon icon="mdi:checkerboard" width="14" />
</button> </button>
<button
class="control-btn small"
title={showGrid ? "Hide grid" : "Show grid"}
onclick={toggleGrid}
class:active={showGrid}
>
<Icon icon="mdi:grid" width="14" />
</button>
<button
class="control-btn small"
title={showAxes ? "Hide axes" : "Show axes"}
onclick={toggleAxes}
class:active={showAxes}
>
<Icon icon="mdi:axis-arrow" width="14" />
</button>
</div>
</div>
<div class="control-group">
<span class="control-label">Rotation</span>
<div class="control-buttons">
<button
class="control-btn small"
title="Slower rotation"
onclick={() => adjustRotationSpeed(-0.5)}
disabled={!autoRotate}
>
<Icon icon="mdi:minus" width="14" />
</button>
<span class="control-value">{rotationSpeed.toFixed(1)}</span
>
<button
class="control-btn small"
title="Faster rotation"
onclick={() => adjustRotationSpeed(0.5)}
disabled={!autoRotate}
>
<Icon icon="mdi:plus" width="14" />
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -475,7 +578,13 @@
<!-- Canvas container --> <!-- Canvas container -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- 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"> <div
class="canvas-container"
bind:this={container}
tabindex="0"
role="application"
aria-label="3D model viewer - use arrow keys to rotate"
>
{#if isLoading} {#if isLoading}
<div class="loading-overlay"> <div class="loading-overlay">
<Icon icon="mdi:loading" width="32" class="spin" /> <Icon icon="mdi:loading" width="32" class="spin" />

View File

@@ -1,18 +1,34 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from "svelte";
import { themeColors } from '$lib/stores/theme'; import { themeColors } from "$lib/stores/theme";
import { terminalSettings, type SpeedPreset, speedPresets } from '$lib/config'; import {
import TuiHeader from './tui/TuiHeader.svelte'; terminalSettings,
import TuiBody from './tui/TuiBody.svelte'; type SpeedPreset,
import TuiFooter from './tui/TuiFooter.svelte'; speedPresets,
import '$lib/assets/css/terminal-tui.css'; } from "$lib/config";
import TuiHeader from "./tui/TuiHeader.svelte";
import TuiBody from "./tui/TuiBody.svelte";
import TuiFooter from "./tui/TuiFooter.svelte";
import "$lib/assets/css/terminal-tui.css";
// Import extracted modules // Import extracted modules
import { parseLine, createTerminalAPI } from './tui/terminal-api'; import { parseLine, createTerminalAPI } from "./tui/terminal-api";
import { runTypingAnimation, skipTypingAnimation } from './tui/terminal-typing'; import {
import { createKeyboardHandler, handleNavigation, scrollToHash } from './tui/terminal-keyboard'; runTypingAnimation,
skipTypingAnimation,
} from "./tui/terminal-typing";
import {
createKeyboardHandler,
handleNavigation,
scrollToHash,
} from "./tui/terminal-keyboard";
import type { TerminalLine, ParsedLine, DisplayedLine, TerminalAPI } from './tui/types'; import type {
TerminalLine,
ParsedLine,
DisplayedLine,
TerminalAPI,
} from "./tui/types";
interface Props { interface Props {
lines?: TerminalLine[]; lines?: TerminalLine[];
@@ -27,18 +43,18 @@
let { let {
lines = $bindable([]), lines = $bindable([]),
title = 'terminal', title = "terminal",
class: className = '', class: className = "",
onComplete, onComplete,
interactive = true, interactive = true,
speed = 'normal', speed = "normal",
autoscroll = true, autoscroll = true,
terminal = $bindable() terminal = $bindable(),
}: Props = $props(); }: Props = $props();
// Calculate speed multiplier from preset or number // Calculate speed multiplier from preset or number
const speedMultiplier = $derived( const speedMultiplier = $derived(
typeof speed === 'number' ? speed : (speedPresets[speed] ?? 1) typeof speed === "number" ? speed : (speedPresets[speed] ?? 1),
); );
// Get colorMap from current theme // Get colorMap from current theme
@@ -46,7 +62,7 @@
// Pre-parse all lines upfront (segments + plain text) // Pre-parse all lines upfront (segments + plain text)
const parsedLines = $derived<ParsedLine[]>( const parsedLines = $derived<ParsedLine[]>(
lines.map(line => parseLine(line, colorMap)) lines.map((line) => parseLine(line, colorMap)),
); );
let displayedLines = $state<DisplayedLine[]>([]); let displayedLines = $state<DisplayedLine[]>([]);
@@ -59,7 +75,7 @@
let bodyElement = $state<HTMLDivElement>(); let bodyElement = $state<HTMLDivElement>();
// Track colorMap identity to detect theme/mode changes // Track colorMap identity to detect theme/mode changes
let lastColorMapId = $state(''); let lastColorMapId = $state("");
// When colorMap changes (theme/mode toggle), update displayedLines with new parsed segments // When colorMap changes (theme/mode toggle), update displayedLines with new parsed segments
$effect(() => { $effect(() => {
@@ -67,16 +83,20 @@
const colorMapId = `${colorMap.red}-${colorMap.text}-${colorMap.primary}`; const colorMapId = `${colorMap.red}-${colorMap.text}-${colorMap.primary}`;
// Only update if colorMap actually changed and animation is complete // Only update if colorMap actually changed and animation is complete
if (colorMapId !== lastColorMapId && isComplete && displayedLines.length > 0) { if (
colorMapId !== lastColorMapId &&
isComplete &&
displayedLines.length > 0
) {
// Store current showImage states before updating // Store current showImage states before updating
const showImageStates = displayedLines.map(d => d.showImage); const showImageStates = displayedLines.map((d) => d.showImage);
// Update with new parsed content // Update with new parsed content
displayedLines = parsedLines.map((parsed, i) => ({ displayedLines = parsedLines.map((parsed, i) => ({
parsed, parsed,
charIndex: parsed.plainText.length, charIndex: parsed.plainText.length,
complete: true, complete: true,
showImage: showImageStates[i] ?? (parsed.line.type === 'image') showImage: showImageStates[i] ?? parsed.line.type === "image",
})); }));
} }
@@ -85,9 +105,9 @@
// Helper to check if a line or its children contain buttons // Helper to check if a line or its children contain buttons
function hasButtons(line: TerminalLine): boolean { function hasButtons(line: TerminalLine): boolean {
if (line.type === 'button') return true; if (line.type === "button") return true;
if (line.type === 'group' && line.children) { if (line.type === "group" && line.children) {
return line.children.some(child => hasButtons(child)); return line.children.some((child) => hasButtons(child));
} }
return false; return false;
} }
@@ -95,8 +115,8 @@
// Get all interactive button indices (including buttons nested in groups) // Get all interactive button indices (including buttons nested in groups)
const buttonIndices = $derived( const buttonIndices = $derived(
displayedLines displayedLines
.map((item, i) => hasButtons(item.parsed.line) ? i : -1) .map((item, i) => (hasButtons(item.parsed.line) ? i : -1))
.filter(i => i !== -1) .filter((i) => i !== -1),
); );
// ======================================================================== // ========================================================================
@@ -112,17 +132,29 @@
interactive, interactive,
autoscroll, autoscroll,
getBodyElement: () => bodyElement, getBodyElement: () => bodyElement,
getState: () => ({ displayedLines, currentLineIndex, isTyping, isComplete, skipRequested, selectedIndex }), getState: () => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
setState: (updates) => { setState: (updates) => {
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines; if (updates.displayedLines !== undefined)
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex; displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping; if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined) isComplete = updates.isComplete; if (updates.isComplete !== undefined)
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested; isComplete = updates.isComplete;
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex; if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
}, },
onComplete, onComplete,
scrollToHash: () => scrollToHash(bodyElement) scrollToHash: () => scrollToHash(bodyElement),
}); });
} }
@@ -131,18 +163,30 @@
parsedLines, parsedLines,
buttonIndices, buttonIndices,
interactive, interactive,
() => ({ displayedLines, currentLineIndex, isTyping, isComplete, skipRequested, selectedIndex }), () => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
(updates) => { (updates) => {
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines; if (updates.displayedLines !== undefined)
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex; displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping; if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined) isComplete = updates.isComplete; if (updates.isComplete !== undefined)
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested; isComplete = updates.isComplete;
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex; if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
}, },
() => bodyElement, () => bodyElement,
autoscroll, autoscroll,
onComplete onComplete,
); );
} }
@@ -158,20 +202,25 @@
isComplete, isComplete,
currentLineIndex, currentLineIndex,
selectedIndex, selectedIndex,
skipRequested skipRequested,
}), }),
setState: (updates) => { setState: (updates) => {
if (updates.lines !== undefined) lines = updates.lines; if (updates.lines !== undefined) lines = updates.lines;
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines; if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.isTyping !== undefined) isTyping = updates.isTyping; if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined) isComplete = updates.isComplete; if (updates.isComplete !== undefined)
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex; isComplete = updates.isComplete;
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex; if (updates.currentLineIndex !== undefined)
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested; currentLineIndex = updates.currentLineIndex;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
}, },
getColorMap: () => colorMap, getColorMap: () => colorMap,
getBodyElement: () => bodyElement, getBodyElement: () => bodyElement,
typeText typeText,
}); });
// ======================================================================== // ========================================================================
@@ -184,20 +233,29 @@
getInteractive: () => interactive, getInteractive: () => interactive,
getButtonIndices: () => buttonIndices, getButtonIndices: () => buttonIndices,
getSelectedIndex: () => selectedIndex, getSelectedIndex: () => selectedIndex,
setSelectedIndex: (index) => { selectedIndex = index; }, setSelectedIndex: (index) => {
selectedIndex = index;
},
getDisplayedLines: () => displayedLines, getDisplayedLines: () => displayedLines,
getBodyElement: () => bodyElement, getBodyElement: () => bodyElement,
skipAnimation, skipAnimation,
scrollMargin: terminalSettings.scrollMargin scrollMargin: terminalSettings.scrollMargin,
}); });
function handleButtonClick(index: number) { function handleButtonClick(index: number, line?: TerminalLine) {
if (line) {
// If line is provided (e.g. from nested group), use it directly
// We don't update selectedIndex because it might not map to a top-level line
handleNavigation(line as any);
} else {
// Fallback to top-level index lookup
selectedIndex = index; selectedIndex = index;
handleNavigation(displayedLines[index]?.parsed.line); handleNavigation(displayedLines[index]?.parsed.line as any);
}
} }
function handleLinkClick(index: number) { function handleLinkClick(index: number) {
handleNavigation(displayedLines[index]?.parsed.line); handleNavigation(displayedLines[index]?.parsed.line as any);
} }
onMount(() => { onMount(() => {
@@ -231,20 +289,29 @@
<!-- TUI Content --> <!-- TUI Content -->
<div class="tui-content"> <div class="tui-content">
<TuiHeader {title} {interactive} hasButtons={buttonIndices.length > 0} /> <TuiHeader
{title}
{interactive}
hasButtons={buttonIndices.length > 0}
/>
<!-- Main terminal area --> <!-- Main terminal area -->
<TuiBody bind:ref={bodyElement} <TuiBody
bind:ref={bodyElement}
{displayedLines} {displayedLines}
{currentLineIndex} {currentLineIndex}
{isTyping} {isTyping}
{selectedIndex} {selectedIndex}
onButtonClick={handleButtonClick} onButtonClick={handleButtonClick}
onHoverButton={(i) => selectedIndex = i} onHoverButton={(i) => (selectedIndex = i)}
onLinkClick={handleLinkClick} onLinkClick={handleLinkClick}
terminalSettings={terminalSettings} {terminalSettings}
/> />
<TuiFooter isTyping={isTyping} linesCount={displayedLines.length} skipAnimation={skipAnimation} /> <TuiFooter
{isTyping}
linesCount={displayedLines.length}
{skipAnimation}
/>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
import { getSegmentsUpToChar } from "./utils"; import { getSegmentsUpToChar } from "./utils";
import { user } from "$lib/config"; import { user } from "$lib/config";
import TuiLine from "./TuiLine.svelte"; import TuiLine from "./TuiLine.svelte";
import type { DisplayedLine } from "./types"; import type { DisplayedLine, TerminalLine } from "./types";
import "$lib/assets/css/tui-body.css"; import "$lib/assets/css/tui-body.css";
interface Props { interface Props {
@@ -11,7 +11,7 @@
isTyping?: boolean; isTyping?: boolean;
selectedIndex?: number; selectedIndex?: number;
ref?: HTMLDivElement | undefined; ref?: HTMLDivElement | undefined;
onButtonClick: (idx: number) => void; onButtonClick: (idx: number, line?: TerminalLine) => void;
onHoverButton: (idx: number) => void; onHoverButton: (idx: number) => void;
onLinkClick: (idx: number) => void; onLinkClick: (idx: number) => void;
terminalSettings: { showCursor: boolean }; terminalSettings: { showCursor: boolean };
@@ -92,6 +92,11 @@
showImage={item.displayed.showImage} showImage={item.displayed.showImage}
{selectedIndex} {selectedIndex}
inline={true} inline={true}
showCursor={terminalSettings.showCursor &&
item.index === currentLineIndex &&
!item.displayed.complete &&
isTyping &&
item.displayed.parsed.line.type !== "image"}
{onButtonClick} {onButtonClick}
{onHoverButton} {onHoverButton}
{onLinkClick} {onLinkClick}

View File

@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import Icon from '@iconify/svelte'; import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils'; import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import { themeColors } from '$lib/stores/theme'; import { themeColors } from "$lib/stores/theme";
import type { ButtonLine } from './types'; import type { ButtonLine } from "./types";
import '$lib/assets/css/tui-button.css'; import "$lib/assets/css/tui-button.css";
interface Props { interface Props {
line: ButtonLine; line: ButtonLine;
index: number; index: number;
selected: boolean; selected: boolean;
onClick: (idx: number) => void; onClick: (idx: number, line?: ButtonLine) => void;
onHover: (idx: number) => void; onHover: (idx: number) => void;
inline?: boolean; inline?: boolean;
} }
@@ -20,31 +20,39 @@
selected, selected,
onClick, onClick,
onHover, onHover,
inline = false inline = false,
}: Props = $props(); }: Props = $props();
// Determine if this is an external link // Determine if this is an external link
const isExternal = $derived(line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')))); const isExternal = $derived(
line.external ||
(line.href &&
(line.href.startsWith("http://") ||
line.href.startsWith("https://"))),
);
// Parse color formatting in content using theme colorMap // Parse color formatting in content using theme colorMap
const segments = $derived(parseColorText(line.content, $themeColors.colorMap)); const segments = $derived(
parseColorText(line.content, $themeColors.colorMap),
);
</script> </script>
<button <button
class="tui-button" class="tui-button"
class:selected={selected} class:selected
class:inline={inline} class:inline
class:no-border={line.border === false}
style="--btn-color: {getButtonStyle(line.style)}" style="--btn-color: {getButtonStyle(line.style)}"
onclick={() => onClick(index)} onclick={() => onClick(index, line)}
onmouseenter={() => onHover(index)} onmouseenter={() => onHover(index)}
data-href={line.href || ''} data-href={line.href || ""}
data-external={isExternal ? 'true' : 'false'} data-external={isExternal ? "true" : "false"}
> >
<span class="btn-indicator">{selected ? '▶' : ' '}</span> <span class="btn-indicator">{selected ? "▶" : " "}</span>
{#if line.icon} {#if line.icon}
<Icon icon={line.icon} width="16" /> <Icon icon={line.icon} width="16" />
{/if} {/if}
<span class="btn-text"> <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="14" class="inline-icon" />

View File

@@ -1,6 +1,11 @@
<script lang="ts"> <script lang="ts">
import Icon from "@iconify/svelte"; import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils"; import {
getButtonStyle,
parseColorText,
getSegmentStyle,
parseDimension,
} from "./utils";
import type { CardLine } from "./types"; import type { CardLine } 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";
@@ -8,9 +13,18 @@
interface Props { interface Props {
line: CardLine; line: CardLine;
inline?: boolean; inline?: boolean;
onButtonClick?: (idx: number, line?: any) => void;
onHoverButton?: (idx: number) => void;
onLinkClick?: (idx: number) => void;
} }
let { line, inline = false }: Props = $props(); let {
line,
inline = false,
onButtonClick = () => {},
onHoverButton = () => {},
onLinkClick = () => {},
}: Props = $props();
const segments = $derived(parseColorText(line.content)); const segments = $derived(parseColorText(line.content));
const titleSegments = $derived( const titleSegments = $derived(
@@ -19,13 +33,22 @@
const footerSegments = $derived( const footerSegments = $derived(
line.cardFooter ? parseColorText(line.cardFooter) : [], line.cardFooter ? parseColorText(line.cardFooter) : [],
); );
const cardStyle = $derived(
[
`--card-color: ${getButtonStyle(line.style)}`,
line.cardWidth ? `width: ${parseDimension(line.cardWidth)}` : "",
line.cardHeight ? `height: ${parseDimension(line.cardHeight)}` : "",
line.cardFloat === "start" ? "margin-right: auto" : "",
line.cardFloat === "center" ? "margin-inline: auto" : "",
line.cardFloat === "end" ? "margin-left: auto" : "",
]
.filter(Boolean)
.join("; "),
);
</script> </script>
<div <div class="tui-card" class:inline style={cardStyle}>
class="tui-card"
class:inline
style="--card-color: {getButtonStyle(line.style)}"
>
{#if line.cardTitle} {#if line.cardTitle}
<div class="card-header"> <div class="card-header">
{#if line.icon} {#if line.icon}
@@ -77,6 +100,9 @@
showImage={true} showImage={true}
selectedIndex={-1} selectedIndex={-1}
inline={false} inline={false}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/> />
{/each} {/each}
</div> </div>
@@ -153,7 +179,6 @@
} }
.card-children { .card-children {
margin-top: 0.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;

View File

@@ -2,12 +2,12 @@
import { parseColorText, getPlainText } from "./utils"; import { parseColorText, getPlainText } from "./utils";
import { themeColors } from "$lib/stores/theme"; import { themeColors } from "$lib/stores/theme";
import TuiLine from "./TuiLine.svelte"; import TuiLine from "./TuiLine.svelte";
import type { GroupLine } from "./types"; import type { GroupLine, TerminalLine } from "./types";
interface Props { interface Props {
line: GroupLine; line: GroupLine;
inline?: boolean; inline?: boolean;
onButtonClick?: (idx: number) => void; onButtonClick?: (idx: number, line?: TerminalLine) => void;
onHoverButton?: (idx: number) => void; onHoverButton?: (idx: number) => void;
onLinkClick?: (idx: number) => void; onLinkClick?: (idx: number) => void;
} }
@@ -43,6 +43,7 @@
start: "flex-start", start: "flex-start",
center: "center", center: "center",
end: "flex-end", end: "flex-end",
stretch: "stretch",
}; };
styles.push( styles.push(
`align-items: ${alignMap[line.groupAlign] || "flex-start"}`, `align-items: ${alignMap[line.groupAlign] || "flex-start"}`,
@@ -59,6 +60,7 @@
class:inline class:inline
class:grid={line.display === "grid"} class:grid={line.display === "grid"}
class:block={line.display === "block"} class:block={line.display === "block"}
class:expand={line.groupExpand}
style={groupStyle()} style={groupStyle()}
id={line.id} id={line.id}
> >
@@ -102,6 +104,10 @@
width: 100%; width: 100%;
} }
.tui-group.expand > :global(*) {
flex: 1;
}
@keyframes lineSlideIn { @keyframes lineSlideIn {
from { from {
opacity: 0; opacity: 0;

View File

@@ -29,7 +29,7 @@
selectedIndex?: number; selectedIndex?: number;
inline?: boolean; inline?: boolean;
showCursor?: boolean; showCursor?: boolean;
onButtonClick?: (idx: number) => void; onButtonClick?: (idx: number, line?: TerminalLine) => void;
onHoverButton?: (idx: number) => void; onHoverButton?: (idx: number) => void;
onLinkClick?: (idx: number) => void; onLinkClick?: (idx: number) => void;
} }
@@ -110,7 +110,7 @@
<TuiTooltip {line} /> <TuiTooltip {line} />
</div>{/if} </div>{/if}
{:else if line.type === "card"} {:else if line.type === "card"}
<TuiCard {line} {inline} /> <TuiCard {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
{:else if line.type === "cardgrid"} {:else if line.type === "cardgrid"}
<TuiCardGrid {line} {inline} /> <TuiCardGrid {line} {inline} />
{:else if line.type === "progress"} {:else if line.type === "progress"}
@@ -214,6 +214,7 @@
/>{:else if getSegmentStyle(seg)}<span />{:else if getSegmentStyle(seg)}<span
style={getSegmentStyle(seg)}>{seg.text}</span style={getSegmentStyle(seg)}>{seg.text}</span
>{:else}{seg.text}{/if}{/each} >{:else}{seg.text}{/if}{/each}
{#if showCursor}<span class="cursor"></span>{/if}
</span> </span>
{:else} {:else}
<div class="tui-line {line.type}" class:complete id={line.id}> <div class="tui-line {line.type}" class:complete id={line.id}>

View File

@@ -12,6 +12,9 @@
let { line, inline = false }: Props = $props(); let { line, inline = false }: Props = $props();
let containerWidth = $state(0);
const charWidth = 9.6; // Approximate width of a character in monospace font (adjust as needed)
const progress = $derived(Math.min(100, Math.max(0, line.progress ?? 0))); const progress = $derived(Math.min(100, Math.max(0, line.progress ?? 0)));
const label = $derived(line.progressLabel || `${progress}%`); const label = $derived(line.progressLabel || `${progress}%`);
const contentSegments = $derived( const contentSegments = $derived(
@@ -20,12 +23,19 @@
const labelSegments = $derived( const labelSegments = $derived(
parseColorText(label, $themeColors.colorMap), parseColorText(label, $themeColors.colorMap),
); );
// Calculate how many characters fit in the bar
const charCapacity = $derived(
Math.max(10, Math.floor((containerWidth - 30) / charWidth)),
);
</script> </script>
<div <div
class="tui-progress" class="tui-progress"
class:inline class:inline
class:text-style={line.progressStyle === "text"}
style="--progress-color: {getButtonStyle(line.style)}" style="--progress-color: {getButtonStyle(line.style)}"
bind:clientWidth={containerWidth}
> >
{#if line.content} {#if line.content}
<div class="progress-label"> <div class="progress-label">
@@ -40,17 +50,42 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<div class="progress-bar">
{#if line.progressStyle === "text"}
<div class="progress-text-bar">
<span class="bracket">[</span>
<span class="bar-content">
{#each Array(charCapacity) as _, i}
<span class="char"
>{i < Math.floor((progress / 100) * charCapacity)
? "#"
: "."}</span
>
{/each}
</span>
<span class="bracket">]</span>
</div>
{:else}
<div
class="progress-bar"
class:continuous={line.progressStyle === "continuous"}
>
<div class="progress-fill" style="width: {progress}%"> <div class="progress-fill" style="width: {progress}%">
<span class="progress-glow"></span> <span class="progress-glow"></span>
</div> </div>
{#if line.progressStyle !== "continuous"}
<div class="progress-blocks"> <div class="progress-blocks">
{#each Array(20) as _, i} {#each Array(20) as _, i}
<span class="block" class:filled={i < Math.floor(progress / 5)} <span
class="block"
class:filled={i < Math.floor(progress / 5)}
></span> ></span>
{/each} {/each}
</div> </div>
{/if}
</div> </div>
{/if}
<div class="progress-value"> <div class="progress-value">
{#each labelSegments as segment} {#each labelSegments as segment}
{#if segment.icon} {#if segment.icon}
@@ -198,4 +233,30 @@
min-width: 100%; min-width: 100%;
} }
} }
/* Continuous Style */
.progress-bar.continuous .progress-blocks {
display: none;
}
/* Text Style */
.progress-text-bar {
font-family: monospace;
font-size: 1rem;
color: var(--terminal-dim);
white-space: nowrap;
}
.progress-text-bar .char {
color: var(--terminal-dim);
}
.tui-progress:not(.text-style) .progress-text-bar {
display: none;
}
.progress-text-bar .bracket {
color: var(--terminal-text);
font-weight: bold;
}
</style> </style>

View File

@@ -48,13 +48,17 @@ export function createTerminalAPI(options: TerminalAPIOptions): TerminalAPI {
function refreshDisplayedLines() { function refreshDisplayedLines() {
const state = getState(); const state = getState();
const colorMap = getColorMap(); const colorMap = getColorMap();
const displayedLines = state.lines.map((line, i) => { // Only update lines that are already displayed to avoid forcing untyped lines to appear
const displayedLines = state.displayedLines.map((displayed, i) => {
const line = state.lines[i];
if (!line) return displayed; // Should not happen if state is consistent
const parsed = parseLine(line, colorMap); const parsed = parseLine(line, colorMap);
return { return {
parsed, parsed,
charIndex: parsed.plainText.length, charIndex: displayed.complete ? parsed.plainText.length : displayed.charIndex,
complete: true, complete: displayed.complete,
showImage: state.displayedLines[i]?.showImage ?? (line.type === 'image') showImage: displayed.showImage ?? (line.type === 'image')
}; };
}); });
setState({ displayedLines, lines: [...state.lines] }); setState({ displayedLines, lines: [...state.lines] });

View File

@@ -77,6 +77,8 @@ export interface ButtonLine extends BaseTerminalLine {
href?: string; href?: string;
icon?: string; icon?: string;
external?: boolean; external?: boolean;
textStyle?: 'start' | 'center' | 'end';
border?: boolean;
} }
export interface LinkLine extends BaseTerminalLine { export interface LinkLine extends BaseTerminalLine {
@@ -101,12 +103,16 @@ export interface CardLine extends BaseTerminalLine {
image?: string; image?: string;
imageAlt?: string; imageAlt?: string;
children?: TerminalLine[]; children?: TerminalLine[];
cardWidth?: string | number;
cardHeight?: string | number;
cardFloat?: 'start' | 'center' | 'end';
} }
export interface ProgressLine extends BaseTerminalLine { export interface ProgressLine extends BaseTerminalLine {
type: 'progress'; type: 'progress';
progress: number; // 0-100 progress: number; // 0-100
progressLabel?: string; progressLabel?: string;
progressStyle?: 'block' | 'continuous' | 'text';
} }
export interface AccordionLine extends BaseTerminalLine { export interface AccordionLine extends BaseTerminalLine {
@@ -195,8 +201,9 @@ export interface GroupLine extends BaseTerminalLine {
type: 'group'; type: 'group';
children: TerminalLine[]; children: TerminalLine[];
groupDirection?: 'row' | 'column'; groupDirection?: 'row' | 'column';
groupAlign?: 'start' | 'center' | 'end'; groupAlign?: 'start' | 'center' | 'end' | 'stretch';
groupGap?: string; groupGap?: string;
groupExpand?: boolean;
} }
// Discriminated union of all line types // Discriminated union of all line types

View File

@@ -211,3 +211,26 @@ export function getButtonStyle(style?: string): string {
return 'var(--terminal-text)'; return 'var(--terminal-text)';
} }
} }
export function parseDimension(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined;
if (typeof value === 'number') {
// If it's a decimal between 0 and 1, treat as percentage
if (value > 0 && value <= 1) {
return `${value * 100}%`;
}
return `${value}px`;
}
if (typeof value === 'string') {
// Handle fractions like "1/2", "1/3"
if (value.includes('/')) {
const [num, den] = value.split('/').map(Number);
if (!isNaN(num) && !isNaN(den) && den !== 0) {
return `${(num / den) * 100}%`;
}
}
// Return as is if it already has a unit or is just a number string
return value;
}
return undefined;
}

View File

@@ -6,6 +6,7 @@ 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: '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: '🏆' },
@@ -45,6 +46,12 @@ export const pageMeta: Record<string, PageMeta> = {
icon: 'mdi:home', icon: 'mdi:home',
keywords: ['home', 'portfolio', 'about'] keywords: ['home', 'portfolio', 'about']
}, },
'/about': {
title: `${user.displayname} — About`,
description: `About — ${user.title} portfolio and projects.`,
icon: 'mdi:account',
keywords: ['about', 'portfolio', 'about']
},
'/portfolio': { '/portfolio': {
title: `${user.displayname} — Portfolio`, title: `${user.displayname} — Portfolio`,
description: 'Selected projects, highlights and case studies.', description: 'Selected projects, highlights and case studies.',

View File

@@ -134,6 +134,7 @@ export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
// 'models': 'instant', // No typing animation // 'models': 'instant', // No typing animation
// 'hackathons': 0.5, // Custom: 2x faster than normal // 'hackathons': 0.5, // Custom: 2x faster than normal
'home': 'fast', 'home': 'fast',
'about': 'normal',
'portfolio': 'instant', 'portfolio': 'instant',
'models': 'fast', 'models': 'fast',
'projects': 'fast', 'projects': 'fast',
@@ -147,6 +148,7 @@ export const pageAutoscrollSettings: Record<string, boolean> = {
// 'home': true, // Enable autoscroll // 'home': true, // Enable autoscroll
// 'portfolio': false, // Disable autoscroll // 'portfolio': false, // Disable autoscroll
'home': true, 'home': true,
'about': false,
'portfolio': false, 'portfolio': false,
'models': false, 'models': false,
'projects': false, 'projects': false,

View File

@@ -3,7 +3,7 @@
// ============================================================================ // ============================================================================
export const user = { export const user = {
name: 'Gagan M', name: 'Gagan "Adith" M',
displayname: 'Sir Blob', displayname: 'Sir Blob',
username: 'sirblob', username: 'sirblob',
hostname: 'engineering', hostname: 'engineering',

82
src/lib/pages/about.ts Normal file
View File

@@ -0,0 +1,82 @@
import type { TerminalLine } from '$lib/components/tui/types';
import { user } from '$lib/config';
const songs = [
{
name: 'Song A',
url: 'https://example.com'
},
{
name: 'Song B',
url: 'https://example.com'
},
{
name: 'Song C',
url: 'https://example.com'
}
]
const artist = [
{
name: 'Artist A',
url: 'https://example.com'
},
{
name: 'Artist B',
url: 'https://example.com'
},
{
name: 'Artist C',
url: 'https://example.com'
}
]
export const lines: TerminalLine[] = [
{ type: 'command', content: 'cat ~/about.md' },
{ type: 'blank', content: '' },
{ type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 180, inline: true },
{
type: 'group',
content: '',
groupDirection: 'column',
groupAlign: 'start',
inline: true,
children: [
{ type: 'header', content: `(&bold)${user.name}(&)` },
{ type: 'info', content: `(&accent)${user.title}(&)` },
{ type: 'output', content: `(&white)Location:(&) (&primary)${user.location}(&)` },
{ type: 'output', content: `(&muted)${user.bio}(&)` },
]
},
{ type: 'blank', content: '' },
{ type: 'divider', content: 'Music Recommendations' },
{ type: 'output', content: '(&accent)Artist:(&) ', inline: true },
...artist.flatMap((a, i): TerminalLine[] => {
const item: TerminalLine = { type: 'link', content: `(&white)${a.name}(&)`, href: a.url, inline: true };
return i < artist.length - 1 ? [item, { type: 'output', content: ' (&muted)•(&) ', inline: true }] : [item];
}),
{ type: 'blank', content: '' },
{ type: 'output', content: '(&accent)Songs:(&) ', inline: true },
...songs.flatMap((s, i): TerminalLine[] => {
const item: TerminalLine = { type: 'link', content: `(&white)${s.name}(&)`, href: s.url, inline: true };
return i < songs.length - 1 ? [item, { type: 'output', content: ' (&muted)•(&) ', inline: true }] : [item];
}),
{ type: 'blank', content: '' },
{
type: 'card',
id: 'music-player',
content: '',
children: [
{ type: 'info', content: 'Loading playlist...' }
]
},
{ type: 'blank', content: '' },
];

View File

@@ -110,6 +110,15 @@ export const lines: TerminalLine[] = [
action: () => console.log('Error clicked') action: () => console.log('Error clicked')
}, },
{ type: 'blank', content: '' }, { type: 'blank', content: '' },
{
type: 'button',
content: 'Borderless Button',
icon: 'mdi:border-none',
style: 'primary',
border: false,
action: () => console.log('Borderless clicked')
},
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Link Buttons(&)' }, { type: 'info', content: '(&blue,bold)Link Buttons(&)' },
{ {
@@ -219,6 +228,20 @@ export const lines: TerminalLine[] = [
}, },
{ type: 'blank', content: '' }, { type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Full Width Group(&)' },
{
type: 'group',
content: '',
groupDirection: 'row',
groupGap: '1rem',
groupExpand: true,
children: [
{ type: 'button', content: 'Full Width A', style: 'primary' },
{ type: 'button', content: 'Full Width B', style: 'accent' }
]
},
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Group with Links(&)' }, { type: 'info', content: '(&blue,bold)Group with Links(&)' },
{ {
type: 'group', type: 'group',
@@ -409,6 +432,25 @@ export const lines: TerminalLine[] = [
}, },
{ type: 'blank', content: '' }, { type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Card Sizing & Floating(&)' },
{
type: 'card',
content: 'Centered 50% width card',
cardTitle: 'Centered Card',
cardWidth: '1/2',
cardFloat: 'center',
style: 'primary'
},
{
type: 'card',
content: 'Floated right 300px card',
cardTitle: 'Floated Card',
cardWidth: 300,
cardFloat: 'end',
style: 'accent'
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// TABLES // TABLES
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════

View File

@@ -4,7 +4,7 @@ import { user, skills, projects } from '$lib/config';
export const lines: TerminalLine[] = [ export const lines: TerminalLine[] = [
// Header command // Header command
{ type: 'command', content: 'cat ~/about.md' }, { type: 'command', content: 'cat ~/portfolio.md' },
{ type: 'blank', content: '' }, { type: 'blank', content: '' },
// Avatar image // Avatar image

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import { page } from "$app/stores"; import { page } from "$app/stores";
import { goto } from "$app/navigation";
import TerminalTUI from "$lib/components/TerminalTUI.svelte"; import TerminalTUI from "$lib/components/TerminalTUI.svelte";
import type { TerminalLine } from "$lib/components/tui/types"; import type { TerminalLine } from "$lib/components/tui/types";
import { user, site } from "$lib/config"; import { user } from "$lib/config";
// Fun 404 messages // Fun 404 messages
const notFoundMessages = [ const notFoundMessages = [
@@ -62,7 +62,7 @@
return [ return [
"(&pink) █ █ ███ █ █ (&)", "(&pink) █ █ ███ █ █ (&)",
"(&pink) █ █ █ █ █ █ (&)", "(&pink) █ █ █ █ █ █ (&)",
"(&pink) ███ █ █ ███ (&)", "(&pink) ███ █ █ ███ (&)",
"(&pink) █ █ █ █ (&)", "(&pink) █ █ █ █ (&)",
"(&pink) █ ███ █ (&)", "(&pink) █ ███ █ (&)",
]; ];
@@ -89,8 +89,8 @@
]; ];
} }
const funMessage = getFunMessage(); const funMessage = $derived(getFunMessage());
const asciiLines = getAsciiArt(); const asciiLines = $derived(getAsciiArt());
// Build the terminal lines for the error page (derived to capture reactive values) // Build the terminal lines for the error page (derived to capture reactive values)
const lines = $derived<TerminalLine[]>([ const lines = $derived<TerminalLine[]>([
@@ -99,7 +99,7 @@
{ type: "blank", content: "" }, { type: "blank", content: "" },
// ASCII art // ASCII art
...asciiLines.map((line) => ({ type: "output" as const, content: line })), ...asciiLines.map((line: string) => ({ type: "output" as const, content: line })),
{ type: "blank", content: "" }, { type: "blank", content: "" },
// Error status (styled with leading X like the screenshot) // Error status (styled with leading X like the screenshot)
@@ -107,25 +107,15 @@
type: "error", type: "error",
content: `(&error,bold)X Error ${status}: ${errorMessage}(&)`, content: `(&error,bold)X Error ${status}: ${errorMessage}(&)`,
}, },
{ type: "blank", content: "" },
// Fun message as a comment
{ type: "output", content: `(&muted,italic)# ${funMessage}(&)` }, { type: "output", content: `(&muted,italic)# ${funMessage}(&)` },
{ type: "blank", content: "" },
{ type: "divider", content: "SUGGESTIONS" }, { type: "divider", content: "SUGGESTIONS" },
{ type: "blank", content: "" },
// Suggestions // Suggestions
{ type: "command", content: "cat suggestions.txt" }, { type: "command", content: "cat suggestions.txt" },
{ type: "info", content: "Check if the URL is correct" }, { type: "info", content: "Check if the URL is correct" },
{ type: "info", content: "Try refreshing the page" }, { type: "info", content: "Try refreshing the page" },
{ type: "info", content: "Contact me if the problem persists" }, { type: "info", content: "Contact me if the problem persists" },
{ type: "blank", content: "" },
{ type: "divider", content: "ACTIONS" }, { type: "divider", content: "ACTIONS" },
{ type: "blank", content: "" },
// Navigation buttons // Navigation buttons
{ {
type: "button", type: "button",
@@ -141,6 +131,7 @@
style: "accent", style: "accent",
action: () => history.back(), action: () => history.back(),
}, },
{ type: "blank", content: "" },
]); ]);
</script> </script>

View File

@@ -0,0 +1,25 @@
import ytpl from 'ytpl';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
const playlistUrl = 'https://music.youtube.com/playlist?list=PLHqPHHvmOjJ6JwiYc3Fg-O5JGbVppI1VR&si=CIb9WIKhjKsRV3p3';
const playlist = await ytpl(playlistUrl, { limit: Infinity });
const songs = playlist.items.map((item, index) => {
return {
index: index + 1,
title: item.title,
duration: item.duration, // format is usually mm:ss
author: item.author.name,
isLive: item.isLive,
url: item.shortUrl
};
});
return {
songs
};
}

View File

@@ -0,0 +1,173 @@
<script lang="ts">
import { onMount } from "svelte";
import TerminalTUI from "$lib/components/TerminalTUI.svelte";
import { user } from "$lib/config";
import { getPageSpeedMultiplier, getPageAutoscroll } from "$lib";
import { lines as sourceLines } from "$lib/pages/about";
import type { TerminalAPI } from "$lib/components/tui/types";
import { onDestroy } from "svelte";
let lines = $state(sourceLines.map((l) => ({ ...l })));
const speed = getPageSpeedMultiplier("about");
const autoscroll = getPageAutoscroll("about");
let { data } = $props();
const { songs } = data;
let terminal = $state<TerminalAPI>();
let currentSongIndex = $state(0);
let isPlaying = $state(false);
let progress = $state(0);
let timer: ReturnType<typeof setInterval>;
onMount(() => {
// Music player will start when terminal finishes typing
});
function handleTerminalComplete() {
if (songs && songs.length > 0) {
// Initial update
updatePlayerDisplay();
togglePlay();
}
}
onDestroy(() => {
if (timer) clearInterval(timer);
});
function togglePlay() {
isPlaying = !isPlaying;
if (isPlaying) {
timer = setInterval(tick, 500);
} else {
clearInterval(timer);
}
updatePlayerDisplay();
}
function tick() {
progress += 0.5; // Increment progress
if (progress >= 100) {
nextSong();
} else {
updatePlayerDisplay();
}
}
function nextSong() {
currentSongIndex = (currentSongIndex + 1) % songs.length;
progress = 0;
updatePlayerDisplay();
}
function prevSong() {
currentSongIndex = (currentSongIndex - 1 + songs.length) % songs.length;
progress = 0;
updatePlayerDisplay();
}
function updatePlayerDisplay() {
if (!terminal) return;
const song = songs[currentSongIndex];
terminal.updateById("music-player", {
children: [
{
type: "output",
content: `(&primary)Now Playing:(&) (&text)${song.title}(&)`,
},
{
type: "output",
content: `(&primary)Artist:(&) (&text)${song.author}(&)`,
},
{
type: "progress",
content: "",
progress: progress,
progressLabel: `${formatTime(Math.floor((progress / 100) * parseDuration(song.duration || "0:00")))} / ${song.duration}`,
inline: false,
progressStyle: "text",
},
{
type: "group",
content: "",
groupDirection: "row",
display: "flex",
groupExpand: true,
children: [
{
type: "button",
content: "⏮",
style: "secondary",
action: prevSong,
textStyle: "center",
},
{
type: "button",
content: isPlaying ? "⏸" : "▶",
style: "primary",
action: togglePlay,
textStyle: "center",
},
{
type: "button",
content: "⏭",
style: "secondary",
action: nextSong,
textStyle: "center",
}
],
},
{
type: "button",
content: "(&icon, mdi:youtube) Listen on YouTube",
style: "primary",
href: song.url,
external: true,
textStyle: "center",
},
],
});
}
function parseDuration(duration: string): number {
const parts = duration.split(":").map(Number);
if (parts.length === 2) return parts[0] * 60 + parts[1];
if (parts.length === 3)
return parts[0] * 3600 + parts[1] * 60 + parts[2];
return 0;
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
</script>
<svelte:head>
<title>About | {user.displayname}</title>
<meta name="description" content="About {user.displayname}" />
</svelte:head>
<div class="about-container">
<TerminalTUI
{lines}
title="~/about"
interactive={true}
{speed}
{autoscroll}
bind:terminal
onComplete={handleTerminalComplete}
/>
</div>
<style>
.about-container {
padding: 2rem 1rem;
min-height: calc(100vh - 60px);
}
</style>