Website Redesign 7

This commit is contained in:
2025-11-28 02:40:12 +00:00
parent 9b9a201c3e
commit 96e2d0650c
72 changed files with 7504 additions and 1231 deletions
+421
View File
@@ -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>