Bug Fixes and Formatting Update

This commit is contained in:
2025-11-28 05:13:49 +00:00
parent c61cb39475
commit 88b068a2b5
18 changed files with 863 additions and 199 deletions
+74 -1
View File
@@ -268,6 +268,67 @@
controls.update();
}
function rotateCamera(direction: 'left' | 'right' | 'up' | 'down') {
if (!controls) return;
const rotateSpeed = 0.1;
const spherical = new THREE.Spherical();
const offset = new THREE.Vector3();
// Get current camera position relative to target
offset.copy(camera.position).sub(controls.target);
spherical.setFromVector3(offset);
switch (direction) {
case 'left':
spherical.theta += rotateSpeed;
break;
case 'right':
spherical.theta -= rotateSpeed;
break;
case 'up':
spherical.phi = Math.max(0.1, spherical.phi - rotateSpeed);
break;
case 'down':
spherical.phi = Math.min(Math.PI - 0.1, spherical.phi + rotateSpeed);
break;
}
offset.setFromSpherical(spherical);
camera.position.copy(controls.target).add(offset);
controls.update();
}
function handleKeydown(event: KeyboardEvent) {
// Only handle if this component is focused or in fullscreen
if (!container?.contains(document.activeElement) && !isFullscreen) return;
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
rotateCamera('left');
break;
case 'ArrowRight':
event.preventDefault();
rotateCamera('right');
break;
case 'ArrowUp':
event.preventDefault();
rotateCamera('up');
break;
case 'ArrowDown':
event.preventDefault();
rotateCamera('down');
break;
case 'Escape':
if (isFullscreen) {
isFullscreen = false;
setTimeout(handleResize, 100);
}
break;
}
}
function toggleControlsPanel() {
showControls = !showControls;
}
@@ -277,6 +338,7 @@
initScene();
animate();
window.addEventListener('resize', handleResize);
window.addEventListener('keydown', handleKeydown);
}
});
@@ -288,6 +350,7 @@
renderer.dispose();
}
window.removeEventListener('resize', handleResize);
window.removeEventListener('keydown', handleKeydown);
});
</script>
@@ -410,7 +473,8 @@
{/if}
<!-- Canvas container -->
<div class="canvas-container" bind:this={container}>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="canvas-container" bind:this={container} tabindex="0" role="application" aria-label="3D model viewer - use arrow keys to rotate">
{#if isLoading}
<div class="loading-overlay">
<Icon icon="mdi:loading" width="32" class="spin" />
@@ -435,6 +499,10 @@
<Icon icon="mdi:magnify-plus-minus" width="14" />
Scroll to zoom
</span>
<span class="hint">
<Icon icon="mdi:arrow-all" width="14" />
Arrow keys
</span>
</div>
</div>
@@ -557,6 +625,11 @@
color-mix(in srgb, var(--viewer-primary) 5%, transparent),
transparent 70%
);
outline: none;
}
.canvas-container:focus {
box-shadow: inset 0 0 0 2px var(--viewer-primary);
}
.canvas-container :global(canvas) {
+1 -1
View File
@@ -142,7 +142,7 @@
<Icon icon="mdi:folder-multiple" width="14" />
{:else if currentPath === '/models'}
<Icon icon="mdi:cube-outline" width="14" />
{:else if currentPath === '/hackathons'}
{:else if currentPath === '/projects'}
<Icon icon="mdi:trophy" width="14" />
{:else}
<Icon icon="mdi:file" width="14" />
+22
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { themeColors } from '$lib/stores/theme';
import { toggleMode } from '$lib/stores/theme';
import { terminalSettings, type SpeedPreset, speedPresets } from '$lib/config';
@@ -65,6 +66,24 @@
});
}
// Scroll to hash target (anchor link like #skills)
function scrollToHash() {
if (!browser || !bodyElement) return;
const hash = window.location.hash;
if (!hash) return;
const targetId = hash.slice(1); // Remove the #
const targetElement = bodyElement.querySelector(`#${CSS.escape(targetId)}`);
if (targetElement) {
// Small delay to ensure layout is complete
setTimeout(() => {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
// Get all interactive button indices
let buttonIndices = $derived(
displayedLines
@@ -167,6 +186,9 @@
selectedIndex = buttonIndices[0];
}
// Scroll to hash anchor if present in URL
scrollToHash();
onComplete?.();
}
+158 -66
View File
@@ -22,79 +22,137 @@
export let onLinkClick: (idx: number) => void;
export let terminalSettings: any;
// Group displayed lines into regular items and inline groups
type GroupedItem =
| { type: 'single'; index: number; displayed: DisplayedLine }
| { type: 'inline-group'; indices: number[]; items: DisplayedLine[] };
$: groupedLines = (() => {
const result: GroupedItem[] = [];
let i = 0;
while (i < displayedLines.length) {
const displayed = displayedLines[i];
const line = displayed.parsed.line;
if (line.inline) {
// Start an inline group
const group: GroupedItem = { type: 'inline-group', indices: [], items: [] };
while (i < displayedLines.length && displayedLines[i].parsed.line.inline) {
group.indices.push(i);
group.items.push(displayedLines[i]);
i++;
}
result.push(group);
} else {
result.push({ type: 'single', index: i, displayed });
i++;
}
}
return result;
})();
</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>
{#each groupedLines as group}
{#if group.type === 'inline-group'}
<!-- Inline group container -->
<div class="tui-inline-group">
{#each group.items as displayed, j}
{@const line = displayed.parsed.line}
{@const idx = group.indices[j]}
{@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
{#if line.type === 'button'}
<TuiButton {line} index={idx} selected={selectedIndex === idx} onClick={onButtonClick} onHover={onHoverButton} inline={true} />
{:else if line.type === 'link'}
<TuiLink {line} onClick={() => onLinkClick(idx)} />
{:else if line.type === 'tooltip'}
<TuiTooltip {line} />
{:else if line.type === 'progress'}
<TuiProgress {line} inline={true} />
{:else}
<span class="inline-content {line.type}">
{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}
{/each}
</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}
{:else if group.type === 'single'}
{@const i = group.index}
{@const displayed = group.displayed}
{@const line = displayed.parsed.line}
{@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
{@const complete = displayed.complete}
{@const showImage = displayed.showImage}
{#if line.type === 'divider'}
<div class="tui-divider" id={line.id}>
<span class="divider-line"></span>
{#if line.content}
<span class="divider-text">{line.content}</span>
{/if}
<span class="divider-line"></span>
</div>
{:else if line.type === 'button'}
<TuiButton {line} index={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 id={line.id}>
{#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 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}
{/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}
</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 terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
<span class="cursor"></span>
{/if}
</div>
{/if}
{/if}
{/each}
@@ -120,6 +178,40 @@
min-height: 0;
}
/* Inline group wrapper - groups consecutive inline elements */
.tui-inline-group {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.2rem;
animation: lineSlideIn 0.15s ease-out;
}
.inline-content {
display: inline;
white-space: pre-wrap;
}
.inline-content.output {
color: var(--terminal-muted);
}
.inline-content.info {
color: var(--terminal-primary);
}
.inline-content.success {
color: #a6e3a1;
}
.inline-content.error {
color: #f38ba8;
}
.inline-content.warning {
color: #f9e2af;
}
/* Lines */
.tui-line {
display: flex;
+36 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle } from './utils';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
@@ -8,14 +8,19 @@
export let selected: boolean;
export let onClick: (idx: number) => void;
export let onHover: (idx: number) => void;
export let inline: boolean = false;
// Determine if this is an external link
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
// Parse color formatting in content
$: segments = parseColorText(line.content);
</script>
<button
class="tui-button"
class:selected={selected}
class:inline={inline}
style="--btn-color: {getButtonStyle(line.style)}"
on:click={() => onClick(index)}
on:mouseenter={() => onHover(index)}
@@ -24,7 +29,17 @@
{#if line.icon}
<Icon icon={line.icon} width="16" />
{/if}
<span class="btn-text">{line.content}</span>
<span class="btn-text">
{#each segments 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 line.href}
{#if isExternal}
<Icon icon="mdi:open-in-new" width="14" class="btn-arrow" />
@@ -53,6 +68,19 @@
transition: all 0.15s ease;
}
/* Inline button styles */
.tui-button.inline {
width: auto;
display: inline-flex;
margin: 0;
padding: 0.35rem 0.6rem;
border: 1px solid color-mix(in srgb, var(--btn-color) 40%, transparent);
}
.tui-button.inline .btn-indicator {
display: none;
}
.tui-button:hover,
.tui-button.selected {
background: color-mix(in srgb, var(--btn-color) 15%, transparent);
@@ -76,4 +104,10 @@
.tui-button.selected :global(.btn-arrow) {
opacity: 1;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>
+19 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle } from './utils';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
@@ -8,6 +8,9 @@
// Determine if this is an external link
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
// Parse color formatting in content
$: segments = parseColorText(line.content);
</script>
<span class="tui-link" style="--link-color: {getButtonStyle(line.style)}">
@@ -15,7 +18,15 @@
<Icon icon={line.icon} width="14" class="link-icon" />
{/if}
<button class="link-text" on:click={onClick}>
{line.content}
{#each segments 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}
</button>
{#if isExternal}
<Icon icon="mdi:open-in-new" width="12" class="link-external" />
@@ -62,4 +73,10 @@
.tui-link:hover :global(.link-external) {
opacity: 0.8;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>
+59 -4
View File
@@ -1,16 +1,30 @@
<script lang="ts">
import { getButtonStyle } from './utils';
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
export let inline: boolean = false;
$: progress = Math.min(100, Math.max(0, line.progress ?? 0));
$: label = line.progressLabel || `${progress}%`;
$: contentSegments = parseColorText(line.content);
$: labelSegments = parseColorText(label);
</script>
<div class="tui-progress" style="--progress-color: {getButtonStyle(line.style)}">
<div class="tui-progress" class:inline={inline} style="--progress-color: {getButtonStyle(line.style)}">
{#if line.content}
<div class="progress-label">{line.content}</div>
<div class="progress-label">
{#each contentSegments 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}
</div>
{/if}
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%">
@@ -22,7 +36,17 @@
{/each}
</div>
</div>
<div class="progress-value">{label}</div>
<div class="progress-value">
{#each labelSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="12" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
</div>
<style>
@@ -31,6 +55,31 @@
font-size: 0.85rem;
}
/* Inline progress styles */
.tui-progress.inline {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 0;
min-width: 120px;
}
.tui-progress.inline .progress-label {
margin-bottom: 0;
white-space: nowrap;
}
.tui-progress.inline .progress-bar {
flex: 1;
min-width: 80px;
height: 0.8rem;
}
.tui-progress.inline .progress-value {
margin-top: 0;
white-space: nowrap;
}
.progress-label {
color: var(--terminal-text);
margin-bottom: 0.25rem;
@@ -99,4 +148,10 @@
margin-top: 0.25rem;
font-weight: 600;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
</style>
+4
View File
@@ -13,6 +13,10 @@ export interface TerminalLine {
image?: string;
imageAlt?: string;
imageWidth?: number;
// For anchor scrolling (e.g., #skills)
id?: string;
// For inline rendering (multiple elements on same line)
inline?: boolean;
// For button and link types
action?: () => void;
href?: string;
+13 -9
View File
@@ -18,9 +18,11 @@ export interface TextSegment {
action?: () => void;
}
// Color map for text and background colors
export const colorMap: Record<string, string> = {
// Basic colors
// Color maps for each theme
export type ThemeColorMap = Record<string, string>;
// Default color map (fallback)
export const defaultColorMap: ThemeColorMap = {
'red': '#f38ba8',
'green': '#a6e3a1',
'yellow': '#f9e2af',
@@ -33,7 +35,6 @@ export const colorMap: Record<string, string> = {
'pink': '#f5c2e7',
'black': '#1e1e2e',
'surface': '#313244',
// Semantic colors
'primary': 'var(--terminal-primary)',
'accent': 'var(--terminal-accent)',
'muted': 'var(--terminal-muted)',
@@ -43,10 +44,13 @@ export const colorMap: Record<string, string> = {
'info': '#89b4fa',
};
// Legacy alias for backwards compatibility
export const colorMap = defaultColorMap;
// Text style keywords
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
export function parseColorText(text: string): TextSegment[] {
export function parseColorText(text: string, colors: ThemeColorMap = colorMap): TextSegment[] {
const segments: TextSegment[] = [];
// Match both (&specs)content(&) and (&icon, iconName) patterns
const regex = /\(&([^)]+)\)(.*?)\(&\)|\(&icon,\s*([^)]+)\)/g;
@@ -81,15 +85,15 @@ export function parseColorText(text: string): TextSegment[] {
// Background color (bg-colorname or bg-#hex)
else if (spec.startsWith('bg-')) {
const bgColor = spec.slice(3);
if (colorMap[bgColor]) {
segment.background = colorMap[bgColor];
if (colors[bgColor]) {
segment.background = colors[bgColor];
} else if (bgColor.startsWith('#')) {
segment.background = bgColor;
}
}
// Foreground color
else if (colorMap[spec] && !textStyles.includes(spec)) {
segment.color = colorMap[spec];
else if (colors[spec] && !textStyles.includes(spec)) {
segment.color = colors[spec];
} else if (spec.startsWith('#')) {
segment.color = spec;
}