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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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)';
}
}