Element Bug Fixes
This commit is contained in:
10
README.md
10
README.md
@@ -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.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 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 },
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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] });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
82
src/lib/pages/about.ts
Normal 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: '' },
|
||||||
|
];
|
||||||
@@ -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
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
25
src/routes/about/+page.server.ts
Normal file
25
src/routes/about/+page.server.ts
Normal 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
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
173
src/routes/about/+page.svelte
Normal file
173
src/routes/about/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user