Rewrite website as a Hyprland desktop
This commit is contained in:
1 parent
7c52779cf6
commit
3065ad6092
197 files changed
+5609
-17779
No files matched your search
@@ -1,61 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Canvas } from '@threlte/core';
|
||||
import { T } from '@threlte/core';
|
||||
import ParticleField from './ParticleField.svelte';
|
||||
import { themeColors, mode } from '$lib/stores/theme';
|
||||
import '$lib/assets/css/background-3d.css';
|
||||
|
||||
// Reactive theme colors
|
||||
const bgColor = $derived($themeColors.background);
|
||||
const primaryColor = $derived($themeColors.primary);
|
||||
|
||||
let webglAvailable = $state(false);
|
||||
|
||||
function isWebGLAvailable() {
|
||||
try {
|
||||
const testCanvas = document.createElement('canvas');
|
||||
const context =
|
||||
testCanvas.getContext('webgl2') ||
|
||||
testCanvas.getContext('webgl') ||
|
||||
testCanvas.getContext('experimental-webgl');
|
||||
return !!(window.WebGLRenderingContext && context);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
webglAvailable = isWebGLAvailable();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="scene-container" style="--scene-bg: {bgColor};">
|
||||
{#if webglAvailable}
|
||||
<Canvas>
|
||||
<T.PerspectiveCamera
|
||||
makeDefault
|
||||
position={[0, 0, 8]}
|
||||
fov={75}
|
||||
near={0.1}
|
||||
far={1000}
|
||||
/>
|
||||
|
||||
<!-- Ambient light -->
|
||||
<T.AmbientLight intensity={0.3} />
|
||||
|
||||
<!-- Directional light with theme color -->
|
||||
<T.DirectionalLight
|
||||
position={[5, 5, 5]}
|
||||
intensity={0.5}
|
||||
color={primaryColor}
|
||||
/>
|
||||
|
||||
<!-- Particle system -->
|
||||
<ParticleField particleCount={600} />
|
||||
|
||||
<!-- Background color -->
|
||||
<T.Color args={[bgColor]} attach="background" />
|
||||
</Canvas>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,624 +0,0 @@
|
||||
<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";
|
||||
|
||||
interface Props {
|
||||
modelPath: string;
|
||||
modelName?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
modelPath,
|
||||
modelName = "Model",
|
||||
class: className = "",
|
||||
}: Props = $props();
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let scene: THREE.Scene;
|
||||
let camera: THREE.PerspectiveCamera;
|
||||
let renderer: THREE.WebGLRenderer;
|
||||
let controls: OrbitControls;
|
||||
let model: THREE.Group | null = null;
|
||||
let animationId: number;
|
||||
let sceneInitialized = false;
|
||||
|
||||
let isLoading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let isFullscreen = $state(false);
|
||||
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(() => {
|
||||
if (modelPath && sceneInitialized) {
|
||||
// Remove old model
|
||||
if (model) {
|
||||
scene.remove(model);
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
child.geometry.dispose();
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach((m) => m.dispose());
|
||||
} else {
|
||||
child.material.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
model = null;
|
||||
}
|
||||
isLoading = true;
|
||||
loadError = null;
|
||||
loadModel();
|
||||
}
|
||||
});
|
||||
|
||||
function initScene() {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
// Scene
|
||||
scene = new THREE.Scene();
|
||||
scene.background = null; // Transparent background
|
||||
|
||||
// Camera
|
||||
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
|
||||
camera.position.set(0, 1, 3);
|
||||
|
||||
// Renderer
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
});
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1.2;
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Controls
|
||||
controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.enablePan = false;
|
||||
controls.minDistance = 1;
|
||||
controls.maxDistance = 10;
|
||||
controls.autoRotate = autoRotate;
|
||||
controls.autoRotateSpeed = 2;
|
||||
|
||||
// Lighting
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1);
|
||||
directionalLight1.position.set(5, 5, 5);
|
||||
directionalLight1.castShadow = true;
|
||||
directionalLight1.userData.baseIntensity = 1;
|
||||
scene.add(directionalLight1);
|
||||
|
||||
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
|
||||
directionalLight2.position.set(-5, 3, -5);
|
||||
directionalLight2.userData.baseIntensity = 0.5;
|
||||
scene.add(directionalLight2);
|
||||
|
||||
// Subtle rim light
|
||||
const rimLight = new THREE.DirectionalLight(0x88ccff, 0.3);
|
||||
rimLight.position.set(0, 3, -5);
|
||||
rimLight.userData.baseIntensity = 0.3;
|
||||
scene.add(rimLight);
|
||||
|
||||
// Ground plane (subtle)
|
||||
const groundGeometry = new THREE.CircleGeometry(2, 32);
|
||||
const groundMaterial = new THREE.MeshStandardMaterial({
|
||||
color: 0x222222,
|
||||
transparent: true,
|
||||
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) {
|
||||
child.castShadow = true;
|
||||
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);
|
||||
controls.update();
|
||||
},
|
||||
(progress) => {
|
||||
// Loading progress
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error loading model:", error);
|
||||
loadError = "Failed to load model";
|
||||
isLoading = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animationId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function toggleAutoRotate() {
|
||||
autoRotate = !autoRotate;
|
||||
if (controls) {
|
||||
controls.autoRotate = autoRotate;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
isFullscreen = !isFullscreen;
|
||||
setTimeout(handleResize, 100);
|
||||
}
|
||||
|
||||
function resetCamera() {
|
||||
if (!camera || !controls) return;
|
||||
camera.position.set(0, 1, 3);
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function toggleWireframe() {
|
||||
wireframe = !wireframe;
|
||||
if (model) {
|
||||
model.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh && child.material) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material.forEach(
|
||||
(m) => (m.wireframe = wireframe),
|
||||
);
|
||||
} else {
|
||||
child.material.wireframe = wireframe;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
ground.visible = showGround;
|
||||
}
|
||||
}
|
||||
|
||||
function adjustLighting(delta: number) {
|
||||
lightIntensity = Math.max(0.2, Math.min(2, lightIntensity + delta));
|
||||
if (scene) {
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.DirectionalLight) {
|
||||
child.intensity =
|
||||
child.userData.baseIntensity * lightIntensity;
|
||||
} else if (child instanceof THREE.AmbientLight) {
|
||||
child.intensity = 0.4 * lightIntensity;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
if (!camera || !controls) return;
|
||||
const direction = new THREE.Vector3();
|
||||
camera.getWorldDirection(direction);
|
||||
camera.position.addScaledVector(direction, 0.5);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (!camera || !controls) return;
|
||||
const direction = new THREE.Vector3();
|
||||
camera.getWorldDirection(direction);
|
||||
camera.position.addScaledVector(direction, -0.5);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function rotateCamera(direction: "left" | "right" | "up" | "down") {
|
||||
if (!controls) return;
|
||||
|
||||
const rotateSpeed = 0.1;
|
||||
const spherical = new THREE.Spherical();
|
||||
const offset = new THREE.Vector3();
|
||||
|
||||
// Get current camera position relative to target
|
||||
offset.copy(camera.position).sub(controls.target);
|
||||
spherical.setFromVector3(offset);
|
||||
|
||||
switch (direction) {
|
||||
case "left":
|
||||
spherical.theta += rotateSpeed;
|
||||
break;
|
||||
case "right":
|
||||
spherical.theta -= rotateSpeed;
|
||||
break;
|
||||
case "up":
|
||||
spherical.phi = Math.max(0.1, spherical.phi - rotateSpeed);
|
||||
break;
|
||||
case "down":
|
||||
spherical.phi = Math.min(
|
||||
Math.PI - 0.1,
|
||||
spherical.phi + rotateSpeed,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
offset.setFromSpherical(spherical);
|
||||
camera.position.copy(controls.target).add(offset);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
// Only handle if this component is focused or in fullscreen
|
||||
if (!container?.contains(document.activeElement) && !isFullscreen)
|
||||
return;
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
event.preventDefault();
|
||||
rotateCamera("left");
|
||||
break;
|
||||
case "ArrowRight":
|
||||
event.preventDefault();
|
||||
rotateCamera("right");
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
rotateCamera("up");
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
rotateCamera("down");
|
||||
break;
|
||||
case "Escape":
|
||||
if (isFullscreen) {
|
||||
isFullscreen = false;
|
||||
setTimeout(handleResize, 100);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleControlsPanel() {
|
||||
showControls = !showControls;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (container) {
|
||||
try {
|
||||
initScene();
|
||||
animate();
|
||||
} catch (err) {
|
||||
console.error("WebGL unavailable:", err);
|
||||
loadError = "3D viewer unavailable (WebGL not supported)";
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
}
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="model-viewer {className}"
|
||||
class:fullscreen={isFullscreen}
|
||||
style="
|
||||
--viewer-bg: {$themeColors.terminal};
|
||||
--viewer-border: {$themeColors.border};
|
||||
--viewer-text: {$themeColors.text};
|
||||
--viewer-muted: {$themeColors.textMuted};
|
||||
--viewer-primary: {$themeColors.primary};
|
||||
--viewer-accent: {$themeColors.accent};
|
||||
"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="viewer-header">
|
||||
<div class="header-left">
|
||||
<Icon icon="mdi:cube-outline" width="16" />
|
||||
<span class="model-name">{modelName}</span>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<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"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
title="Reset camera"
|
||||
onclick={resetCamera}
|
||||
>
|
||||
<Icon icon="mdi:camera-flip" width="16" />
|
||||
</button>
|
||||
<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"}
|
||||
onclick={toggleFullscreen}
|
||||
>
|
||||
<Icon
|
||||
icon={isFullscreen
|
||||
? "mdi:fullscreen-exit"
|
||||
: "mdi:fullscreen"}
|
||||
width="16"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extended controls panel -->
|
||||
{#if showControls}
|
||||
<div class="controls-panel">
|
||||
<div class="control-group">
|
||||
<span class="control-label">View</span>
|
||||
<div class="control-buttons">
|
||||
<button
|
||||
class="control-btn small"
|
||||
title="Zoom in"
|
||||
onclick={zoomIn}
|
||||
>
|
||||
<Icon icon="mdi:magnify-plus" width="14" />
|
||||
</button>
|
||||
<button
|
||||
class="control-btn small"
|
||||
title="Zoom out"
|
||||
onclick={zoomOut}
|
||||
>
|
||||
<Icon icon="mdi:magnify-minus" width="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<span class="control-label">Lighting</span>
|
||||
<div class="control-buttons">
|
||||
<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"
|
||||
title="Increase brightness"
|
||||
onclick={() => adjustLighting(0.2)}
|
||||
>
|
||||
<Icon icon="mdi:brightness-7" width="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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"}
|
||||
onclick={toggleWireframe}
|
||||
class:active={wireframe}
|
||||
>
|
||||
<Icon
|
||||
icon={wireframe ? "mdi:cube" : "mdi:cube-outline"}
|
||||
width="14"
|
||||
/>
|
||||
</button>
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
{#if isLoading}
|
||||
<div class="loading-overlay">
|
||||
<Icon icon="mdi:loading" width="32" class="spin" />
|
||||
<span>Loading model...</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if loadError}
|
||||
<div class="error-overlay">
|
||||
<Icon icon="mdi:alert-circle" width="32" />
|
||||
<span>{loadError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer hints -->
|
||||
<div class="viewer-footer">
|
||||
<span class="hint">
|
||||
<Icon icon="mdi:mouse" width="14" />
|
||||
Drag to rotate
|
||||
</span>
|
||||
<span class="hint">
|
||||
<Icon icon="mdi:magnify-plus-minus" width="14" />
|
||||
Scroll to zoom
|
||||
</span>
|
||||
<span class="hint">
|
||||
<Icon icon="mdi:arrow-all" width="14" />
|
||||
Arrow keys
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,144 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { mode, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { user, navigation } from '$lib/config';
|
||||
import Icon from '@iconify/svelte';
|
||||
import '$lib/assets/css/navbar.css';
|
||||
|
||||
let themeDropdownOpen = $state(false);
|
||||
let navExpanded = $state(false);
|
||||
|
||||
function handleThemeSelect(theme: ColorTheme) {
|
||||
setColorTheme(theme);
|
||||
themeDropdownOpen = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
themeDropdownOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getThemeIcon(theme: ColorTheme): string {
|
||||
switch (theme) {
|
||||
case 'arch': return 'mdi:arch';
|
||||
case 'catppuccin': return 'solar:cat-bold';
|
||||
default: return 'mdi:palette';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<nav
|
||||
class="navbar"
|
||||
style="
|
||||
--nav-bg: {$themeColors.backgroundLight};
|
||||
--nav-border: {$themeColors.border};
|
||||
--nav-text: {$themeColors.text};
|
||||
--nav-primary: {$themeColors.primary};
|
||||
--nav-accent: {$themeColors.accent};
|
||||
--nav-muted: {$themeColors.textMuted};
|
||||
"
|
||||
>
|
||||
<div class="nav-container">
|
||||
<!-- Logo / Brand -->
|
||||
<a href="/" class="nav-brand">
|
||||
<span class="terminal-prompt">
|
||||
<span class="user">{user.username}@{user.hostname}</span>
|
||||
<span class="separator">:</span>
|
||||
<span class="path">~</span>
|
||||
<span class="symbol">$</span>
|
||||
</span>
|
||||
<span class="cursor"></span>
|
||||
</a>
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button
|
||||
class="mobile-toggle"
|
||||
onclick={() => navExpanded = !navExpanded}
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<span class="hamburger" class:open={navExpanded}></span>
|
||||
</button>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="nav-links" class:expanded={navExpanded}>
|
||||
{#each navigation as nav}
|
||||
<a href={nav.path} class="nav-link" onclick={() => navExpanded = false}>
|
||||
<span class="link-prefix">./</span>{nav.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Theme Controls -->
|
||||
<div class="nav-controls">
|
||||
<!-- Theme Selector Dropdown -->
|
||||
<div class="theme-selector">
|
||||
<button
|
||||
class="theme-button"
|
||||
onclick={() => themeDropdownOpen = !themeDropdownOpen}
|
||||
aria-expanded={themeDropdownOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span class="theme-icon">
|
||||
<Icon icon={getThemeIcon($colorTheme)} width="18" />
|
||||
</span>
|
||||
<span class="theme-label">{$colorTheme}</span>
|
||||
<Icon icon="mdi:chevron-down" width="16" class="dropdown-arrow {themeDropdownOpen ? 'open' : ''}" />
|
||||
</button>
|
||||
|
||||
{#if themeDropdownOpen}
|
||||
<div
|
||||
class="theme-dropdown"
|
||||
transition:fly={{ y: -10, duration: 200 }}
|
||||
>
|
||||
{#each themeOptions as option}
|
||||
<button
|
||||
class="theme-option"
|
||||
class:active={$colorTheme === option.value}
|
||||
onclick={() => handleThemeSelect(option.value)}
|
||||
>
|
||||
<span class="option-icon">
|
||||
<Icon icon={getThemeIcon(option.value)} width="18" />
|
||||
</span>
|
||||
<span class="option-label">{option.label}</span>
|
||||
{#if $colorTheme === option.value}
|
||||
<Icon icon="mdi:check" width="16" class="check-mark" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dark/Light Mode Toggle -->
|
||||
<button
|
||||
class="mode-toggle"
|
||||
onclick={toggleMode}
|
||||
aria-label="Toggle dark/light mode"
|
||||
title={$mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{#if $mode === 'dark'}
|
||||
<div transition:fade={{ duration: 150 }}>
|
||||
<Icon icon="mdi:white-balance-sunny" width="20" />
|
||||
</div>
|
||||
{:else}
|
||||
<div transition:fade={{ duration: 150 }}>
|
||||
<Icon icon="mdi:moon-waning-crescent" width="20" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop for dropdown -->
|
||||
{#if themeDropdownOpen}
|
||||
<button
|
||||
class="backdrop"
|
||||
transition:fade={{ duration: 150 }}
|
||||
onclick={() => themeDropdownOpen = false}
|
||||
aria-label="Close dropdown"
|
||||
></button>
|
||||
{/if}
|
||||
</nav>
|
||||
@@ -1,532 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { mode, getThemeIcon, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
|
||||
import { page } from '$app/stores';
|
||||
import { fly, fade, slide } from 'svelte/transition';
|
||||
import { user, navigation } from '$lib/config';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import '$lib/assets/css/navbar-waybar.css';
|
||||
|
||||
// State
|
||||
let themeDropdownOpen = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
let currentTime = $state(new Date());
|
||||
|
||||
// Discord presence from layout data
|
||||
let discordStatus = $state('offline');
|
||||
let discordDisplayName: string | null = $state(null);
|
||||
let discordActivities: any[] = $state([]);
|
||||
let discordAvatarUrl: string | null = $state(null);
|
||||
let discordGuildTag: string | null = $state(null);
|
||||
let guildTagBadgeImage: string | null = $state(null);
|
||||
let discordPopoverOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
discordStatus = $page.data?.status ?? 'offline';
|
||||
discordDisplayName = $page.data?.displayName ?? null;
|
||||
discordAvatarUrl = $page.data?.avatarUrl ?? null;
|
||||
discordGuildTag = $page.data?.guildTag ?? null;
|
||||
guildTagBadgeImage = $page.data?.guildTagBadgeImage ?? null;
|
||||
// Filter out Custom Status (type 4)
|
||||
discordActivities = ($page.data?.activities ?? []).filter((a: any) => a.type !== 4);
|
||||
});
|
||||
|
||||
// Derived: first displayable activity (if any)
|
||||
let currentActivity = $derived(discordActivities.length > 0 ? discordActivities[0] : null);
|
||||
|
||||
// Battery state (computed from EST time)
|
||||
let batteryLevel = $state(50);
|
||||
let batteryCharging = $state(false);
|
||||
|
||||
// Derived values (Svelte 5 runes)
|
||||
let currentPath = $derived($page.url.pathname);
|
||||
|
||||
// Time update interval
|
||||
let timeInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
onMount(() => {
|
||||
// Update time every second
|
||||
timeInterval = setInterval(() => {
|
||||
currentTime = new Date();
|
||||
|
||||
// Compute battery using America/New_York timezone (DST-aware)
|
||||
function zoneEpochFromMs(ms: number, timeZone: string) {
|
||||
const date = new Date(ms);
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
const parts = fmt.formatToParts(date).reduce((acc: any, p: any) => {
|
||||
acc[p.type] = p.value;
|
||||
return acc;
|
||||
}, {});
|
||||
const iso = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}Z`;
|
||||
return Date.parse(iso);
|
||||
}
|
||||
|
||||
function computeBatteryFromMs(ms: number) {
|
||||
const dischargeMs = 5 * 3600 * 1000; // 5 hours
|
||||
const chargeMs = 1 * 3600 * 1000; // 1 hour
|
||||
const cycleMs = dischargeMs + chargeMs; // 6 hours
|
||||
// position in cycle using America/New_York wall-clock epoch (DST-aware)
|
||||
const estMs = zoneEpochFromMs(ms, 'America/New_York');
|
||||
let pos = estMs % cycleMs;
|
||||
if (pos < 0) pos += cycleMs;
|
||||
if (pos < dischargeMs) {
|
||||
// discharging: 100 -> 0
|
||||
const pct = 100 - (pos / dischargeMs) * 100;
|
||||
return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: false };
|
||||
} else {
|
||||
// charging: 0 -> 100
|
||||
const chargePos = pos - dischargeMs;
|
||||
const pct = (chargePos / chargeMs) * 100;
|
||||
return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: true };
|
||||
}
|
||||
}
|
||||
|
||||
const { level, charging } = computeBatteryFromMs(Date.now());
|
||||
batteryLevel = level;
|
||||
batteryCharging = charging;
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (timeInterval) clearInterval(timeInterval);
|
||||
});
|
||||
|
||||
// Format time
|
||||
function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Get current workspace/page index
|
||||
function getWorkspaceIndex(path: string): number {
|
||||
const idx = navigation.findIndex(n => n.path === path);
|
||||
return idx >= 0 ? idx + 1 : 1;
|
||||
}
|
||||
|
||||
// Battery icon based on level
|
||||
function getBatteryIcon(level: number, charging: boolean): string {
|
||||
if (charging) return 'mdi:battery-charging';
|
||||
if (level > 90) return 'mdi:battery';
|
||||
if (level > 70) return 'mdi:battery-80';
|
||||
if (level > 50) return 'mdi:battery-60';
|
||||
if (level > 30) return 'mdi:battery-40';
|
||||
if (level > 10) return 'mdi:battery-20';
|
||||
return 'mdi:battery-alert';
|
||||
}
|
||||
|
||||
function handleThemeSelect(theme: ColorTheme) {
|
||||
setColorTheme(theme);
|
||||
themeDropdownOpen = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
themeDropdownOpen = false;
|
||||
mobileMenuOpen = false;
|
||||
discordPopoverOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDiscordPopover() {
|
||||
discordPopoverOpen = !discordPopoverOpen;
|
||||
}
|
||||
|
||||
function getActivityTypeLabel(type: number): string {
|
||||
switch (type) {
|
||||
case 0: return 'Playing';
|
||||
case 1: return 'Streaming';
|
||||
case 2: return 'Listening to';
|
||||
case 3: return 'Watching';
|
||||
case 5: return 'Competing in';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<nav
|
||||
class="waybar"
|
||||
style="
|
||||
--bar-bg: {$themeColors.background};
|
||||
--bar-bg-module: {$themeColors.backgroundLight};
|
||||
--bar-border: {$themeColors.border};
|
||||
--bar-text: {$themeColors.text};
|
||||
--bar-primary: {$themeColors.primary};
|
||||
--bar-accent: {$themeColors.accent};
|
||||
--bar-muted: {$themeColors.textMuted};
|
||||
--bar-success: {$themeColors.colorMap.success};
|
||||
--bar-warning: {$themeColors.colorMap.warning};
|
||||
--bar-error: {$themeColors.colorMap.error};
|
||||
"
|
||||
>
|
||||
<!-- Left modules -->
|
||||
<div class="bar-left">
|
||||
<!-- Arch logo / Launcher -->
|
||||
<!-- <a href="/" class="module launcher" title="Home">
|
||||
<img src="/favicon.png" alt="Blob Icon" width="16" />
|
||||
</a> -->
|
||||
|
||||
<!-- Mobile menu toggle -->
|
||||
<button
|
||||
class="mobile-menu-toggle"
|
||||
onclick={() => mobileMenuOpen = !mobileMenuOpen}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Toggle navigation menu"
|
||||
>
|
||||
<Icon icon={mobileMenuOpen ? 'mdi:close' : 'mdi:menu'} width="20" />
|
||||
</button>
|
||||
|
||||
<!-- Workspaces (desktop) -->
|
||||
<div class="module workspaces desktop-only">
|
||||
{#each navigation as nav}
|
||||
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
|
||||
{#if nav.external}
|
||||
<a
|
||||
href={nav.path}
|
||||
class="workspace external"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={nav.name}
|
||||
>
|
||||
<Icon icon="mdi:open-in-new" width="12" />
|
||||
<span class="ws-name">{nav.name}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={nav.path}
|
||||
class="workspace"
|
||||
class:active={isActive}
|
||||
title={nav.name}
|
||||
>
|
||||
<span class="ws-name">{nav.name}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Current window title (desktop) -->
|
||||
<div class="module window-title desktop-only">
|
||||
<span class="title-icon">
|
||||
{#if currentPath === '/'}
|
||||
<Icon icon="mdi:home" width="14" />
|
||||
{:else if currentPath === '/portfolio'}
|
||||
<Icon icon="mdi:folder-multiple" width="14" />
|
||||
{:else if currentPath === '/models'}
|
||||
<Icon icon="mdi:cube-outline" width="14" />
|
||||
{:else if currentPath === '/projects'}
|
||||
<Icon icon="mdi:trophy" width="14" />
|
||||
{:else}
|
||||
<Icon icon="mdi:file" width="14" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="title-text">
|
||||
{navigation.find(n => n.path === currentPath)?.name || 'page'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center modules -->
|
||||
<div class="bar-center">
|
||||
<div class="module clock">
|
||||
<Icon icon="mdi:clock-outline" width="14" />
|
||||
<span class="time">{formatTime(currentTime)}</span>
|
||||
<span class="date">{formatDate(currentTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right modules -->
|
||||
<div class="bar-right">
|
||||
<!-- (No system modules — minimal Waybar look) -->
|
||||
|
||||
<!-- Discord status with popover -->
|
||||
<div class="module discord-status">
|
||||
<button
|
||||
class="discord-status-btn"
|
||||
onclick={toggleDiscordPopover}
|
||||
title={discordDisplayName ? `${discordDisplayName} — ${discordStatus}` : `Discord: ${discordStatus}`}
|
||||
aria-expanded={discordPopoverOpen}
|
||||
>
|
||||
<span
|
||||
class="status-dot"
|
||||
class:online={discordStatus === 'online'}
|
||||
class:idle={discordStatus === 'idle'}
|
||||
class:dnd={discordStatus === 'dnd'}
|
||||
class:offline={discordStatus === 'offline'}
|
||||
></span>
|
||||
{#if discordDisplayName}
|
||||
<span class="status-text">{discordDisplayName}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if discordPopoverOpen}
|
||||
<div
|
||||
class="discord-popover"
|
||||
transition:fly={{ y: -10, duration: 150 }}
|
||||
>
|
||||
<!-- Header with avatar and name -->
|
||||
<div class="popover-header">
|
||||
<div class="popover-avatar">
|
||||
{#if discordAvatarUrl}
|
||||
<img src={discordAvatarUrl} alt="" class="avatar-img" />
|
||||
{:else}
|
||||
<Icon icon="mdi:account-circle" width="48" />
|
||||
{/if}
|
||||
<span
|
||||
class="popover-status-dot"
|
||||
class:online={discordStatus === 'online'}
|
||||
class:idle={discordStatus === 'idle'}
|
||||
class:dnd={discordStatus === 'dnd'}
|
||||
class:offline={discordStatus === 'offline'}
|
||||
></span>
|
||||
</div>
|
||||
<div class="popover-user-info">
|
||||
<div class="popover-name-row">
|
||||
<span class="popover-username">{discordDisplayName ?? 'Unknown'}</span>
|
||||
{#if discordGuildTag}
|
||||
<span class="popover-clan-tag">
|
||||
<img src={guildTagBadgeImage} alt={discordGuildTag ?? 'Guild'} class="popover-clan-badge" />
|
||||
{discordGuildTag}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="popover-status-label">{discordStatus}</span>
|
||||
</div>
|
||||
<button class="popover-close" onclick={() => discordPopoverOpen = false} aria-label="Close">
|
||||
<Icon icon="mdi:close" width="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="popover-divider"></div>
|
||||
|
||||
<!-- Activities -->
|
||||
{#if discordActivities.length > 0}
|
||||
<div class="popover-activities">
|
||||
{#each discordActivities as activity}
|
||||
<div class="activity-card">
|
||||
<div class="activity-type-label">{getActivityTypeLabel(activity.type)}</div>
|
||||
<div class="activity-content">
|
||||
{#if activity.assets?.large_image || activity.app_icon}
|
||||
<div class="activity-images">
|
||||
<img
|
||||
src={activity.assets?.large_image ?? activity.app_icon}
|
||||
alt=""
|
||||
class="activity-large-image"
|
||||
/>
|
||||
{#if activity.assets?.small_image}
|
||||
<img
|
||||
src={activity.assets.small_image}
|
||||
alt=""
|
||||
class="activity-small-image"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="activity-info">
|
||||
<span class="activity-name">{activity.name}</span>
|
||||
{#if activity.details}
|
||||
<span class="activity-details">{activity.details}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="popover-no-activity">
|
||||
<Icon icon="mdi:sleep" width="24" />
|
||||
<span>No current activity</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Battery module (EST-based simulated battery cycle) -->
|
||||
<div class="module battery" title="Battery">
|
||||
<button
|
||||
class="battery-btn"
|
||||
aria-label={`Battery ${batteryLevel}% ${batteryCharging ? 'charging' : 'discharging'}`}
|
||||
class:low={batteryLevel < 20 && !batteryCharging}
|
||||
class:charging={batteryCharging}
|
||||
>
|
||||
<Icon icon={getBatteryIcon(batteryLevel, batteryCharging)} width="16" />
|
||||
<span class="battery-level" class:battery-alert={batteryLevel < 20 && !batteryCharging} class:charging={batteryCharging}>{batteryLevel}%</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Theme selector -->
|
||||
<div class="module theme-selector">
|
||||
<button
|
||||
class="theme-trigger"
|
||||
onclick={() => themeDropdownOpen = !themeDropdownOpen}
|
||||
aria-expanded={themeDropdownOpen}
|
||||
title="Theme: {$colorTheme}"
|
||||
>
|
||||
<Icon icon={getThemeIcon($colorTheme)} width="16" />
|
||||
</button>
|
||||
|
||||
{#if themeDropdownOpen}
|
||||
<div
|
||||
class="theme-dropdown"
|
||||
transition:fly={{ y: -10, duration: 150 }}
|
||||
>
|
||||
<div class="dropdown-header">Theme</div>
|
||||
{#each themeOptions as option, i}
|
||||
<button
|
||||
class="theme-option"
|
||||
class:active={$colorTheme === option.value}
|
||||
onclick={() => handleThemeSelect(option.value)}
|
||||
>
|
||||
<Icon icon={getThemeIcon(option.value)} width="16" />
|
||||
<span class="theme-label">
|
||||
{option.label} <span class="theme-number">[{i+1}]</span>
|
||||
</span>
|
||||
{#if $colorTheme === option.value}
|
||||
<Icon icon="mdi:check" width="14" class="check" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="dropdown-divider"></div>
|
||||
<div class="dropdown-header">Mode</div>
|
||||
<button class="theme-option" onclick={toggleMode}>
|
||||
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="16" />
|
||||
<span>{$mode === 'dark' ? 'Light Mode' : 'Dark Mode'} [T]</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop for dropdowns -->
|
||||
{#if themeDropdownOpen || discordPopoverOpen}
|
||||
<button
|
||||
class="backdrop"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onclick={() => { themeDropdownOpen = false; discordPopoverOpen = false; }}
|
||||
aria-label="Close"
|
||||
></button>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu backdrop (separate, behind the menu) -->
|
||||
{#if mobileMenuOpen}
|
||||
<button
|
||||
class="mobile-backdrop"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onclick={() => { mobileMenuOpen = false; }}
|
||||
aria-label="Close menu"
|
||||
style="
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
top: var(--navbar-height);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 997;
|
||||
border: none;
|
||||
cursor: default;
|
||||
"
|
||||
></button>
|
||||
{/if}
|
||||
|
||||
<!-- Mobile menu dropdown -->
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
class="mobile-menu"
|
||||
transition:slide={{ duration: 200 }}
|
||||
style="
|
||||
--bar-bg: {$themeColors.background};
|
||||
--bar-bg-module: {$themeColors.backgroundLight};
|
||||
--bar-border: {$themeColors.border};
|
||||
--bar-text: {$themeColors.text};
|
||||
--bar-primary: {$themeColors.primary};
|
||||
--bar-accent: {$themeColors.accent};
|
||||
--bar-muted: {$themeColors.textMuted};
|
||||
"
|
||||
>
|
||||
<div class="mobile-nav-links">
|
||||
{#each navigation as nav}
|
||||
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
|
||||
{#if nav.external}
|
||||
<a
|
||||
href={nav.path}
|
||||
class="mobile-nav-link external"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={closeMobileMenu}
|
||||
>
|
||||
<Icon icon="mdi:open-in-new" width="16" />
|
||||
<span>{nav.name}</span>
|
||||
</a>
|
||||
{:else}
|
||||
<a
|
||||
href={nav.path}
|
||||
class="mobile-nav-link"
|
||||
class:active={isActive}
|
||||
onclick={closeMobileMenu}
|
||||
>
|
||||
{#if nav.path === '/'}
|
||||
<Icon icon="mdi:home" width="16" />
|
||||
{:else if nav.path === '/portfolio'}
|
||||
<Icon icon="mdi:folder-multiple" width="16" />
|
||||
{:else if nav.path === '/models'}
|
||||
<Icon icon="mdi:cube-outline" width="16" />
|
||||
{:else if nav.path === '/projects'}
|
||||
<Icon icon="mdi:trophy" width="16" />
|
||||
{:else}
|
||||
<Icon icon="mdi:file" width="16" />
|
||||
{/if}
|
||||
<span>{nav.name}</span>
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mobile-menu-divider"></div>
|
||||
|
||||
<div class="mobile-theme-section">
|
||||
<div class="mobile-section-header">Theme</div>
|
||||
<div class="mobile-theme-options">
|
||||
{#each themeOptions as option, i}
|
||||
<button
|
||||
class="mobile-theme-btn"
|
||||
class:active={$colorTheme === option.value}
|
||||
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
|
||||
>
|
||||
<Icon icon={getThemeIcon(option.value)} width="18" />
|
||||
<span class="theme-label">{option.label} <span class="theme-number">[{i+1}]</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="mobile-mode-btn" onclick={() => { toggleMode(); closeMobileMenu(); }}>
|
||||
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="18" />
|
||||
<span>{$mode === 'dark' ? 'Switch to Light' : 'Switch to Dark'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,111 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { T, useTask } from '@threlte/core';
|
||||
import { onMount } from 'svelte';
|
||||
import * as THREE from 'three';
|
||||
import { themeColors, mode } from '$lib/stores/theme';
|
||||
|
||||
interface Props {
|
||||
particleCount?: number;
|
||||
}
|
||||
|
||||
let { particleCount = 500 }: Props = $props();
|
||||
|
||||
let particlesGeometry: THREE.BufferGeometry;
|
||||
let particlesMaterial: THREE.PointsMaterial;
|
||||
let particles = $state<THREE.Points | null>(null);
|
||||
let positions: Float32Array;
|
||||
let velocities: Float32Array;
|
||||
let time = 0;
|
||||
|
||||
// Reactive theme values - use accent or a darker color for light mode visibility
|
||||
const currentMode = $derived($mode);
|
||||
const particleColor = $derived(
|
||||
currentMode === 'dark'
|
||||
? $themeColors.primary
|
||||
: $themeColors.accent || $themeColors.primary
|
||||
);
|
||||
|
||||
// Create particles
|
||||
function createParticles() {
|
||||
positions = new Float32Array(particleCount * 3);
|
||||
velocities = new Float32Array(particleCount * 3);
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const i3 = i * 3;
|
||||
// Spread particles in a larger area
|
||||
positions[i3] = (Math.random() - 0.5) * 20;
|
||||
positions[i3 + 1] = (Math.random() - 0.5) * 20;
|
||||
positions[i3 + 2] = (Math.random() - 0.5) * 20;
|
||||
|
||||
// Random velocities
|
||||
velocities[i3] = (Math.random() - 0.5) * 0.01;
|
||||
velocities[i3 + 1] = (Math.random() - 0.5) * 0.01;
|
||||
velocities[i3 + 2] = (Math.random() - 0.5) * 0.01;
|
||||
}
|
||||
|
||||
particlesGeometry = new THREE.BufferGeometry();
|
||||
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
|
||||
particlesMaterial = new THREE.PointsMaterial({
|
||||
size: 0.05,
|
||||
color: new THREE.Color(particleColor),
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: currentMode === 'dark' ? 0.6 : 0.8,
|
||||
blending: currentMode === 'dark' ? THREE.AdditiveBlending : THREE.NormalBlending,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
particles = new THREE.Points(particlesGeometry, particlesMaterial);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createParticles();
|
||||
});
|
||||
|
||||
// Update particle color and opacity based on theme
|
||||
$effect(() => {
|
||||
if (particlesMaterial) {
|
||||
particlesMaterial.color.set(particleColor);
|
||||
particlesMaterial.opacity = currentMode === 'dark' ? 0.6 : 0.8;
|
||||
particlesMaterial.blending = currentMode === 'dark' ? THREE.AdditiveBlending : THREE.NormalBlending;
|
||||
particlesMaterial.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Animation loop
|
||||
useTask((delta) => {
|
||||
if (!particles || !particlesGeometry) return;
|
||||
|
||||
time += delta;
|
||||
const positionAttribute = particlesGeometry.getAttribute('position') as THREE.BufferAttribute;
|
||||
const positionArray = positionAttribute.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const i3 = i * 3;
|
||||
|
||||
// Add some wave motion
|
||||
positionArray[i3] += velocities[i3] + Math.sin(time + i * 0.1) * 0.001;
|
||||
positionArray[i3 + 1] += velocities[i3 + 1] + Math.cos(time + i * 0.1) * 0.001;
|
||||
positionArray[i3 + 2] += velocities[i3 + 2];
|
||||
|
||||
// Wrap around boundaries
|
||||
if (positionArray[i3] > 10) positionArray[i3] = -10;
|
||||
if (positionArray[i3] < -10) positionArray[i3] = 10;
|
||||
if (positionArray[i3 + 1] > 10) positionArray[i3 + 1] = -10;
|
||||
if (positionArray[i3 + 1] < -10) positionArray[i3 + 1] = 10;
|
||||
if (positionArray[i3 + 2] > 10) positionArray[i3 + 2] = -10;
|
||||
if (positionArray[i3 + 2] < -10) positionArray[i3 + 2] = 10;
|
||||
}
|
||||
|
||||
positionAttribute.needsUpdate = true;
|
||||
|
||||
// Rotate the entire particle system slowly
|
||||
particles.rotation.y += delta * 0.05;
|
||||
particles.rotation.x += delta * 0.02;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if particles}
|
||||
<T is={particles} />
|
||||
{/if}
|
||||
@@ -1,167 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { themeColors } from '$lib/stores/theme';
|
||||
import '$lib/assets/css/terminal.css';
|
||||
|
||||
interface Props {
|
||||
lines?: TerminalLine[];
|
||||
typeSpeed?: number;
|
||||
startDelay?: number;
|
||||
showCursor?: boolean;
|
||||
autoType?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
interface TerminalLine {
|
||||
type: 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info';
|
||||
content: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
lines = [],
|
||||
typeSpeed = 30,
|
||||
startDelay = 500,
|
||||
showCursor = true,
|
||||
autoType = true,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
|
||||
let displayedLines = $state<{ line: TerminalLine; text: string; complete: boolean }[]>([]);
|
||||
let currentLineIndex = $state(0);
|
||||
let currentCharIndex = $state(0);
|
||||
let isTyping = $state(false);
|
||||
let terminalElement: HTMLDivElement;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function typeText() {
|
||||
if (!autoType || lines.length === 0) {
|
||||
displayedLines = lines.map(line => ({ line, text: line.content, complete: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
isTyping = true;
|
||||
await sleep(startDelay);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
displayedLines = [...displayedLines, { line, text: '', complete: false }];
|
||||
currentLineIndex = i;
|
||||
|
||||
if (line.delay) {
|
||||
await sleep(line.delay);
|
||||
}
|
||||
|
||||
// Type out the line character by character
|
||||
for (let j = 0; j <= line.content.length; j++) {
|
||||
currentCharIndex = j;
|
||||
displayedLines[i] = {
|
||||
line,
|
||||
text: line.content.slice(0, j),
|
||||
complete: j === line.content.length
|
||||
};
|
||||
|
||||
// Scroll to bottom
|
||||
if (terminalElement) {
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
}
|
||||
|
||||
if (j < line.content.length) {
|
||||
await sleep(line.type === 'output' ? typeSpeed / 3 : typeSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
displayedLines[i].complete = true;
|
||||
|
||||
// Pause between lines
|
||||
if (i < lines.length - 1) {
|
||||
await sleep(150);
|
||||
}
|
||||
}
|
||||
|
||||
isTyping = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
typeText();
|
||||
});
|
||||
|
||||
function getLinePrefix(type: TerminalLine['type']): string {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
case 'prompt':
|
||||
return '';
|
||||
case 'error':
|
||||
return '✗ ';
|
||||
case 'success':
|
||||
return '✓ ';
|
||||
case 'info':
|
||||
return 'ℹ ';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="terminal {className}"
|
||||
style="
|
||||
--terminal-bg: {$themeColors.terminal};
|
||||
--terminal-text: {$themeColors.text};
|
||||
--terminal-muted: {$themeColors.textMuted};
|
||||
--terminal-border: {$themeColors.border};
|
||||
--terminal-prompt: {$themeColors.terminalPrompt};
|
||||
--terminal-user: {$themeColors.terminalUser};
|
||||
--terminal-path: {$themeColors.terminalPath};
|
||||
--terminal-primary: {$themeColors.primary};
|
||||
--terminal-secondary: {$themeColors.secondary};
|
||||
--terminal-accent: {$themeColors.accent};
|
||||
--terminal-bg-light: {$themeColors.backgroundLight};
|
||||
"
|
||||
bind:this={terminalElement}
|
||||
>
|
||||
<!-- Terminal Header -->
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-buttons">
|
||||
<span class="btn close"></span>
|
||||
<span class="btn minimize"></span>
|
||||
<span class="btn maximize"></span>
|
||||
</div>
|
||||
<div class="terminal-title">
|
||||
<span class="terminal-icon">🐧</span>
|
||||
<span>arch@terminal: ~</span>
|
||||
</div>
|
||||
<div class="terminal-spacer"></div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Body -->
|
||||
<div class="terminal-body">
|
||||
{#each displayedLines as { line, text, complete }, i}
|
||||
<div class="terminal-line {line.type}">
|
||||
{#if line.type === 'command' || line.type === 'prompt'}
|
||||
<span class="prompt">
|
||||
<span class="user">user@arch</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="content">
|
||||
{getLinePrefix(line.type)}{text}
|
||||
</span>
|
||||
{#if showCursor && i === currentLineIndex && !complete && isTyping}
|
||||
<span class="cursor"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if showCursor && !isTyping && displayedLines.length > 0}
|
||||
<div class="terminal-line prompt">
|
||||
<span class="prompt">
|
||||
<span class="user">user@arch</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
|
||||
</span>
|
||||
<span class="cursor"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,221 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { themeColors } from '$lib/stores/theme';
|
||||
import { user, terminalSettings } from '$lib/config';
|
||||
import { calculateTypeSpeed } from '$lib';
|
||||
import '$lib/assets/css/terminal-page.css';
|
||||
|
||||
export type LineType = 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'image' | 'blank' | 'header';
|
||||
|
||||
export interface TerminalLine {
|
||||
type: LineType;
|
||||
content: string;
|
||||
delay?: number;
|
||||
image?: string; // URL for image type
|
||||
imageAlt?: string;
|
||||
imageWidth?: number; // max width in pixels
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lines?: TerminalLine[];
|
||||
title?: string;
|
||||
showHeader?: boolean;
|
||||
class?: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
lines = [],
|
||||
title = 'terminal',
|
||||
showHeader = true,
|
||||
class: className = '',
|
||||
onComplete
|
||||
}: Props = $props();
|
||||
|
||||
let displayedLines = $state<{ line: TerminalLine; text: string; complete: boolean; showImage: boolean }[]>([]);
|
||||
let currentLineIndex = $state(0);
|
||||
let isTyping = $state(false);
|
||||
let isComplete = $state(false);
|
||||
let terminalElement: HTMLDivElement;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function typeText() {
|
||||
if (lines.length === 0) return;
|
||||
|
||||
isTyping = true;
|
||||
await sleep(terminalSettings.startDelay);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
displayedLines = [...displayedLines, { line, text: '', complete: false, showImage: false }];
|
||||
currentLineIndex = i;
|
||||
|
||||
if (line.delay) {
|
||||
await sleep(line.delay);
|
||||
}
|
||||
|
||||
// Handle different line types
|
||||
if (line.type === 'image') {
|
||||
// For images, show immediately after a brief pause
|
||||
await sleep(100);
|
||||
displayedLines[i] = { line, text: line.content, complete: true, showImage: true };
|
||||
} else if (line.type === 'blank') {
|
||||
// Blank lines are instant
|
||||
displayedLines[i] = { line, text: '', complete: true, showImage: false };
|
||||
} else if (line.type === 'header') {
|
||||
// Headers type out faster
|
||||
const typeSpeed = 5;
|
||||
for (let j = 0; j <= line.content.length; j++) {
|
||||
displayedLines[i] = {
|
||||
line,
|
||||
text: line.content.slice(0, j),
|
||||
complete: j === line.content.length,
|
||||
showImage: false
|
||||
};
|
||||
if (j < line.content.length) await sleep(typeSpeed);
|
||||
}
|
||||
} else {
|
||||
// Calculate typing speed based on content length
|
||||
const typeSpeed = calculateTypeSpeed(line.content.length);
|
||||
|
||||
// Type out the line character by character
|
||||
for (let j = 0; j <= line.content.length; j++) {
|
||||
displayedLines[i] = {
|
||||
line,
|
||||
text: line.content.slice(0, j),
|
||||
complete: j === line.content.length,
|
||||
showImage: false
|
||||
};
|
||||
|
||||
// Scroll to bottom
|
||||
if (terminalElement) {
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
}
|
||||
|
||||
if (j < line.content.length) {
|
||||
await sleep(typeSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
displayedLines[i].complete = true;
|
||||
|
||||
// Scroll to bottom after each line
|
||||
if (terminalElement) {
|
||||
terminalElement.scrollTop = terminalElement.scrollHeight;
|
||||
}
|
||||
|
||||
// Pause between lines
|
||||
if (i < lines.length - 1) {
|
||||
await sleep(terminalSettings.lineDelay);
|
||||
}
|
||||
}
|
||||
|
||||
isTyping = false;
|
||||
isComplete = true;
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
typeText();
|
||||
});
|
||||
|
||||
function getLinePrefix(type: LineType): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return '✗ ';
|
||||
case 'success':
|
||||
return '✓ ';
|
||||
case 'info':
|
||||
return 'ℹ ';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getPromptString(): string {
|
||||
return `${user.username}@${user.hostname}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="terminal-page {className}"
|
||||
style="
|
||||
--terminal-bg: {$themeColors.terminal};
|
||||
--terminal-text: {$themeColors.text};
|
||||
--terminal-muted: {$themeColors.textMuted};
|
||||
--terminal-border: {$themeColors.border};
|
||||
--terminal-prompt: {$themeColors.terminalPrompt};
|
||||
--terminal-user: {$themeColors.terminalUser};
|
||||
--terminal-path: {$themeColors.terminalPath};
|
||||
--terminal-primary: {$themeColors.primary};
|
||||
--terminal-secondary: {$themeColors.secondary};
|
||||
--terminal-accent: {$themeColors.accent};
|
||||
--terminal-bg-light: {$themeColors.backgroundLight};
|
||||
"
|
||||
bind:this={terminalElement}
|
||||
>
|
||||
{#if showHeader}
|
||||
<!-- Terminal Header -->
|
||||
<div class="terminal-header">
|
||||
<div class="terminal-buttons">
|
||||
<span class="btn close"></span>
|
||||
<span class="btn minimize"></span>
|
||||
<span class="btn maximize"></span>
|
||||
</div>
|
||||
<div class="terminal-title">
|
||||
<span class="terminal-icon">🐧</span>
|
||||
<span>{getPromptString()}: {title}</span>
|
||||
</div>
|
||||
<div class="terminal-spacer"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Terminal Body -->
|
||||
<div class="terminal-body">
|
||||
{#each displayedLines as { line, text, complete, showImage }, i}
|
||||
<div class="terminal-line {line.type}" class:complete>
|
||||
{#if line.type === 'command' || line.type === 'prompt'}
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}@{user.hostname}</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if line.type === 'image' && showImage}
|
||||
<div class="terminal-image">
|
||||
<img
|
||||
src={line.image}
|
||||
alt={line.imageAlt || 'Terminal image'}
|
||||
style="max-width: {line.imageWidth || 300}px"
|
||||
/>
|
||||
{#if line.content}
|
||||
<span class="image-caption">{line.content}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if line.type === 'header'}
|
||||
<span class="content header-text">{text}</span>
|
||||
{:else if line.type !== 'blank'}
|
||||
<span class="content">
|
||||
{getLinePrefix(line.type)}{text}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
|
||||
<span class="cursor"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if terminalSettings.showCursor && !isTyping && isComplete}
|
||||
<div class="terminal-line prompt">
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}@{user.hostname}</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
|
||||
</span>
|
||||
<span class="cursor"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,358 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
themeColors,
|
||||
mode,
|
||||
colorTheme,
|
||||
toggleMode,
|
||||
setColorTheme,
|
||||
themeOptions,
|
||||
} from "$lib/stores/theme";
|
||||
import {
|
||||
terminalSettings,
|
||||
user,
|
||||
skills,
|
||||
navigation,
|
||||
type SpeedPreset,
|
||||
speedPresets,
|
||||
} from "$lib/config";
|
||||
import { runCommand, type CommandContext } from "./tui/terminal-commands";
|
||||
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 type {
|
||||
TerminalLine,
|
||||
ParsedLine,
|
||||
DisplayedLine,
|
||||
TerminalAPI,
|
||||
} from "./tui/types";
|
||||
|
||||
interface Props {
|
||||
lines?: TerminalLine[];
|
||||
title?: string;
|
||||
class?: string;
|
||||
onComplete?: () => void;
|
||||
interactive?: boolean;
|
||||
speed?: SpeedPreset | number;
|
||||
autoscroll?: boolean;
|
||||
terminal?: TerminalAPI;
|
||||
enableCommands?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
lines = $bindable([]),
|
||||
title = "terminal",
|
||||
class: className = "",
|
||||
onComplete,
|
||||
interactive = true,
|
||||
speed = "normal",
|
||||
autoscroll = true,
|
||||
terminal = $bindable(),
|
||||
enableCommands = true,
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate speed multiplier from preset or number
|
||||
const speedMultiplier = $derived(
|
||||
typeof speed === "number" ? speed : (speedPresets[speed] ?? 1),
|
||||
);
|
||||
|
||||
// Get colorMap from current theme
|
||||
const colorMap = $derived($themeColors.colorMap);
|
||||
|
||||
// Pre-parse all lines upfront (segments + plain text)
|
||||
const parsedLines = $derived<ParsedLine[]>(
|
||||
lines.map((line) => parseLine(line, colorMap)),
|
||||
);
|
||||
|
||||
let displayedLines = $state<DisplayedLine[]>([]);
|
||||
let currentLineIndex = $state(0);
|
||||
let isTyping = $state(false);
|
||||
let isComplete = $state(false);
|
||||
let selectedIndex = $state(-1);
|
||||
let skipRequested = $state(false);
|
||||
let terminalElement: HTMLDivElement;
|
||||
let bodyElement = $state<HTMLDivElement>();
|
||||
|
||||
// Track colorMap identity to detect theme/mode changes
|
||||
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
|
||||
) {
|
||||
// Store current showImage states before updating
|
||||
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",
|
||||
}));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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),
|
||||
);
|
||||
|
||||
// ========================================================================
|
||||
// TYPING ANIMATION
|
||||
// ========================================================================
|
||||
|
||||
function typeText() {
|
||||
runTypingAnimation({
|
||||
parsedLines,
|
||||
speedMultiplier,
|
||||
terminalSettings,
|
||||
buttonIndices,
|
||||
interactive,
|
||||
autoscroll,
|
||||
getBodyElement: () => bodyElement,
|
||||
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.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;
|
||||
},
|
||||
onComplete,
|
||||
scrollToHash: () => scrollToHash(bodyElement),
|
||||
});
|
||||
}
|
||||
|
||||
function skipAnimation() {
|
||||
skipTypingAnimation(
|
||||
parsedLines,
|
||||
buttonIndices,
|
||||
interactive,
|
||||
() => ({
|
||||
displayedLines,
|
||||
currentLineIndex,
|
||||
isTyping,
|
||||
isComplete,
|
||||
skipRequested,
|
||||
selectedIndex,
|
||||
}),
|
||||
(updates) => {
|
||||
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;
|
||||
},
|
||||
() => bodyElement,
|
||||
autoscroll,
|
||||
onComplete,
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// TERMINAL API
|
||||
// ========================================================================
|
||||
|
||||
terminal = createTerminalAPI({
|
||||
getState: () => ({
|
||||
lines,
|
||||
displayedLines,
|
||||
isTyping,
|
||||
isComplete,
|
||||
currentLineIndex,
|
||||
selectedIndex,
|
||||
skipRequested,
|
||||
}),
|
||||
setState: (updates) => {
|
||||
if (updates.lines !== undefined) lines = updates.lines;
|
||||
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;
|
||||
},
|
||||
getColorMap: () => colorMap,
|
||||
getBodyElement: () => bodyElement,
|
||||
typeText,
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// COMMAND HANDLING
|
||||
// ========================================================================
|
||||
|
||||
const commandContext: CommandContext = {
|
||||
terminal: terminal!,
|
||||
navigate: (path, external) => {
|
||||
if (external) window.open(path, "_blank", "noopener,noreferrer");
|
||||
else goto(path);
|
||||
},
|
||||
toggleMode,
|
||||
setColorTheme: (name) => setColorTheme(name as any),
|
||||
getMode: () => get(mode),
|
||||
getColorTheme: () => get(colorTheme),
|
||||
themeNames: themeOptions.map((option) => option.value),
|
||||
user,
|
||||
skills,
|
||||
navigation,
|
||||
};
|
||||
|
||||
function handleCommand(input: string) {
|
||||
runCommand(input, commandContext);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// KEYBOARD HANDLING
|
||||
// ========================================================================
|
||||
|
||||
const handleKeydown = createKeyboardHandler({
|
||||
getIsTyping: () => isTyping,
|
||||
getIsComplete: () => isComplete,
|
||||
getInteractive: () => interactive,
|
||||
getButtonIndices: () => buttonIndices,
|
||||
getSelectedIndex: () => selectedIndex,
|
||||
setSelectedIndex: (index) => {
|
||||
selectedIndex = index;
|
||||
},
|
||||
getDisplayedLines: () => displayedLines,
|
||||
getBodyElement: () => bodyElement,
|
||||
skipAnimation,
|
||||
scrollMargin: terminalSettings.scrollMargin,
|
||||
});
|
||||
|
||||
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 as any);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
typeText();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<div
|
||||
class="tui-terminal {className}"
|
||||
style="
|
||||
--terminal-bg: {$themeColors.terminal};
|
||||
--terminal-text: {$themeColors.text};
|
||||
--terminal-muted: {$themeColors.textMuted};
|
||||
--terminal-border: {$themeColors.border};
|
||||
--terminal-prompt: {$themeColors.terminalPrompt};
|
||||
--terminal-user: {$themeColors.terminalUser};
|
||||
--terminal-path: {$themeColors.terminalPath};
|
||||
--terminal-primary: {$themeColors.primary};
|
||||
--terminal-secondary: {$themeColors.secondary};
|
||||
--terminal-accent: {$themeColors.accent};
|
||||
--terminal-bg-light: {$themeColors.backgroundLight};
|
||||
"
|
||||
bind:this={terminalElement}
|
||||
role="region"
|
||||
aria-label="Terminal interface"
|
||||
>
|
||||
<!-- Hyprland-style border glow -->
|
||||
<div class="tui-border-glow"></div>
|
||||
|
||||
<!-- TUI Content -->
|
||||
<div class="tui-content">
|
||||
<TuiHeader
|
||||
{title}
|
||||
{interactive}
|
||||
hasButtons={buttonIndices.length > 0}
|
||||
/>
|
||||
|
||||
<!-- Main terminal area -->
|
||||
<TuiBody
|
||||
bind:ref={bodyElement}
|
||||
{displayedLines}
|
||||
{currentLineIndex}
|
||||
{isTyping}
|
||||
{selectedIndex}
|
||||
onButtonClick={handleButtonClick}
|
||||
onHoverButton={(i) => (selectedIndex = i)}
|
||||
onLinkClick={handleLinkClick}
|
||||
{terminalSettings}
|
||||
interactiveInput={enableCommands && interactive}
|
||||
onCommand={handleCommand}
|
||||
/>
|
||||
|
||||
<TuiFooter
|
||||
{isTyping}
|
||||
linesCount={displayedLines.length}
|
||||
{skipAnimation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
let { html }: { html: string } = $props();
|
||||
|
||||
let contentElement = $state<HTMLDivElement>();
|
||||
|
||||
function copyCode(code: string, button: HTMLButtonElement) {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
button.textContent = 'copied';
|
||||
setTimeout(() => (button.textContent = 'copy'), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
html;
|
||||
contentElement?.querySelectorAll('pre').forEach((block) => {
|
||||
const code = block.querySelector('code');
|
||||
if (!code || block.querySelector('.copy-button')) return;
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'copy-button';
|
||||
button.textContent = 'copy';
|
||||
button.addEventListener('click', () => copyCode(code.textContent ?? '', button));
|
||||
block.appendChild(button);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="post-content" bind:this={contentElement}>
|
||||
{@html html}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.post-content {
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.post-content :global(h1),
|
||||
.post-content :global(h2),
|
||||
.post-content :global(h3) {
|
||||
margin: 1.75rem 0 0.75rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.post-content :global(h2::before) {
|
||||
content: '## ';
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.post-content :global(p) {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.post-content :global(a) {
|
||||
color: var(--blue);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.post-content :global(code) {
|
||||
padding: 0.1rem 0.35rem;
|
||||
background: var(--lighter-background);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.post-content :global(pre) {
|
||||
position: relative;
|
||||
margin: 0 0 1rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
background: var(--dark-background);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
}
|
||||
|
||||
.post-content :global(pre code) {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.post-content :global(.copy-button) {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
background: var(--background);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.post-content :global(.copy-button:hover) {
|
||||
border-color: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.post-content :global(.tok-keyword) {
|
||||
color: var(--magenta);
|
||||
}
|
||||
|
||||
.post-content :global(.tok-string) {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.post-content :global(.tok-comment) {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.post-content :global(.tok-number) {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.post-content :global(blockquote) {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.25rem 0 0.25rem 1rem;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.post-content :global(ul),
|
||||
.post-content :global(ol) {
|
||||
margin: 0 0 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.post-content :global(ul) {
|
||||
list-style: square;
|
||||
}
|
||||
|
||||
.post-content :global(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.post-content :global(hr) {
|
||||
margin: 1.75rem 0;
|
||||
border: none;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { dwindleLayout, fullscreenLayout, tileStyle, type TileRect } from '#lib/desktop/tiling';
|
||||
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
|
||||
import { closePage } from '#lib/desktop/actions';
|
||||
import Terminal from '#lib/components/terminal/Terminal.svelte';
|
||||
import DesktopIcons from './DesktopIcons.svelte';
|
||||
import Window from './Window.svelte';
|
||||
|
||||
let {
|
||||
pageOpen,
|
||||
pageTitle,
|
||||
pageIcon,
|
||||
pageKey,
|
||||
children
|
||||
}: { pageOpen: boolean; pageTitle: string; pageIcon: string; pageKey: string; children: Snippet } =
|
||||
$props();
|
||||
|
||||
const gapInPixels = 10;
|
||||
const narrowWidth = 768;
|
||||
|
||||
let areaWidth = $state(1280);
|
||||
let areaHeight = $state(720);
|
||||
|
||||
const isNarrow = $derived(areaWidth < narrowWidth);
|
||||
const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.terminals.map((terminal) => terminal.id)]);
|
||||
|
||||
const layout = $derived.by(() => {
|
||||
const rects = new Map<string, TileRect>();
|
||||
const soloId = windowManager.maximizedId ?? (isNarrow ? windowManager.focusedId : null);
|
||||
if (soloId && openWindowIds.includes(soloId)) {
|
||||
rects.set(soloId, fullscreenLayout());
|
||||
return rects;
|
||||
}
|
||||
dwindleLayout(openWindowIds.length, areaWidth, areaHeight).forEach((rect, index) => rects.set(openWindowIds[index], rect));
|
||||
return rects;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (openWindowIds.length && !openWindowIds.includes(windowManager.focusedId)) {
|
||||
windowManager.focus(openWindowIds.at(-1)!);
|
||||
}
|
||||
});
|
||||
|
||||
function styleFor(id: string): string {
|
||||
const rect = layout.get(id);
|
||||
return rect ? tileStyle(rect, gapInPixels) : 'display: none';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="desktop" class:narrow={isNarrow}>
|
||||
{#if isNarrow && openWindowIds.length > 1}
|
||||
<nav class="window-tabs" aria-label="Open windows">
|
||||
{#each openWindowIds as id (id)}
|
||||
<button class:active={windowManager.focusedId === id} onclick={() => windowManager.focus(id)}>
|
||||
{id === pageWindowId ? pageTitle : id}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<div class="icons-layer">
|
||||
<DesktopIcons />
|
||||
</div>
|
||||
|
||||
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
||||
{#if pageOpen}
|
||||
<Window
|
||||
title="kitty"
|
||||
path={pageTitle}
|
||||
icon={pageIcon}
|
||||
focused={windowManager.focusedId === pageWindowId}
|
||||
tileStyle={styleFor(pageWindowId)}
|
||||
scrollKey={pageKey}
|
||||
onfocus={() => windowManager.focus(pageWindowId)}
|
||||
onclose={closePage}
|
||||
>
|
||||
{@render children()}
|
||||
</Window>
|
||||
{/if}
|
||||
|
||||
{#each windowManager.terminals as terminal (terminal.id)}
|
||||
<Window
|
||||
title="kitty"
|
||||
path="~"
|
||||
focused={windowManager.focusedId === terminal.id}
|
||||
tileStyle={styleFor(terminal.id)}
|
||||
onfocus={() => windowManager.focus(terminal.id)}
|
||||
onclose={() => windowManager.closeTerminal(terminal.id)}
|
||||
>
|
||||
<Terminal
|
||||
startupCommand={terminal.startupCommand}
|
||||
focused={windowManager.focusedId === terminal.id}
|
||||
onexit={() => windowManager.closeTerminal(terminal.id)}
|
||||
/>
|
||||
</Window>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.desktop {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: calc(var(--gaps-out) - 4px);
|
||||
}
|
||||
|
||||
.icons-layer {
|
||||
position: absolute;
|
||||
inset: calc(var(--gaps-out) - 4px);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.tiling-area {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tiling-area > :global(.window) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.desktop.narrow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.window-tabs {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 4px 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.window-tabs button {
|
||||
flex-shrink: 0;
|
||||
padding: 0.15rem 0.6rem;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border-inactive);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.window-tabs button.active {
|
||||
border-color: var(--border-active-solid);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.icons-layer :global(.desktop-icons) {
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(auto-fill, 112px);
|
||||
grid-template-rows: none;
|
||||
grid-auto-rows: 104px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { desktop } from '#lib/content';
|
||||
import { runDesktopAction } from '#lib/desktop/actions';
|
||||
</script>
|
||||
|
||||
<nav class="desktop-icons" aria-label="Desktop">
|
||||
{#each desktop.icons as item (item.label)}
|
||||
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
|
||||
<span class="icon-box">
|
||||
<Icon icon={item.icon} width="36" />
|
||||
</span>
|
||||
<span class="icon-label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.desktop-icons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: repeat(auto-fill, 104px);
|
||||
grid-auto-columns: 112px;
|
||||
gap: 6px;
|
||||
height: 100%;
|
||||
padding: 4px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.desktop-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.desktop-icon:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.icon-box {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--background);
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--accent);
|
||||
transition:
|
||||
background 0.12s,
|
||||
color 0.12s,
|
||||
transform 0.12s;
|
||||
}
|
||||
|
||||
.desktop-icon:hover .icon-box,
|
||||
.desktop-icon:focus-visible .icon-box {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.desktop-icon:active .icon-box {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
max-width: 100%;
|
||||
padding: 1px 6px;
|
||||
background: color-mix(in srgb, var(--background) 88%, transparent);
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.desktop-icon:hover .icon-label,
|
||||
.desktop-icon:focus-visible .icon-label {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { desktop, type DesktopAction } from '#lib/content';
|
||||
import { allSitePages } from '#lib/site/pages';
|
||||
import { runDesktopAction } from '#lib/desktop/actions';
|
||||
import { windowManager } from '#lib/desktop/windows.svelte';
|
||||
|
||||
interface LauncherEntry {
|
||||
label: string;
|
||||
detail: string;
|
||||
icon: string;
|
||||
open: DesktopAction;
|
||||
}
|
||||
|
||||
const pageEntries: LauncherEntry[] = allSitePages.map((page) => ({
|
||||
label: page.title,
|
||||
detail: page.path,
|
||||
icon: page.icon,
|
||||
open: { page: page.path }
|
||||
}));
|
||||
|
||||
const iconEntries: LauncherEntry[] = desktop.icons
|
||||
.filter((item) => !('page' in item.open))
|
||||
.map((item) => ({
|
||||
label: item.label,
|
||||
detail: 'terminal' in item.open ? `kitty ${item.open.terminal}`.trim() : 'url' in item.open ? item.open.url : '',
|
||||
icon: item.icon,
|
||||
open: item.open
|
||||
}));
|
||||
|
||||
const entries = [...pageEntries, ...iconEntries];
|
||||
|
||||
let query = $state('');
|
||||
let selectedIndex = $state(0);
|
||||
let searchElement = $state<HTMLInputElement>();
|
||||
|
||||
const filteredEntries = $derived(
|
||||
entries.filter((entry) => `${entry.label} ${entry.detail}`.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
query;
|
||||
selectedIndex = 0;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
searchElement?.focus();
|
||||
});
|
||||
|
||||
function close() {
|
||||
windowManager.launcherOpen = false;
|
||||
}
|
||||
|
||||
function launch(entry: LauncherEntry | undefined) {
|
||||
if (entry) runDesktopAction(entry.open);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') close();
|
||||
else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.min(filteredEntries.length - 1, selectedIndex + 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
selectedIndex = Math.max(0, selectedIndex - 1);
|
||||
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="backdrop" aria-label="Close launcher" onclick={close}></button>
|
||||
<div class="launcher" role="dialog" aria-label="Launcher">
|
||||
<div class="launcher-surface">
|
||||
<label class="search">
|
||||
<Icon icon="mdi:magnify" width="20" />
|
||||
<input bind:this={searchElement} bind:value={query} onkeydown={handleKeydown} placeholder="Search apps and pages" />
|
||||
</label>
|
||||
<ul class="results">
|
||||
{#each filteredEntries as entry, index (entry.label + entry.detail)}
|
||||
<li>
|
||||
<button class="result" class:selected={index === selectedIndex} onclick={() => launch(entry)} onmouseenter={() => (selectedIndex = index)}>
|
||||
<Icon icon={entry.icon} width="20" />
|
||||
<span class="result-label">{entry.label}</span>
|
||||
<span class="result-detail">{entry.detail}</span>
|
||||
</button>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="empty">No matches</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: color-mix(in srgb, var(--dark-background) 40%, transparent);
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.launcher {
|
||||
position: fixed;
|
||||
top: 18vh;
|
||||
left: 50%;
|
||||
z-index: 70;
|
||||
width: min(640px, calc(100vw - 24px));
|
||||
padding: var(--border-size);
|
||||
background: var(--border-active);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.launcher-surface {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.results {
|
||||
max-height: 50vh;
|
||||
margin: 0;
|
||||
padding: 0.3rem;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.result.selected {
|
||||
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.result-detail,
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let {
|
||||
title,
|
||||
path = '',
|
||||
icon = 'ph:terminal-window-duotone',
|
||||
focused,
|
||||
tileStyle,
|
||||
scrollKey = '',
|
||||
onfocus,
|
||||
onclose,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
focused: boolean;
|
||||
tileStyle: string;
|
||||
scrollKey?: string;
|
||||
onfocus: () => void;
|
||||
onclose: () => void;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
|
||||
let contentElement = $state<HTMLDivElement>();
|
||||
|
||||
$effect(() => {
|
||||
scrollKey;
|
||||
if (contentElement) contentElement.scrollTop = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="window"
|
||||
class:focused
|
||||
style={tileStyle}
|
||||
aria-label="{title} {path}"
|
||||
onpointerdown={onfocus}
|
||||
onfocusin={onfocus}
|
||||
>
|
||||
<div class="window-surface">
|
||||
<header class="title-bar">
|
||||
<span class="title">
|
||||
<span class="app-name"><Icon {icon} width="15" />{title}</span>
|
||||
{#if path}
|
||||
<span class="path">{path}</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button class="close-button" title="Close (Alt+Q)" aria-label="Close {title} {path}" onclick={onclose}>
|
||||
<Icon icon="mdi:close" width="15" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="window-content" bind:this={contentElement}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.window {
|
||||
position: absolute;
|
||||
padding: var(--border-size);
|
||||
background: var(--border-inactive);
|
||||
animation: window-pop-in 0.2s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
transition:
|
||||
left 0.25s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
top 0.25s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
width 0.25s cubic-bezier(0.23, 1, 0.32, 1),
|
||||
height 0.25s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
.window.focused {
|
||||
background: var(--border-active);
|
||||
}
|
||||
|
||||
.window-surface {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
height: 28px;
|
||||
padding: 0 0.3rem 0 0.8rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.focused .app-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.focused .path {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.window-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { ModelScene } from '#lib/models/model-scene';
|
||||
|
||||
let { modelUrl, modelName }: { modelUrl: string; modelName: string } = $props();
|
||||
|
||||
let container = $state<HTMLDivElement>();
|
||||
let modelScene = $state<ModelScene | null>(null);
|
||||
let status = $state<'loading' | 'ready' | 'error' | 'unsupported'>('loading');
|
||||
let autoRotate = $state(true);
|
||||
let wireframe = $state(false);
|
||||
let brightness = $state(0.6);
|
||||
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
||||
import('#lib/models/model-scene')
|
||||
.then(({ ModelScene }) => {
|
||||
if (disposed || !container) return;
|
||||
modelScene = new ModelScene(container);
|
||||
resizeObserver = new ResizeObserver(() => modelScene?.resize());
|
||||
resizeObserver.observe(container);
|
||||
})
|
||||
.catch(() => (status = 'unsupported'));
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver?.disconnect();
|
||||
modelScene?.dispose();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!modelScene) return;
|
||||
status = 'loading';
|
||||
wireframe = false;
|
||||
modelScene
|
||||
.load(modelUrl)
|
||||
.then(() => (status = 'ready'))
|
||||
.catch(() => (status = 'error'));
|
||||
});
|
||||
|
||||
$effect(() => modelScene?.setAutoRotate(autoRotate));
|
||||
$effect(() => modelScene?.setWireframe(wireframe));
|
||||
$effect(() => modelScene?.setBrightness(brightness));
|
||||
</script>
|
||||
|
||||
<div class="viewer">
|
||||
<div class="toolbar">
|
||||
<span class="model-name">{modelName}</span>
|
||||
<div class="controls">
|
||||
<button class:active={autoRotate} title="Auto rotate" onclick={() => (autoRotate = !autoRotate)}>
|
||||
<Icon icon="mdi:rotate-3d-variant" width="16" />
|
||||
</button>
|
||||
<button class:active={wireframe} title="Wireframe" onclick={() => (wireframe = !wireframe)}>
|
||||
<Icon icon="mdi:cube-outline" width="16" />
|
||||
</button>
|
||||
<button title="Darker" onclick={() => (brightness = Math.max(0, brightness - 0.2))}>
|
||||
<Icon icon="mdi:brightness-5" width="16" />
|
||||
</button>
|
||||
<button title="Brighter" onclick={() => (brightness = Math.min(3, brightness + 0.2))}>
|
||||
<Icon icon="mdi:brightness-7" width="16" />
|
||||
</button>
|
||||
<button title="Reset camera" onclick={() => modelScene?.resetCamera()}>
|
||||
<Icon icon="mdi:camera-retake" width="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="canvas-area" bind:this={container} tabindex="0" role="application" aria-label="3D view of {modelName}">
|
||||
{#if status !== 'ready'}
|
||||
<div class="status muted">
|
||||
{#if status === 'loading'}
|
||||
loading model...
|
||||
{:else if status === 'error'}
|
||||
failed to load model
|
||||
{:else}
|
||||
WebGL is not available in this browser
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="hints muted">drag to rotate · scroll to zoom · arrow keys after clicking</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.model-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
border-color: color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
.controls button.active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
position: relative;
|
||||
height: 380px;
|
||||
background: radial-gradient(circle, var(--lighter-background), var(--dark-background));
|
||||
}
|
||||
|
||||
.canvas-area :global(canvas) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hints {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { Project, ProjectCategory } from '#lib/content';
|
||||
import LinkButton from '#lib/components/ui/LinkButton.svelte';
|
||||
import Tag from '#lib/components/ui/Tag.svelte';
|
||||
|
||||
let { project, category }: { project: Project; category: ProjectCategory } = $props();
|
||||
|
||||
const linkButtons = $derived(
|
||||
[
|
||||
{ href: project.links.demo, label: 'View Demo', icon: 'mdi:open-in-new' },
|
||||
{ href: project.links.package, label: 'View Package', icon: 'mdi:package-variant' },
|
||||
{ href: project.links.code, label: 'View Code', icon: 'mdi:github' },
|
||||
{ href: project.links.devpost, label: 'Devpost', icon: 'simple-icons:devpost' },
|
||||
{ href: project.links.article, label: 'Article', icon: 'mdi:newspaper-variant-outline' }
|
||||
].filter((button) => button.href)
|
||||
);
|
||||
</script>
|
||||
|
||||
<article class="project-entry" class:has-image={!!project.image}>
|
||||
<h2 class="project-title">{project.name}</h2>
|
||||
|
||||
{#if project.image}
|
||||
<img class="project-image" src={project.image} alt={project.name} loading="lazy" />
|
||||
{/if}
|
||||
|
||||
<div class="project-body">
|
||||
<div class="badges">
|
||||
<span class="badge category"><Icon icon={category.icon} width="15" /> {category.title}</span>
|
||||
{#if project.featured}
|
||||
<span class="badge featured">Featured</span>
|
||||
{/if}
|
||||
{#if project.version}
|
||||
<span class="badge version">v{project.version}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if project.event}
|
||||
<div class="event">
|
||||
{project.event.name}{#if project.event.year}<span class="muted"> ({project.event.year})</span>{/if}
|
||||
</div>
|
||||
{#if project.event.university}
|
||||
<div class="muted">{project.event.university}{project.event.location ? `, ${project.event.location}` : ''}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if project.organization}
|
||||
<div class="event">{project.organization}</div>
|
||||
{/if}
|
||||
{#if project.period}
|
||||
<div class="muted">{project.period}</div>
|
||||
{/if}
|
||||
|
||||
{#if project.description}
|
||||
<p>{project.description}</p>
|
||||
{/if}
|
||||
|
||||
{#each project.awards ?? [] as award (award.track)}
|
||||
<div class="award"><Icon icon="mdi:trophy" width="16" /> {award.place} - {award.track}</div>
|
||||
{/each}
|
||||
|
||||
{#if project.tags.length}
|
||||
<div class="tags">
|
||||
{#each project.tags as tag, index (tag)}
|
||||
<Tag label={tag} colorIndex={index} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.demoMayBeOffline}
|
||||
<div class="warning"><Icon icon="mdi:alert" width="16" /> Demo may be unavailable</div>
|
||||
{/if}
|
||||
|
||||
{#if linkButtons.length}
|
||||
<div class="buttons">
|
||||
{#each linkButtons as button (button.label)}
|
||||
<LinkButton href={button.href!} icon={button.icon}>{button.label}</LinkButton>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.project-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem 1.25rem;
|
||||
padding: 1rem 0 1.5rem;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
.project-entry.has-image {
|
||||
grid-template-columns: minmax(180px, 320px) 1fr;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.project-image {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
object-fit: contain;
|
||||
background: var(--dark-background);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
}
|
||||
|
||||
.project-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.badges,
|
||||
.tags,
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.45rem;
|
||||
color: var(--background);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.category {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.featured {
|
||||
background: var(--orange);
|
||||
}
|
||||
|
||||
.version {
|
||||
background: var(--blue);
|
||||
}
|
||||
|
||||
.event {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.award {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--orange);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@container (max-width: 700px) {
|
||||
.project-entry.has-image {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { projectCategories } from '#lib/content';
|
||||
|
||||
const chipColors = ['magenta', 'orange', 'green', 'cyan'];
|
||||
</script>
|
||||
|
||||
<nav class="project-nav">
|
||||
<a href="/projects" class:active={page.url.pathname === '/projects'} style="--chip-color: var(--accent)">~/projects</a>
|
||||
{#each projectCategories as category, index (category.id)}
|
||||
<a
|
||||
href="/projects/{category.id}"
|
||||
class:active={page.url.pathname === `/projects/${category.id}`}
|
||||
style="--chip-color: var(--{chipColors[index % chipColors.length]})">{category.title}</a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.project-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
a {
|
||||
padding: 0.05rem 0.55rem;
|
||||
border: 1px solid var(--chip-color);
|
||||
color: var(--chip-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a.active {
|
||||
background: var(--chip-color);
|
||||
color: var(--background);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { windowManager } from '#lib/desktop/windows.svelte';
|
||||
import ChaiIcon from './ChaiIcon.svelte';
|
||||
import Clock from './Clock.svelte';
|
||||
import Workspaces from './Workspaces.svelte';
|
||||
import ThemeMenu from './ThemeMenu.svelte';
|
||||
import DiscordStatus from './DiscordStatus.svelte';
|
||||
import Battery from './Battery.svelte';
|
||||
</script>
|
||||
|
||||
<header class="bar">
|
||||
<div class="bar-section left">
|
||||
<button class="tray-button" title="Apps (Alt+Space)" onclick={() => (windowManager.launcherOpen = true)}>
|
||||
<ChaiIcon size={18} />
|
||||
</button>
|
||||
<Workspaces />
|
||||
</div>
|
||||
|
||||
<button class="clock-button" title="Open a terminal (Alt+Enter)" onclick={() => windowManager.openTerminal()}>
|
||||
<Clock />
|
||||
</button>
|
||||
|
||||
<div class="bar-section right">
|
||||
<DiscordStatus />
|
||||
<Battery />
|
||||
<ThemeMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.bar {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
height: var(--bar-height);
|
||||
padding: 0 8px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.clock-button {
|
||||
height: var(--bar-height);
|
||||
padding: 0 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.clock-button:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.bar :global(.tray-button) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: var(--bar-height);
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.bar :global(.tray-button:hover) {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.bar {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { batteryAt, batteryIcon, type BatteryState } from '#lib/desktop/battery';
|
||||
|
||||
let battery = $state<BatteryState | null>(null);
|
||||
|
||||
const isLow = $derived(!!battery && battery.level < 20 && !battery.charging);
|
||||
|
||||
onMount(() => {
|
||||
battery = batteryAt(new Date());
|
||||
const timer = setInterval(() => (battery = batteryAt(new Date())), 10000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if battery}
|
||||
<span
|
||||
class="battery"
|
||||
class:low={isLow}
|
||||
class:charging={battery.charging}
|
||||
title="Battery {battery.level}% {battery.charging ? '(charging)' : ''}"
|
||||
>
|
||||
<Icon icon={batteryIcon(battery)} width="18" />
|
||||
<span class="level">{battery.level}%</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.battery {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.charging {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.low {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.level {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
let { size = 14 }: { size?: number } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
|
||||
<rect x="8.6" y="1.6" width="1.3" height="4.6" rx="0.65" transform="rotate(-12 9.25 3.9)" />
|
||||
<rect x="12.4" y="2.4" width="1.3" height="4.6" rx="0.65" transform="rotate(12 13.05 4.7)" />
|
||||
<path d="M17.2 11 a3.4 3.4 0 0 1 0 6 v-1.7 a1.7 1.7 0 0 0 0 -2.6 z" />
|
||||
<path d="M4 9 h14 l-2.5 9.2 a1.6 1.6 0 0 1 -1.55 1.2 h-5.9 a1.6 1.6 0 0 1 -1.55 -1.2 z" />
|
||||
<rect x="3" y="20.4" width="18" height="2" rx="1" />
|
||||
</svg>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let now = $state<Date | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
now = new Date();
|
||||
const timer = setInterval(() => (now = new Date()), 15000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
function formatClock(date: Date): string {
|
||||
const weekday = date.toLocaleDateString('en-US', { weekday: 'long' });
|
||||
const day = date.toLocaleDateString('en-US', { day: '2-digit' });
|
||||
const month = date.toLocaleDateString('en-US', { month: 'long' });
|
||||
const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
return `${weekday} ${day} ${month} ${time}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<span class="clock full">{now ? formatClock(now) : ''}</span>
|
||||
<span class="clock short">{now ? formatClock(now).split(' ').slice(1).join(' ') : ''}</span>
|
||||
|
||||
<style>
|
||||
.clock {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.short {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.full {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.short {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,294 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
|
||||
|
||||
let { presence, onclose }: { presence: DiscordPresence; onclose: () => void } = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<button class="backdrop" aria-label="Close Discord status" onclick={onclose}></button>
|
||||
<div class="popover" role="dialog" aria-label="Discord status">
|
||||
<div class="popover-surface">
|
||||
<header class="profile">
|
||||
<div class="avatar">
|
||||
{#if presence.avatarUrl}
|
||||
<img src={presence.avatarUrl} alt="" />
|
||||
{:else}
|
||||
<Icon icon="mdi:account-circle" width="52" />
|
||||
{/if}
|
||||
<span class="avatar-dot {presence.status}"></span>
|
||||
</div>
|
||||
<div class="identity">
|
||||
<div class="name-row">
|
||||
<span class="name">{presence.displayName}</span>
|
||||
{#if presence.guildTag}
|
||||
<span class="guild-tag">
|
||||
{#if presence.guildTagBadgeUrl}
|
||||
<img src={presence.guildTagBadgeUrl} alt="" />
|
||||
{/if}
|
||||
{presence.guildTag}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="status-label">{presence.status}</span>
|
||||
</div>
|
||||
<button class="close" aria-label="Close" onclick={onclose}>
|
||||
<Icon icon="mdi:close" width="18" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if presence.activities.length}
|
||||
<div class="activities">
|
||||
{#each presence.activities as activity, index (index)}
|
||||
<div class="activity">
|
||||
<div class="activity-label">{activityLabels[activity.type] ?? ''}</div>
|
||||
<div class="activity-body">
|
||||
{#if activity.largeImage}
|
||||
<div class="activity-images">
|
||||
<img class="large-image" src={activity.largeImage} alt="" />
|
||||
{#if activity.smallImage}
|
||||
<img class="small-image" src={activity.smallImage} alt="" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="activity-text">
|
||||
<span class="activity-name">{activity.name}</span>
|
||||
{#if activity.details}
|
||||
<span class="activity-details">{activity.details}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-activity">
|
||||
<Icon icon="mdi:sleep" width="28" />
|
||||
<span>No current activity</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
width: 340px;
|
||||
padding: var(--border-size);
|
||||
background: var(--border-active);
|
||||
animation: window-pop-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.popover-surface {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--background));
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-dot {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border: 3px solid color-mix(in srgb, var(--accent) 8%, var(--background));
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.avatar-dot.online {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.avatar-dot.idle {
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.avatar-dot.dnd {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.guild-tag {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.guild-tag img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.close {
|
||||
align-self: flex-start;
|
||||
padding: 0.15rem;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.activities {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.activity {
|
||||
padding: 0.75rem;
|
||||
background: var(--lighter-background);
|
||||
}
|
||||
|
||||
.activity-label {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.activity-body {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.activity-images {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.large-image {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.small-image {
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 3px solid var(--lighter-background);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.activity-name,
|
||||
.activity-details {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.activity-name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.activity-details {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.no-activity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.popover {
|
||||
position: fixed;
|
||||
top: calc(var(--bar-height) + 6px);
|
||||
right: 8px;
|
||||
left: 8px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { profile } from '#lib/content';
|
||||
import { offlinePresence, type DiscordPresence } from '#lib/discord/types';
|
||||
import DiscordPopover from './DiscordPopover.svelte';
|
||||
|
||||
const refreshInterval = 30000;
|
||||
|
||||
let presence = $state<DiscordPresence>(offlinePresence);
|
||||
let open = $state(false);
|
||||
|
||||
const displayName = $derived(presence.displayName ?? profile.displayName);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch('/api/discord');
|
||||
if (response.ok) presence = await response.json();
|
||||
} catch {
|
||||
presence = offlinePresence;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
const timer = setInterval(refresh, refreshInterval);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="discord-status">
|
||||
<button
|
||||
class="status-button"
|
||||
title="{displayName} - {presence.status}"
|
||||
aria-expanded={open}
|
||||
onclick={() => (open = !open)}
|
||||
>
|
||||
<span class="status-dot {presence.status}"></span>
|
||||
<span class="status-name">{displayName}</span>
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<DiscordPopover presence={{ ...presence, displayName }} onclose={() => (open = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.discord-status {
|
||||
position: relative;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.status-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
height: calc(var(--bar-height) - 8px);
|
||||
padding: 0 0.6rem;
|
||||
background: var(--lighter-background);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
}
|
||||
|
||||
.status-button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.status-name {
|
||||
max-width: 9rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.status-dot.idle {
|
||||
background: var(--yellow);
|
||||
}
|
||||
|
||||
.status-dot.dnd {
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.status-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
|
||||
<div class="popup" role="dialog" aria-label={title}>
|
||||
<div class="popup-title">{title}</div>
|
||||
<div class="popup-body">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.popup {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
width: 320px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: var(--border-size);
|
||||
background: var(--border-active);
|
||||
animation: window-pop-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: var(--background);
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 0.4rem;
|
||||
background: var(--background);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { themes } from '#lib/theme/themes';
|
||||
import { themeState } from '#lib/theme/theme.svelte';
|
||||
import PopupCard from './PopupCard.svelte';
|
||||
|
||||
let open = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="theme-menu">
|
||||
<button class="tray-button" title="Theme" onclick={() => (open = !open)}>
|
||||
<Icon icon="mdi:palette-outline" width="18" />
|
||||
</button>
|
||||
|
||||
{#if open}
|
||||
<PopupCard title="Themes" onclose={() => (open = false)}>
|
||||
{#each themes as theme (theme.id)}
|
||||
<button
|
||||
class="theme-option"
|
||||
class:selected={theme.id === themeState.currentId}
|
||||
onclick={() => themeState.set(theme.id)}
|
||||
>
|
||||
<span class="swatches">
|
||||
{#each [theme.colors.background, theme.colors.accent, theme.colors.red, theme.colors.green, theme.colors.foreground] as swatch, index (index)}
|
||||
<span class="swatch" style="background: {swatch}"></span>
|
||||
{/each}
|
||||
</span>
|
||||
<span class="theme-name">{theme.name}</span>
|
||||
<span class="theme-mode">{theme.mode}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</PopupCard>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.theme-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
border-color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.theme-option.selected {
|
||||
background: color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
}
|
||||
|
||||
.swatches {
|
||||
display: flex;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 12px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.theme-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.theme-mode {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { desktop } from '#lib/content';
|
||||
import { activeWorkspace, openPage } from '#lib/desktop/actions';
|
||||
|
||||
const workspaceSlots = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||
|
||||
const currentWorkspace = $derived(activeWorkspace(page.url.pathname));
|
||||
|
||||
function workspaceFor(number: number) {
|
||||
return desktop.workspaces.find((workspace) => workspace.number === number);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="workspaces">
|
||||
{#each workspaceSlots as number (number)}
|
||||
{@const workspace = workspaceFor(number)}
|
||||
{@const isFocused = currentWorkspace?.number === number}
|
||||
<button
|
||||
class="workspace"
|
||||
class:occupied={!!workspace}
|
||||
title={workspace?.name}
|
||||
disabled={!workspace}
|
||||
onclick={() => workspace && openPage(workspace.path)}
|
||||
>
|
||||
{#if isFocused}
|
||||
<span class="focused-marker"></span>
|
||||
{:else}
|
||||
{number}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workspaces {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: var(--bar-height);
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.workspace:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.workspace.occupied {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.workspace.occupied:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.workspace:not(.occupied) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.focused-marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
background: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { profile } from '#lib/content';
|
||||
import { themeState } from '#lib/theme/theme.svelte';
|
||||
|
||||
const launchDate = new Date('2020-01-01T00:00:00Z');
|
||||
const uptimeDays = Math.floor((Date.now() - launchDate.getTime()) / 86400000);
|
||||
const paletteColors = ['red', 'yellow', 'orange', 'green', 'cyan', 'blue', 'magenta', 'accent'];
|
||||
|
||||
const rows = $derived([
|
||||
['OS', 'Arch Linux x86_64'],
|
||||
['Host', profile.hostname],
|
||||
['WM', 'Hyprland (web)'],
|
||||
['Shell', 'blob-shell'],
|
||||
['Terminal', 'kitty'],
|
||||
['Theme', themeState.current.name],
|
||||
['Name', profile.displayName],
|
||||
['Title', profile.title],
|
||||
['Uptime', `${uptimeDays} days`],
|
||||
['School', profile.education.school],
|
||||
['Languages', (profile.skills.Languages ?? []).slice(0, 5).join(', ')]
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="fetch">
|
||||
<img class="avatar" src={profile.avatar} alt={profile.displayName} />
|
||||
<div class="info">
|
||||
<div class="heading">
|
||||
<span class="user">{profile.username}</span><span class="at">@</span><span class="host">{profile.hostname}</span>
|
||||
</div>
|
||||
<div class="separator">{'-'.repeat(profile.username.length + profile.hostname.length + 1)}</div>
|
||||
{#each rows as [label, value] (label)}
|
||||
<div class="row"><span class="label">{label}</span>: {value}</div>
|
||||
{/each}
|
||||
<div class="palette">
|
||||
{#each paletteColors as color (color)}
|
||||
<span class="block" style="background: var(--{color})"></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.fetch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: min(240px, 40%);
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.info {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.user,
|
||||
.host {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.at,
|
||||
.separator {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.palette {
|
||||
display: flex;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.block {
|
||||
width: 1.5rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { Span } from '#lib/terminal/output';
|
||||
import { isExternalUrl, openUrl } from '#lib/desktop/actions';
|
||||
|
||||
let { spans }: { spans: Span[] } = $props();
|
||||
|
||||
function toneStyle(item: Span): string {
|
||||
return item.tone ? `color: var(--${item.tone})` : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="output-line">
|
||||
{#each spans as item, index (index)}
|
||||
{#if item.href}
|
||||
<a
|
||||
href={item.href}
|
||||
class:bold={item.bold}
|
||||
style={toneStyle(item)}
|
||||
target={isExternalUrl(item.href) ? '_blank' : undefined}
|
||||
rel="noopener noreferrer"
|
||||
onclick={(event) => {
|
||||
if (isExternalUrl(item.href!)) return;
|
||||
event.preventDefault();
|
||||
openUrl(item.href!);
|
||||
}}>{item.text}</a
|
||||
>
|
||||
{:else}
|
||||
<span class:bold={item.bold} style={toneStyle(item)}>{item.text}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.output-line {
|
||||
min-height: 1.5em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
background: var(--selection);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { profile } from '#lib/content';
|
||||
import { openPage, openUrl } from '#lib/desktop/actions';
|
||||
import { themeState } from '#lib/theme/theme.svelte';
|
||||
import { runCommand, type CommandContext } from '#lib/terminal/commands';
|
||||
import { complete } from '#lib/terminal/completion';
|
||||
import { line, span, type OutputBlock } from '#lib/terminal/output';
|
||||
import OutputLine from './OutputLine.svelte';
|
||||
import Fetch from './Fetch.svelte';
|
||||
|
||||
let { startupCommand = '', focused, onexit }: { startupCommand?: string; focused: boolean; onexit: () => void } =
|
||||
$props();
|
||||
|
||||
const typingDelay = 35;
|
||||
|
||||
let blocks = $state<OutputBlock[]>([]);
|
||||
let input = $state('');
|
||||
let isTyping = $state(false);
|
||||
let history: string[] = [];
|
||||
let historyIndex = 0;
|
||||
let inputElement = $state<HTMLInputElement>();
|
||||
let screenElement = $state<HTMLDivElement>();
|
||||
|
||||
const context: CommandContext = {
|
||||
print: (...newBlocks) => blocks.push(...newBlocks),
|
||||
clear: () => (blocks = []),
|
||||
exit: onexit,
|
||||
openPage,
|
||||
openUrl,
|
||||
setTheme: (themeId) => themeState.set(themeId),
|
||||
currentThemeId: () => themeState.currentId
|
||||
};
|
||||
|
||||
async function execute(command: string) {
|
||||
blocks.push({ kind: 'prompt', command });
|
||||
if (command.trim()) history.push(command);
|
||||
historyIndex = history.length;
|
||||
runCommand(command, context);
|
||||
await tick();
|
||||
screenElement?.scrollTo({ top: screenElement.scrollHeight });
|
||||
}
|
||||
|
||||
async function typeOut(command: string) {
|
||||
isTyping = true;
|
||||
for (const character of command) {
|
||||
input += character;
|
||||
await new Promise((resolve) => setTimeout(resolve, typingDelay));
|
||||
}
|
||||
input = '';
|
||||
isTyping = false;
|
||||
await execute(command);
|
||||
}
|
||||
|
||||
async function showCompletion() {
|
||||
const result = complete(input);
|
||||
input = result.input;
|
||||
if (result.options.length === 0) return;
|
||||
blocks.push({ kind: 'prompt', command: input }, line(span(result.options.join(' '), 'cyan')));
|
||||
await tick();
|
||||
screenElement?.scrollTo({ top: screenElement.scrollHeight });
|
||||
}
|
||||
|
||||
function recallHistory(step: number) {
|
||||
if (history.length === 0) return;
|
||||
historyIndex = Math.min(history.length, Math.max(0, historyIndex + step));
|
||||
input = history[historyIndex] ?? '';
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (isTyping) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const command = input;
|
||||
input = '';
|
||||
execute(command);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
recallHistory(-1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
recallHistory(1);
|
||||
} else if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
showCompletion();
|
||||
} else if (event.key === 'l' && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
context.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
if (!window.getSelection()?.toString()) inputElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (focused) inputElement?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
if (startupCommand) typeOut(startupCommand);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="terminal" bind:this={screenElement} onclick={focusInput} role="presentation">
|
||||
{#each blocks as block, index (index)}
|
||||
{#if block.kind === 'prompt'}
|
||||
<div class="prompt-line">
|
||||
<span class="prompt">{profile.username}@{profile.hostname}</span>
|
||||
<span class="path">~</span>
|
||||
<span class="symbol">$</span>
|
||||
<span>{block.command}</span>
|
||||
</div>
|
||||
{:else if block.kind === 'fetch'}
|
||||
<Fetch />
|
||||
{:else}
|
||||
<OutputLine spans={block.spans} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<label class="prompt-line input-line">
|
||||
<span class="prompt">{profile.username}@{profile.hostname}</span>
|
||||
<span class="path">~</span>
|
||||
<span class="symbol">$</span>
|
||||
<span class="input-wrapper">
|
||||
<input
|
||||
bind:this={inputElement}
|
||||
bind:value={input}
|
||||
onkeydown={handleKeydown}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
aria-label="Terminal input"
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.terminal {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.1rem 1.5rem;
|
||||
line-height: 1.5;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.prompt-line {
|
||||
display: flex;
|
||||
gap: 0.5ch;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.path {
|
||||
color: var(--blue);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.symbol {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
</style>
|
||||
@@ -1,186 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { AccordionLine } from "./types";
|
||||
import TuiLine from "./TuiLine.svelte";
|
||||
import "$lib/assets/css/tui-accordion.css";
|
||||
|
||||
interface Props {
|
||||
line: AccordionLine;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
let { line, inline = false }: Props = $props();
|
||||
|
||||
let openItems = $state(
|
||||
new Set(untrack(() => line.accordionOpen) ? [0] : []),
|
||||
);
|
||||
|
||||
function toggleItem(index: number) {
|
||||
const newSet = new Set(openItems);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
openItems = newSet;
|
||||
}
|
||||
|
||||
const items = $derived(
|
||||
line.accordionItems || [{ title: line.content, content: "" }],
|
||||
);
|
||||
const isSingleItemMode = $derived(
|
||||
!line.accordionItems && line.children && line.children.length > 0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-accordion"
|
||||
class:inline
|
||||
style="--accordion-accent: {getButtonStyle(line.style)}"
|
||||
>
|
||||
{#each items as item, i}
|
||||
{@const contentSegments = parseColorText(
|
||||
item.content,
|
||||
$themeColors.colorMap,
|
||||
)}
|
||||
<div class="accordion-item" class:open={openItems.has(i)}>
|
||||
<button class="accordion-header" onclick={() => toggleItem(i)}>
|
||||
<Icon
|
||||
icon={openItems.has(i)
|
||||
? "mdi:chevron-down"
|
||||
: "mdi:chevron-right"}
|
||||
width="16"
|
||||
class="accordion-icon"
|
||||
/>
|
||||
<span class="accordion-title">{item.title}</span>
|
||||
</button>
|
||||
{#if openItems.has(i)}
|
||||
<div class="accordion-content">
|
||||
{#each contentSegments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}
|
||||
>{segment.text}</span
|
||||
>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if isSingleItemMode && line.children}
|
||||
<div
|
||||
class="accordion-children"
|
||||
class:flex={line.display === "flex"}
|
||||
class:grid={line.display === "grid"}
|
||||
>
|
||||
{#each line.children as child, k}
|
||||
<TuiLine
|
||||
line={child}
|
||||
index={k}
|
||||
segments={parseColorText(child.content, $themeColors.colorMap)}
|
||||
complete={true}
|
||||
showImage={true}
|
||||
selectedIndex={-1}
|
||||
inline={false}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-accordion {
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion-item {
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.accordion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--terminal-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.accordion-header:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.accordion-item.open .accordion-header {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
:global(.accordion-icon) {
|
||||
color: var(--accordion-accent);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0.75rem;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.accordion-children {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.accordion-children.flex {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.accordion-children.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,313 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getSegmentsUpToChar } from "./utils";
|
||||
import { user } from "$lib/config";
|
||||
import TuiLine from "./TuiLine.svelte";
|
||||
import type { DisplayedLine, TerminalLine } from "./types";
|
||||
import "$lib/assets/css/tui-body.css";
|
||||
|
||||
interface Props {
|
||||
displayedLines: DisplayedLine[];
|
||||
currentLineIndex?: number;
|
||||
isTyping?: boolean;
|
||||
selectedIndex?: number;
|
||||
ref?: HTMLDivElement | undefined;
|
||||
onButtonClick: (idx: number, line?: TerminalLine) => void;
|
||||
onHoverButton: (idx: number) => void;
|
||||
onLinkClick: (idx: number) => void;
|
||||
terminalSettings: { showCursor: boolean };
|
||||
interactiveInput?: boolean;
|
||||
onCommand?: (command: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
displayedLines = [],
|
||||
currentLineIndex = 0,
|
||||
isTyping = false,
|
||||
selectedIndex = -1,
|
||||
ref = $bindable(undefined),
|
||||
onButtonClick,
|
||||
onHoverButton,
|
||||
onLinkClick,
|
||||
terminalSettings,
|
||||
interactiveInput = false,
|
||||
onCommand,
|
||||
}: Props = $props();
|
||||
|
||||
let inputEl = $state<HTMLInputElement>();
|
||||
let commandValue = $state("");
|
||||
let history = $state<string[]>([]);
|
||||
let historyIndex = $state(-1);
|
||||
let inputFocused = $state(false);
|
||||
let caretPos = $state(0);
|
||||
|
||||
const beforeText = $derived(commandValue.slice(0, caretPos));
|
||||
const cursorChar = $derived(commandValue.slice(caretPos, caretPos + 1) || " ");
|
||||
const afterRest = $derived(commandValue.slice(caretPos + 1));
|
||||
const afterFull = $derived(commandValue.slice(caretPos));
|
||||
|
||||
function syncCaret() {
|
||||
caretPos = inputEl?.selectionStart ?? commandValue.length;
|
||||
}
|
||||
|
||||
function recall(value: string) {
|
||||
commandValue = value;
|
||||
caretPos = value.length;
|
||||
}
|
||||
|
||||
function submitCommand() {
|
||||
const value = commandValue;
|
||||
commandValue = "";
|
||||
caretPos = 0;
|
||||
if (value.trim()) history = [...history, value];
|
||||
historyIndex = -1;
|
||||
onCommand?.(value);
|
||||
queueMicrotask(() => inputEl?.focus());
|
||||
}
|
||||
|
||||
function handleInputKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
submitCommand();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (history.length === 0) return;
|
||||
const nextIndex =
|
||||
historyIndex === -1
|
||||
? history.length - 1
|
||||
: Math.max(0, historyIndex - 1);
|
||||
historyIndex = nextIndex;
|
||||
recall(history[nextIndex]);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (historyIndex === -1) return;
|
||||
const nextIndex = historyIndex + 1;
|
||||
if (nextIndex >= history.length) {
|
||||
historyIndex = -1;
|
||||
recall("");
|
||||
} else {
|
||||
historyIndex = nextIndex;
|
||||
recall(history[nextIndex]);
|
||||
}
|
||||
} else {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
function focusCommandInput(event: MouseEvent) {
|
||||
if (!interactiveInput) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("a, button, input, textarea, .tui-button, .tui-link"))
|
||||
return;
|
||||
inputEl?.focus();
|
||||
}
|
||||
|
||||
// Group consecutive inline items together
|
||||
type ProcessedGroup =
|
||||
| { kind: "single"; index: number; displayed: DisplayedLine }
|
||||
| {
|
||||
kind: "inline";
|
||||
items: Array<{ index: number; displayed: DisplayedLine }>;
|
||||
};
|
||||
|
||||
const processedGroups = $derived.by(() => {
|
||||
const groups: ProcessedGroup[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < displayedLines.length) {
|
||||
const displayed = displayedLines[i];
|
||||
const isInline =
|
||||
displayed.parsed.line.inline ||
|
||||
displayed.parsed.line.display === "inline";
|
||||
|
||||
if (isInline) {
|
||||
const inlineItems: Array<{
|
||||
index: number;
|
||||
displayed: DisplayedLine;
|
||||
}> = [];
|
||||
// Collect consecutive inline items
|
||||
while (i < displayedLines.length) {
|
||||
const nextLine = displayedLines[i].parsed.line;
|
||||
const nextIsInline =
|
||||
nextLine.inline || nextLine.display === "inline";
|
||||
|
||||
if (!nextIsInline) break;
|
||||
|
||||
inlineItems.push({
|
||||
index: i,
|
||||
displayed: displayedLines[i],
|
||||
});
|
||||
i++;
|
||||
}
|
||||
groups.push({ kind: "inline", items: inlineItems });
|
||||
} else {
|
||||
groups.push({ kind: "single", index: i, displayed });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div class="tui-body" bind:this={ref} onclick={focusCommandInput}>
|
||||
{#each processedGroups as group, gi (gi)}
|
||||
{#if group.kind === "inline"}
|
||||
<div class="tui-inline-group">
|
||||
{#each group.items as item (item.index)}
|
||||
<TuiLine
|
||||
line={item.displayed.parsed.line}
|
||||
index={item.index}
|
||||
segments={getSegmentsUpToChar(
|
||||
item.displayed.parsed.segments,
|
||||
item.displayed.charIndex,
|
||||
)}
|
||||
complete={item.displayed.complete}
|
||||
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}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<TuiLine
|
||||
line={group.displayed.parsed.line}
|
||||
index={group.index}
|
||||
segments={getSegmentsUpToChar(
|
||||
group.displayed.parsed.segments,
|
||||
group.displayed.charIndex,
|
||||
)}
|
||||
complete={group.displayed.complete}
|
||||
showImage={group.displayed.showImage}
|
||||
{selectedIndex}
|
||||
inline={false}
|
||||
showCursor={terminalSettings.showCursor &&
|
||||
group.index === currentLineIndex &&
|
||||
!group.displayed.complete &&
|
||||
isTyping &&
|
||||
group.displayed.parsed.line.type !== "image"}
|
||||
{onButtonClick}
|
||||
{onHoverButton}
|
||||
{onLinkClick}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if interactiveInput}
|
||||
{#if !isTyping}
|
||||
<div class="tui-line prompt tui-command-line">
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at"
|
||||
>@</span
|
||||
><span class="host">{user.hostname}</span>
|
||||
<span class="separator">:</span><span class="path">~</span><span
|
||||
class="symbol">$</span
|
||||
>
|
||||
</span>
|
||||
<span class="tui-input-wrap">
|
||||
<input
|
||||
class="tui-command-input"
|
||||
bind:this={inputEl}
|
||||
bind:value={commandValue}
|
||||
onkeydown={handleInputKeydown}
|
||||
onkeyup={syncCaret}
|
||||
oninput={syncCaret}
|
||||
onclick={syncCaret}
|
||||
onfocus={() => {
|
||||
inputFocused = true;
|
||||
syncCaret();
|
||||
}}
|
||||
onblur={() => (inputFocused = false)}
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
aria-label="Terminal command input"
|
||||
/><span class="tui-input-mirror" aria-hidden="true">{beforeText}{#if inputFocused}<span class="cursor-block blink">{cursorChar}</span>{afterRest}{:else}{afterFull}{/if}</span>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
||||
<div class="tui-line prompt">
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at"
|
||||
>@</span
|
||||
><span class="host">{user.hostname}</span>
|
||||
<span class="separator">:</span><span class="path">~</span><span
|
||||
class="symbol">$</span
|
||||
>
|
||||
</span>
|
||||
<span class="cursor blink"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-command-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tui-input-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 0.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tui-command-input {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: transparent;
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.tui-command-input:focus,
|
||||
.tui-command-input:focus-visible {
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.tui-input-mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: pre;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.cursor-block {
|
||||
display: inline-block;
|
||||
height: 1.2em;
|
||||
line-height: 1.2em;
|
||||
color: var(--terminal-bg);
|
||||
background: var(--terminal-accent, var(--terminal-primary));
|
||||
}
|
||||
|
||||
.cursor-block.blink {
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,74 +0,0 @@
|
||||
<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";
|
||||
|
||||
interface Props {
|
||||
line: ButtonLine;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onClick: (idx: number, line?: ButtonLine) => void;
|
||||
onHover: (idx: number) => void;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
index,
|
||||
selected,
|
||||
onClick,
|
||||
onHover,
|
||||
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://"))),
|
||||
);
|
||||
|
||||
// Parse color formatting in content using theme colorMap
|
||||
const segments = $derived(
|
||||
parseColorText(line.content, $themeColors.colorMap),
|
||||
);
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="tui-button"
|
||||
class:selected
|
||||
class:inline
|
||||
class:no-border={line.border === false}
|
||||
class:flex-expand={line.flex}
|
||||
style="--btn-color: {getButtonStyle(line.style)}"
|
||||
onclick={() => onClick(index, line)}
|
||||
onmouseenter={() => onHover(index)}
|
||||
data-href={line.href || ""}
|
||||
data-external={isExternal ? "true" : "false"}
|
||||
>
|
||||
<span class="btn-indicator">{selected ? "▶" : " "}</span>
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="16" />
|
||||
{/if}
|
||||
<span class="btn-text" style:text-align={line.textStyle || "left"}>
|
||||
{#each segments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{#if line.href}
|
||||
{#if isExternal}
|
||||
<Icon icon="mdi:open-in-new" width="14" class="btn-arrow" />
|
||||
{:else}
|
||||
<Icon icon="mdi:chevron-right" width="16" class="btn-arrow" />
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
@@ -1,291 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import {
|
||||
getButtonStyle,
|
||||
parseColorText,
|
||||
getSegmentStyle,
|
||||
parseDimension,
|
||||
} from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { CardLine, TerminalLine } from "./types";
|
||||
import TuiLine from "./TuiLine.svelte";
|
||||
import "$lib/assets/css/tui-card.css";
|
||||
|
||||
interface Props {
|
||||
line: CardLine;
|
||||
inline?: boolean;
|
||||
onButtonClick?: (idx: number, line?: any) => void;
|
||||
onHoverButton?: (idx: number) => void;
|
||||
onLinkClick?: (idx: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
onButtonClick = () => {},
|
||||
onHoverButton = () => {},
|
||||
onLinkClick = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
const segments = $derived(parseColorText(line.content, $themeColors.colorMap));
|
||||
const titleSegments = $derived(
|
||||
line.cardTitle ? parseColorText(line.cardTitle, $themeColors.colorMap) : [],
|
||||
);
|
||||
const footerSegments = $derived(
|
||||
line.cardFooter ? parseColorText(line.cardFooter, $themeColors.colorMap) : [],
|
||||
);
|
||||
|
||||
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("; "),
|
||||
);
|
||||
const processedChildren = $derived.by(() => {
|
||||
if (!line.children) return [];
|
||||
const groups: Array<
|
||||
| { kind: "single"; index: number; line: TerminalLine }
|
||||
| {
|
||||
kind: "inline";
|
||||
items: Array<{ index: number; line: TerminalLine }>;
|
||||
}
|
||||
> = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < line.children.length) {
|
||||
const child = line.children[i];
|
||||
const isInline = child.inline || child.display === "inline";
|
||||
|
||||
if (isInline) {
|
||||
const inlineItems: Array<{
|
||||
index: number;
|
||||
line: TerminalLine;
|
||||
}> = [];
|
||||
while (i < line.children.length) {
|
||||
const nextChild = line.children[i];
|
||||
const nextIsInline =
|
||||
nextChild.inline || nextChild.display === "inline";
|
||||
if (!nextIsInline) break;
|
||||
|
||||
inlineItems.push({ index: i, line: nextChild });
|
||||
i++;
|
||||
}
|
||||
groups.push({ kind: "inline", items: inlineItems });
|
||||
} else {
|
||||
groups.push({ kind: "single", index: i, line: child });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="tui-card" class:inline style={cardStyle}>
|
||||
{#if line.cardTitle}
|
||||
<div class="card-header">
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="16" class="card-icon" />
|
||||
{/if}
|
||||
<span class="card-title">
|
||||
{#if line.link}
|
||||
<a
|
||||
href={line.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="card-title-link"
|
||||
>
|
||||
{#each titleSegments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</a>
|
||||
{:else}
|
||||
{#each titleSegments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-body">
|
||||
{#if line.image}
|
||||
<img src={line.image} alt={line.imageAlt || ""} class="card-image" />
|
||||
{/if}
|
||||
<div class="card-content">
|
||||
{#each segments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if line.children && line.children.length > 0}
|
||||
<div
|
||||
class="card-children"
|
||||
class:flex={line.display === "flex"}
|
||||
class:grid={line.display === "grid"}
|
||||
>
|
||||
{#each processedChildren as group}
|
||||
{#if group.kind === "inline"}
|
||||
<div class="tui-inline-group">
|
||||
{#each group.items as item}
|
||||
<TuiLine
|
||||
line={item.line}
|
||||
index={item.index}
|
||||
segments={parseColorText(item.line.content, $themeColors.colorMap)}
|
||||
complete={true}
|
||||
showImage={true}
|
||||
selectedIndex={-1}
|
||||
inline={true}
|
||||
{onButtonClick}
|
||||
{onHoverButton}
|
||||
{onLinkClick}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<TuiLine
|
||||
line={group.line}
|
||||
index={group.index}
|
||||
segments={parseColorText(group.line.content, $themeColors.colorMap)}
|
||||
complete={true}
|
||||
showImage={true}
|
||||
selectedIndex={-1}
|
||||
inline={false}
|
||||
{onButtonClick}
|
||||
{onHoverButton}
|
||||
{onLinkClick}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if line.cardFooter}
|
||||
<div class="card-footer">
|
||||
{#each footerSegments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-card {
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--terminal-bg) 80%,
|
||||
var(--card-accent) 5%
|
||||
);
|
||||
margin: 0.5rem 0;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.tui-card:hover {
|
||||
border-color: var(--card-accent);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.card-icon) {
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
color: var(--terminal-text);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.card-title-link {
|
||||
color: var(--card-accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.card-title-link:hover {
|
||||
text-decoration-style: solid;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.card-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.card-children.flex {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-children.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(max(200px, calc((100% - 5rem) / 6)), 1fr)
|
||||
);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--terminal-border);
|
||||
font-size: 0.75rem;
|
||||
color: var(--terminal-muted);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -1,367 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { CardGridLine } from "./types";
|
||||
import "$lib/assets/css/tui-card-grid.css";
|
||||
|
||||
interface Props {
|
||||
line: CardGridLine;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
let { line, inline = false }: Props = $props();
|
||||
|
||||
const cards = $derived(line.cards || []);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-card-grid"
|
||||
class:inline
|
||||
style="--card-warning: {$themeColors.colorMap.warning};"
|
||||
>
|
||||
{#each cards as card}
|
||||
<article class="tui-card" class:featured={card.featured}>
|
||||
{#if card.image}
|
||||
<div class="card-image">
|
||||
<img src={card.image} alt={card.title} loading="lazy" />
|
||||
</div>
|
||||
{:else if card.featured}
|
||||
<div class="card-header-badge">
|
||||
<span class="featured-badge">★ Featured</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">{card.title}</h3>
|
||||
|
||||
{#if card.hackathonName}
|
||||
<div class="card-meta">
|
||||
<span class="meta-icon">🏛️</span>
|
||||
<span class="hackathon-name">{card.hackathonName}</span>
|
||||
{#if card.year}<span class="year">({card.year})</span
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if card.university}
|
||||
<div class="card-location">
|
||||
<span class="meta-icon">📍</span>
|
||||
{card.university}{card.location
|
||||
? `, ${card.location}`
|
||||
: ""}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if card.awards && card.awards.length > 0}
|
||||
<div class="awards">
|
||||
{#each card.awards as award}
|
||||
<div class="award">
|
||||
<span class="award-icon">🏆</span>
|
||||
<span class="award-text"
|
||||
>{award.place} — {award.track}</span
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="card-desc">{card.description}</p>
|
||||
|
||||
{#if card.tags && card.tags.length > 0}
|
||||
<div class="tags">
|
||||
{#each card.tags as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if card.liveWarning}
|
||||
<div class="warning">Demo may be unavailable</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-actions">
|
||||
{#if card.link}
|
||||
<a
|
||||
href={card.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="action-btn primary"
|
||||
>
|
||||
<Icon icon="mdi:open-in-new" width="12" />
|
||||
<span>Demo</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if card.repo}
|
||||
<a
|
||||
href={card.repo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="action-btn"
|
||||
>
|
||||
<Icon icon="mdi:github" width="12" />
|
||||
<span>Code</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if card.devpost}
|
||||
<a
|
||||
href={card.devpost}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="action-btn"
|
||||
>
|
||||
<Icon icon="mdi:rocket-launch" width="12" />
|
||||
<span>Devpost</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tui-card {
|
||||
background: var(--terminal-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s ease;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
.tui-card:hover {
|
||||
border-color: var(--terminal-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tui-card.featured {
|
||||
border-color: var(--terminal-accent);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
overflow: hidden;
|
||||
background: var(--terminal-bg-light);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.tui-card:hover .card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-header-badge {
|
||||
padding: 0.5rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.featured-badge {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: var(--terminal-accent);
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.card-header-badge .featured-badge {
|
||||
position: static;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--terminal-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--terminal-primary);
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.hackathon-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.year {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.card-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.65rem;
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.awards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.award {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.award-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.award-text {
|
||||
color: #a6e3a1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
margin: 0;
|
||||
font-size: 0.7rem;
|
||||
color: var(--terminal-muted);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--terminal-primary) 15%,
|
||||
transparent
|
||||
);
|
||||
color: var(--terminal-primary);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.55rem;
|
||||
font-weight: 500;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--card-warning, #b8860b);
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--card-warning, #b8860b) 8%,
|
||||
transparent
|
||||
);
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--card-warning, #b8860b) 20%, transparent);
|
||||
border-radius: 6px;
|
||||
width: fit-content;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02) inset;
|
||||
}
|
||||
|
||||
.warning:before {
|
||||
content: "⚠";
|
||||
display: inline-block;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 4px;
|
||||
color: var(--terminal-text);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.15s ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--terminal-primary);
|
||||
border-color: var(--terminal-primary);
|
||||
color: var(--terminal-bg);
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--terminal-primary);
|
||||
border-color: var(--terminal-primary);
|
||||
color: var(--terminal-bg);
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--terminal-accent);
|
||||
border-color: var(--terminal-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.tui-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,151 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { CheckboxLine } from "./types";
|
||||
import "$lib/assets/css/tui-checkbox.css";
|
||||
|
||||
interface Props {
|
||||
line: CheckboxLine;
|
||||
inline?: boolean;
|
||||
checked?: boolean;
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
checked = $bindable(false),
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
const labelSegments = $derived(
|
||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
||||
);
|
||||
const isDisabled = $derived(line.inputDisabled || false);
|
||||
const indeterminate = $derived(line.checkboxIndeterminate || false);
|
||||
|
||||
function handleChange() {
|
||||
if (isDisabled) return;
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleChange();
|
||||
}
|
||||
}
|
||||
|
||||
function getCheckboxSymbol(
|
||||
checked: boolean,
|
||||
indeterminate: boolean,
|
||||
): string {
|
||||
if (indeterminate) return "[-]";
|
||||
return checked ? "[✓]" : "[ ]";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-checkbox"
|
||||
class:inline
|
||||
class:checked
|
||||
class:disabled={isDisabled}
|
||||
style="--checkbox-color: {getButtonStyle(line.style)}"
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? "mixed" : checked}
|
||||
aria-disabled={isDisabled}
|
||||
tabindex={isDisabled ? -1 : 0}
|
||||
onclick={handleChange}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<span class="checkbox-box" class:indeterminate>
|
||||
{getCheckboxSymbol(checked, indeterminate)}
|
||||
</span>
|
||||
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="14" class="checkbox-icon" />
|
||||
{/if}
|
||||
|
||||
{#if line.content}
|
||||
<span class="checkbox-label">
|
||||
{#each labelSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.35rem 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tui-checkbox.inline {
|
||||
display: inline-flex;
|
||||
margin: 0 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.tui-checkbox:hover:not(.disabled) {
|
||||
background: color-mix(in srgb, var(--checkbox-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.tui-checkbox:focus-visible {
|
||||
outline: 1px solid var(--checkbox-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tui-checkbox.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
color: var(--terminal-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.tui-checkbox.checked .checkbox-box,
|
||||
.checkbox-box.indeterminate {
|
||||
color: var(--checkbox-color);
|
||||
}
|
||||
|
||||
:global(.checkbox-icon) {
|
||||
color: var(--checkbox-color);
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.tui-checkbox.disabled .checkbox-label {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
/* Mobile: inline checkboxes become full-width */
|
||||
@media (max-width: 768px) {
|
||||
.tui-checkbox.inline {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import "$lib/assets/css/tui-footer.css";
|
||||
|
||||
interface Props {
|
||||
isTyping: boolean;
|
||||
linesCount: number;
|
||||
skipAnimation: () => void;
|
||||
}
|
||||
|
||||
let { isTyping, linesCount, skipAnimation }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="tui-statusbar bottom">
|
||||
<span class="status-left">
|
||||
<Icon icon="mdi:console" width="14" />
|
||||
<span>TUI</span>
|
||||
</span>
|
||||
<span class="status-center">
|
||||
{#if isTyping}
|
||||
<span class="typing-indicator">Loading...</span>
|
||||
{:else}
|
||||
Ready
|
||||
{/if}
|
||||
</span>
|
||||
<span class="status-right">
|
||||
{#if isTyping}
|
||||
<button class="skip-btn" onclick={skipAnimation}>
|
||||
<Icon icon="mdi:skip-forward" width="12" />
|
||||
<span>Skip (Y)</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="line-count">{linesCount} lines</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,198 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { parseColorText, getPlainText } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import TuiLine from "./TuiLine.svelte";
|
||||
import type { GroupLine, TerminalLine } from "./types";
|
||||
|
||||
interface Props {
|
||||
line: GroupLine;
|
||||
inline?: boolean;
|
||||
onButtonClick?: (idx: number, line?: TerminalLine) => void;
|
||||
onHoverButton?: (idx: number) => void;
|
||||
onLinkClick?: (idx: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
onButtonClick = () => {},
|
||||
onHoverButton = () => {},
|
||||
onLinkClick = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
// Get colorMap from current theme
|
||||
const colorMap = $derived($themeColors.colorMap);
|
||||
|
||||
// Parse children with current colorMap
|
||||
const parsedChildren = $derived(
|
||||
(line.children || []).map((child) => {
|
||||
const segments = parseColorText(child.content, colorMap);
|
||||
return { line: child, segments, plainText: getPlainText(segments) };
|
||||
}),
|
||||
);
|
||||
|
||||
// Group consecutive inline elements
|
||||
const processedChildren = $derived.by(() => {
|
||||
const groups: Array<
|
||||
| { kind: "single"; index: number; line: TerminalLine }
|
||||
| {
|
||||
kind: "inline";
|
||||
items: Array<{ index: number; line: TerminalLine }>;
|
||||
}
|
||||
> = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < parsedChildren.length) {
|
||||
const child = parsedChildren[i];
|
||||
const isInline = child.line.inline || child.line.display === "inline";
|
||||
|
||||
if (isInline) {
|
||||
const inlineItems: Array<{
|
||||
index: number;
|
||||
line: TerminalLine;
|
||||
}> = [];
|
||||
while (i < parsedChildren.length) {
|
||||
const nextChild = parsedChildren[i];
|
||||
const nextIsInline =
|
||||
nextChild.line.inline || nextChild.line.display === "inline";
|
||||
if (!nextIsInline) break;
|
||||
|
||||
inlineItems.push({
|
||||
index: i,
|
||||
line: nextChild.line,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
groups.push({ kind: "inline", items: inlineItems });
|
||||
} else {
|
||||
groups.push({ kind: "single", index: i, line: child.line });
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Style for the group container
|
||||
const groupStyle = $derived(() => {
|
||||
const styles: string[] = [];
|
||||
// Only apply flex styles if not grid or block
|
||||
if (line.display !== "grid" && line.display !== "block") {
|
||||
if (line.groupDirection === "column")
|
||||
styles.push("flex-direction: column");
|
||||
if (line.groupAlign) {
|
||||
const alignMap: Record<string, string> = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
stretch: "stretch",
|
||||
};
|
||||
styles.push(
|
||||
`align-items: ${alignMap[line.groupAlign] || "flex-start"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (line.groupGap) styles.push(`gap: ${line.groupGap}`);
|
||||
return styles.join("; ");
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-group"
|
||||
class:inline
|
||||
class:grid={line.display === "grid"}
|
||||
class:block={line.display === "block"}
|
||||
class:expand={line.groupExpand}
|
||||
style={groupStyle()}
|
||||
id={line.id}
|
||||
>
|
||||
{#each processedChildren as group}
|
||||
{#if group.kind === "inline"}
|
||||
<div class="tui-inline-group">
|
||||
{#each group.items as item}
|
||||
<TuiLine
|
||||
line={item.line}
|
||||
index={item.index}
|
||||
segments={parseColorText(item.line.content, colorMap)}
|
||||
complete={true}
|
||||
showImage={item.line.type === "image"}
|
||||
selectedIndex={-1}
|
||||
inline={true}
|
||||
{onButtonClick}
|
||||
{onHoverButton}
|
||||
{onLinkClick}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<TuiLine
|
||||
line={group.line}
|
||||
index={group.index}
|
||||
segments={parseColorText(group.line.content, colorMap)}
|
||||
complete={true}
|
||||
showImage={group.line.type === "image"}
|
||||
selectedIndex={-1}
|
||||
inline={false}
|
||||
{onButtonClick}
|
||||
{onHoverButton}
|
||||
{onLinkClick}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
animation: lineSlideIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.tui-group.inline {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.tui-group.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(
|
||||
auto-fill,
|
||||
minmax(max(200px, calc((100% - 3.75rem) / 5)), 1fr)
|
||||
);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tui-group.block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tui-group.expand > :global(*),
|
||||
.tui-group.expand > :global(*) > :global(*) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tui-group.expand :global(.tui-button) {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@keyframes lineSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* .tui-group.inline removed to allow inline flow on mobile */
|
||||
|
||||
.tui-group.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { user } from "$lib/config";
|
||||
import {
|
||||
colorTheme,
|
||||
getThemeIcon,
|
||||
type ColorTheme,
|
||||
} from "$lib/stores/theme";
|
||||
import "$lib/assets/css/tui-header.css";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
interactive?: boolean;
|
||||
hasButtons?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
title = "terminal",
|
||||
interactive = true,
|
||||
hasButtons = false,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="tui-statusbar top">
|
||||
<span class="status-left">
|
||||
<Icon icon={getThemeIcon($colorTheme)} width="14" />
|
||||
<span class="user-host">{user.username}@{user.hostname}</span>
|
||||
</span>
|
||||
<span class="status-center">{title}</span>
|
||||
<span class="status-right">
|
||||
{#if interactive && hasButtons}
|
||||
<span class="hint">↑↓ navigate</span>
|
||||
<span class="hint">⏎ select</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,218 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { InputLine } from "./types";
|
||||
import "$lib/assets/css/tui-input.css";
|
||||
|
||||
interface Props {
|
||||
line: InputLine;
|
||||
inline?: boolean;
|
||||
value?: string;
|
||||
oninput?: (value: string) => void;
|
||||
onchange?: (value: string) => void;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
value = $bindable(""),
|
||||
oninput,
|
||||
onchange,
|
||||
onfocus,
|
||||
onblur,
|
||||
}: Props = $props();
|
||||
|
||||
const labelSegments = $derived(
|
||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
||||
);
|
||||
const placeholder = $derived(line.inputPlaceholder || "");
|
||||
const inputType = $derived(line.inputType || "text");
|
||||
const isDisabled = $derived(line.inputDisabled || false);
|
||||
const hasError = $derived(line.inputError);
|
||||
const errorMessage = $derived(line.inputErrorMessage || "");
|
||||
const prefix = $derived(line.inputPrefix || "");
|
||||
const suffix = $derived(line.inputSuffix || "");
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
value = target.value;
|
||||
oninput?.(value);
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
onchange?.(target.value);
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
onfocus?.();
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
onblur?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-input"
|
||||
class:inline
|
||||
class:error={hasError}
|
||||
class:disabled={isDisabled}
|
||||
style="--input-color: {getButtonStyle(line.style)}"
|
||||
>
|
||||
{#if line.content}
|
||||
<label class="input-label">
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
||||
{/if}
|
||||
{#each labelSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="input-wrapper">
|
||||
<span class="input-prompt">❯</span>
|
||||
{#if prefix}
|
||||
<span class="input-affix prefix">{prefix}</span>
|
||||
{/if}
|
||||
<input
|
||||
type={inputType}
|
||||
{placeholder}
|
||||
bind:value
|
||||
disabled={isDisabled}
|
||||
oninput={handleInput}
|
||||
onchange={handleChange}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
/>
|
||||
{#if suffix}
|
||||
<span class="input-affix suffix">{suffix}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if hasError && errorMessage}
|
||||
<div class="input-error">
|
||||
<Icon icon="mdi:alert-circle" width="12" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-input {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tui-input.inline {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.label-icon) {
|
||||
color: var(--input-color);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
||||
border: 1px solid var(--terminal-muted);
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within {
|
||||
border-color: var(--input-color);
|
||||
box-shadow: 0 0 0 1px
|
||||
color-mix(in srgb, var(--input-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.tui-input.error .input-wrapper {
|
||||
border-color: #f38ba8;
|
||||
}
|
||||
|
||||
.tui-input.disabled .input-wrapper {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-prompt {
|
||||
color: var(--input-color);
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.input-affix {
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.input-affix.prefix {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.input-affix.suffix {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--terminal-text);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--terminal-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
color: #f38ba8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Mobile: inline inputs become full-width */
|
||||
@media (max-width: 768px) {
|
||||
.tui-input.inline {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,251 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getSegmentStyle, getLinePrefix } from "./utils";
|
||||
import { handleNavigation } from "./terminal-keyboard";
|
||||
import { user } from "$lib/config";
|
||||
import TuiButton from "./TuiButton.svelte";
|
||||
import TuiLink from "./TuiLink.svelte";
|
||||
import TuiCard from "./TuiCard.svelte";
|
||||
import TuiProgress from "./TuiProgress.svelte";
|
||||
import TuiAccordion from "./TuiAccordion.svelte";
|
||||
import TuiTable from "./TuiTable.svelte";
|
||||
import TuiTooltip from "./TuiTooltip.svelte";
|
||||
import TuiCardGrid from "./TuiCardGrid.svelte";
|
||||
import TuiInput from "./TuiInput.svelte";
|
||||
import TuiTextarea from "./TuiTextarea.svelte";
|
||||
import TuiCheckbox from "./TuiCheckbox.svelte";
|
||||
import TuiRadio from "./TuiRadio.svelte";
|
||||
import TuiSelect from "./TuiSelect.svelte";
|
||||
import TuiToggle from "./TuiToggle.svelte";
|
||||
import TuiGroup from "./TuiGroup.svelte";
|
||||
import type { TerminalLine, TextSegment } from "./types";
|
||||
|
||||
interface Props {
|
||||
line: TerminalLine;
|
||||
index: number;
|
||||
segments: TextSegment[];
|
||||
complete?: boolean;
|
||||
showImage?: boolean;
|
||||
selectedIndex?: number;
|
||||
inline?: boolean;
|
||||
showCursor?: boolean;
|
||||
onButtonClick?: (idx: number, line?: TerminalLine) => void;
|
||||
onHoverButton?: (idx: number) => void;
|
||||
onLinkClick?: (idx: number) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
index,
|
||||
segments,
|
||||
complete = true,
|
||||
showImage = false,
|
||||
selectedIndex = -1,
|
||||
inline = false,
|
||||
showCursor = false,
|
||||
onButtonClick = () => {},
|
||||
onHoverButton = () => {},
|
||||
onLinkClick = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
// Component types that have their own wrapper
|
||||
const componentTypes = new Set([
|
||||
"button",
|
||||
"card",
|
||||
"cardgrid",
|
||||
"progress",
|
||||
"accordion",
|
||||
"table",
|
||||
"input",
|
||||
"textarea",
|
||||
"checkbox",
|
||||
"radio",
|
||||
"select",
|
||||
"toggle",
|
||||
"group",
|
||||
]);
|
||||
|
||||
// Types that need special handling
|
||||
const isComponent = $derived(componentTypes.has(line.type));
|
||||
const isBlank = $derived(line.type === "blank");
|
||||
const isDivider = $derived(line.type === "divider");
|
||||
const isImage = $derived(line.type === "image");
|
||||
const isPrompt = $derived(
|
||||
line.type === "command" || line.type === "prompt",
|
||||
);
|
||||
const isHeader = $derived(line.type === "header");
|
||||
const isLink = $derived(line.type === "link");
|
||||
const isTooltip = $derived(line.type === "tooltip");
|
||||
|
||||
const blankHeight = $derived(
|
||||
line.type === "blank" && line.height !== undefined
|
||||
? typeof line.height === "number"
|
||||
? `${line.height}px`
|
||||
: line.height
|
||||
: undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class:mobile-hidden={line.mobile === false} style="display: contents">
|
||||
{#if isBlank}
|
||||
{#if inline}<span
|
||||
class="inline-blank"
|
||||
style={blankHeight ? `width: ${blankHeight}` : undefined}
|
||||
></span>{:else}<div
|
||||
class="tui-line blank"
|
||||
style={blankHeight
|
||||
? `height: ${blankHeight}; min-height: ${blankHeight};`
|
||||
: undefined}
|
||||
></div>{/if}
|
||||
{:else if isDivider}
|
||||
<div class="tui-divider" id={line.id}>
|
||||
<span class="divider-line"></span>
|
||||
{#if line.content}<span class="divider-text">{line.content}</span>{/if}
|
||||
<span class="divider-line"></span>
|
||||
</div>
|
||||
{:else if line.type === "button"}
|
||||
<TuiButton
|
||||
{line}
|
||||
{index}
|
||||
selected={selectedIndex === index}
|
||||
onClick={onButtonClick}
|
||||
onHover={onHoverButton}
|
||||
{inline}
|
||||
/>
|
||||
{:else if line.type === "link"}
|
||||
{#if inline}
|
||||
<TuiLink {line} onClick={() => handleNavigation(line)} />
|
||||
{:else}
|
||||
<div class="tui-line link">
|
||||
<TuiLink {line} onClick={() => onLinkClick(index)} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if line.type === "tooltip"}
|
||||
{#if inline}<TuiTooltip {line} />{:else}<div class="tui-line">
|
||||
<TuiTooltip {line} />
|
||||
</div>{/if}
|
||||
{:else if line.type === "card"}
|
||||
<TuiCard {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
|
||||
{:else if line.type === "cardgrid"}
|
||||
<TuiCardGrid {line} {inline} />
|
||||
{:else if line.type === "progress"}
|
||||
<TuiProgress {line} {inline} />
|
||||
{:else if line.type === "accordion"}
|
||||
<TuiAccordion {line} {inline} />
|
||||
{:else if line.type === "table"}
|
||||
<TuiTable {line} {inline} />
|
||||
{:else if line.type === "input"}
|
||||
<TuiInput {line} {inline} />
|
||||
{:else if line.type === "textarea"}
|
||||
<TuiTextarea {line} {inline} />
|
||||
{:else if line.type === "checkbox"}
|
||||
<TuiCheckbox {line} {inline} />
|
||||
{:else if line.type === "radio"}
|
||||
<TuiRadio {line} {inline} />
|
||||
{:else if line.type === "select"}
|
||||
<TuiSelect {line} {inline} />
|
||||
{:else if line.type === "toggle"}
|
||||
<TuiToggle {line} {inline} />
|
||||
{:else if line.type === "group"}
|
||||
<TuiGroup {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
|
||||
{:else if line.type === "image" && showImage}
|
||||
<div class="tui-image" class:inline-image={inline}>
|
||||
<img
|
||||
src={line.image}
|
||||
alt={line.imageAlt || "Image"}
|
||||
style="width: {line.imageWidth + "px" || "100%"}"
|
||||
/>
|
||||
{#if line.content}<span class="image-caption">{line.content}</span>{/if}
|
||||
</div>
|
||||
{:else if line.type === "header"}
|
||||
{#if inline}
|
||||
<span class="header-text inline-header">
|
||||
<Icon icon="mdi:pound" width="20" class="header-icon" />
|
||||
{#each segments as seg}{#if seg.icon}<Icon
|
||||
icon={seg.icon}
|
||||
width={seg.iconSize ?? 20}
|
||||
class="inline-icon"
|
||||
/>{:else if getSegmentStyle(seg)}<span
|
||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="tui-line header" class:complete id={line.id}>
|
||||
<span class="content header-text">
|
||||
<Icon icon="mdi:pound" width="25" class="header-icon" />
|
||||
{#each segments as seg}{#if seg.icon}<Icon
|
||||
icon={seg.icon}
|
||||
width={seg.iconSize ?? 25}
|
||||
class="inline-icon"
|
||||
/>{:else if getSegmentStyle(seg)}<span
|
||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</span>
|
||||
{#if showCursor}<span class="cursor"></span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if line.type === "command" || line.type === "prompt"}
|
||||
{#if inline}
|
||||
<span class="inline-content {line.type}">
|
||||
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
||||
icon={seg.icon}
|
||||
width={seg.iconSize ?? 14}
|
||||
class="inline-icon"
|
||||
/>{:else if getSegmentStyle(seg)}<span
|
||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</span>
|
||||
{:else}
|
||||
<div class="tui-line {line.type}" class:complete id={line.id}>
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at"
|
||||
>@</span
|
||||
><span class="host">{user.hostname}</span>
|
||||
<span class="separator">:</span><span class="path">~</span><span
|
||||
class="symbol">$</span
|
||||
>
|
||||
</span>
|
||||
<span class="content">
|
||||
{getLinePrefix(
|
||||
line.type,
|
||||
)}{#each segments as seg}{#if seg.icon}<Icon
|
||||
icon={seg.icon}
|
||||
width={seg.iconSize ?? 14}
|
||||
class="inline-icon"
|
||||
/>{:else if getSegmentStyle(seg)}<span
|
||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</span>
|
||||
{#if showCursor}<span class="cursor"></span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if !isImage}
|
||||
{#if inline}
|
||||
<span class="inline-content {line.type}">
|
||||
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
||||
icon={seg.icon}
|
||||
width={seg.iconSize ?? 14}
|
||||
class="inline-icon"
|
||||
/>{: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}>
|
||||
<span class="content">
|
||||
{getLinePrefix(
|
||||
line.type,
|
||||
)}{#each segments as seg}{#if seg.icon}<Icon
|
||||
icon={seg.icon}
|
||||
width={seg.iconSize ?? 14}
|
||||
class="inline-icon"
|
||||
/>{:else if getSegmentStyle(seg)}<span
|
||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
||||
>{:else}{seg.text}{/if}{/each}
|
||||
</span>
|
||||
{#if showCursor}<span class="cursor"></span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { LinkLine } from "./types";
|
||||
import "$lib/assets/css/tui-link.css";
|
||||
|
||||
interface Props {
|
||||
line: LinkLine;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
let { line, onClick }: Props = $props();
|
||||
|
||||
// Determine if this is an external link
|
||||
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),
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="tui-link" style="--link-color: {getButtonStyle(line.style)}">
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="14" class="link-icon" />
|
||||
{/if}
|
||||
<button class="link-text" onclick={onClick}>
|
||||
{#each segments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</button>
|
||||
{#if isExternal}
|
||||
<Icon icon="mdi:open-in-new" width="12" class="link-external" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.tui-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
:global(.link-icon) {
|
||||
color: var(--link-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: var(--link-color);
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.link-text:hover {
|
||||
text-decoration-style: solid;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
:global(.link-external) {
|
||||
color: var(--link-color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tui-link:hover :global(.link-external) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
:global(.inline-icon) {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 0.15em;
|
||||
}
|
||||
</style>
|
||||
@@ -1,262 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { ProgressLine } from "./types";
|
||||
import "$lib/assets/css/tui-progress.css";
|
||||
|
||||
interface Props {
|
||||
line: ProgressLine;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
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(
|
||||
parseColorText(line.content, $themeColors.colorMap),
|
||||
);
|
||||
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">
|
||||
{#each contentSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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}%">
|
||||
<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>
|
||||
{/if}
|
||||
|
||||
<div class="progress-value">
|
||||
{#each labelSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 12} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-progress {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Inline progress styles */
|
||||
.tui-progress.inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.tui-progress.inline .progress-label {
|
||||
margin-bottom: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tui-progress.inline .progress-bar {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
height: 0.8rem;
|
||||
}
|
||||
|
||||
.tui-progress.inline .progress-value {
|
||||
margin-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
color: var(--terminal-text);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
height: 1.2rem;
|
||||
background: var(--terminal-border);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--progress-color),
|
||||
color-mix(in srgb, var(--progress-color) 80%, white 20%)
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-glow {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 20px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.3)
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-blocks {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.block {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 1px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.block.filled {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
text-align: right;
|
||||
color: var(--progress-color);
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.inline-icon) {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 0.15em;
|
||||
}
|
||||
|
||||
/* Mobile: inline progress becomes full-width */
|
||||
@media (max-width: 768px) {
|
||||
.tui-progress.inline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tui-progress.inline .progress-bar {
|
||||
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>
|
||||
@@ -1,206 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { RadioLine } from "./types";
|
||||
import "$lib/assets/css/tui-radio.css";
|
||||
|
||||
interface Props {
|
||||
line: RadioLine;
|
||||
inline?: boolean;
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
value = $bindable(""),
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
const labelSegments = $derived(
|
||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
||||
);
|
||||
const options = $derived(line.radioOptions || []);
|
||||
const isDisabled = $derived(line.inputDisabled || false);
|
||||
const isHorizontal = $derived(line.radioHorizontal || false);
|
||||
|
||||
function handleSelect(optionValue: string) {
|
||||
if (isDisabled) return;
|
||||
value = optionValue;
|
||||
onchange?.(value);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, optionValue: string) {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleSelect(optionValue);
|
||||
}
|
||||
}
|
||||
|
||||
function getRadioSymbol(selected: boolean): string {
|
||||
return selected ? "(●)" : "( )";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-radio-group"
|
||||
class:inline
|
||||
class:disabled={isDisabled}
|
||||
style="--radio-color: {getButtonStyle(line.style)}"
|
||||
role="radiogroup"
|
||||
>
|
||||
{#if line.content}
|
||||
<div class="radio-label">
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
||||
{/if}
|
||||
{#each labelSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="radio-options" class:horizontal={isHorizontal}>
|
||||
{#each options as option}
|
||||
{@const isSelected = value === option.value}
|
||||
{@const optionSegments = parseColorText(
|
||||
option.label,
|
||||
$themeColors.colorMap,
|
||||
)}
|
||||
<div
|
||||
class="radio-option"
|
||||
class:selected={isSelected}
|
||||
class:option-disabled={option.disabled}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
aria-disabled={isDisabled || option.disabled}
|
||||
tabindex={isDisabled || option.disabled ? -1 : 0}
|
||||
onclick={() => !option.disabled && handleSelect(option.value)}
|
||||
onkeydown={(e) =>
|
||||
!option.disabled && handleKeydown(e, option.value)}
|
||||
>
|
||||
<span class="radio-symbol">
|
||||
{getRadioSymbol(isSelected)}
|
||||
</span>
|
||||
{#if option.icon}
|
||||
<Icon icon={option.icon} width="14" class="option-icon" />
|
||||
{/if}
|
||||
<span class="option-label">
|
||||
{#each optionSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon
|
||||
icon={segment.icon}
|
||||
width={segment.iconSize ?? 14}
|
||||
class="inline-icon"
|
||||
/>
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}
|
||||
>{segment.text}</span
|
||||
>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-radio-group {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tui-radio-group.inline {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.tui-radio-group.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.radio-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.label-icon) {
|
||||
color: var(--radio-color);
|
||||
}
|
||||
|
||||
.radio-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.radio-options.horizontal {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.radio-option:hover:not(.option-disabled) {
|
||||
background: color-mix(in srgb, var(--radio-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.radio-option:focus-visible {
|
||||
outline: 1px solid var(--radio-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.radio-option.option-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.radio-symbol {
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
color: var(--terminal-muted);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.radio-option.selected .radio-symbol {
|
||||
color: var(--radio-color);
|
||||
}
|
||||
|
||||
:global(.option-icon) {
|
||||
color: var(--radio-color);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.radio-option.option-disabled .option-label {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -1,434 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { SelectLine } from "./types";
|
||||
import "$lib/assets/css/tui-select.css";
|
||||
|
||||
interface Props {
|
||||
line: SelectLine;
|
||||
inline?: boolean;
|
||||
value?: string;
|
||||
onchange?: (value: string) => void;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
value = $bindable(""),
|
||||
onchange,
|
||||
onfocus,
|
||||
onblur,
|
||||
}: Props = $props();
|
||||
|
||||
const labelSegments = $derived(
|
||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
||||
);
|
||||
const options = $derived(line.selectOptions || []);
|
||||
const placeholder = $derived(
|
||||
line.inputPlaceholder || "Select an option...",
|
||||
);
|
||||
const isDisabled = $derived(line.inputDisabled || false);
|
||||
const hasError = $derived(line.inputError);
|
||||
const errorMessage = $derived(line.inputErrorMessage || "");
|
||||
const searchable = $derived(line.selectSearchable || false);
|
||||
|
||||
let isOpen = $state(false);
|
||||
let searchQuery = $state("");
|
||||
let highlightedIndex = $state(0);
|
||||
let selectRef: HTMLDivElement | undefined = $state();
|
||||
|
||||
const filteredOptions = $derived(
|
||||
searchable && searchQuery
|
||||
? options.filter(
|
||||
(opt) =>
|
||||
opt.label
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()) ||
|
||||
opt.value
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: options,
|
||||
);
|
||||
|
||||
const selectedOption = $derived(options.find((opt) => opt.value === value));
|
||||
const displayValue = $derived(selectedOption?.label || "");
|
||||
|
||||
function handleToggle() {
|
||||
if (isDisabled) return;
|
||||
isOpen = !isOpen;
|
||||
if (isOpen) {
|
||||
highlightedIndex = 0;
|
||||
searchQuery = "";
|
||||
onfocus?.();
|
||||
} else {
|
||||
onblur?.();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(optionValue: string) {
|
||||
if (isDisabled) return;
|
||||
value = optionValue;
|
||||
isOpen = false;
|
||||
searchQuery = "";
|
||||
onchange?.(value);
|
||||
onblur?.();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (isDisabled) return;
|
||||
|
||||
switch (e.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
e.preventDefault();
|
||||
if (isOpen && filteredOptions[highlightedIndex]) {
|
||||
handleSelect(filteredOptions[highlightedIndex].value);
|
||||
} else {
|
||||
handleToggle();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
isOpen = true;
|
||||
} else {
|
||||
highlightedIndex = Math.min(
|
||||
highlightedIndex + 1,
|
||||
filteredOptions.length - 1,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
isOpen = false;
|
||||
searchQuery = "";
|
||||
break;
|
||||
case "Home":
|
||||
if (isOpen) {
|
||||
e.preventDefault();
|
||||
highlightedIndex = 0;
|
||||
}
|
||||
break;
|
||||
case "End":
|
||||
if (isOpen) {
|
||||
e.preventDefault();
|
||||
highlightedIndex = filteredOptions.length - 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (selectRef && !selectRef.contains(e.target as Node)) {
|
||||
isOpen = false;
|
||||
searchQuery = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearchInput(e: Event) {
|
||||
searchQuery = (e.target as HTMLInputElement).value;
|
||||
highlightedIndex = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} />
|
||||
|
||||
<div
|
||||
class="tui-select"
|
||||
class:inline
|
||||
class:open={isOpen}
|
||||
class:error={hasError}
|
||||
class:disabled={isDisabled}
|
||||
style="--select-color: {getButtonStyle(line.style)}"
|
||||
bind:this={selectRef}
|
||||
>
|
||||
{#if line.content}
|
||||
<label class="select-label">
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
||||
{/if}
|
||||
{#each labelSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="select-trigger"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="select-listbox"
|
||||
aria-haspopup="listbox"
|
||||
aria-disabled={isDisabled}
|
||||
tabindex={isDisabled ? -1 : 0}
|
||||
onclick={handleToggle}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<span class="select-prompt">❯</span>
|
||||
<span class="select-value" class:placeholder={!selectedOption}>
|
||||
{displayValue || placeholder}
|
||||
</span>
|
||||
<span class="select-arrow">{isOpen ? "▲" : "▼"}</span>
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="select-dropdown" role="listbox" id="select-listbox">
|
||||
{#if searchable}
|
||||
<div class="select-search">
|
||||
<Icon icon="mdi:magnify" width="14" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
oninput={handleSearchInput}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="select-options">
|
||||
{#each filteredOptions as option, i}
|
||||
{@const optionSegments = parseColorText(option.label, $themeColors.colorMap)}
|
||||
<div
|
||||
class="select-option"
|
||||
class:selected={value === option.value}
|
||||
class:highlighted={i === highlightedIndex}
|
||||
class:option-disabled={option.disabled}
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
aria-disabled={option.disabled}
|
||||
tabindex={option.disabled ? -1 : 0}
|
||||
onclick={() =>
|
||||
!option.disabled && handleSelect(option.value)}
|
||||
onkeydown={(e) =>
|
||||
e.key === "Enter" &&
|
||||
!option.disabled &&
|
||||
handleSelect(option.value)}
|
||||
onmouseenter={() => (highlightedIndex = i)}
|
||||
>
|
||||
{#if option.icon}
|
||||
<Icon
|
||||
icon={option.icon}
|
||||
width="14"
|
||||
class="option-icon"
|
||||
/>
|
||||
{/if}
|
||||
<span class="option-label">
|
||||
{#each optionSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon
|
||||
icon={segment.icon}
|
||||
width={segment.iconSize ?? 14}
|
||||
class="inline-icon"
|
||||
/>
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}
|
||||
>{segment.text}</span
|
||||
>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{#if value === option.value}
|
||||
<Icon
|
||||
icon="mdi:check"
|
||||
width="14"
|
||||
class="check-icon"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="select-empty">No options found</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasError && errorMessage}
|
||||
<div class="select-error">
|
||||
<Icon icon="mdi:alert-circle" width="12" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-select {
|
||||
position: relative;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tui-select.inline {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.label-icon) {
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.select-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
||||
border: 1px solid var(--terminal-muted);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tui-select.open .select-trigger,
|
||||
.select-trigger:focus-visible {
|
||||
border-color: var(--select-color);
|
||||
box-shadow: 0 0 0 1px
|
||||
color-mix(in srgb, var(--select-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.tui-select.error .select-trigger {
|
||||
border-color: #f38ba8;
|
||||
}
|
||||
|
||||
.tui-select.disabled .select-trigger {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.select-prompt {
|
||||
color: var(--select-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.select-value {
|
||||
flex: 1;
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.select-value.placeholder {
|
||||
color: var(--terminal-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.select-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: var(--terminal-bg);
|
||||
border: 1px solid var(--select-color);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.select-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px solid var(--terminal-muted);
|
||||
}
|
||||
|
||||
.select-search input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--terminal-text);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.select-search input::placeholder {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.select-options {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.select-option.highlighted {
|
||||
background: color-mix(in srgb, var(--select-color) 15%, transparent);
|
||||
}
|
||||
|
||||
.select-option.selected {
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.select-option.option-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(.option-icon) {
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(.check-icon) {
|
||||
color: var(--select-color);
|
||||
}
|
||||
|
||||
.select-empty {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
color: var(--terminal-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.select-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.35rem;
|
||||
color: #f38ba8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,112 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { TableLine } from "./types";
|
||||
import "$lib/assets/css/tui-table.css";
|
||||
|
||||
interface Props {
|
||||
line: TableLine;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
let { line, inline = false }: Props = $props();
|
||||
|
||||
const headers = $derived(line.tableHeaders || []);
|
||||
const rows = $derived(line.tableRows || []);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-table-wrapper"
|
||||
class:inline
|
||||
style="--table-accent: {getButtonStyle(line.style)}"
|
||||
>
|
||||
{#if line.content}
|
||||
<div class="table-title">{line.content}</div>
|
||||
{/if}
|
||||
<table class="tui-table">
|
||||
{#if headers.length > 0}
|
||||
<thead>
|
||||
<tr>
|
||||
{#each headers as header}
|
||||
<th>{header}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
{/if}
|
||||
<tbody>
|
||||
{#each rows as row, i}
|
||||
<tr class:alt={i % 2 === 1}>
|
||||
{#each row as cell}
|
||||
{@const cellSegments = parseColorText(
|
||||
cell,
|
||||
$themeColors.colorMap,
|
||||
)}
|
||||
<td>
|
||||
{#each cellSegments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}
|
||||
>{segment.text}</span
|
||||
>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-table-wrapper {
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-title {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--terminal-text);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.tui-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--table-accent);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: var(--terminal-text);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
tr.alt {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: color-mix(in srgb, var(--table-accent) 5%, transparent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,238 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { TextareaLine } from "./types";
|
||||
import "$lib/assets/css/tui-textarea.css";
|
||||
|
||||
interface Props {
|
||||
line: TextareaLine;
|
||||
inline?: boolean;
|
||||
value?: string;
|
||||
oninput?: (value: string) => void;
|
||||
onchange?: (value: string) => void;
|
||||
onfocus?: () => void;
|
||||
onblur?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
value = $bindable(""),
|
||||
oninput,
|
||||
onchange,
|
||||
onfocus,
|
||||
onblur,
|
||||
}: Props = $props();
|
||||
|
||||
const labelSegments = $derived(
|
||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
||||
);
|
||||
const placeholder = $derived(line.inputPlaceholder || "");
|
||||
const isDisabled = $derived(line.inputDisabled || false);
|
||||
const hasError = $derived(line.inputError);
|
||||
const errorMessage = $derived(line.inputErrorMessage || "");
|
||||
const rows = $derived(line.textareaRows || 4);
|
||||
const maxLength = $derived(line.textareaMaxLength);
|
||||
|
||||
let isFocused = $state(false);
|
||||
let charCount = $state(value.length);
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
value = target.value;
|
||||
charCount = value.length;
|
||||
oninput?.(value);
|
||||
}
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
onchange?.(target.value);
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
isFocused = true;
|
||||
onfocus?.();
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
isFocused = false;
|
||||
onblur?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-textarea"
|
||||
class:inline
|
||||
class:focused={isFocused}
|
||||
class:error={hasError}
|
||||
class:disabled={isDisabled}
|
||||
style="--input-color: {getButtonStyle(line.style)}"
|
||||
>
|
||||
{#if line.content}
|
||||
<label class="textarea-label">
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
||||
{/if}
|
||||
{#each labelSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="textarea-wrapper">
|
||||
<div class="line-numbers">
|
||||
{#each Array(Math.max(rows, value.split("\n").length)) as _, i}
|
||||
<span class="line-num">{i + 1}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<textarea
|
||||
{placeholder}
|
||||
{rows}
|
||||
maxlength={maxLength}
|
||||
disabled={isDisabled}
|
||||
bind:value
|
||||
oninput={handleInput}
|
||||
onchange={handleChange}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="textarea-footer">
|
||||
{#if hasError && errorMessage}
|
||||
<div class="textarea-error">
|
||||
<Icon icon="mdi:alert-circle" width="12" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if maxLength}
|
||||
<div class="char-count" class:warning={charCount > maxLength * 0.9}>
|
||||
{charCount}/{maxLength}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-textarea {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tui-textarea.inline {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.textarea-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
:global(.label-icon) {
|
||||
color: var(--input-color);
|
||||
}
|
||||
|
||||
.textarea-wrapper {
|
||||
display: flex;
|
||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
||||
border: 1px solid var(--terminal-muted);
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tui-textarea.focused .textarea-wrapper {
|
||||
border-color: var(--input-color);
|
||||
box-shadow: 0 0 0 1px
|
||||
color-mix(in srgb, var(--input-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.tui-textarea.error .textarea-wrapper {
|
||||
border-color: #f38ba8;
|
||||
}
|
||||
|
||||
.tui-textarea.disabled .textarea-wrapper {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.line-numbers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0;
|
||||
background: color-mix(in srgb, var(--terminal-bg) 60%, black);
|
||||
border-right: 1px solid var(--terminal-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.line-num {
|
||||
padding: 0 0.5rem;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
min-width: 2rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--terminal-text);
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--terminal-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
textarea:disabled {
|
||||
cursor: not-allowed;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.textarea-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.textarea-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: #f38ba8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
margin-left: auto;
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.char-count.warning {
|
||||
color: #f9e2af;
|
||||
}
|
||||
</style>
|
||||
@@ -1,192 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { ToggleLine } from "./types";
|
||||
import "$lib/assets/css/tui-toggle.css";
|
||||
|
||||
interface Props {
|
||||
line: ToggleLine;
|
||||
inline?: boolean;
|
||||
checked?: boolean;
|
||||
onchange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
line,
|
||||
inline = false,
|
||||
checked = $bindable(false),
|
||||
onchange,
|
||||
}: Props = $props();
|
||||
|
||||
const labelSegments = $derived(
|
||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
||||
);
|
||||
const isDisabled = $derived(line.inputDisabled || false);
|
||||
const onLabel = $derived(line.toggleOnLabel || "ON");
|
||||
const offLabel = $derived(line.toggleOffLabel || "OFF");
|
||||
const showLabels = $derived(line.toggleShowLabels !== false);
|
||||
|
||||
function handleToggle() {
|
||||
if (isDisabled) return;
|
||||
checked = !checked;
|
||||
onchange?.(checked);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === " " || e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleToggle();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="tui-toggle"
|
||||
class:inline
|
||||
class:checked
|
||||
class:disabled={isDisabled}
|
||||
style="--toggle-color: {getButtonStyle(line.style)}"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-disabled={isDisabled}
|
||||
tabindex={isDisabled ? -1 : 0}
|
||||
onclick={handleToggle}
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="14" class="toggle-icon" />
|
||||
{/if}
|
||||
|
||||
{#if line.content}
|
||||
<span class="toggle-label">
|
||||
{#each labelSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<div class="toggle-track">
|
||||
{#if showLabels}
|
||||
<span class="toggle-off-label">{offLabel}</span>
|
||||
{/if}
|
||||
<span class="toggle-switch">
|
||||
<span class="toggle-knob">{checked ? "●" : "○"}</span>
|
||||
</span>
|
||||
{#if showLabels}
|
||||
<span class="toggle-on-label">{onLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.35rem 0;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tui-toggle.inline {
|
||||
display: inline-flex;
|
||||
margin: 0 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.tui-toggle:hover:not(.disabled) {
|
||||
background: color-mix(in srgb, var(--toggle-color) 10%, transparent);
|
||||
}
|
||||
|
||||
.tui-toggle:focus-visible {
|
||||
outline: 1px solid var(--toggle-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tui-toggle.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(.toggle-icon) {
|
||||
color: var(--toggle-color);
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
||||
border: 1px solid var(--terminal-muted);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tui-toggle.checked .toggle-track {
|
||||
border-color: var(--toggle-color);
|
||||
background: color-mix(in srgb, var(--toggle-color) 15%, transparent);
|
||||
}
|
||||
|
||||
.toggle-off-label,
|
||||
.toggle-on-label {
|
||||
color: var(--terminal-muted);
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.tui-toggle:not(.checked) .toggle-off-label {
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.tui-toggle.checked .toggle-on-label {
|
||||
color: var(--toggle-color);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
font-size: 1rem;
|
||||
color: var(--terminal-muted);
|
||||
transition: all 0.2s ease;
|
||||
transform: translateX(-0.5rem);
|
||||
}
|
||||
|
||||
.tui-toggle.checked .toggle-knob {
|
||||
color: var(--toggle-color);
|
||||
transform: translateX(0.5rem);
|
||||
}
|
||||
|
||||
/* Mobile: inline toggles become full-width */
|
||||
@media (max-width: 768px) {
|
||||
.tui-toggle.inline {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,176 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
||||
import { themeColors } from "$lib/stores/theme";
|
||||
import type { TooltipLine } from "./types";
|
||||
import "$lib/assets/css/tui-tooltip.css";
|
||||
|
||||
interface Props {
|
||||
line: TooltipLine;
|
||||
}
|
||||
|
||||
let { line }: Props = $props();
|
||||
|
||||
let showTooltip = $state(false);
|
||||
let triggerEl: HTMLSpanElement | undefined = $state();
|
||||
let tooltipStyle = $state("");
|
||||
|
||||
const contentSegments = $derived(parseColorText(line.content, $themeColors.colorMap));
|
||||
const position = $derived(line.tooltipPosition || "top");
|
||||
|
||||
function updateTooltipPosition() {
|
||||
if (!triggerEl) return;
|
||||
|
||||
const rect = triggerEl.getBoundingClientRect();
|
||||
const scrollY = window.scrollY;
|
||||
const scrollX = window.scrollX;
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (position) {
|
||||
case "top":
|
||||
top = rect.top + scrollY - 8;
|
||||
left = rect.left + scrollX + rect.width / 2;
|
||||
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-50%) translateY(-100%);`;
|
||||
break;
|
||||
case "bottom":
|
||||
top = rect.bottom + scrollY + 8;
|
||||
left = rect.left + scrollX + rect.width / 2;
|
||||
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-50%);`;
|
||||
break;
|
||||
case "left":
|
||||
top = rect.top + scrollY + rect.height / 2;
|
||||
left = rect.left + scrollX - 8;
|
||||
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-100%) translateY(-50%);`;
|
||||
break;
|
||||
case "right":
|
||||
top = rect.top + scrollY + rect.height / 2;
|
||||
left = rect.right + scrollX + 8;
|
||||
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateY(-50%);`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
updateTooltipPosition();
|
||||
showTooltip = true;
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
showTooltip = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="tui-tooltip-trigger"
|
||||
style="--tooltip-color: {getButtonStyle(line.style)}"
|
||||
bind:this={triggerEl}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onfocus={handleMouseEnter}
|
||||
onblur={handleMouseLeave}
|
||||
>
|
||||
<span class="trigger-text">
|
||||
{#each contentSegments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
<span class="tooltip-indicator">(?)</span>
|
||||
</span>
|
||||
|
||||
{#if showTooltip && line.tooltipText}
|
||||
<span
|
||||
class="tooltip {position}"
|
||||
style="{tooltipStyle} --tooltip-color: {getButtonStyle(line.style)}"
|
||||
>
|
||||
{line.tooltipText}
|
||||
<span class="tooltip-arrow"></span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tui-tooltip-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.trigger-text {
|
||||
border-bottom: 1px dotted var(--tooltip-color);
|
||||
}
|
||||
|
||||
.tooltip-indicator {
|
||||
font-size: 0.7rem;
|
||||
color: var(--tooltip-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
z-index: 99999;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--terminal-bg);
|
||||
border: 1px solid var(--tooltip-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--terminal-text);
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
animation: tooltipFadeIn 0.15s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes tooltipFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Arrow styles - simplified for fixed positioning */
|
||||
.tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--terminal-bg);
|
||||
border: 1px solid var(--tooltip-color);
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.tooltip.top .tooltip-arrow {
|
||||
bottom: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
}
|
||||
|
||||
.tooltip.bottom .tooltip-arrow {
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(-135deg);
|
||||
}
|
||||
|
||||
.tooltip.left .tooltip-arrow {
|
||||
right: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(-45deg);
|
||||
}
|
||||
|
||||
.tooltip.right .tooltip-arrow {
|
||||
left: -5px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(135deg);
|
||||
}
|
||||
</style>
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Terminal API Factory
|
||||
*
|
||||
* Creates a reactive API for manipulating terminal content programmatically.
|
||||
* Use this with TerminalTUI's bindable `terminal` prop.
|
||||
*/
|
||||
|
||||
import type { TerminalLine, ParsedLine, DisplayedLine, TerminalAPI } from './types';
|
||||
import { parseColorText, getPlainText } from './utils';
|
||||
import type { ThemeColorMap } from './utils';
|
||||
|
||||
export interface TerminalState {
|
||||
lines: TerminalLine[];
|
||||
displayedLines: DisplayedLine[];
|
||||
isTyping: boolean;
|
||||
isComplete: boolean;
|
||||
currentLineIndex: number;
|
||||
selectedIndex: number;
|
||||
skipRequested: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalAPIOptions {
|
||||
getState: () => TerminalState;
|
||||
setState: (updates: Partial<TerminalState>) => void;
|
||||
getColorMap: () => ThemeColorMap;
|
||||
getBodyElement: () => HTMLDivElement | undefined;
|
||||
typeText: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single terminal line into segments
|
||||
*/
|
||||
export function parseLine(line: TerminalLine, colorMap: ThemeColorMap): ParsedLine {
|
||||
const segments = parseColorText(line.content, colorMap);
|
||||
return {
|
||||
line,
|
||||
segments,
|
||||
plainText: getPlainText(segments)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a terminal API instance
|
||||
*/
|
||||
export function createTerminalAPI(options: TerminalAPIOptions): TerminalAPI {
|
||||
const { getState, setState, getColorMap, getBodyElement, typeText } = options;
|
||||
|
||||
function refreshDisplayedLines() {
|
||||
const state = getState();
|
||||
const colorMap = getColorMap();
|
||||
// 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: displayed.complete ? parsed.plainText.length : displayed.charIndex,
|
||||
complete: displayed.complete,
|
||||
showImage: displayed.showImage ?? (line.type === 'image')
|
||||
};
|
||||
});
|
||||
setState({ displayedLines, lines: [...state.lines] });
|
||||
}
|
||||
|
||||
function apiScrollToBottom() {
|
||||
const bodyElement = getBodyElement();
|
||||
if (!bodyElement) return;
|
||||
bodyElement.scrollTo({
|
||||
top: bodyElement.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
function apiScrollToLine(index: number) {
|
||||
const bodyElement = getBodyElement();
|
||||
if (!bodyElement) return;
|
||||
const lineElements = bodyElement.querySelectorAll('.tui-line');
|
||||
const target = lineElements[index] as HTMLElement | undefined;
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clear: () => {
|
||||
setState({
|
||||
lines: [],
|
||||
displayedLines: [],
|
||||
isComplete: true,
|
||||
isTyping: false
|
||||
});
|
||||
},
|
||||
|
||||
write: (line: TerminalLine) => {
|
||||
const state = getState();
|
||||
const colorMap = getColorMap();
|
||||
const parsed = parseLine(line, colorMap);
|
||||
setState({
|
||||
lines: [...state.lines, line],
|
||||
displayedLines: [...state.displayedLines, {
|
||||
parsed,
|
||||
charIndex: parsed.plainText.length,
|
||||
complete: true,
|
||||
showImage: line.type === 'image'
|
||||
}]
|
||||
});
|
||||
setTimeout(apiScrollToBottom, 10);
|
||||
},
|
||||
|
||||
writeLines: (newLines: TerminalLine[]) => {
|
||||
const state = getState();
|
||||
const colorMap = getColorMap();
|
||||
const newDisplayed = newLines.map(line => {
|
||||
const parsed = parseLine(line, colorMap);
|
||||
return {
|
||||
parsed,
|
||||
charIndex: parsed.plainText.length,
|
||||
complete: true,
|
||||
showImage: line.type === 'image'
|
||||
};
|
||||
});
|
||||
setState({
|
||||
lines: [...state.lines, ...newLines],
|
||||
displayedLines: [...state.displayedLines, ...newDisplayed]
|
||||
});
|
||||
setTimeout(apiScrollToBottom, 10);
|
||||
},
|
||||
|
||||
update: (index: number, updates: Partial<TerminalLine>) => {
|
||||
const state = getState();
|
||||
if (index < 0 || index >= state.lines.length) return;
|
||||
// Cast to TerminalLine to allow partial updates across union types
|
||||
state.lines[index] = { ...state.lines[index], ...updates } as TerminalLine;
|
||||
refreshDisplayedLines();
|
||||
},
|
||||
|
||||
updateContent: (index: number, content: string) => {
|
||||
const state = getState();
|
||||
if (index < 0 || index >= state.lines.length) return;
|
||||
state.lines[index] = { ...state.lines[index], content };
|
||||
refreshDisplayedLines();
|
||||
},
|
||||
|
||||
insert: (index: number, line: TerminalLine) => {
|
||||
const state = getState();
|
||||
const clampedIndex = Math.max(0, Math.min(index, state.lines.length));
|
||||
setState({
|
||||
lines: [...state.lines.slice(0, clampedIndex), line, ...state.lines.slice(clampedIndex)]
|
||||
});
|
||||
refreshDisplayedLines();
|
||||
},
|
||||
|
||||
remove: (index: number) => {
|
||||
const state = getState();
|
||||
if (index < 0 || index >= state.lines.length) return;
|
||||
setState({
|
||||
lines: [...state.lines.slice(0, index), ...state.lines.slice(index + 1)]
|
||||
});
|
||||
refreshDisplayedLines();
|
||||
},
|
||||
|
||||
removeRange: (startIndex: number, count: number) => {
|
||||
const state = getState();
|
||||
if (startIndex < 0 || startIndex >= state.lines.length) return;
|
||||
setState({
|
||||
lines: [...state.lines.slice(0, startIndex), ...state.lines.slice(startIndex + count)]
|
||||
});
|
||||
refreshDisplayedLines();
|
||||
},
|
||||
|
||||
setLines: (newLines: TerminalLine[]) => {
|
||||
setState({ lines: [...newLines] });
|
||||
refreshDisplayedLines();
|
||||
},
|
||||
|
||||
getLineCount: () => getState().lines.length,
|
||||
|
||||
getLine: (index: number) => getState().lines[index],
|
||||
|
||||
getLines: () => [...getState().lines],
|
||||
|
||||
findById: (id: string) => getState().lines.findIndex(line => line.id === id),
|
||||
|
||||
updateById: (id: string, updates: Partial<TerminalLine>) => {
|
||||
const state = getState();
|
||||
const index = state.lines.findIndex(line => line.id === id);
|
||||
if (index !== -1) {
|
||||
// Cast to TerminalLine to allow partial updates across union types
|
||||
state.lines[index] = { ...state.lines[index], ...updates } as TerminalLine;
|
||||
refreshDisplayedLines();
|
||||
}
|
||||
},
|
||||
|
||||
removeById: (id: string) => {
|
||||
const state = getState();
|
||||
const index = state.lines.findIndex(line => line.id === id);
|
||||
if (index !== -1) {
|
||||
setState({
|
||||
lines: [...state.lines.slice(0, index), ...state.lines.slice(index + 1)]
|
||||
});
|
||||
refreshDisplayedLines();
|
||||
}
|
||||
},
|
||||
|
||||
scrollToBottom: apiScrollToBottom,
|
||||
|
||||
scrollToLine: apiScrollToLine,
|
||||
|
||||
isAnimating: () => getState().isTyping,
|
||||
|
||||
skip: () => {
|
||||
const state = getState();
|
||||
if (!state.isTyping) return;
|
||||
|
||||
const colorMap = getColorMap();
|
||||
const displayedLines = state.lines.map(line => {
|
||||
const parsed = parseLine(line, colorMap);
|
||||
return {
|
||||
parsed,
|
||||
charIndex: parsed.plainText.length,
|
||||
complete: true,
|
||||
showImage: line.type === 'image'
|
||||
};
|
||||
});
|
||||
|
||||
setState({
|
||||
skipRequested: true,
|
||||
displayedLines,
|
||||
isTyping: false,
|
||||
isComplete: true,
|
||||
currentLineIndex: state.lines.length - 1
|
||||
});
|
||||
|
||||
apiScrollToBottom();
|
||||
},
|
||||
|
||||
restart: () => {
|
||||
setState({
|
||||
displayedLines: [],
|
||||
currentLineIndex: 0,
|
||||
isTyping: false,
|
||||
isComplete: false,
|
||||
skipRequested: false,
|
||||
selectedIndex: -1
|
||||
});
|
||||
typeText();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import type { TerminalAPI, TerminalLine } from './types';
|
||||
import { user } from '$lib/config';
|
||||
|
||||
export interface CommandContext {
|
||||
terminal: TerminalAPI;
|
||||
navigate: (path: string, external?: boolean) => void;
|
||||
toggleMode: () => void;
|
||||
setColorTheme: (name: string) => void;
|
||||
getMode: () => string;
|
||||
getColorTheme: () => string;
|
||||
themeNames: string[];
|
||||
user: typeof user;
|
||||
skills: Record<string, string[]>;
|
||||
navigation: Array<{ name: string; path: string; external?: boolean }>;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string;
|
||||
description: string;
|
||||
usage?: string;
|
||||
aliases?: string[];
|
||||
hidden?: boolean;
|
||||
run: (args: string[], ctx: CommandContext) => void;
|
||||
}
|
||||
|
||||
const out = (content: string): TerminalLine => ({ type: 'output', content });
|
||||
const ok = (content: string): TerminalLine => ({ type: 'success', content });
|
||||
const err = (content: string): TerminalLine => ({ type: 'error', content });
|
||||
const info = (content: string): TerminalLine => ({ type: 'info', content });
|
||||
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
|
||||
|
||||
interface PageNode {
|
||||
name: string;
|
||||
path: string;
|
||||
children?: PageNode[];
|
||||
}
|
||||
|
||||
const pageTree: PageNode[] = [
|
||||
{ name: 'home', path: '/' },
|
||||
{ name: 'about', path: '/about' },
|
||||
{ name: 'portfolio', path: '/portfolio' },
|
||||
{
|
||||
name: 'projects',
|
||||
path: '/projects',
|
||||
children: [
|
||||
{ name: 'opensource', path: '/projects/opensource' },
|
||||
{ name: 'packages', path: '/projects/packages' },
|
||||
{ name: 'hackathons', path: '/projects/hackathons' },
|
||||
{ name: 'research', path: '/projects/research' }
|
||||
]
|
||||
},
|
||||
{ name: 'models', path: '/models' },
|
||||
{ name: 'components', path: '/components' },
|
||||
{ name: 'links', path: '/links' },
|
||||
{ name: 'blog', path: '/blog' }
|
||||
];
|
||||
|
||||
const pageRoutes: Record<string, string> = pageTree.reduce((routes, node) => {
|
||||
routes[node.name] = node.path;
|
||||
node.children?.forEach((child) => {
|
||||
routes[child.name] = child.path;
|
||||
});
|
||||
return routes;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
function resolvePagePath(target: string): string | undefined {
|
||||
if (pageRoutes[target] !== undefined) return pageRoutes[target];
|
||||
|
||||
const segments = target.split('/').filter(Boolean);
|
||||
if (segments.length < 2) return undefined;
|
||||
|
||||
let nodes: PageNode[] = pageTree;
|
||||
let path: string | undefined;
|
||||
for (const segment of segments) {
|
||||
const node = nodes.find((candidate) => candidate.name === segment);
|
||||
if (!node) return undefined;
|
||||
path = node.path;
|
||||
nodes = node.children ?? [];
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function cowsay(message: string): string[] {
|
||||
const text = message || 'Moo?';
|
||||
const border = '-'.repeat(text.length + 2);
|
||||
return [
|
||||
` ${border}`,
|
||||
`< ${text} >`,
|
||||
` ${border}`,
|
||||
' \\ ^__^',
|
||||
' \\ (oo)\\_______',
|
||||
' (__)\\ )\\/\\',
|
||||
' ||----w |',
|
||||
' || ||'
|
||||
];
|
||||
}
|
||||
|
||||
const baseCommands: Command[] = [
|
||||
{
|
||||
name: 'help',
|
||||
aliases: ['h', '?'],
|
||||
description: 'List all available commands',
|
||||
run: (_args, ctx) => {
|
||||
const rows = commands
|
||||
.filter((command) => !command.hidden)
|
||||
.map((command) => [
|
||||
`(&primary,bold)${command.name}(&)` +
|
||||
(command.aliases?.length ? ` (&muted)(${command.aliases.join(', ')})(&)` : ''),
|
||||
command.description
|
||||
]);
|
||||
ctx.terminal.write({
|
||||
type: 'table',
|
||||
content: 'Available Commands',
|
||||
tableHeaders: ['Command', 'Description'],
|
||||
tableRows: rows,
|
||||
style: 'accent'
|
||||
} as TerminalLine);
|
||||
ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cowsay(&), or (&accent)cd projects(&)'));
|
||||
ctx.terminal.write(blank());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
aliases: ['cls'],
|
||||
description: 'Clear the terminal',
|
||||
run: (_args, ctx) => ctx.terminal.clear()
|
||||
},
|
||||
{
|
||||
name: 'whoami',
|
||||
description: 'Show a short bio',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.writeLines([
|
||||
out(`(&primary,bold)${ctx.user.displayname}(&) (&muted)— ${ctx.user.title}(&)`),
|
||||
out(`(&muted)${ctx.user.bio}(&)`),
|
||||
blank()
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'neofetch',
|
||||
aliases: ['banner'],
|
||||
description: 'Display system info',
|
||||
run: (_args, ctx) => {
|
||||
const launched = new Date('2020-01-01T00:00:00Z');
|
||||
const days = Math.floor((Date.now() - launched.getTime()) / 86400000);
|
||||
const infoLines: TerminalLine[] = [
|
||||
out(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`),
|
||||
out('(&muted)------------------------------(&)'),
|
||||
out(`(&blue,bold)Name(&): ${ctx.user.displayname}`),
|
||||
out(`(&blue,bold)Title(&): ${ctx.user.title}`),
|
||||
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
|
||||
out(`(&blue,bold)Shell(&): tui-terminal`),
|
||||
out(`(&blue,bold)Theme(&): ${ctx.getColorTheme()} (${ctx.getMode()})`),
|
||||
out(`(&blue,bold)Uptime(&): ${days} days since launch`),
|
||||
out(`(&blue,bold)Languages(&): ${ctx.skills.languages.slice(0, 6).join(', ')}`),
|
||||
out('(&red)███(&)(&yellow)███(&)(&green)███(&)(&cyan)███(&)(&blue)███(&)(&magenta)███(&)')
|
||||
];
|
||||
ctx.terminal.writeLines([
|
||||
{
|
||||
type: 'image',
|
||||
content: '',
|
||||
image: ctx.user.avatar,
|
||||
imageAlt: ctx.user.displayname,
|
||||
imageWidth: 325,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
content: '',
|
||||
inline: true,
|
||||
groupDirection: 'column',
|
||||
groupGap: '0',
|
||||
children: infoLines
|
||||
},
|
||||
blank()
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
description: 'List skills by category',
|
||||
run: (_args, ctx) => {
|
||||
const order = ['languages', 'frameworks', 'tools', 'platforms', 'applications', 'databases', 'interests'];
|
||||
const lines: TerminalLine[] = [];
|
||||
for (const key of order) {
|
||||
const values = ctx.skills[key];
|
||||
if (!values?.length) continue;
|
||||
lines.push(info(`(&blue,bold)${key}(&)`));
|
||||
lines.push(out(' ' + values.map((value) => `(&cyan)${value}(&)`).join(' (&muted)•(&) ')));
|
||||
}
|
||||
lines.push(blank());
|
||||
ctx.terminal.writeLines(lines);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contact',
|
||||
aliases: ['socials'],
|
||||
description: 'Show social links',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.write(info('(&accent,bold)Find me online:(&)'));
|
||||
ctx.terminal.writeLines(
|
||||
ctx.user.socials.map((social) => ({
|
||||
type: 'button',
|
||||
content: social.name,
|
||||
icon: social.icon,
|
||||
href: social.link,
|
||||
style: 'primary',
|
||||
inline: true
|
||||
})) as TerminalLine[]
|
||||
);
|
||||
ctx.terminal.write(blank());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
description: 'Show contact email',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.write(out(`(&accent)${ctx.user.email}(&)`));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cd',
|
||||
aliases: ['goto', 'open'],
|
||||
description: 'Navigate to a page',
|
||||
usage: 'cd <page> (e.g. projects/opensource)',
|
||||
run: (args, ctx) => {
|
||||
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
|
||||
if (navItem) {
|
||||
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||
ctx.navigate(navItem.path, navItem.external);
|
||||
return;
|
||||
}
|
||||
const resolvedPath = resolvePagePath(target);
|
||||
if (resolvedPath !== undefined) {
|
||||
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||
ctx.navigate(resolvedPath);
|
||||
return;
|
||||
}
|
||||
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ls',
|
||||
aliases: ['dir'],
|
||||
description: 'List the pages you can visit',
|
||||
run: (_args, ctx) => {
|
||||
const lines: TerminalLine[] = [];
|
||||
|
||||
pageTree.forEach((node) => {
|
||||
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${node.name}(&)/`));
|
||||
node.children?.forEach((child, i) => {
|
||||
const branch = i === node.children!.length - 1 ? '└──' : '├──';
|
||||
lines.push(out(`(&muted) ${branch}(&) (&primary,bold)${child.name}(&)/`));
|
||||
});
|
||||
});
|
||||
|
||||
ctx.navigation.forEach((item) => {
|
||||
if (item.external && pageRoutes[item.name] === undefined) {
|
||||
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${item.name}(&)/`));
|
||||
}
|
||||
});
|
||||
|
||||
ctx.terminal.writeLines(lines);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
description: 'List or set the color theme',
|
||||
usage: 'theme [name]',
|
||||
run: (args, ctx) => {
|
||||
if (!args.length) {
|
||||
ctx.terminal.write(out('Themes: ' + ctx.themeNames.map((name) => `(&blue)${name}(&)`).join(' ')));
|
||||
ctx.terminal.write(info(`Current: (&accent)${ctx.getColorTheme()}(&)`));
|
||||
return;
|
||||
}
|
||||
const name = args[0].toLowerCase();
|
||||
if (ctx.themeNames.includes(name)) {
|
||||
ctx.setColorTheme(name);
|
||||
ctx.terminal.write(ok(`Theme set to (&accent)${name}(&)`));
|
||||
} else {
|
||||
ctx.terminal.write(err(`Unknown theme: ${name}. Available: ${ctx.themeNames.join(', ')}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
description: 'Toggle or set dark/light mode',
|
||||
usage: 'mode [dark|light]',
|
||||
run: (args, ctx) => {
|
||||
const current = ctx.getMode();
|
||||
if (!args.length) {
|
||||
ctx.toggleMode();
|
||||
ctx.terminal.write(ok(`Switched to ${current === 'dark' ? 'light' : 'dark'} mode`));
|
||||
return;
|
||||
}
|
||||
const requested = args[0].toLowerCase();
|
||||
if (requested === 'dark' || requested === 'light') {
|
||||
if (requested !== current) ctx.toggleMode();
|
||||
ctx.terminal.write(ok(`${requested} mode active`));
|
||||
} else {
|
||||
ctx.terminal.write(err('Usage: mode [dark|light]'));
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Print text back',
|
||||
usage: 'echo <text>',
|
||||
run: (args, ctx) => ctx.terminal.write(out(args.join(' ')))
|
||||
},
|
||||
{
|
||||
name: 'cowsay',
|
||||
description: 'Make a cow say something',
|
||||
usage: 'cowsay <text>',
|
||||
run: (args, ctx) => {
|
||||
const lines = cowsay(args.join(' '));
|
||||
ctx.terminal.writeLines(lines.map((line) => out(`(&accent)${line}(&)`)));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
description: 'Show the current date and time (Eastern)',
|
||||
run: (_args, ctx) => {
|
||||
const now = new Date();
|
||||
const easternString = now.toLocaleString('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
const easternParts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(now);
|
||||
const part = (type: string) => Number(easternParts.find((p) => p.type === type)?.value);
|
||||
const easternDate = new Date(Date.UTC(part('year'), part('month') - 1, part('day')));
|
||||
const startOfYear = new Date(Date.UTC(part('year'), 0, 1));
|
||||
const dayOfYear = Math.floor((easternDate.getTime() - startOfYear.getTime()) / 86400000) + 1;
|
||||
|
||||
ctx.terminal.writeLines([
|
||||
out(easternString),
|
||||
info(`(&muted)Day ${dayOfYear} of ${part('year')}(&)`)
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'sudo',
|
||||
hidden: true,
|
||||
description: 'Elevated privileges',
|
||||
run: (_args, ctx) =>
|
||||
ctx.terminal.write(
|
||||
err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`)
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const pageCommands: Command[] = Object.entries(pageRoutes).map(([name, path]) => ({
|
||||
name,
|
||||
description: `Go to the ${name} page`,
|
||||
hidden: true,
|
||||
run: (_args, ctx) => ctx.navigate(path)
|
||||
}));
|
||||
|
||||
const socialCommands: Command[] = user.socials.map((social) => ({
|
||||
name: social.name.toLowerCase(),
|
||||
description: `Open ${social.name}`,
|
||||
hidden: true,
|
||||
run: (_args, ctx) => ctx.navigate(social.link, true)
|
||||
}));
|
||||
|
||||
export const commands: Command[] = [...baseCommands, ...pageCommands, ...socialCommands];
|
||||
|
||||
function findCommand(name: string): Command | undefined {
|
||||
const lower = name.toLowerCase();
|
||||
return commands.find(
|
||||
(command) => command.name === lower || command.aliases?.some((alias) => alias.toLowerCase() === lower)
|
||||
);
|
||||
}
|
||||
|
||||
export function runCommand(input: string, ctx: CommandContext): void {
|
||||
const raw = input.trim();
|
||||
ctx.terminal.write({ type: 'command', content: raw });
|
||||
if (!raw) return;
|
||||
|
||||
const parts = raw.split(/\s+/);
|
||||
const name = parts[0];
|
||||
const args = parts.slice(1);
|
||||
const command = findCommand(name);
|
||||
|
||||
if (!command) {
|
||||
ctx.terminal.write(err(`command not found: (&bold)${name}(&) — type (&accent)help(&) for a list.`));
|
||||
return;
|
||||
}
|
||||
|
||||
command.run(args, ctx);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Terminal Keyboard Handler
|
||||
*
|
||||
* Handles keyboard navigation and interaction for the terminal.
|
||||
*/
|
||||
|
||||
import type { DisplayedLine } from './types';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export interface KeyboardHandlerOptions {
|
||||
getIsTyping: () => boolean;
|
||||
getIsComplete: () => boolean;
|
||||
getInteractive: () => boolean;
|
||||
getButtonIndices: () => number[];
|
||||
getSelectedIndex: () => number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
getDisplayedLines: () => DisplayedLine[];
|
||||
getBodyElement: () => HTMLDivElement | undefined;
|
||||
skipAnimation: () => void;
|
||||
scrollMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll selected button into view with margin for context
|
||||
*/
|
||||
export function scrollToSelected(
|
||||
bodyElement: HTMLDivElement | undefined,
|
||||
selectedIndex: number,
|
||||
displayedLines: DisplayedLine[],
|
||||
scrollMargin: number = 80
|
||||
): void {
|
||||
if (!bodyElement || selectedIndex < 0) return;
|
||||
|
||||
// Get all buttons in DOM order (including nested ones in groups)
|
||||
const allButtons = bodyElement.querySelectorAll('.tui-button');
|
||||
const selectedButton = allButtons[selectedIndex] as HTMLElement | undefined;
|
||||
|
||||
if (selectedButton && bodyElement) {
|
||||
const containerRect = bodyElement.getBoundingClientRect();
|
||||
const buttonRect = selectedButton.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.top < containerRect.top + scrollMargin) {
|
||||
const scrollAmount = buttonRect.top - containerRect.top - scrollMargin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
} else if (buttonRect.bottom > containerRect.bottom - scrollMargin) {
|
||||
const scrollAmount = buttonRect.bottom - containerRect.bottom + scrollMargin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle button/link click navigation
|
||||
*/
|
||||
export function handleNavigation(line: { action?: () => void; href?: string; external?: boolean } | undefined): void {
|
||||
if (!line) return;
|
||||
|
||||
if (line.action) {
|
||||
line.action();
|
||||
} else if (line.href) {
|
||||
const isExternal = line.external || line.href.startsWith('http://') || line.href.startsWith('https://');
|
||||
if (isExternal) {
|
||||
window.open(line.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
// Use SvelteKit's goto for client-side navigation (no page reload)
|
||||
goto(line.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard event handler for the terminal
|
||||
*/
|
||||
export function createKeyboardHandler(options: KeyboardHandlerOptions) {
|
||||
const {
|
||||
getIsTyping,
|
||||
getIsComplete,
|
||||
getInteractive,
|
||||
getButtonIndices,
|
||||
getSelectedIndex,
|
||||
setSelectedIndex,
|
||||
getDisplayedLines,
|
||||
getBodyElement,
|
||||
skipAnimation,
|
||||
scrollMargin = 80
|
||||
} = options;
|
||||
|
||||
return function handleKeydown(event: KeyboardEvent): void {
|
||||
// Ignore key events originating from editable fields (e.g. command input)
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip animation on Y key
|
||||
if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) {
|
||||
event.preventDefault();
|
||||
skipAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getInteractive() || !getIsComplete()) return;
|
||||
|
||||
const bodyElement = getBodyElement();
|
||||
if (!bodyElement) return;
|
||||
|
||||
// Get all buttons from the DOM (including nested ones in groups)
|
||||
const allButtons = bodyElement.querySelectorAll('.tui-button');
|
||||
const buttonCount = allButtons.length;
|
||||
if (buttonCount === 0) return;
|
||||
|
||||
const selectedIndex = getSelectedIndex();
|
||||
const displayedLines = getDisplayedLines();
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'j') {
|
||||
event.preventDefault();
|
||||
const nextIdx = selectedIndex < 0 ? 0 : (selectedIndex + 1) % buttonCount;
|
||||
setSelectedIndex(nextIdx);
|
||||
scrollToSelected(bodyElement, nextIdx, displayedLines, scrollMargin);
|
||||
// Update visual selection on buttons
|
||||
allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === nextIdx));
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'k') {
|
||||
event.preventDefault();
|
||||
const prevIdx = selectedIndex < 0 ? buttonCount - 1 : (selectedIndex - 1 + buttonCount) % buttonCount;
|
||||
setSelectedIndex(prevIdx);
|
||||
scrollToSelected(bodyElement, prevIdx, displayedLines, scrollMargin);
|
||||
// Update visual selection on buttons
|
||||
allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === prevIdx));
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < buttonCount) {
|
||||
const selectedButton = allButtons[selectedIndex] as HTMLElement;
|
||||
if (selectedButton) {
|
||||
// Read navigation data from button data attributes
|
||||
const href = selectedButton.dataset.href;
|
||||
const isExternal = selectedButton.dataset.external === 'true';
|
||||
|
||||
if (href) {
|
||||
handleNavigation({ href, external: isExternal });
|
||||
} else {
|
||||
// Fallback to click for buttons with actions (non-navigational)
|
||||
selectedButton.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to hash target (anchor link like #skills)
|
||||
*/
|
||||
export function scrollToHash(bodyElement: HTMLDivElement | undefined): void {
|
||||
if (!browser || !bodyElement) return;
|
||||
|
||||
const hash = window.location.hash;
|
||||
if (!hash) return;
|
||||
|
||||
const targetId = hash.slice(1); // Remove the #
|
||||
const targetElement = bodyElement.querySelector(`#${CSS.escape(targetId)}`);
|
||||
|
||||
if (targetElement) {
|
||||
// Small delay to ensure layout is complete
|
||||
setTimeout(() => {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Terminal Typing Animation Engine
|
||||
*
|
||||
* Handles the character-by-character typing animation for terminal lines.
|
||||
*/
|
||||
|
||||
import type { ParsedLine, DisplayedLine } from './types';
|
||||
import type { TerminalSettings } from '$lib/config';
|
||||
import { calculateTypeSpeed } from '$lib';
|
||||
|
||||
export interface TypingState {
|
||||
displayedLines: DisplayedLine[];
|
||||
currentLineIndex: number;
|
||||
isTyping: boolean;
|
||||
isComplete: boolean;
|
||||
skipRequested: boolean;
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export interface TypingOptions {
|
||||
parsedLines: ParsedLine[];
|
||||
speedMultiplier: number;
|
||||
terminalSettings: TerminalSettings;
|
||||
buttonIndices: number[];
|
||||
interactive: boolean;
|
||||
autoscroll: boolean;
|
||||
getBodyElement: () => HTMLDivElement | undefined;
|
||||
getState: () => TypingState;
|
||||
setState: (updates: Partial<TypingState>) => void;
|
||||
onComplete?: () => void;
|
||||
scrollToHash: () => void;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll terminal body to bottom
|
||||
*/
|
||||
function scrollToBottom(bodyElement: HTMLDivElement | undefined, autoscroll: boolean) {
|
||||
if (!autoscroll || !bodyElement) return;
|
||||
bodyElement.scrollTo({
|
||||
top: bodyElement.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the typing animation for all lines
|
||||
*/
|
||||
export async function runTypingAnimation(options: TypingOptions): Promise<void> {
|
||||
const {
|
||||
parsedLines,
|
||||
speedMultiplier,
|
||||
terminalSettings,
|
||||
buttonIndices,
|
||||
interactive,
|
||||
autoscroll,
|
||||
getBodyElement,
|
||||
getState,
|
||||
setState,
|
||||
onComplete,
|
||||
scrollToHash
|
||||
} = options;
|
||||
|
||||
if (parsedLines.length === 0) return;
|
||||
|
||||
setState({ isTyping: true });
|
||||
|
||||
// Apply speed multiplier to start delay
|
||||
const startDelayMs = speedMultiplier === 0 ? 0 : terminalSettings.startDelay * speedMultiplier;
|
||||
await sleep(startDelayMs);
|
||||
if (getState().skipRequested) return;
|
||||
|
||||
for (let i = 0; i < parsedLines.length; i++) {
|
||||
const state = getState();
|
||||
if (state.skipRequested) return;
|
||||
|
||||
const parsed = parsedLines[i];
|
||||
const line = parsed.line;
|
||||
const plainLength = parsed.plainText.length;
|
||||
|
||||
// Add new line to displayed lines
|
||||
const currentDisplayed = [...state.displayedLines, { parsed, charIndex: 0, complete: false, showImage: false }];
|
||||
setState({ displayedLines: currentDisplayed, currentLineIndex: i });
|
||||
|
||||
if (line.delay && speedMultiplier > 0) {
|
||||
await sleep(line.delay * speedMultiplier);
|
||||
if (getState().skipRequested) return;
|
||||
}
|
||||
|
||||
const bodyElement = getBodyElement();
|
||||
|
||||
// Handle different line types
|
||||
if (line.type === 'image') {
|
||||
await sleep(speedMultiplier === 0 ? 0 : 100 * speedMultiplier);
|
||||
if (getState().skipRequested) return;
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: true }, getState, setState);
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
} else if (line.type === 'blank' || line.type === 'divider') {
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
|
||||
} else if (line.type === 'button') {
|
||||
// Buttons appear instantly
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
} else if (speedMultiplier === 0) {
|
||||
// Instant mode - no typing animation
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
|
||||
if (i % 5 === 0) scrollToBottom(bodyElement, autoscroll);
|
||||
} else if (line.type === 'header') {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier * 0.25);
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (getState().skipRequested) return;
|
||||
updateDisplayedLine(i, {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
}, getState, setState);
|
||||
if (j < plainLength) await sleep(typeSpeed);
|
||||
}
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
} else {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier);
|
||||
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (getState().skipRequested) return;
|
||||
updateDisplayedLine(i, {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
}, getState, setState);
|
||||
|
||||
if (j % 10 === 0) scrollToBottom(bodyElement, autoscroll);
|
||||
|
||||
if (j < plainLength) {
|
||||
await sleep(typeSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (getState().skipRequested) return;
|
||||
|
||||
// Mark line as complete
|
||||
const finalState = getState();
|
||||
const updatedLines = [...finalState.displayedLines];
|
||||
if (updatedLines[i]) {
|
||||
updatedLines[i].complete = true;
|
||||
setState({ displayedLines: updatedLines });
|
||||
}
|
||||
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
|
||||
if (i < parsedLines.length - 1 && speedMultiplier > 0) {
|
||||
await sleep(terminalSettings.lineDelay * speedMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
setState({ isTyping: false, isComplete: true });
|
||||
|
||||
if (interactive && buttonIndices.length > 0) {
|
||||
setState({ selectedIndex: buttonIndices[0] });
|
||||
}
|
||||
|
||||
// Scroll to hash anchor if present in URL
|
||||
scrollToHash();
|
||||
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single displayed line at index
|
||||
*/
|
||||
function updateDisplayedLine(
|
||||
index: number,
|
||||
update: DisplayedLine,
|
||||
getState: () => TypingState,
|
||||
setState: (updates: Partial<TypingState>) => void
|
||||
) {
|
||||
const state = getState();
|
||||
const updatedLines = [...state.displayedLines];
|
||||
updatedLines[index] = update;
|
||||
setState({ displayedLines: updatedLines });
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip animation and show all content instantly
|
||||
*/
|
||||
export function skipTypingAnimation(
|
||||
parsedLines: ParsedLine[],
|
||||
buttonIndices: number[],
|
||||
interactive: boolean,
|
||||
getState: () => TypingState,
|
||||
setState: (updates: Partial<TypingState>) => void,
|
||||
getBodyElement: () => HTMLDivElement | undefined,
|
||||
autoscroll: boolean,
|
||||
onComplete?: () => void
|
||||
): void {
|
||||
const state = getState();
|
||||
if (!state.isTyping) return;
|
||||
|
||||
const displayedLines = parsedLines.map(parsed => ({
|
||||
parsed,
|
||||
charIndex: parsed.plainText.length,
|
||||
complete: true,
|
||||
showImage: parsed.line.type === 'image'
|
||||
}));
|
||||
|
||||
setState({
|
||||
skipRequested: true,
|
||||
displayedLines,
|
||||
isTyping: false,
|
||||
isComplete: true,
|
||||
currentLineIndex: parsedLines.length - 1,
|
||||
selectedIndex: interactive && buttonIndices.length > 0 ? buttonIndices[0] : -1
|
||||
});
|
||||
|
||||
scrollToBottom(getBodyElement(), autoscroll);
|
||||
onComplete?.();
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import type { TextSegment } from './utils';
|
||||
import type { Card } from '$lib/config';
|
||||
|
||||
// Re-export TextSegment for convenience
|
||||
export type { TextSegment };
|
||||
|
||||
export type LineType =
|
||||
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
|
||||
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
|
||||
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid'
|
||||
| 'input' | 'textarea' | 'checkbox' | 'radio' | 'select' | 'toggle'
|
||||
| 'group';
|
||||
|
||||
// Option type for radio and select components
|
||||
export interface FormOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Base interface for all terminal lines
|
||||
export interface BaseTerminalLine {
|
||||
type: LineType;
|
||||
content: string;
|
||||
id?: string;
|
||||
inline?: boolean;
|
||||
display?: 'flex' | 'block' | 'grid' | 'inline';
|
||||
delay?: number;
|
||||
style?: 'primary' | 'secondary' | 'accent' | 'warning' | 'error';
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
// Specific line types
|
||||
export interface CommandLine extends BaseTerminalLine {
|
||||
type: 'command';
|
||||
}
|
||||
|
||||
export interface OutputLine extends BaseTerminalLine {
|
||||
type: 'output';
|
||||
}
|
||||
|
||||
export interface PromptLine extends BaseTerminalLine {
|
||||
type: 'prompt';
|
||||
}
|
||||
|
||||
export interface ErrorLine extends BaseTerminalLine {
|
||||
type: 'error';
|
||||
}
|
||||
|
||||
export interface SuccessLine extends BaseTerminalLine {
|
||||
type: 'success';
|
||||
}
|
||||
|
||||
export interface InfoLine extends BaseTerminalLine {
|
||||
type: 'info';
|
||||
}
|
||||
|
||||
export interface WarningLine extends BaseTerminalLine {
|
||||
type: 'warning';
|
||||
}
|
||||
|
||||
export interface BlankLine extends BaseTerminalLine {
|
||||
type: 'blank';
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export interface DividerLine extends BaseTerminalLine {
|
||||
type: 'divider';
|
||||
}
|
||||
|
||||
export interface HeaderLine extends BaseTerminalLine {
|
||||
type: 'header';
|
||||
}
|
||||
|
||||
export interface ButtonLine extends BaseTerminalLine {
|
||||
type: 'button';
|
||||
action?: () => void;
|
||||
href?: string;
|
||||
icon?: string;
|
||||
external?: boolean;
|
||||
textStyle?: 'start' | 'center' | 'end';
|
||||
border?: boolean;
|
||||
flex?: boolean;
|
||||
}
|
||||
|
||||
export interface LinkLine extends BaseTerminalLine {
|
||||
type: 'link';
|
||||
href: string;
|
||||
external?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ImageLine extends BaseTerminalLine {
|
||||
type: 'image';
|
||||
image: string;
|
||||
imageAlt?: string;
|
||||
imageWidth?: number;
|
||||
}
|
||||
|
||||
export interface CardLine extends BaseTerminalLine {
|
||||
type: 'card';
|
||||
cardTitle?: string;
|
||||
cardFooter?: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
children?: TerminalLine[];
|
||||
cardWidth?: string | number;
|
||||
cardHeight?: string | number;
|
||||
cardFloat?: 'start' | 'center' | 'end';
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface ProgressLine extends BaseTerminalLine {
|
||||
type: 'progress';
|
||||
progress: number; // 0-100
|
||||
progressLabel?: string;
|
||||
progressStyle?: 'block' | 'continuous' | 'text';
|
||||
}
|
||||
|
||||
export interface AccordionLine extends BaseTerminalLine {
|
||||
type: 'accordion';
|
||||
accordionItems?: { title: string; content: string }[];
|
||||
children?: TerminalLine[];
|
||||
accordionOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface TableLine extends BaseTerminalLine {
|
||||
type: 'table';
|
||||
tableHeaders: string[];
|
||||
tableRows: string[][];
|
||||
}
|
||||
|
||||
export interface TooltipLine extends BaseTerminalLine {
|
||||
type: 'tooltip';
|
||||
tooltipText: string;
|
||||
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface CardGridLine extends BaseTerminalLine {
|
||||
type: 'cardgrid';
|
||||
cards: Card[];
|
||||
}
|
||||
|
||||
export interface InputLine extends BaseTerminalLine {
|
||||
type: 'input';
|
||||
inputPlaceholder?: string;
|
||||
inputType?: 'text' | 'email' | 'password' | 'number' | 'url' | 'tel' | 'search';
|
||||
inputDisabled?: boolean;
|
||||
inputError?: boolean;
|
||||
inputErrorMessage?: string;
|
||||
inputPrefix?: string;
|
||||
inputSuffix?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface TextareaLine extends BaseTerminalLine {
|
||||
type: 'textarea';
|
||||
inputPlaceholder?: string;
|
||||
textareaRows?: number;
|
||||
textareaMaxLength?: number;
|
||||
inputDisabled?: boolean;
|
||||
inputError?: boolean;
|
||||
inputErrorMessage?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface CheckboxLine extends BaseTerminalLine {
|
||||
type: 'checkbox';
|
||||
checkboxIndeterminate?: boolean;
|
||||
inputDisabled?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface RadioLine extends BaseTerminalLine {
|
||||
type: 'radio';
|
||||
radioOptions: FormOption[];
|
||||
radioHorizontal?: boolean;
|
||||
inputDisabled?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface SelectLine extends BaseTerminalLine {
|
||||
type: 'select';
|
||||
selectOptions: FormOption[];
|
||||
selectSearchable?: boolean;
|
||||
inputPlaceholder?: string;
|
||||
inputDisabled?: boolean;
|
||||
inputError?: boolean;
|
||||
inputErrorMessage?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ToggleLine extends BaseTerminalLine {
|
||||
type: 'toggle';
|
||||
toggleOnLabel?: string;
|
||||
toggleOffLabel?: string;
|
||||
toggleShowLabels?: boolean;
|
||||
inputDisabled?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface GroupLine extends BaseTerminalLine {
|
||||
type: 'group';
|
||||
children: TerminalLine[];
|
||||
groupDirection?: 'row' | 'column';
|
||||
groupAlign?: 'start' | 'center' | 'end' | 'stretch';
|
||||
groupGap?: string;
|
||||
groupExpand?: boolean;
|
||||
}
|
||||
|
||||
// Discriminated union of all line types
|
||||
export type TerminalLine =
|
||||
| CommandLine | OutputLine | PromptLine | ErrorLine | SuccessLine | InfoLine | WarningLine
|
||||
| BlankLine | DividerLine | HeaderLine | ButtonLine | LinkLine | ImageLine
|
||||
| CardLine | ProgressLine | AccordionLine | TableLine | TooltipLine | CardGridLine
|
||||
| InputLine | TextareaLine | CheckboxLine | RadioLine | SelectLine | ToggleLine
|
||||
| GroupLine;
|
||||
|
||||
// Terminal API for reactive manipulation
|
||||
export interface TerminalAPI {
|
||||
/** Clear all lines from the terminal */
|
||||
clear: () => void;
|
||||
/** Write a new line to the terminal (appends) */
|
||||
write: (line: TerminalLine) => void;
|
||||
/** Write multiple lines to the terminal (appends) */
|
||||
writeLines: (lines: TerminalLine[]) => void;
|
||||
/** Update a specific line by index */
|
||||
update: (index: number, line: Partial<TerminalLine>) => void;
|
||||
/** Update a line's content by index */
|
||||
updateContent: (index: number, content: string) => void;
|
||||
/** Insert a line at a specific index */
|
||||
insert: (index: number, line: TerminalLine) => void;
|
||||
/** Remove a line by index */
|
||||
remove: (index: number) => void;
|
||||
/** Remove lines by range */
|
||||
removeRange: (startIndex: number, count: number) => void;
|
||||
/** Replace all lines */
|
||||
setLines: (lines: TerminalLine[]) => void;
|
||||
/** Get current line count */
|
||||
getLineCount: () => number;
|
||||
/** Get a line by index */
|
||||
getLine: (index: number) => TerminalLine | undefined;
|
||||
/** Get all lines */
|
||||
getLines: () => TerminalLine[];
|
||||
/** Find line index by id */
|
||||
findById: (id: string) => number;
|
||||
/** Update a line by its id */
|
||||
updateById: (id: string, line: Partial<TerminalLine>) => void;
|
||||
/** Remove a line by its id */
|
||||
removeById: (id: string) => void;
|
||||
/** Scroll to bottom */
|
||||
scrollToBottom: () => void;
|
||||
/** Scroll to a specific line index */
|
||||
scrollToLine: (index: number) => void;
|
||||
/** Check if terminal is currently animating */
|
||||
isAnimating: () => boolean;
|
||||
/** Skip current animation */
|
||||
skip: () => void;
|
||||
/** Restart the typing animation */
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
// Pre-parsed line with segments ready for rendering
|
||||
export interface ParsedLine {
|
||||
line: TerminalLine;
|
||||
segments: TextSegment[];
|
||||
plainText: string; // Plain text without color codes for length calculation
|
||||
}
|
||||
|
||||
// Display state for a line during typing animation
|
||||
export interface DisplayedLine {
|
||||
parsed: ParsedLine;
|
||||
charIndex: number; // How many plain-text characters to show
|
||||
complete: boolean;
|
||||
showImage: boolean;
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
// Shared utilities used by TUI components
|
||||
|
||||
export interface TextSegment {
|
||||
text: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
overline?: boolean;
|
||||
// For inline icons
|
||||
icon?: string;
|
||||
iconSize?: number;
|
||||
// For inline clickable text
|
||||
href?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
// Color maps for each theme
|
||||
export type ThemeColorMap = Record<string, string>;
|
||||
|
||||
// Default color map (fallback) - hex values as defaults, overwritten by theme colorMap at runtime
|
||||
export const defaultColorMap: ThemeColorMap = {
|
||||
// Basic colors
|
||||
'red': '#f38ba8',
|
||||
'green': '#a6e3a1',
|
||||
'yellow': '#f9e2af',
|
||||
'blue': '#89b4fa',
|
||||
'magenta': '#cba6f7',
|
||||
'cyan': '#94e2d5',
|
||||
'white': '#cdd6f4',
|
||||
'gray': '#6c7086',
|
||||
'orange': '#fab387',
|
||||
'pink': '#f5c2e7',
|
||||
'black': '#1e1e2e',
|
||||
'surface': '#313244',
|
||||
// Extended colors
|
||||
'teal': '#94e2d5',
|
||||
'sky': '#89dceb',
|
||||
'sapphire': '#74c7ec',
|
||||
'lavender': '#b4befe',
|
||||
'peach': '#fab387',
|
||||
'maroon': '#eba0ac',
|
||||
'mauve': '#cba6f7',
|
||||
'flamingo': '#f2cdcd',
|
||||
'rosewater': '#f5e0dc',
|
||||
// Semantic colors
|
||||
'primary': '#cba6f7',
|
||||
'accent': '#a6e3a1',
|
||||
'muted': '#6c7086',
|
||||
'error': '#f38ba8',
|
||||
'success': '#a6e3a1',
|
||||
'warning': '#f9e2af',
|
||||
'info': '#89b4fa',
|
||||
// Theme colors
|
||||
'text': '#cdd6f4',
|
||||
'textMuted': '#a6adc8',
|
||||
'background': '#1e1e2e',
|
||||
'backgroundLight': '#313244',
|
||||
'border': '#45475a',
|
||||
'terminal': '#1e1e2e',
|
||||
'terminalPrompt': '#cba6f7',
|
||||
'terminalUser': '#a6e3a1',
|
||||
'terminalPath': '#89b4fa',
|
||||
};
|
||||
|
||||
// Legacy alias for backwards compatibility
|
||||
export const colorMap = defaultColorMap;
|
||||
|
||||
// Text style keywords
|
||||
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
|
||||
|
||||
export function parseColorText(text: string, colors: ThemeColorMap = colorMap): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
// Match (&icon, iconName) patterns FIRST, then (&specs)content(&)
|
||||
// This prevents (&icon, ...) from being consumed as the start of a color block
|
||||
const regex = /\(&icon,\s*([^)]+)\)|\(&([^)]+)\)(.*?)\(&\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
|
||||
// Check if this is an icon match (Group 1 is the icon name, optionally followed by a size)
|
||||
if (match[1]) {
|
||||
const [iconName, sizeArg] = match[1].split(',').map(part => part.trim());
|
||||
const iconSize = sizeArg ? Number(sizeArg) : undefined;
|
||||
segments.push({ text: '', icon: iconName, iconSize: iconSize && !isNaN(iconSize) ? iconSize : undefined });
|
||||
lastIndex = match.index + match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard text formatting (Group 2 is specs, Group 3 is content)
|
||||
const specs = match[2].split(',').map(s => s.trim().toLowerCase());
|
||||
const content = match[3];
|
||||
const segment: TextSegment = { text: content };
|
||||
|
||||
for (const spec of specs) {
|
||||
// Text styles
|
||||
if (spec === 'bold') segment.bold = true;
|
||||
else if (spec === 'dim') segment.dim = true;
|
||||
else if (spec === 'italic') segment.italic = true;
|
||||
else if (spec === 'underline') segment.underline = true;
|
||||
else if (spec === 'strikethrough' || spec === 'strike') segment.strikethrough = true;
|
||||
else if (spec === 'overline') segment.overline = true;
|
||||
// Background color (bg-colorname or bg-#hex)
|
||||
else if (spec.startsWith('bg-')) {
|
||||
const bgColor = spec.slice(3);
|
||||
if (colors[bgColor]) {
|
||||
segment.background = colors[bgColor];
|
||||
} else if (bgColor.startsWith('#')) {
|
||||
segment.background = bgColor;
|
||||
}
|
||||
}
|
||||
// Foreground color
|
||||
else if (colors[spec] && !textStyles.includes(spec)) {
|
||||
segment.color = colors[spec];
|
||||
} else if (spec.startsWith('#')) {
|
||||
segment.color = spec;
|
||||
}
|
||||
}
|
||||
|
||||
segments.push(segment);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ text: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
segments.push({ text });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Get plain text from segments (for length calculation)
|
||||
export function getPlainText(segments: TextSegment[]): string {
|
||||
return segments.map(s => s.text).join('');
|
||||
}
|
||||
|
||||
// Get segments up to a certain character count (for typing animation)
|
||||
export function getSegmentsUpToChar(segments: TextSegment[], charCount: number): TextSegment[] {
|
||||
const result: TextSegment[] = [];
|
||||
let remaining = charCount;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (remaining <= 0) break;
|
||||
|
||||
if (segment.text.length <= remaining) {
|
||||
result.push(segment);
|
||||
remaining -= segment.text.length;
|
||||
} else {
|
||||
// Partial segment
|
||||
result.push({ ...segment, text: segment.text.slice(0, remaining) });
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getSegmentStyle(segment: TextSegment): string {
|
||||
const styles: string[] = [];
|
||||
if (segment.color) styles.push(`color: ${segment.color}`);
|
||||
if (segment.background) styles.push(`background-color: ${segment.background}; padding: 0.1em 0.25em; border-radius: 3px`);
|
||||
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');
|
||||
if (segment.strikethrough) decorations.push('line-through');
|
||||
if (segment.overline) decorations.push('overline');
|
||||
if (decorations.length > 0) {
|
||||
styles.push(`text-decoration: ${decorations.join(' ')}`);
|
||||
}
|
||||
|
||||
return styles.join('; ');
|
||||
}
|
||||
|
||||
export function getLinePrefix(type: string): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return '✗ ';
|
||||
case 'success':
|
||||
return '✓ ';
|
||||
case 'warning':
|
||||
return '⚠ ';
|
||||
case 'info':
|
||||
return '› ';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getButtonStyle(style?: string): string {
|
||||
switch (style) {
|
||||
case 'primary':
|
||||
return 'var(--terminal-primary)';
|
||||
case 'accent':
|
||||
return 'var(--terminal-accent)';
|
||||
case 'warning':
|
||||
return '#f9e2af';
|
||||
case 'error':
|
||||
return '#f38ba8';
|
||||
default:
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { isExternalUrl } from '#lib/desktop/actions';
|
||||
|
||||
let {
|
||||
href,
|
||||
icon,
|
||||
variant = 'outline',
|
||||
children
|
||||
}: { href: string; icon?: string; variant?: 'outline' | 'filled'; children: Snippet } = $props();
|
||||
|
||||
const external = $derived(isExternalUrl(href));
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="link-button {variant}"
|
||||
{href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{#if icon}
|
||||
<Icon {icon} width="17" />
|
||||
{/if}
|
||||
{@render children()}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.link-button:hover,
|
||||
.filled {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.filled:hover {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { profile } from '#lib/content';
|
||||
|
||||
let { command, title, subtitle, children }: { command: string; title?: string; subtitle?: string; children?: Snippet } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if title}
|
||||
<title>{title} | {profile.displayName}</title>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<meta name="description" content={subtitle} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="command">
|
||||
<span class="prompt">{profile.username}@{profile.hostname}</span>
|
||||
<span class="path">~</span>
|
||||
<span class="symbol">$</span>
|
||||
<span>{command}</span>
|
||||
</div>
|
||||
{#if title}
|
||||
<h1>{title}</h1>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<p class="subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.command {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5ch;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.path {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.symbol {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { title, icon, href, children }: { title?: string; icon?: string; href?: string; children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? 'a' : 'article'}
|
||||
class="panel"
|
||||
class:linked={!!href}
|
||||
{href}
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{#if title}
|
||||
<header class="panel-title">
|
||||
{#if icon}
|
||||
<Icon {icon} width="16" />
|
||||
{/if}
|
||||
<span>{title}</span>
|
||||
</header>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
}
|
||||
|
||||
.linked:hover {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { profile } from '#lib/content';
|
||||
|
||||
let { heading, subheading, text }: { heading: string; subheading: string; text: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="profile-intro">
|
||||
<img src={profile.avatar} alt={profile.displayName} />
|
||||
<div class="details">
|
||||
<h1>{heading}</h1>
|
||||
<div class="subheading">{subheading}</div>
|
||||
<p class="muted">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-intro {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 160px;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { title, id, children }: { title: string; id?: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<section class="section" {id}>
|
||||
<h2 class="section-title"><span>{title}</span></h2>
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.section {
|
||||
margin: 1.75rem 0;
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.section-title::before,
|
||||
.section-title::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
let { sections }: { sections: { id: string; label: string; color: string }[] } = $props();
|
||||
|
||||
function jumpTo(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="section-nav">
|
||||
<span class="label">Links ></span>
|
||||
{#each sections as section (section.id)}
|
||||
<button style="--chip-color: var(--{section.color})" onclick={() => jumpTo(section.id)}>{section.label}</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.section-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.05rem 0.5rem;
|
||||
background: var(--chip-color);
|
||||
border: 1px solid var(--chip-color);
|
||||
color: var(--background);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: transparent;
|
||||
color: var(--chip-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let { label, colorIndex = 0 }: { label: string; colorIndex?: number } = $props();
|
||||
|
||||
const tagColors = ['blue', 'magenta', 'cyan', 'orange', 'green', 'yellow'];
|
||||
const color = $derived(tagColors[colorIndex % tagColors.length]);
|
||||
</script>
|
||||
|
||||
<span class="tag" style="--tag-color: var(--{color})">{label}</span>
|
||||
|
||||
<style>
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--tag-color);
|
||||
color: var(--tag-color);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user