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.
![Terminal Portfolio](./static/og-image.png)
## Features
- 🖥️ **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
// OR
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)
children: [ // Optional nested elements
{ 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)
groupAlign: 'start', // start | center | end
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
children: [
{ type: 'output', content: 'Label:', inline: true },

View File

@@ -31,6 +31,7 @@
"dotenv": "^17.2.3",
"express": "^5.1.0",
"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;
width: 100%;
padding: 0.5rem 0.75rem;
margin: 0.2rem 0;
margin: 0.75rem 0;
background: transparent;
border: 1px solid transparent;
border: 1px solid var(--btn-color);
border-radius: 4px;
color: var(--btn-color);
font-family: inherit;
@@ -16,6 +16,10 @@
transition: all 0.15s ease;
}
.tui-button.no-border {
border-color: transparent;
}
/* Inline button styles */
.tui-button.inline {
width: auto;
@@ -65,4 +69,4 @@
width: 100%;
display: flex;
}
}
}

View File

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

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { themeColors } from '$lib/stores/theme';
import '$lib/assets/css/model-viewer.css';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import Icon from '@iconify/svelte';
import { onMount, onDestroy } from "svelte";
import { themeColors } from "$lib/stores/theme";
import "$lib/assets/css/model-viewer.css";
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import Icon from "@iconify/svelte";
interface Props {
modelPath: string;
@@ -13,7 +13,11 @@
class?: string;
}
let { modelPath, modelName = 'Model', class: className = '' }: Props = $props();
let {
modelPath,
modelName = "Model",
class: className = "",
}: Props = $props();
let container: HTMLDivElement;
let scene: THREE.Scene;
@@ -30,10 +34,15 @@
let autoRotate = $state(true);
let wireframe = $state(false);
let showGround = $state(true);
let showGrid = $state(false);
let showAxes = $state(false);
let lightIntensity = $state(1);
let rotationSpeed = $state(2);
let showControls = $state(false);
let ground: THREE.Mesh | null = null;
let gridHelper: THREE.GridHelper | null = null;
let axesHelper: THREE.AxesHelper | null = null;
// Watch for modelPath changes
$effect(() => {
@@ -45,7 +54,7 @@
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose());
child.material.forEach((m) => m.dispose());
} else {
child.material.dispose();
}
@@ -72,10 +81,10 @@
camera.position.set(0, 1, 3);
// Renderer
renderer = new THREE.WebGLRenderer({
antialias: true,
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
powerPreference: "high-performance",
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -122,39 +131,51 @@
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x222222,
transparent: true,
opacity: 0.3
opacity: 0.3,
});
ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.5;
ground.receiveShadow = true;
ground.receiveShadow = true;
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;
loadModel();
}
function loadModel() {
const loader = new GLTFLoader();
loader.load(
modelPath,
(gltf) => {
model = gltf.scene;
// Center and scale the model
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// Center the model
model.position.sub(center);
// Scale to fit
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 1.5 / maxDim;
model.scale.setScalar(scale);
// Enable shadows
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
@@ -162,10 +183,10 @@
child.receiveShadow = true;
}
});
scene.add(model);
isLoading = false;
// Adjust camera based on model
camera.position.set(0, size.y * scale * 0.5, 2.5);
controls.target.set(0, 0, 0);
@@ -175,10 +196,10 @@
// Loading progress
},
(error) => {
console.error('Error loading model:', error);
loadError = 'Failed to load model';
console.error("Error loading model:", error);
loadError = "Failed to load model";
isLoading = false;
}
},
);
}
@@ -190,10 +211,10 @@
function handleResize() {
if (!container || !camera || !renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
@@ -224,7 +245,9 @@
model.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.wireframe = wireframe);
child.material.forEach(
(m) => (m.wireframe = wireframe),
);
} else {
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() {
showGround = !showGround;
if (ground) {
@@ -245,7 +289,8 @@
if (scene) {
scene.traverse((child) => {
if (child instanceof THREE.DirectionalLight) {
child.intensity = child.userData.baseIntensity * lightIntensity;
child.intensity =
child.userData.baseIntensity * lightIntensity;
} else if (child instanceof THREE.AmbientLight) {
child.intensity = 0.4 * lightIntensity;
}
@@ -269,32 +314,35 @@
controls.update();
}
function rotateCamera(direction: 'left' | 'right' | 'up' | 'down') {
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':
case "left":
spherical.theta += rotateSpeed;
break;
case 'right':
case "right":
spherical.theta -= rotateSpeed;
break;
case 'up':
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);
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();
@@ -302,26 +350,27 @@
function handleKeydown(event: KeyboardEvent) {
// 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) {
case 'ArrowLeft':
case "ArrowLeft":
event.preventDefault();
rotateCamera('left');
rotateCamera("left");
break;
case 'ArrowRight':
case "ArrowRight":
event.preventDefault();
rotateCamera('right');
rotateCamera("right");
break;
case 'ArrowUp':
case "ArrowUp":
event.preventDefault();
rotateCamera('up');
rotateCamera("up");
break;
case 'ArrowDown':
case "ArrowDown":
event.preventDefault();
rotateCamera('down');
rotateCamera("down");
break;
case 'Escape':
case "Escape":
if (isFullscreen) {
isFullscreen = false;
setTimeout(handleResize, 100);
@@ -338,8 +387,8 @@
if (container) {
initScene();
animate();
window.addEventListener('resize', handleResize);
window.addEventListener('keydown', handleKeydown);
window.addEventListener("resize", handleResize);
window.addEventListener("keydown", handleKeydown);
}
});
@@ -350,12 +399,12 @@
if (renderer) {
renderer.dispose();
}
window.removeEventListener('resize', handleResize);
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener("resize", handleResize);
window.removeEventListener("keydown", handleKeydown);
});
</script>
<div
<div
class="model-viewer {className}"
class:fullscreen={isFullscreen}
style="
@@ -374,35 +423,45 @@
<span class="model-name">{modelName}</span>
</div>
<div class="header-controls">
<button
class="control-btn"
title={autoRotate ? 'Stop rotation' : 'Auto rotate'}
<button
class="control-btn"
title={autoRotate ? "Stop rotation" : "Auto rotate"}
onclick={toggleAutoRotate}
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
class="control-btn"
<button
class="control-btn"
title="Reset camera"
onclick={resetCamera}
>
<Icon icon="mdi:camera-flip" width="16" />
</button>
<button
class="control-btn"
<button
class="control-btn"
title="More controls"
onclick={toggleControlsPanel}
class:active={showControls}
>
<Icon icon="mdi:tune" width="16" />
</button>
<button
class="control-btn"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
<button
class="control-btn"
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
onclick={toggleFullscreen}
>
<Icon icon={isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'} width="16" />
<Icon
icon={isFullscreen
? "mdi:fullscreen-exit"
: "mdi:fullscreen"}
width="16"
/>
</button>
</div>
</div>
@@ -413,15 +472,15 @@
<div class="control-group">
<span class="control-label">View</span>
<div class="control-buttons">
<button
class="control-btn small"
<button
class="control-btn small"
title="Zoom in"
onclick={zoomIn}
>
<Icon icon="mdi:magnify-plus" width="14" />
</button>
<button
class="control-btn small"
<button
class="control-btn small"
title="Zoom out"
onclick={zoomOut}
>
@@ -432,16 +491,18 @@
<div class="control-group">
<span class="control-label">Lighting</span>
<div class="control-buttons">
<button
class="control-btn small"
<button
class="control-btn small"
title="Decrease brightness"
onclick={() => adjustLighting(-0.2)}
>
<Icon icon="mdi:brightness-5" width="14" />
</button>
<span class="control-value">{Math.round(lightIntensity * 100)}%</span>
<button
class="control-btn small"
<span class="control-value"
>{Math.round(lightIntensity * 100)}%</span
>
<button
class="control-btn small"
title="Increase brightness"
onclick={() => adjustLighting(0.2)}
>
@@ -452,22 +513,64 @@
<div class="control-group">
<span class="control-label">Display</span>
<div class="control-buttons">
<button
class="control-btn small"
title={wireframe ? 'Solid view' : 'Wireframe view'}
<button
class="control-btn small"
title={wireframe ? "Solid view" : "Wireframe view"}
onclick={toggleWireframe}
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
class="control-btn small"
title={showGround ? 'Hide ground' : 'Show ground'}
<button
class="control-btn small"
title={showGround ? "Hide ground" : "Show ground"}
onclick={toggleGround}
class:active={showGround}
>
<Icon icon="mdi:checkerboard" width="14" />
</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>
@@ -475,7 +578,13 @@
<!-- Canvas 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">
<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" />

View File

@@ -1,18 +1,34 @@
<script lang="ts">
import { onMount } from 'svelte';
import { themeColors } from '$lib/stores/theme';
import { terminalSettings, type SpeedPreset, speedPresets } 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 { onMount } from "svelte";
import { themeColors } from "$lib/stores/theme";
import {
terminalSettings,
type SpeedPreset,
speedPresets,
} 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 { parseLine, createTerminalAPI } from './tui/terminal-api';
import { runTypingAnimation, skipTypingAnimation } from './tui/terminal-typing';
import { createKeyboardHandler, handleNavigation, scrollToHash } from './tui/terminal-keyboard';
import { parseLine, createTerminalAPI } from "./tui/terminal-api";
import {
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 {
lines?: TerminalLine[];
@@ -25,20 +41,20 @@
terminal?: TerminalAPI;
}
let {
lines = $bindable([]),
title = 'terminal',
class: className = '',
let {
lines = $bindable([]),
title = "terminal",
class: className = "",
onComplete,
interactive = true,
speed = 'normal',
speed = "normal",
autoscroll = true,
terminal = $bindable()
terminal = $bindable(),
}: Props = $props();
// Calculate speed multiplier from preset or number
const speedMultiplier = $derived(
typeof speed === 'number' ? speed : (speedPresets[speed] ?? 1)
typeof speed === "number" ? speed : (speedPresets[speed] ?? 1),
);
// Get colorMap from current theme
@@ -46,7 +62,7 @@
// Pre-parse all lines upfront (segments + plain text)
const parsedLines = $derived<ParsedLine[]>(
lines.map(line => parseLine(line, colorMap))
lines.map((line) => parseLine(line, colorMap)),
);
let displayedLines = $state<DisplayedLine[]>([]);
@@ -59,35 +75,39 @@
let bodyElement = $state<HTMLDivElement>();
// 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
$effect(() => {
// Create a simple identity string from a few colorMap values
const colorMapId = `${colorMap.red}-${colorMap.text}-${colorMap.primary}`;
// 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
const showImageStates = displayedLines.map(d => d.showImage);
const showImageStates = displayedLines.map((d) => d.showImage);
// Update with new parsed content
displayedLines = parsedLines.map((parsed, i) => ({
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: showImageStates[i] ?? (parsed.line.type === 'image')
showImage: showImageStates[i] ?? parsed.line.type === "image",
}));
}
lastColorMapId = colorMapId;
});
// Helper to check if a line or its children contain buttons
function hasButtons(line: TerminalLine): boolean {
if (line.type === 'button') return true;
if (line.type === 'group' && line.children) {
return line.children.some(child => hasButtons(child));
if (line.type === "button") return true;
if (line.type === "group" && line.children) {
return line.children.some((child) => hasButtons(child));
}
return false;
}
@@ -95,8 +115,8 @@
// Get all interactive button indices (including buttons nested in groups)
const buttonIndices = $derived(
displayedLines
.map((item, i) => hasButtons(item.parsed.line) ? i : -1)
.filter(i => i !== -1)
.map((item, i) => (hasButtons(item.parsed.line) ? i : -1))
.filter((i) => i !== -1),
);
// ========================================================================
@@ -112,17 +132,29 @@
interactive,
autoscroll,
getBodyElement: () => bodyElement,
getState: () => ({ displayedLines, currentLineIndex, isTyping, isComplete, skipRequested, selectedIndex }),
getState: () => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
setState: (updates) => {
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex;
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined) isComplete = updates.isComplete;
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
},
onComplete,
scrollToHash: () => scrollToHash(bodyElement)
scrollToHash: () => scrollToHash(bodyElement),
});
}
@@ -131,18 +163,30 @@
parsedLines,
buttonIndices,
interactive,
() => ({ displayedLines, currentLineIndex, isTyping, isComplete, skipRequested, selectedIndex }),
() => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
(updates) => {
if (updates.displayedLines !== undefined) displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex;
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined) isComplete = updates.isComplete;
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
},
() => bodyElement,
autoscroll,
onComplete
onComplete,
);
}
@@ -158,20 +202,25 @@
isComplete,
currentLineIndex,
selectedIndex,
skipRequested
skipRequested,
}),
setState: (updates) => {
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.isComplete !== undefined) isComplete = updates.isComplete;
if (updates.currentLineIndex !== undefined) currentLineIndex = updates.currentLineIndex;
if (updates.selectedIndex !== undefined) selectedIndex = updates.selectedIndex;
if (updates.skipRequested !== undefined) skipRequested = updates.skipRequested;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
},
getColorMap: () => colorMap,
getBodyElement: () => bodyElement,
typeText
typeText,
});
// ========================================================================
@@ -184,20 +233,29 @@
getInteractive: () => interactive,
getButtonIndices: () => buttonIndices,
getSelectedIndex: () => selectedIndex,
setSelectedIndex: (index) => { selectedIndex = index; },
setSelectedIndex: (index) => {
selectedIndex = index;
},
getDisplayedLines: () => displayedLines,
getBodyElement: () => bodyElement,
skipAnimation,
scrollMargin: terminalSettings.scrollMargin
scrollMargin: terminalSettings.scrollMargin,
});
function handleButtonClick(index: number) {
selectedIndex = index;
handleNavigation(displayedLines[index]?.parsed.line);
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;
handleNavigation(displayedLines[index]?.parsed.line as any);
}
}
function handleLinkClick(index: number) {
handleNavigation(displayedLines[index]?.parsed.line);
handleNavigation(displayedLines[index]?.parsed.line as any);
}
onMount(() => {
@@ -207,7 +265,7 @@
<svelte:window on:keydown={handleKeydown} />
<div
<div
class="tui-terminal {className}"
style="
--terminal-bg: {$themeColors.terminal};
@@ -228,23 +286,32 @@
>
<!-- Hyprland-style border glow -->
<div class="tui-border-glow"></div>
<!-- TUI Content -->
<div class="tui-content">
<TuiHeader {title} {interactive} hasButtons={buttonIndices.length > 0} />
<TuiHeader
{title}
{interactive}
hasButtons={buttonIndices.length > 0}
/>
<!-- Main terminal area -->
<TuiBody bind:ref={bodyElement}
<TuiBody
bind:ref={bodyElement}
{displayedLines}
{currentLineIndex}
{isTyping}
{selectedIndex}
onButtonClick={handleButtonClick}
onHoverButton={(i) => selectedIndex = i}
onHoverButton={(i) => (selectedIndex = i)}
onLinkClick={handleLinkClick}
terminalSettings={terminalSettings}
{terminalSettings}
/>
<TuiFooter isTyping={isTyping} linesCount={displayedLines.length} skipAnimation={skipAnimation} />
<TuiFooter
{isTyping}
linesCount={displayedLines.length}
{skipAnimation}
/>
</div>
</div>

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
import {
getButtonStyle,
parseColorText,
getSegmentStyle,
parseDimension,
} from "./utils";
import type { CardLine } from "./types";
import TuiLine from "./TuiLine.svelte";
import "$lib/assets/css/tui-card.css";
@@ -8,9 +13,18 @@
interface Props {
line: CardLine;
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 titleSegments = $derived(
@@ -19,13 +33,22 @@
const footerSegments = $derived(
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>
<div
class="tui-card"
class:inline
style="--card-color: {getButtonStyle(line.style)}"
>
<div class="tui-card" class:inline style={cardStyle}>
{#if line.cardTitle}
<div class="card-header">
{#if line.icon}
@@ -77,6 +100,9 @@
showImage={true}
selectedIndex={-1}
inline={false}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/each}
</div>
@@ -153,7 +179,6 @@
}
.card-children {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;

View File

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

View File

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

View File

@@ -12,6 +12,9 @@
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 label = $derived(line.progressLabel || `${progress}%`);
const contentSegments = $derived(
@@ -20,12 +23,19 @@
const labelSegments = $derived(
parseColorText(label, $themeColors.colorMap),
);
// Calculate how many characters fit in the bar
const charCapacity = $derived(
Math.max(10, Math.floor((containerWidth - 30) / charWidth)),
);
</script>
<div
class="tui-progress"
class:inline
class:text-style={line.progressStyle === "text"}
style="--progress-color: {getButtonStyle(line.style)}"
bind:clientWidth={containerWidth}
>
{#if line.content}
<div class="progress-label">
@@ -40,17 +50,42 @@
{/each}
</div>
{/if}
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%">
<span class="progress-glow"></span>
{#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>
<div class="progress-blocks">
{#each Array(20) as _, i}
<span class="block" class:filled={i < Math.floor(progress / 5)}
></span>
{/each}
{:else}
<div
class="progress-bar"
class:continuous={line.progressStyle === "continuous"}
>
<div class="progress-fill" style="width: {progress}%">
<span class="progress-glow"></span>
</div>
{#if line.progressStyle !== "continuous"}
<div class="progress-blocks">
{#each Array(20) as _, i}
<span
class="block"
class:filled={i < Math.floor(progress / 5)}
></span>
{/each}
</div>
{/if}
</div>
</div>
{/if}
<div class="progress-value">
{#each labelSegments as segment}
{#if segment.icon}
@@ -198,4 +233,30 @@
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>

View File

@@ -48,13 +48,17 @@ export function createTerminalAPI(options: TerminalAPIOptions): TerminalAPI {
function refreshDisplayedLines() {
const state = getState();
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);
return {
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: state.displayedLines[i]?.showImage ?? (line.type === 'image')
charIndex: displayed.complete ? parsed.plainText.length : displayed.charIndex,
complete: displayed.complete,
showImage: displayed.showImage ?? (line.type === 'image')
};
});
setState({ displayedLines, lines: [...state.lines] });

View File

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

View File

@@ -169,7 +169,7 @@ export function getSegmentStyle(segment: TextSegment): string {
if (segment.bold) styles.push('font-weight: bold');
if (segment.dim) styles.push('opacity: 0.6');
if (segment.italic) styles.push('font-style: italic');
// Combine text decorations
const decorations: string[] = [];
if (segment.underline) decorations.push('underline');
@@ -178,7 +178,7 @@ export function getSegmentStyle(segment: TextSegment): string {
if (decorations.length > 0) {
styles.push(`text-decoration: ${decorations.join(' ')}`);
}
return styles.join('; ');
}
@@ -211,3 +211,26 @@ export function getButtonStyle(style?: string): string {
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 = [
{ name: 'home', path: '/', icon: '~' },
{ name: 'about', path: '/about', icon: 'mdi:account' },
{ name: 'portfolio', path: '/portfolio', icon: '📁' },
// { name: 'models', path: '/models', icon: '🎨' },
{ name: 'projects', path: '/projects', icon: '🏆' },
@@ -45,6 +46,12 @@ export const pageMeta: Record<string, PageMeta> = {
icon: 'mdi:home',
keywords: ['home', 'portfolio', 'about']
},
'/about': {
title: `${user.displayname} — About`,
description: `About — ${user.title} portfolio and projects.`,
icon: 'mdi:account',
keywords: ['about', 'portfolio', 'about']
},
'/portfolio': {
title: `${user.displayname} — Portfolio`,
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
// 'hackathons': 0.5, // Custom: 2x faster than normal
'home': 'fast',
'about': 'normal',
'portfolio': 'instant',
'models': 'fast',
'projects': 'fast',
@@ -147,6 +148,7 @@ export const pageAutoscrollSettings: Record<string, boolean> = {
// 'home': true, // Enable autoscroll
// 'portfolio': false, // Disable autoscroll
'home': true,
'about': false,
'portfolio': false,
'models': false,
'projects': false,

View File

@@ -3,7 +3,7 @@
// ============================================================================
export const user = {
name: 'Gagan M',
name: 'Gagan "Adith" M',
displayname: 'Sir Blob',
username: 'sirblob',
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')
},
{ 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(&)' },
{
@@ -219,6 +228,20 @@ export const lines: TerminalLine[] = [
},
{ 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: 'group',
@@ -409,6 +432,25 @@ export const lines: TerminalLine[] = [
},
{ 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
// ═══════════════════════════════════════════════════════════════

View File

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

View File

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