Website Redesign 7
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Canvas } from '@threlte/core';
|
||||
import { T } from '@threlte/core';
|
||||
import ParticleField from './ParticleField.svelte';
|
||||
import { themeColors, mode } from '$lib/stores/theme';
|
||||
import * as THREE from 'three';
|
||||
|
||||
let bgColor = $derived($mode === 'dark' ? $themeColors.background : $themeColors.background);
|
||||
</script>
|
||||
|
||||
<div class="scene-container">
|
||||
<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={$themeColors.primary}
|
||||
/>
|
||||
|
||||
<!-- Particle system -->
|
||||
<ParticleField particleCount={600} />
|
||||
|
||||
<!-- Background color -->
|
||||
<T.Color args={[bgColor]} attach="background" />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scene-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.scene-container :global(canvas) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +0,0 @@
|
||||
<script lang="ts">
|
||||
// image source is configurable via prop; default points to a common images folder
|
||||
export let src: string = '/src/lib/images/profile.png';
|
||||
export let alt: string = 'Profile picture';
|
||||
</script>
|
||||
|
||||
<figure class="mx-auto">
|
||||
<section class="img-bg" />
|
||||
<img src={src} alt={alt} class="fill-token rounded-full object-cover object-center" loading="lazy" />
|
||||
</figure>
|
||||
|
||||
<style lang="postcss">
|
||||
figure {
|
||||
@apply flex relative flex-col;
|
||||
}
|
||||
figure img,
|
||||
.img-bg {
|
||||
@apply w-40 h-40 md:w-44 md:h-44 my-10;
|
||||
}
|
||||
.img-bg {
|
||||
@apply absolute z-[-1] rounded-full blur-[50px] transition-all;
|
||||
/* default (light mode): darker blue glow */
|
||||
animation: pulse 5s cubic-bezier(0, 0, 0, 0.5) infinite,
|
||||
glow-light 5s linear infinite;
|
||||
}
|
||||
/* In dark mode use the original multi-color glow */
|
||||
:global(.dark) .img-bg {
|
||||
animation: pulse 5s cubic-bezier(0, 0, 0, 0.5) infinite,
|
||||
glow-dark 5s linear infinite;
|
||||
}
|
||||
@keyframes glow-dark {
|
||||
0% {
|
||||
@apply bg-primary-400/50;
|
||||
}
|
||||
33% {
|
||||
@apply bg-secondary-400/50;
|
||||
}
|
||||
66% {
|
||||
@apply bg-tertiary-400/50;
|
||||
}
|
||||
100% {
|
||||
@apply bg-primary-400/50;
|
||||
}
|
||||
}
|
||||
@keyframes glow-light {
|
||||
/* darker blues for light mode */
|
||||
0% {
|
||||
@apply bg-primary-700/50;
|
||||
}
|
||||
33% {
|
||||
@apply bg-primary-600/50;
|
||||
}
|
||||
66% {
|
||||
@apply bg-primary-800/50;
|
||||
}
|
||||
100% {
|
||||
@apply bg-primary-700/50;
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,49 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Avatar } from "@skeletonlabs/skeleton";
|
||||
|
||||
export let totalassets;
|
||||
|
||||
export let username;
|
||||
export let bio;
|
||||
export let avatar;
|
||||
export let roles;
|
||||
export let contributions;
|
||||
|
||||
// const popupHover = {
|
||||
// event: 'hover',
|
||||
// target: 'popupHover',
|
||||
// placement: 'top'
|
||||
// };
|
||||
|
||||
</script>
|
||||
|
||||
<div class="grid col card bg-initial card-hover overflow-hidden {$$restProps.class}">
|
||||
<header class="mx-auto my-5">
|
||||
<div class="relative inline-block">
|
||||
<span class="badge-icon variant-filled-error absolute -top-0 -right-0 z-10 w-fit p-1">-{Math.round((contributions/totalassets)*100)}%</span>
|
||||
<Avatar src={avatar} width="w-32" />
|
||||
</div>
|
||||
</header>
|
||||
<div class="p-4 space-y-4">
|
||||
<h3 class="h3" data-toc-ignore>{username}</h3>
|
||||
<article>
|
||||
{ #each roles as role}
|
||||
<span class="badge variant-ghost-primary my-1 mx-1">{role}</span>
|
||||
{/each}
|
||||
<br><br>
|
||||
<p>{bio}</p>
|
||||
</article>
|
||||
</div>
|
||||
<hr class="opacity-50" />
|
||||
<footer class="p-4 mx-auto flex justify-start items-center space-x-4">
|
||||
<button type="button" class="chip variant-soft hover:variant-filled">
|
||||
<span class="icon-[flowbite--user-circle-solid] profile-gradient"></span>
|
||||
</button>
|
||||
<button type="button" class="chip variant-soft hover:variant-filled">
|
||||
<span class="icon-[flowbite--lightbulb-outline] projects-gradient"></span>
|
||||
</button>
|
||||
<button type="button" class="chip variant-soft hover:variant-filled">
|
||||
<span class="icon-[lets-icons--insta] insta-gradient"></span>
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { AppBar } from '@skeletonlabs/skeleton';
|
||||
</script>
|
||||
|
||||
<AppBar class="flex justify-center items-center mx-auto p-4">
|
||||
<div class="grid grid-cols-1">
|
||||
<div class="grid mx-auto">
|
||||
<span class="text-lg">Made with ❤️ by Sir Blob</span>
|
||||
</div>
|
||||
</div>
|
||||
</AppBar>
|
||||
@@ -1,150 +0,0 @@
|
||||
<script lang="ts">
|
||||
export let title: string = "Project Title";
|
||||
export let description: string =
|
||||
"Short project description that explains what the project does in one or two lines.";
|
||||
export let image: string = "";
|
||||
export let link: string = "";
|
||||
export let repo: string = "";
|
||||
export let devpost: string = "";
|
||||
export let hackathonName: string = "";
|
||||
export let university: string = "";
|
||||
export let location: string = "";
|
||||
export let year: string = "";
|
||||
export let tags: string[] = [];
|
||||
export let featured: boolean = false;
|
||||
export let awards: { track: string; place: string }[] = [];
|
||||
export let liveWarning: boolean = false;
|
||||
$: placeholderImage = `https://placehold.co/800x400/334155/94a3b8?text=${encodeURIComponent(title)}`;
|
||||
$: displayImage = image || placeholderImage;
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="card bg-surface-300 dark:bg-surface-700 rounded-xl overflow-hidden shadow-lg text-slate-900 dark:text-slate-100 flex flex-col relative"
|
||||
>
|
||||
<!-- Banner Image -->
|
||||
<div class="w-full h-40 bg-slate-200 dark:bg-slate-800 overflow-hidden">
|
||||
<img
|
||||
src={displayImage}
|
||||
alt={title}
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
<!-- Title and Featured Badge -->
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-xl md:text-2xl font-bold leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
{#if featured}
|
||||
<span
|
||||
class="flex-shrink-0 inline-flex items-center text-xs font-semibold bg-amber-500 text-amber-900 px-2 py-1 rounded"
|
||||
>Featured</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
{#if hackathonName}
|
||||
<span class="font-semibold">{hackathonName}</span>
|
||||
{/if}
|
||||
{#if university}
|
||||
<span>{university}</span>
|
||||
{/if}
|
||||
{#if location}
|
||||
<span>{location}</span>
|
||||
{/if}
|
||||
{#if year}
|
||||
<span>{year}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Awards Section -->
|
||||
{#if awards && awards.length > 0}
|
||||
<div class="mt-3 space-y-1">
|
||||
{#each awards as award}
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<span class="text-amber-600 dark:text-amber-400">🏆</span>
|
||||
<span class="font-semibold text-slate-700 dark:text-slate-300">{award.place}</span>
|
||||
<span class="text-slate-600 dark:text-slate-400">—</span>
|
||||
<span class="text-slate-600 dark:text-slate-400">{award.track}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="px-6 pb-4 flex-grow">
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">{description}</p>
|
||||
|
||||
{#if liveWarning && link}
|
||||
<div class="mt-3 flex items-start gap-2 text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
|
||||
<span class="text-base">⚠️</span>
|
||||
<span>Live site may not include all features from the original project.</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer with tags and links -->
|
||||
<div class="px-6 pb-6 pt-0 mt-auto">
|
||||
<!-- Tags Row -->
|
||||
{#if tags && tags.length > 0}
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
{#each tags as tag}
|
||||
<span class="chip variant-filled text-xs text-center whitespace-nowrap">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Link Buttons Row -->
|
||||
<div class="grid grid-cols-[repeat(auto-fit,minmax(80px,1fr))] gap-2">
|
||||
{#if devpost}
|
||||
<a
|
||||
href={devpost}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="btn btn-sm variant-filled-primary w-full"
|
||||
title="View on Devpost"
|
||||
>
|
||||
<span class="icon-[simple-icons--devpost]"></span>
|
||||
<span class="ml-1">About</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if repo}
|
||||
<a
|
||||
href={repo}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="btn btn-sm variant-ghost-secondary w-full"
|
||||
>
|
||||
<span class="icon-[simple-icons--github]"></span>
|
||||
<span class="ml-1">Repo</span>
|
||||
</a>
|
||||
{/if}
|
||||
{#if link}
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="btn btn-sm variant-ghost-tertiary w-full">
|
||||
<span class="ml-1">Website</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style lang="postcss">
|
||||
article {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
h3 {
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,609 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { themeColors } from '$lib/stores/theme';
|
||||
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 lightIntensity = $state(1);
|
||||
let showControls = $state(false);
|
||||
|
||||
let ground: THREE.Mesh | 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;
|
||||
scene.add(ground);
|
||||
|
||||
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 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 toggleControlsPanel() {
|
||||
showControls = !showControls;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (container) {
|
||||
initScene();
|
||||
animate();
|
||||
window.addEventListener('resize', handleResize);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
}
|
||||
if (renderer) {
|
||||
renderer.dispose();
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Canvas container -->
|
||||
<div class="canvas-container" bind:this={container}>
|
||||
{#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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.model-viewer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--viewer-bg);
|
||||
border: 1px solid var(--viewer-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.model-viewer.fullscreen {
|
||||
position: fixed;
|
||||
inset: 60px 0 0 0;
|
||||
z-index: 100;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--viewer-bg) 80%, black);
|
||||
border-bottom: 1px solid var(--viewer-border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--viewer-primary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--viewer-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
background: var(--viewer-border);
|
||||
color: var(--viewer-text);
|
||||
border-color: var(--viewer-border);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background: var(--viewer-primary);
|
||||
color: var(--viewer-bg);
|
||||
border-color: var(--viewer-primary);
|
||||
}
|
||||
|
||||
.control-btn.small {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Extended controls panel */
|
||||
.controls-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--viewer-bg) 90%, black);
|
||||
border-bottom: 1px solid var(--viewer-border);
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--viewer-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.control-value {
|
||||
font-size: 0.7rem;
|
||||
color: var(--viewer-text);
|
||||
min-width: 2.5rem;
|
||||
text-align: center;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
position: relative;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
color-mix(in srgb, var(--viewer-primary) 5%, transparent),
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
.canvas-container :global(canvas) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-overlay,
|
||||
.error-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--viewer-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error-overlay {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
:global(.spin) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.viewer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.5rem;
|
||||
background: color-mix(in srgb, var(--viewer-bg) 80%, black);
|
||||
border-top: 1px solid var(--viewer-border);
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--viewer-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,87 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { AppBar } from '@skeletonlabs/skeleton';
|
||||
import { LightSwitch } from '@skeletonlabs/skeleton';
|
||||
|
||||
let logoElement: HTMLElement;
|
||||
|
||||
function onMobileMenuClick() {
|
||||
const navbar = document.getElementById('navbar-default');
|
||||
const button = document.querySelector('[data-collapse-toggle="navbar-default"]');
|
||||
|
||||
if (navbar) {
|
||||
const expanded = navbar.getAttribute('aria-expanded') === 'true';
|
||||
navbar.setAttribute('aria-expanded', String(!expanded));
|
||||
button?.setAttribute('aria-expanded', String(!expanded));
|
||||
|
||||
navbar.style.display = expanded ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<AppBar class="bg-slate-400">
|
||||
<svelte:fragment slot="lead">
|
||||
<strong bind:this={logoElement} class="text-xl uppercase">
|
||||
<a href="/">
|
||||
<!-- svelte-ignore a11y-missing-attribute -->
|
||||
<img src="/blob_nerd.png" class="rounded-md" style="width: 50px; height: auto;" alt="Logo" />
|
||||
</a>
|
||||
</strong>
|
||||
</svelte:fragment>
|
||||
<svelte:fragment slot="trail">
|
||||
<button on:click={onMobileMenuClick} data-collapse-toggle="navbar-default" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="navbar-default" aria-expanded="false">
|
||||
<span class="sr-only">Open main menu</span>
|
||||
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="hidden w-full md:block md:w-auto" id="navbar-default">
|
||||
<ul class="font-medium flex flex-col p-4 md:p-0 mt-4 border md:flex-row md:space-x-6 rtl:space-x-reverse md:mt-0 md:border-0">
|
||||
<li>
|
||||
<a
|
||||
class="btn hover:variant-ghost-surface center-nav"
|
||||
href="/portfolio"
|
||||
rel="noreferrer">
|
||||
<strong>Portfolio</strong>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="btn hover:variant-ghost-surface center-nav"
|
||||
href="/hackathons"
|
||||
rel="noreferrer">
|
||||
<strong>Hackathons</strong>
|
||||
</a>
|
||||
</li>
|
||||
<!-- <li>
|
||||
<a
|
||||
class="btn hover:variant-ghost-surface center-nav"
|
||||
href="/projects"
|
||||
rel="noreferrer">
|
||||
<strong>Projects</strong>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
class="btn hover:variant-ghost-surface center-nav"
|
||||
href="/game"
|
||||
rel="noreferrer">
|
||||
<strong>Games</strong>
|
||||
</a>
|
||||
</li> -->
|
||||
<li>
|
||||
<a class="btn btn-sm">
|
||||
<LightSwitch class="flex mx-auto items-center"/>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</svelte:fragment>
|
||||
</AppBar>
|
||||
|
||||
<style lang="postcss">
|
||||
.center-nav {
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,476 @@
|
||||
<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';
|
||||
|
||||
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>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--nav-bg);
|
||||
border-bottom: 1px solid var(--nav-border);
|
||||
backdrop-filter: blur(10px);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.terminal-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.terminal-prompt .user {
|
||||
color: var(--nav-accent);
|
||||
}
|
||||
|
||||
.terminal-prompt .separator {
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.terminal-prompt .path {
|
||||
color: var(--nav-primary);
|
||||
}
|
||||
|
||||
.terminal-prompt .symbol {
|
||||
color: var(--nav-text);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
width: 8px;
|
||||
height: 1.2em;
|
||||
background: var(--nav-primary);
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.hamburger {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--nav-text);
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger::before,
|
||||
.hamburger::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--nav-text);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hamburger::before {
|
||||
top: -7px;
|
||||
}
|
||||
|
||||
.hamburger::after {
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.hamburger.open {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hamburger.open::before {
|
||||
top: 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.hamburger.open::after {
|
||||
top: 0;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--nav-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-link::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background: var(--nav-primary);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover::before {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--nav-primary);
|
||||
}
|
||||
|
||||
.link-prefix {
|
||||
color: var(--nav-muted);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.theme-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--nav-border);
|
||||
border-radius: 6px;
|
||||
color: var(--nav-text);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-button:hover {
|
||||
border-color: var(--nav-primary);
|
||||
background: color-mix(in srgb, var(--nav-primary) 10%, transparent);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
:global(.dropdown-arrow) {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.dropdown-arrow.open) {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.theme-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
background: var(--nav-bg);
|
||||
border: 1px solid var(--nav-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--nav-text);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
text-align: left;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
background: color-mix(in srgb, var(--nav-primary) 15%, transparent);
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
background: color-mix(in srgb, var(--nav-primary) 20%, transparent);
|
||||
color: var(--nav-primary);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(.check-mark) {
|
||||
color: var(--nav-primary);
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--nav-border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.mode-toggle:hover {
|
||||
border-color: var(--nav-primary);
|
||||
background: color-mix(in srgb, var(--nav-primary) 10%, transparent);
|
||||
color: var(--nav-primary);
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
z-index: 999;
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: block;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
order: 4;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-links.expanded {
|
||||
max-height: 200px;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
order: 3;
|
||||
margin-left: auto;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.theme-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.theme-button {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,504 @@
|
||||
<script lang="ts">
|
||||
import { mode, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
|
||||
import { page } from '$app/stores';
|
||||
import { fly, fade } from 'svelte/transition';
|
||||
import { user, navigation, colorPalette } from '$lib/config';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
|
||||
// State
|
||||
let themeDropdownOpen = $state(false);
|
||||
let currentTime = $state(new Date());
|
||||
|
||||
// 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();
|
||||
}, 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;
|
||||
}
|
||||
}
|
||||
|
||||
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="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: {colorPalette.success};
|
||||
--bar-warning: {colorPalette.warning};
|
||||
--bar-error: {colorPalette.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>
|
||||
|
||||
<!-- Workspaces -->
|
||||
<div class="module workspaces">
|
||||
{#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 -->
|
||||
<div class="module window-title">
|
||||
<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 === '/hackathons'}
|
||||
<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) -->
|
||||
|
||||
<!-- 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}
|
||||
<button
|
||||
class="theme-option"
|
||||
class:active={$colorTheme === option.value}
|
||||
onclick={() => handleThemeSelect(option.value)}
|
||||
>
|
||||
<Icon icon={getThemeIcon(option.value)} width="16" />
|
||||
<span>{option.label}</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'}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
{#if themeDropdownOpen}
|
||||
<button
|
||||
class="backdrop"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onclick={() => themeDropdownOpen = false}
|
||||
aria-label="Close"
|
||||
></button>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.waybar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
height: var(--navbar-height, 40px);
|
||||
background: var(--bar-bg);
|
||||
border-bottom: 1px solid var(--bar-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 0.5rem;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.8rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-left,
|
||||
.bar-center,
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bar-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar-right {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Modules */
|
||||
.module {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
background: var(--bar-bg-module);
|
||||
border-radius: 4px;
|
||||
color: var(--bar-text);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.module:hover {
|
||||
background: color-mix(in srgb, var(--bar-primary) 20%, var(--bar-bg-module));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* module-group removed (minimal waybar look uses individual modules) */
|
||||
|
||||
/* Launcher */
|
||||
.launcher {
|
||||
color: var(--bar-primary);
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.launcher:hover {
|
||||
color: var(--bar-accent);
|
||||
}
|
||||
|
||||
/* Workspaces */
|
||||
.workspaces {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.3rem;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--bar-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workspace:hover {
|
||||
background: color-mix(in srgb, var(--bar-primary) 25%, transparent);
|
||||
color: var(--bar-text);
|
||||
}
|
||||
|
||||
.workspace.active {
|
||||
background: var(--bar-primary);
|
||||
color: var(--bar-bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.workspace .ws-name {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.workspace.external {
|
||||
color: var(--bar-muted);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.workspace.external:hover {
|
||||
color: var(--bar-accent);
|
||||
}
|
||||
|
||||
/* Window title */
|
||||
.window-title {
|
||||
color: var(--bar-muted);
|
||||
font-size: 0.75rem;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
display: flex;
|
||||
color: var(--bar-primary);
|
||||
}
|
||||
|
||||
.title-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Clock */
|
||||
.clock {
|
||||
background: var(--bar-bg-module);
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.clock .time {
|
||||
font-weight: 600;
|
||||
color: var(--bar-text);
|
||||
}
|
||||
|
||||
.clock .date {
|
||||
color: var(--bar-muted);
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* System modules removed */
|
||||
|
||||
/* Theme selector */
|
||||
.theme-selector {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.theme-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: var(--bar-bg-module);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--bar-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.theme-trigger:hover {
|
||||
background: color-mix(in srgb, var(--bar-primary) 25%, var(--bar-bg-module));
|
||||
color: var(--bar-primary);
|
||||
}
|
||||
|
||||
.theme-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--bar-bg);
|
||||
border: 1px solid var(--bar-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--bar-muted);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--bar-border);
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: var(--bar-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
color: var(--bar-primary);
|
||||
}
|
||||
|
||||
.theme-option span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(.check) {
|
||||
color: var(--bar-primary);
|
||||
}
|
||||
|
||||
/* Backdrop */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
z-index: 999;
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.waybar {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.clock .date {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
|
||||
.workspaces {
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.workspace .ws-name {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<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;
|
||||
|
||||
// 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,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false
|
||||
});
|
||||
|
||||
particles = new THREE.Points(particlesGeometry, particlesMaterial);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createParticles();
|
||||
});
|
||||
|
||||
// Update particle color based on theme
|
||||
$effect(() => {
|
||||
if (particlesMaterial) {
|
||||
const color = new THREE.Color($themeColors.primary);
|
||||
particlesMaterial.color = color;
|
||||
particlesMaterial.opacity = $mode === 'dark' ? 0.6 : 0.4;
|
||||
}
|
||||
});
|
||||
|
||||
// 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}
|
||||
@@ -0,0 +1,350 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { themeColors } from '$lib/stores/theme';
|
||||
|
||||
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-accent: {$themeColors.accent};
|
||||
"
|
||||
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>
|
||||
|
||||
<style>
|
||||
.terminal {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
background: var(--terminal-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
animation: terminalFadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes terminalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.terminal-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.terminal-buttons .btn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.terminal-buttons .btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.terminal-buttons .close {
|
||||
background: #ff5f56;
|
||||
}
|
||||
|
||||
.terminal-buttons .minimize {
|
||||
background: #ffbd2e;
|
||||
}
|
||||
|
||||
.terminal-buttons .maximize {
|
||||
background: #27ca40;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--terminal-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.terminal-spacer {
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 1rem 1.25rem;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.25rem;
|
||||
animation: lineSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes lineSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.prompt {
|
||||
display: inline-flex;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prompt .user {
|
||||
color: var(--terminal-user);
|
||||
}
|
||||
|
||||
.prompt .separator {
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.prompt .path {
|
||||
color: var(--terminal-path);
|
||||
}
|
||||
|
||||
.prompt .symbol {
|
||||
color: var(--terminal-text);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: var(--terminal-text);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.terminal-line.output .content {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.terminal-line.error .content {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.terminal-line.success .content {
|
||||
color: #51cf66;
|
||||
}
|
||||
|
||||
.terminal-line.info .content {
|
||||
color: var(--terminal-primary);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.2em;
|
||||
background: var(--terminal-primary);
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.terminal-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-body::-webkit-scrollbar-thumb {
|
||||
background: var(--terminal-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--terminal-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,445 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { themeColors } from '$lib/stores/theme';
|
||||
import { user, terminalSettings } from '$lib/config';
|
||||
import { calculateTypeSpeed } from '$lib';
|
||||
|
||||
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-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>
|
||||
|
||||
<style>
|
||||
.terminal-page {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
background: var(--terminal-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 25px 50px -12px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
animation: terminalFadeIn 0.5s ease-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@keyframes terminalFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--terminal-bg-light);
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.terminal-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.terminal-buttons .btn {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.terminal-buttons .btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.terminal-buttons .close {
|
||||
background: #ff5f56;
|
||||
}
|
||||
|
||||
.terminal-buttons .minimize {
|
||||
background: #ffbd2e;
|
||||
}
|
||||
|
||||
.terminal-buttons .maximize {
|
||||
background: #27ca40;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--terminal-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.terminal-spacer {
|
||||
width: 52px;
|
||||
}
|
||||
|
||||
.terminal-body {
|
||||
padding: 1rem 1.25rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.terminal-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.25rem;
|
||||
animation: lineSlideIn 0.2s ease-out;
|
||||
min-height: 1.6em;
|
||||
}
|
||||
|
||||
.terminal-line.blank {
|
||||
min-height: 0.8em;
|
||||
}
|
||||
|
||||
@keyframes lineSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.prompt {
|
||||
display: inline-flex;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prompt .user {
|
||||
color: var(--terminal-user);
|
||||
}
|
||||
|
||||
.prompt .separator {
|
||||
color: var(--terminal-text);
|
||||
}
|
||||
|
||||
.prompt .path {
|
||||
color: var(--terminal-path);
|
||||
}
|
||||
|
||||
.prompt .symbol {
|
||||
color: var(--terminal-text);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: var(--terminal-text);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.terminal-line.output .content {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.terminal-line.error .content {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.terminal-line.success .content {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.terminal-line.info .content {
|
||||
color: var(--terminal-primary);
|
||||
}
|
||||
|
||||
.terminal-line.header .content {
|
||||
color: var(--terminal-accent);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
color: var(--terminal-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.terminal-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.terminal-image img {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--terminal-border);
|
||||
background: var(--terminal-bg-light);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-caption {
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.2em;
|
||||
background: var(--terminal-primary);
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.terminal-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-body::-webkit-scrollbar-thumb {
|
||||
background: var(--terminal-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.terminal-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--terminal-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { themeColors } from '$lib/stores/theme';
|
||||
import { toggleMode } from '$lib/stores/theme';
|
||||
import { terminalSettings, type SpeedPreset, speedPresets } from '$lib/config';
|
||||
import { calculateTypeSpeed } from '$lib';
|
||||
import TuiHeader from './tui/TuiHeader.svelte';
|
||||
import TuiBody from './tui/TuiBody.svelte';
|
||||
import TuiFooter from './tui/TuiFooter.svelte';
|
||||
import { parseColorText, getPlainText } from './tui/utils';
|
||||
|
||||
import type { TerminalLine, ParsedLine, DisplayedLine } from './tui/types';
|
||||
|
||||
interface Props {
|
||||
lines?: TerminalLine[];
|
||||
title?: string;
|
||||
class?: string;
|
||||
onComplete?: () => void;
|
||||
interactive?: boolean;
|
||||
speed?: SpeedPreset | number;
|
||||
}
|
||||
|
||||
let {
|
||||
lines = [],
|
||||
title = 'terminal',
|
||||
class: className = '',
|
||||
onComplete,
|
||||
interactive = true,
|
||||
speed = 'normal'
|
||||
}: Props = $props();
|
||||
|
||||
// Calculate speed multiplier from preset or number
|
||||
const speedMultiplier = $derived(
|
||||
typeof speed === 'number' ? speed : (speedPresets[speed] ?? 1)
|
||||
);
|
||||
|
||||
// Pre-parse all lines upfront (segments + plain text)
|
||||
const parsedLines = $derived<ParsedLine[]>(
|
||||
lines.map(line => {
|
||||
const segments = parseColorText(line.content);
|
||||
return {
|
||||
line,
|
||||
segments,
|
||||
plainText: getPlainText(segments)
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
let displayedLines = $state<DisplayedLine[]>([]);
|
||||
let currentLineIndex = $state(0);
|
||||
let isTyping = $state(false);
|
||||
let isComplete = $state(false);
|
||||
let selectedIndex = $state(-1);
|
||||
let terminalElement: HTMLDivElement;
|
||||
let bodyElement = $state<HTMLDivElement>();
|
||||
|
||||
// Autoscroll to bottom
|
||||
function scrollToBottom() {
|
||||
if (bodyElement) {
|
||||
bodyElement.scrollTo({
|
||||
top: bodyElement.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get all interactive button indices
|
||||
let buttonIndices = $derived(
|
||||
displayedLines
|
||||
.map((item, i) => item.parsed.line.type === 'button' ? i : -1)
|
||||
.filter(i => i !== -1)
|
||||
);
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function typeText() {
|
||||
if (parsedLines.length === 0) return;
|
||||
|
||||
isTyping = true;
|
||||
|
||||
// Apply speed multiplier to start delay
|
||||
const startDelayMs = speedMultiplier === 0 ? 0 : terminalSettings.startDelay * speedMultiplier;
|
||||
await sleep(startDelayMs);
|
||||
if (skipRequested) return;
|
||||
|
||||
for (let i = 0; i < parsedLines.length; i++) {
|
||||
if (skipRequested) return;
|
||||
|
||||
const parsed = parsedLines[i];
|
||||
const line = parsed.line;
|
||||
const plainLength = parsed.plainText.length;
|
||||
|
||||
displayedLines = [...displayedLines, { parsed, charIndex: 0, complete: false, showImage: false }];
|
||||
currentLineIndex = i;
|
||||
|
||||
if (line.delay && speedMultiplier > 0) {
|
||||
await sleep(line.delay * speedMultiplier);
|
||||
if (skipRequested) return;
|
||||
}
|
||||
|
||||
// Handle different line types
|
||||
if (line.type === 'image') {
|
||||
await sleep(speedMultiplier === 0 ? 0 : 100 * speedMultiplier);
|
||||
if (skipRequested) return;
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: true };
|
||||
scrollToBottom();
|
||||
} else if (line.type === 'blank' || line.type === 'divider') {
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
|
||||
} else if (line.type === 'button') {
|
||||
// Buttons appear instantly
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
|
||||
scrollToBottom();
|
||||
} else if (speedMultiplier === 0) {
|
||||
// Instant mode - no typing animation
|
||||
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
|
||||
if (i % 5 === 0) scrollToBottom();
|
||||
} else if (line.type === 'header') {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier * 0.25);
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (skipRequested) return;
|
||||
displayedLines[i] = {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
};
|
||||
if (j < plainLength) await sleep(typeSpeed);
|
||||
}
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier);
|
||||
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (skipRequested) return;
|
||||
displayedLines[i] = {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
};
|
||||
|
||||
if (j % 10 === 0) scrollToBottom();
|
||||
|
||||
if (j < plainLength) {
|
||||
await sleep(typeSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skipRequested) return;
|
||||
displayedLines[i].complete = true;
|
||||
|
||||
scrollToBottom();
|
||||
|
||||
if (i < parsedLines.length - 1 && speedMultiplier > 0) {
|
||||
await sleep(terminalSettings.lineDelay * speedMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
isTyping = false;
|
||||
isComplete = true;
|
||||
|
||||
if (interactive && buttonIndices.length > 0) {
|
||||
selectedIndex = buttonIndices[0];
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
// Scroll selected button into view with margin for context
|
||||
function scrollToSelected() {
|
||||
if (bodyElement && selectedIndex >= 0) {
|
||||
const buttons = bodyElement.querySelectorAll('.tui-button');
|
||||
const selectedButton = Array.from(buttons).find((_, i) => {
|
||||
const btnIndices = displayedLines
|
||||
.map((item, idx) => item.parsed.line.type === 'button' ? idx : -1)
|
||||
.filter(idx => idx !== -1);
|
||||
return btnIndices[i] === selectedIndex;
|
||||
}) as HTMLElement | undefined;
|
||||
|
||||
if (selectedButton && bodyElement) {
|
||||
const containerRect = bodyElement.getBoundingClientRect();
|
||||
const buttonRect = selectedButton.getBoundingClientRect();
|
||||
const margin = 80;
|
||||
|
||||
if (buttonRect.top < containerRect.top + margin) {
|
||||
const scrollAmount = buttonRect.top - containerRect.top - margin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
else if (buttonRect.bottom > containerRect.bottom - margin) {
|
||||
const scrollAmount = buttonRect.bottom - containerRect.bottom + margin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip animation and show all content instantly
|
||||
let skipRequested = $state(false);
|
||||
|
||||
function skipAnimation() {
|
||||
if (!isTyping) return;
|
||||
skipRequested = true;
|
||||
|
||||
displayedLines = parsedLines.map(parsed => ({
|
||||
parsed,
|
||||
charIndex: parsed.plainText.length,
|
||||
complete: true,
|
||||
showImage: parsed.line.type === 'image'
|
||||
}));
|
||||
|
||||
isTyping = false;
|
||||
isComplete = true;
|
||||
currentLineIndex = parsedLines.length - 1;
|
||||
|
||||
if (interactive && buttonIndices.length > 0) {
|
||||
selectedIndex = buttonIndices[0];
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
// Toggle theme with T key
|
||||
if (event.key === 't' || event.key === 'T') {
|
||||
event.preventDefault();
|
||||
toggleMode();
|
||||
return;
|
||||
}
|
||||
if (isTyping && (event.key === 'y' || event.key === 'Y')) {
|
||||
event.preventDefault();
|
||||
skipAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!interactive || !isComplete || buttonIndices.length === 0) return;
|
||||
|
||||
const currentButtonIdx = buttonIndices.indexOf(selectedIndex);
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'j') {
|
||||
event.preventDefault();
|
||||
const nextIdx = (currentButtonIdx + 1) % buttonIndices.length;
|
||||
selectedIndex = buttonIndices[nextIdx];
|
||||
scrollToSelected();
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'k') {
|
||||
event.preventDefault();
|
||||
const prevIdx = (currentButtonIdx - 1 + buttonIndices.length) % buttonIndices.length;
|
||||
selectedIndex = buttonIndices[prevIdx];
|
||||
scrollToSelected();
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const selectedLine = displayedLines[selectedIndex]?.parsed.line;
|
||||
if (selectedLine?.action) {
|
||||
selectedLine.action();
|
||||
} else if (selectedLine?.href) {
|
||||
const isExternal = selectedLine.external || selectedLine.href.startsWith('http://') || selectedLine.href.startsWith('https://');
|
||||
if (isExternal) {
|
||||
window.open(selectedLine.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
window.location.href = selectedLine.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleButtonClick(index: number) {
|
||||
selectedIndex = index;
|
||||
const line = displayedLines[index]?.parsed.line;
|
||||
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 {
|
||||
window.location.href = line.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleLinkClick(index: number) {
|
||||
const line = displayedLines[index]?.parsed.line;
|
||||
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 {
|
||||
window.location.href = line.href;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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-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={terminalSettings}
|
||||
/>
|
||||
|
||||
<TuiFooter isTyping={isTyping} linesCount={displayedLines.length} skipAnimation={skipAnimation} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-terminal {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||
background: var(--terminal-bg);
|
||||
border: 2px solid var(--terminal-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 95%;
|
||||
|
||||
margin: 0 auto;
|
||||
height: calc(100vh - var(--navbar-height) - 80px);
|
||||
max-height: calc(100vh - var(--navbar-height) - 80px);
|
||||
animation: tuiFadeIn 0.4s ease-out;
|
||||
}
|
||||
|
||||
.tui-terminal:focus-within .tui-border-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes tuiFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hyprland-style animated border glow */
|
||||
.tui-border-glow {
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
var(--terminal-primary),
|
||||
var(--terminal-accent),
|
||||
var(--terminal-primary),
|
||||
var(--terminal-accent)
|
||||
);
|
||||
background-size: 400% 400%;
|
||||
animation: borderGlow 8s ease infinite;
|
||||
opacity: 0.5;
|
||||
z-index: -1;
|
||||
filter: blur(4px);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes borderGlow {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
.tui-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--terminal-bg);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.tui-terminal {
|
||||
width: 95%;
|
||||
height: calc(100vh - var(--navbar-height) - 60px);
|
||||
max-height: calc(100vh - var(--navbar-height) - 60px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
|
||||
let openItems: Set<number> = new Set(line.accordionOpen ? [0] : []);
|
||||
|
||||
function toggleItem(index: number) {
|
||||
if (openItems.has(index)) {
|
||||
openItems.delete(index);
|
||||
} else {
|
||||
openItems.add(index);
|
||||
}
|
||||
openItems = openItems; // trigger reactivity
|
||||
}
|
||||
|
||||
$: items = line.accordionItems || [{ title: line.content, content: '' }];
|
||||
</script>
|
||||
|
||||
<div class="tui-accordion" style="--accordion-accent: {getButtonStyle(line.style)}">
|
||||
{#each items as item, i}
|
||||
{@const contentSegments = parseColorText(item.content)}
|
||||
<div class="accordion-item" class:open={openItems.has(i)}>
|
||||
<button class="accordion-header" on:click={() => 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}
|
||||
</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;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { getSegmentStyle, getLinePrefix, getSegmentsUpToChar } from './utils';
|
||||
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 type { DisplayedLine } from './types';
|
||||
|
||||
export let displayedLines: DisplayedLine[] = [];
|
||||
export let currentLineIndex = 0;
|
||||
export let isTyping = false;
|
||||
export let selectedIndex = -1;
|
||||
export let ref: HTMLDivElement | undefined;
|
||||
export let onButtonClick: (idx: number) => void;
|
||||
export let onHoverButton: (idx: number) => void;
|
||||
export let onLinkClick: (idx: number) => void;
|
||||
export let terminalSettings: any;
|
||||
|
||||
</script>
|
||||
|
||||
<div class="tui-body" bind:this={ref}>
|
||||
{#each displayedLines as { parsed, charIndex, complete, showImage }, i}
|
||||
{@const line = parsed.line}
|
||||
{@const visibleSegments = getSegmentsUpToChar(parsed.segments, charIndex)}
|
||||
{#if line.type === 'divider'}
|
||||
<div class="tui-divider">
|
||||
<span class="divider-line"></span>
|
||||
{#if line.content}
|
||||
<span class="divider-text">{line.content}</span>
|
||||
{/if}
|
||||
<span class="divider-line"></span>
|
||||
</div>
|
||||
{:else if line.type === 'button'}
|
||||
<TuiButton {line} index={i} selected={selectedIndex === i} onClick={onButtonClick} onHover={onHoverButton} />
|
||||
{:else if line.type === 'link'}
|
||||
<div class="tui-line link">
|
||||
<TuiLink {line} onClick={() => onLinkClick(i)} />
|
||||
</div>
|
||||
{:else if line.type === 'card'}
|
||||
<TuiCard {line} />
|
||||
{:else if line.type === 'cardgrid'}
|
||||
<TuiCardGrid {line} />
|
||||
{:else if line.type === 'progress'}
|
||||
<TuiProgress {line} />
|
||||
{:else if line.type === 'accordion'}
|
||||
<TuiAccordion {line} />
|
||||
{:else if line.type === 'table'}
|
||||
<TuiTable {line} />
|
||||
{:else if line.type === 'tooltip'}
|
||||
<div class="tui-line">
|
||||
<TuiTooltip {line} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tui-line {line.type}" class:complete>
|
||||
{#if line.type === 'command' || line.type === 'prompt'}
|
||||
<span class="prompt">
|
||||
<span class="user">{user.username}</span><span class="at">@</span><span class="host">{user.hostname}</span>
|
||||
<span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if line.type === 'image' && showImage}
|
||||
<div class="tui-image">
|
||||
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
|
||||
{#if line.content}
|
||||
<span class="image-caption">{line.content}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if line.type === 'header'}
|
||||
<span class="content header-text">
|
||||
<Icon icon="mdi:pound" width="14" class="header-icon" />
|
||||
{#each visibleSegments as segment}
|
||||
{#if segment.icon}
|
||||
<Icon icon={segment.icon} width="16" class="inline-icon" />
|
||||
{:else if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</span>
|
||||
{:else if line.type !== 'blank'}
|
||||
<span class="content">
|
||||
{getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
|
||||
<span class="cursor"></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#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"></span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-body {
|
||||
flex: 1;
|
||||
padding: 1rem 1.25rem 2rem 1.25rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Lines */
|
||||
.tui-line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.2rem;
|
||||
animation: lineSlideIn 0.15s ease-out;
|
||||
min-height: 1.7em;
|
||||
}
|
||||
|
||||
.tui-line.blank {
|
||||
min-height: 0.5em;
|
||||
}
|
||||
|
||||
@keyframes lineSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Prompt styling */
|
||||
.prompt {
|
||||
display: inline-flex;
|
||||
margin-right: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prompt .user {
|
||||
color: var(--terminal-user);
|
||||
}
|
||||
|
||||
.prompt .at {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.prompt .host {
|
||||
color: var(--terminal-accent);
|
||||
}
|
||||
|
||||
.prompt .separator {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.prompt .path {
|
||||
color: var(--terminal-path);
|
||||
}
|
||||
|
||||
.prompt .symbol {
|
||||
color: var(--terminal-muted);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content {
|
||||
color: var(--terminal-text);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tui-line.output .content {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.tui-line.error .content {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.tui-line.success .content {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.tui-line.info .content {
|
||||
color: var(--terminal-primary);
|
||||
}
|
||||
|
||||
.tui-line.warning .content {
|
||||
color: #f9e2af;
|
||||
}
|
||||
|
||||
.header-text {
|
||||
color: var(--terminal-accent);
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.header-icon) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
:global(.inline-icon) {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin: 0 0.15em;
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.tui-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--terminal-border),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.divider-text {
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
/* Images */
|
||||
.tui-image {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.tui-image img {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--terminal-border);
|
||||
background: var(--terminal-bg-light);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-caption {
|
||||
color: var(--terminal-muted);
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Cursor */
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.2em;
|
||||
background: var(--terminal-primary);
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@keyframes cursorBlink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.tui-body::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.tui-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tui-body::-webkit-scrollbar-thumb {
|
||||
background: var(--terminal-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tui-body::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--terminal-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { getButtonStyle } from './utils';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
export let index: number;
|
||||
export let selected: boolean;
|
||||
export let onClick: (idx: number) => void;
|
||||
export let onHover: (idx: number) => void;
|
||||
|
||||
// Determine if this is an external link
|
||||
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="tui-button"
|
||||
class:selected={selected}
|
||||
style="--btn-color: {getButtonStyle(line.style)}"
|
||||
on:click={() => onClick(index)}
|
||||
on:mouseenter={() => onHover(index)}
|
||||
>
|
||||
<span class="btn-indicator">{selected ? '▶' : ' '}</span>
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="16" />
|
||||
{/if}
|
||||
<span class="btn-text">{line.content}</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>
|
||||
|
||||
<style>
|
||||
.tui-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0.2rem 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
color: var(--btn-color);
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tui-button:hover,
|
||||
.tui-button.selected {
|
||||
background: color-mix(in srgb, var(--btn-color) 15%, transparent);
|
||||
border-color: var(--btn-color);
|
||||
}
|
||||
|
||||
.btn-indicator {
|
||||
color: var(--btn-color);
|
||||
font-size: 0.8rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:global(.btn-arrow) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tui-button.selected :global(.btn-arrow) {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
|
||||
$: segments = parseColorText(line.content);
|
||||
$: titleSegments = line.cardTitle ? parseColorText(line.cardTitle) : [];
|
||||
$: footerSegments = line.cardFooter ? parseColorText(line.cardFooter) : [];
|
||||
</script>
|
||||
|
||||
<div class="tui-card" style="--card-accent: {getButtonStyle(line.style)}">
|
||||
{#if line.cardTitle}
|
||||
<div class="card-header">
|
||||
{#if line.icon}
|
||||
<Icon icon={line.icon} width="16" class="card-icon" />
|
||||
{/if}
|
||||
<span class="card-title">
|
||||
{#each titleSegments as segment}
|
||||
{#if getSegmentStyle(segment)}
|
||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
||||
{:else}
|
||||
{segment.text}
|
||||
{/if}
|
||||
{/each}
|
||||
</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>
|
||||
</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-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-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>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
|
||||
$: cards = line.cards || [];
|
||||
</script>
|
||||
|
||||
<div class="tui-card-grid">
|
||||
{#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" />
|
||||
{#if card.featured}
|
||||
<span class="featured-badge">★ Featured</span>
|
||||
{/if}
|
||||
</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.slice(0, 5) as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
{#if card.tags.length > 5}
|
||||
<span class="tag more">+{card.tags.length - 5}</span>
|
||||
{/if}
|
||||
</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;
|
||||
}
|
||||
|
||||
.tag.more {
|
||||
background: color-mix(in srgb, var(--terminal-muted) 20%, transparent);
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.warning {
|
||||
font-size: 0.6rem;
|
||||
color: #f9e2af;
|
||||
padding: 0.2rem 0.35rem;
|
||||
background: rgba(249, 226, 175, 0.1);
|
||||
border-radius: 3px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
export let isTyping: boolean;
|
||||
export let linesCount: number;
|
||||
export let skipAnimation: () => void;
|
||||
</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" on:click={skipAnimation}>
|
||||
<Icon icon="mdi:skip-forward" width="12" />
|
||||
<span>Skip (Y)</span>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="line-count">{linesCount} lines</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--terminal-bg-light);
|
||||
border-color: var(--terminal-border);
|
||||
}
|
||||
|
||||
.tui-statusbar.bottom {
|
||||
border-top: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.status-left, .status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.status-center {
|
||||
color: var(--terminal-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--terminal-border);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
color: var(--terminal-muted);
|
||||
font-family: inherit;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.skip-btn:hover {
|
||||
background: var(--terminal-primary);
|
||||
color: var(--terminal-bg);
|
||||
border-color: var(--terminal-primary);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.line-count {
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { user } from '$lib/config';
|
||||
import type { SpeedPreset } from '$lib/config';
|
||||
|
||||
export let title = 'terminal';
|
||||
export let interactive = true;
|
||||
export let hasButtons = false;
|
||||
</script>
|
||||
|
||||
<div class="tui-statusbar top">
|
||||
<span class="status-left">
|
||||
<Icon icon="mdi:arch" width="14" />
|
||||
<span>{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>
|
||||
|
||||
<style>
|
||||
.tui-statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--terminal-bg-light);
|
||||
border-color: var(--terminal-border);
|
||||
}
|
||||
|
||||
.tui-statusbar.top {
|
||||
border-bottom: 1px solid var(--terminal-border);
|
||||
}
|
||||
|
||||
.status-left, .status-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--terminal-muted);
|
||||
}
|
||||
|
||||
.status-center {
|
||||
color: var(--terminal-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hint {
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--terminal-border);
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hint {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { getButtonStyle } from './utils';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
export let onClick: () => void;
|
||||
|
||||
// Determine if this is an external link
|
||||
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
|
||||
</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" on:click={onClick}>
|
||||
{line.content}
|
||||
</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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { getButtonStyle } from './utils';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
|
||||
$: progress = Math.min(100, Math.max(0, line.progress ?? 0));
|
||||
$: label = line.progressLabel || `${progress}%`;
|
||||
</script>
|
||||
|
||||
<div class="tui-progress" style="--progress-color: {getButtonStyle(line.style)}">
|
||||
{#if line.content}
|
||||
<div class="progress-label">{line.content}</div>
|
||||
{/if}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progress}%">
|
||||
<span class="progress-glow"></span>
|
||||
</div>
|
||||
<div class="progress-blocks">
|
||||
{#each Array(20) as _, i}
|
||||
<span class="block" class:filled={i < Math.floor(progress / 5)}></span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-value">{label}</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tui-progress {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
|
||||
$: headers = line.tableHeaders || [];
|
||||
$: rows = line.tableRows || [];
|
||||
</script>
|
||||
|
||||
<div class="tui-table-wrapper" 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)}
|
||||
<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>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script lang="ts">
|
||||
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
|
||||
import type { TerminalLine } from './types';
|
||||
|
||||
export let line: TerminalLine;
|
||||
|
||||
let showTooltip = false;
|
||||
let triggerEl: HTMLSpanElement;
|
||||
let tooltipStyle = '';
|
||||
|
||||
$: contentSegments = parseColorText(line.content);
|
||||
$: position = 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}
|
||||
on:mouseenter={handleMouseEnter}
|
||||
on:mouseleave={handleMouseLeave}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:focus={handleMouseEnter}
|
||||
on:blur={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>
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { TextSegment } from './utils';
|
||||
import type { Card } from '$lib/config';
|
||||
|
||||
export type LineType =
|
||||
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
|
||||
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
|
||||
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid';
|
||||
|
||||
export interface TerminalLine {
|
||||
type: LineType;
|
||||
content: string;
|
||||
delay?: number;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
imageWidth?: number;
|
||||
// For button and link types
|
||||
action?: () => void;
|
||||
href?: string;
|
||||
icon?: string;
|
||||
external?: boolean; // Opens in new tab
|
||||
// For styling
|
||||
style?: 'primary' | 'secondary' | 'accent' | 'warning' | 'error';
|
||||
// For card type
|
||||
cardTitle?: string;
|
||||
cardFooter?: string;
|
||||
// For progress type
|
||||
progress?: number; // 0-100
|
||||
progressLabel?: string;
|
||||
// For accordion type
|
||||
accordionOpen?: boolean;
|
||||
accordionItems?: { title: string; content: string }[];
|
||||
// For table type
|
||||
tableHeaders?: string[];
|
||||
tableRows?: string[][];
|
||||
// For tooltip type
|
||||
tooltipText?: string;
|
||||
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
|
||||
// For cardgrid type
|
||||
cards?: Card[];
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// 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 map for text and background colors
|
||||
export const colorMap: Record<string, string> = {
|
||||
// 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',
|
||||
// Semantic colors
|
||||
'primary': 'var(--terminal-primary)',
|
||||
'accent': 'var(--terminal-accent)',
|
||||
'muted': 'var(--terminal-muted)',
|
||||
'error': '#f38ba8',
|
||||
'success': '#a6e3a1',
|
||||
'warning': '#f9e2af',
|
||||
'info': '#89b4fa',
|
||||
};
|
||||
|
||||
// Text style keywords
|
||||
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
|
||||
|
||||
export function parseColorText(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
// Match both (&specs)content(&) and (&icon, iconName) patterns
|
||||
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 (match[3] is the icon name)
|
||||
if (match[3]) {
|
||||
const iconName = match[3].trim();
|
||||
segments.push({ text: '', icon: iconName });
|
||||
lastIndex = match.index + match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const specs = match[1].split(',').map(s => s.trim().toLowerCase());
|
||||
const content = match[2];
|
||||
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 (colorMap[bgColor]) {
|
||||
segment.background = colorMap[bgColor];
|
||||
} else if (bgColor.startsWith('#')) {
|
||||
segment.background = bgColor;
|
||||
}
|
||||
}
|
||||
// Foreground color
|
||||
else if (colorMap[spec] && !textStyles.includes(spec)) {
|
||||
segment.color = colorMap[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 '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)';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user