Website Redesign 7
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user