Bug Fixes and Formatting Update
This commit is contained in:
169
README.md
169
README.md
@@ -21,6 +21,7 @@ An Arch Linux terminal-themed portfolio website with Hyprland-style TUI componen
|
||||
- **Portfolio** (`/portfolio`) - Skills, projects, and contact info
|
||||
- **Models** (`/models`) - 3D model gallery with interactive viewer
|
||||
- **Hackathons** (`/hackathons`) - Hackathon projects and achievements
|
||||
- **Components** (`/components`) - Showcase of all TUI components
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -198,10 +199,24 @@ const lines: TerminalLine[] = [
|
||||
{ type: 'info', content: 'Information' }, // Primary with › prefix
|
||||
{ type: 'header', content: 'Section Title' }, // Bold with # icon
|
||||
{ type: 'blank', content: '' }, // Empty line
|
||||
{ type: 'divider', content: 'SECTION' }, // Horizontal divider
|
||||
{ type: 'divider', content: 'SECTION', id: 'section' }, // Horizontal divider with anchor ID
|
||||
];
|
||||
```
|
||||
|
||||
### Line Properties
|
||||
|
||||
All line types support these optional properties:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'output',
|
||||
content: 'Hello world',
|
||||
id: 'my-section', // Anchor ID for URL hash scrolling (e.g., /page#my-section)
|
||||
inline: true, // Render inline with adjacent inline elements
|
||||
delay: 500, // Delay before this line appears (ms)
|
||||
}
|
||||
```
|
||||
|
||||
### Button (Full-width interactive)
|
||||
|
||||
```typescript
|
||||
@@ -212,11 +227,28 @@ const lines: TerminalLine[] = [
|
||||
style: 'primary', // primary | accent | warning | error
|
||||
href: 'https://github.com', // URL to navigate to
|
||||
external: true, // Open in new tab (auto-detected for http/https)
|
||||
inline: true, // Render as compact inline button
|
||||
// OR
|
||||
action: () => doSomething(), // Custom action
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Elements
|
||||
|
||||
Multiple elements can be rendered on the same line using `inline: true`:
|
||||
|
||||
```typescript
|
||||
// These will appear on the same line
|
||||
{ type: 'output', content: 'Status:', inline: true },
|
||||
{ type: 'success', content: 'Online', inline: true },
|
||||
{ type: 'button', content: 'Refresh', icon: 'mdi:refresh', inline: true },
|
||||
|
||||
// Next line without inline breaks the group
|
||||
{ type: 'blank', content: '' },
|
||||
```
|
||||
|
||||
Supported inline types: `button`, `link`, `tooltip`, `progress`, `output`, `info`, `success`, `error`, `warning`
|
||||
|
||||
### Link (Inline clickable text)
|
||||
|
||||
```typescript
|
||||
@@ -322,10 +354,29 @@ Main terminal component:
|
||||
title="~/directory"
|
||||
interactive={true}
|
||||
speed="normal"
|
||||
autoscroll={true}
|
||||
onComplete={() => console.log('Done!')}
|
||||
/>
|
||||
```
|
||||
|
||||
Props:
|
||||
- `lines` - Array of TerminalLine objects
|
||||
- `title` - Terminal window title
|
||||
- `interactive` - Enable keyboard navigation
|
||||
- `speed` - Typing speed preset or multiplier
|
||||
- `autoscroll` - Auto-scroll as content types (default: true)
|
||||
- `onComplete` - Callback when typing animation finishes
|
||||
|
||||
### Anchor Scrolling
|
||||
|
||||
Add `id` to any line to create an anchor that can be linked to:
|
||||
|
||||
```typescript
|
||||
{ type: 'divider', content: 'SKILLS', id: 'skills' },
|
||||
```
|
||||
|
||||
Then link to it with `/portfolio#skills` - the page will scroll to that section after typing completes.
|
||||
|
||||
### Component Structure
|
||||
|
||||
```
|
||||
@@ -346,9 +397,29 @@ src/lib/components/
|
||||
└── TuiTooltip.svelte # Hover tooltip
|
||||
```
|
||||
|
||||
## 3D Models
|
||||
## 3D Model Viewer
|
||||
|
||||
Place `.glb` files in `/static/models/` and update the models page to display them.
|
||||
The `ModelViewer` component provides an interactive Three.js viewer for `.glb` models.
|
||||
|
||||
### Features
|
||||
- **Mouse Controls**: Drag to rotate, scroll to zoom
|
||||
- **Arrow Key Controls**: Use arrow keys to orbit the camera (click viewer to focus first)
|
||||
- **Auto-rotate**: Toggle automatic rotation
|
||||
- **Wireframe Mode**: View model wireframe
|
||||
- **Adjustable Lighting**: Increase/decrease scene brightness
|
||||
- **Fullscreen Mode**: Expand to full viewport (press `Escape` to exit)
|
||||
- **Ground Plane**: Optional shadow-receiving ground
|
||||
|
||||
### Usage
|
||||
|
||||
```svelte
|
||||
<ModelViewer
|
||||
modelPath="/models/my-model.glb"
|
||||
modelName="My Model"
|
||||
/>
|
||||
```
|
||||
|
||||
Place `.glb` files in `/static/models/` and they'll be accessible at `/models/filename.glb`.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -384,3 +455,95 @@ bun run preview
|
||||
| `Y` | Skip typing animation |
|
||||
| `T` | Toggle dark/light mode |
|
||||
|
||||
### 3D Model Viewer
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `←` | Rotate camera left |
|
||||
| `→` | Rotate camera right |
|
||||
| `↑` | Rotate camera up |
|
||||
| `↓` | Rotate camera down |
|
||||
| `Escape` | Exit fullscreen |
|
||||
|
||||
## Theme System
|
||||
|
||||
Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contains colors for both dark and light modes.
|
||||
|
||||
### Theme File Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Theme Name",
|
||||
"icon": "🎨",
|
||||
"dark": {
|
||||
"colors": {
|
||||
"primary": "#89b4fa",
|
||||
"secondary": "#313244",
|
||||
"accent": "#a6e3a1",
|
||||
"background": "#1e1e2e",
|
||||
"backgroundLight": "#313244",
|
||||
"text": "#cdd6f4",
|
||||
"textMuted": "#a6adc8",
|
||||
"border": "#45475a",
|
||||
"terminal": "#1e1e2e",
|
||||
"terminalPrompt": "#cba6f7",
|
||||
"terminalUser": "#a6e3a1",
|
||||
"terminalPath": "#89b4fa"
|
||||
},
|
||||
"colorMap": {
|
||||
"red": "#f38ba8",
|
||||
"green": "#a6e3a1",
|
||||
"blue": "#89b4fa",
|
||||
"primary": "var(--terminal-primary)",
|
||||
"accent": "var(--terminal-accent)",
|
||||
"muted": "var(--terminal-muted)"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"colors": { /* light mode colors */ },
|
||||
"colorMap": { /* light mode color map */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Theme
|
||||
|
||||
1. Create a new file: `src/lib/assets/themes/mytheme.theme.json`
|
||||
2. Import it in `src/lib/stores/theme.ts`:
|
||||
```typescript
|
||||
import myTheme from '$lib/assets/themes/mytheme.theme.json';
|
||||
```
|
||||
3. Add it to the themes object:
|
||||
```typescript
|
||||
const themes: Record<ColorTheme, ThemeJson> = {
|
||||
arch: archTheme,
|
||||
catppuccin: catppuccinTheme,
|
||||
mytheme: myTheme as ThemeJson
|
||||
};
|
||||
```
|
||||
4. Update the `ColorTheme` type to include your theme name
|
||||
|
||||
### Available Themes
|
||||
- **Arch Linux** (`arch`) - Classic terminal colors with Arch blue
|
||||
- **Catppuccin** (`catppuccin`) - Soft, pastel Mocha/Latte colors
|
||||
|
||||
### Theme-Specific Colors
|
||||
|
||||
Beyond the basic colors, themes include:
|
||||
- `teal`, `sky`, `sapphire`, `lavender`
|
||||
- `peach`, `maroon`, `mauve`
|
||||
- `flamingo`, `rosewater`
|
||||
|
||||
```typescript
|
||||
// These colors adapt to the current theme
|
||||
'(&teal)Teal text(&)'
|
||||
'(&lavender)Lavender text(&)'
|
||||
'(&peach)Peach text(&)'
|
||||
```
|
||||
|
||||
## Mobile Considerations
|
||||
|
||||
- **Viewport height**: Uses `100dvh` (dynamic viewport height) to properly handle mobile browser chrome
|
||||
- **Background color**: A fallback dark background (`#1e1e2e`) is set on `html` and `body` to prevent white bars when the page content doesn't fill the viewport
|
||||
- **Overflow handling**: Hidden horizontal scrollbar to prevent accidental horizontal scroll on mobile
|
||||
|
||||
|
||||
96
src/lib/assets/themes/arch.theme.json
Normal file
96
src/lib/assets/themes/arch.theme.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "Arch Linux",
|
||||
"icon": "🐧",
|
||||
"dark": {
|
||||
"colors": {
|
||||
"primary": "#1793d1",
|
||||
"secondary": "#333333",
|
||||
"accent": "#23d18b",
|
||||
"background": "#0d1117",
|
||||
"backgroundLight": "#161b22",
|
||||
"text": "#c9d1d9",
|
||||
"textMuted": "#8b949e",
|
||||
"border": "#30363d",
|
||||
"terminal": "#0d1117",
|
||||
"terminalPrompt": "#1793d1",
|
||||
"terminalUser": "#23d18b",
|
||||
"terminalPath": "#1793d1"
|
||||
},
|
||||
"colorMap": {
|
||||
"red": "#fa7970",
|
||||
"green": "#23d18b",
|
||||
"yellow": "#faa356",
|
||||
"blue": "#1793d1",
|
||||
"magenta": "#cea5fb",
|
||||
"cyan": "#89dceb",
|
||||
"white": "#c9d1d9",
|
||||
"gray": "#6e7681",
|
||||
"orange": "#ff9500",
|
||||
"pink": "#ff79c6",
|
||||
"black": "#0d1117",
|
||||
"surface": "#161b22",
|
||||
"teal": "#77dfd8",
|
||||
"sky": "#00bfff",
|
||||
"sapphire": "#0099cc",
|
||||
"lavender": "#b4a7d6",
|
||||
"peach": "#ff9966",
|
||||
"maroon": "#fa7970",
|
||||
"mauve": "#cea5fb",
|
||||
"flamingo": "#e06c75",
|
||||
"rosewater": "#e8b4b8",
|
||||
"primary": "var(--terminal-primary)",
|
||||
"accent": "var(--terminal-accent)",
|
||||
"muted": "var(--terminal-muted)",
|
||||
"error": "#fa7970",
|
||||
"success": "#23d18b",
|
||||
"warning": "#faa356",
|
||||
"info": "#1793d1"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"colors": {
|
||||
"primary": "#1793d1",
|
||||
"secondary": "#e0e0e0",
|
||||
"accent": "#005a2b",
|
||||
"background": "#f6f8fa",
|
||||
"backgroundLight": "#ffffff",
|
||||
"text": "#111111",
|
||||
"textMuted": "#57606a",
|
||||
"border": "#d0d7de",
|
||||
"terminal": "#ffffff",
|
||||
"terminalPrompt": "#005a87",
|
||||
"terminalUser": "#005a2b",
|
||||
"terminalPath": "#005a87"
|
||||
},
|
||||
"colorMap": {
|
||||
"red": "#a6101d",
|
||||
"green": "#005a2b",
|
||||
"yellow": "#945b00",
|
||||
"blue": "#0a5696",
|
||||
"magenta": "#7820a0",
|
||||
"cyan": "#006060",
|
||||
"white": "#111111",
|
||||
"gray": "#57606a",
|
||||
"orange": "#c04500",
|
||||
"pink": "#a01b58",
|
||||
"black": "#ffffff",
|
||||
"surface": "#eaeef2",
|
||||
"teal": "#006060",
|
||||
"sky": "#005a87",
|
||||
"sapphire": "#004870",
|
||||
"lavender": "#4a4ec0",
|
||||
"peach": "#b05010",
|
||||
"maroon": "#802020",
|
||||
"mauve": "#602090",
|
||||
"flamingo": "#901040",
|
||||
"rosewater": "#a05040",
|
||||
"primary": "var(--terminal-primary)",
|
||||
"accent": "var(--terminal-accent)",
|
||||
"muted": "var(--terminal-muted)",
|
||||
"error": "#a6101d",
|
||||
"success": "#005a2b",
|
||||
"warning": "#945b00",
|
||||
"info": "#0a5696"
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/lib/assets/themes/catppuccin.theme.json
Normal file
96
src/lib/assets/themes/catppuccin.theme.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "Catppuccin",
|
||||
"icon": "🐱",
|
||||
"dark": {
|
||||
"colors": {
|
||||
"primary": "#89b4fa",
|
||||
"secondary": "#313244",
|
||||
"accent": "#a6e3a1",
|
||||
"background": "#1e1e2e",
|
||||
"backgroundLight": "#313244",
|
||||
"text": "#cdd6f4",
|
||||
"textMuted": "#a6adc8",
|
||||
"border": "#45475a",
|
||||
"terminal": "#1e1e2e",
|
||||
"terminalPrompt": "#cba6f7",
|
||||
"terminalUser": "#a6e3a1",
|
||||
"terminalPath": "#89b4fa"
|
||||
},
|
||||
"colorMap": {
|
||||
"red": "#f38ba8",
|
||||
"green": "#a6e3a1",
|
||||
"yellow": "#f9e2af",
|
||||
"blue": "#89b4fa",
|
||||
"magenta": "#cba6f7",
|
||||
"cyan": "#94e2d5",
|
||||
"white": "#cdd6f4",
|
||||
"gray": "#6c7086",
|
||||
"orange": "#fab387",
|
||||
"pink": "#f5c2e7",
|
||||
"black": "#1e1e2e",
|
||||
"surface": "#313244",
|
||||
"teal": "#94e2d5",
|
||||
"sky": "#89dceb",
|
||||
"sapphire": "#74c7ec",
|
||||
"lavender": "#b4befe",
|
||||
"peach": "#fab387",
|
||||
"maroon": "#eba0ac",
|
||||
"mauve": "#cba6f7",
|
||||
"flamingo": "#f2cdcd",
|
||||
"rosewater": "#f5e0dc",
|
||||
"primary": "var(--terminal-primary)",
|
||||
"accent": "var(--terminal-accent)",
|
||||
"muted": "var(--terminal-muted)",
|
||||
"error": "#f38ba8",
|
||||
"success": "#a6e3a1",
|
||||
"warning": "#f9e2af",
|
||||
"info": "#89b4fa"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"colors": {
|
||||
"primary": "#1e66f5",
|
||||
"secondary": "#ccd0da",
|
||||
"accent": "#40a02b",
|
||||
"background": "#eff1f5",
|
||||
"backgroundLight": "#dce0e8",
|
||||
"text": "#4c4f69",
|
||||
"textMuted": "#6c6f85",
|
||||
"border": "#bcc0cc",
|
||||
"terminal": "#eff1f5",
|
||||
"terminalPrompt": "#8839ef",
|
||||
"terminalUser": "#40a02b",
|
||||
"terminalPath": "#1e66f5"
|
||||
},
|
||||
"colorMap": {
|
||||
"red": "#d20f39",
|
||||
"green": "#40a02b",
|
||||
"yellow": "#df8e1d",
|
||||
"blue": "#1e66f5",
|
||||
"magenta": "#8839ef",
|
||||
"cyan": "#179299",
|
||||
"white": "#4c4f69",
|
||||
"gray": "#9ca0b0",
|
||||
"orange": "#fe640b",
|
||||
"pink": "#ea76cb",
|
||||
"black": "#eff1f5",
|
||||
"surface": "#ccd0da",
|
||||
"teal": "#179299",
|
||||
"sky": "#04a5e5",
|
||||
"sapphire": "#209fb5",
|
||||
"lavender": "#7287fd",
|
||||
"peach": "#fe640b",
|
||||
"maroon": "#e64553",
|
||||
"mauve": "#8839ef",
|
||||
"flamingo": "#dd7878",
|
||||
"rosewater": "#dc8a78",
|
||||
"primary": "var(--terminal-primary)",
|
||||
"accent": "var(--terminal-accent)",
|
||||
"muted": "var(--terminal-muted)",
|
||||
"error": "#d20f39",
|
||||
"success": "#40a02b",
|
||||
"warning": "#df8e1d",
|
||||
"info": "#1e66f5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ export const user = {
|
||||
title: 'Engineering Student',
|
||||
email: 'sirblob0@gmail.com',
|
||||
location: 'Washington DC-Baltimore Area',
|
||||
bio: `Hi, I am Sir Blob — a engineer who loves making things.` +
|
||||
bio: `Hi, I am Sir Blob — a engineer who loves making things. ` +
|
||||
`I build fun coding projects, participate in game jams and hackathons, and enjoy games like Minecraft and Pokémon TCG Live.`,
|
||||
|
||||
// Prefer an absolute avatar URL if you want to pull directly from GitHub
|
||||
avatar: 'https://avatars.githubusercontent.com/u/76974209?v=4',
|
||||
avatar: '/blob_nerd.png',
|
||||
|
||||
// Social links - array of { name, icon (Iconify), link }
|
||||
socials: [
|
||||
@@ -113,12 +113,11 @@ export const terminalButtons = {
|
||||
|
||||
export const skills = {
|
||||
languages: ['Python', 'JavaScript', 'TypeScript', 'C', 'C++', 'Java', 'Node.js'],
|
||||
frameworks: ['Arduino', 'Bootstrap', 'TailwindCSS', 'Discord.js', 'React', 'Electron', 'Svelte', 'MongoDB'],
|
||||
// Applications / IDEs and major platforms
|
||||
frameworks: ['Arduino', 'Bootstrap', 'TailwindCSS', 'Discord.js', 'React', 'Electron', 'Svelte'],
|
||||
applications: ['Windows', 'Linux', 'macOS', 'IntelliJ', 'VS Code', 'Git', 'Blender', 'Godot'],
|
||||
tools: ['Git', 'Docker', 'Neovim', 'VS Code', 'Figma'],
|
||||
databases: ['PostgreSQL', 'MongoDB', 'Redis', 'SQLite'],
|
||||
cloud: ['AWS', 'Vercel', 'Cloudflare', 'DigitalOcean'],
|
||||
platforms: ['Windows', 'Linux', 'macOS', 'Arduino', 'Raspberry Pi'],
|
||||
tools: ['Git', 'Docker', 'Neovim', 'VS Code'],
|
||||
databases: ['MongoDB', 'Redis', 'SQLite'],
|
||||
interests: ['Open Source', '3D Graphics', 'CLI Tools', 'Game Dev']
|
||||
};
|
||||
|
||||
@@ -485,7 +484,7 @@ export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
|
||||
'home': 'fast',
|
||||
'portfolio': 'fast',
|
||||
'models': 'fast',
|
||||
'hackathons': 'normal'
|
||||
'projects': 'normal'
|
||||
};
|
||||
|
||||
// Per-page autoscroll settings (whether to auto-scroll as content types)
|
||||
@@ -496,18 +495,18 @@ export const pageAutoscrollSettings: Record<string, boolean> = {
|
||||
// 'portfolio': false, // Disable autoscroll
|
||||
'home': true,
|
||||
'portfolio': false,
|
||||
'models': true,
|
||||
'hackathons': false,
|
||||
'components': true
|
||||
'models': false,
|
||||
'projects': false,
|
||||
'components': false
|
||||
};
|
||||
|
||||
// Speed preset multipliers (lower = faster)
|
||||
export const speedPresets: Record<SpeedPreset, number> = {
|
||||
'instant': 0, // No delay, instant display
|
||||
'fast': 0.3, // 3x faster
|
||||
'normal': 0.8, // Default speed
|
||||
'slow': 2, // 2x slower
|
||||
'typewriter': 3 // 3x slower, classic typewriter feel
|
||||
'fast': 0.1, // 3x faster
|
||||
'normal': 0.5, // Default speed
|
||||
'slow': 1.5, // 2x slower
|
||||
'typewriter': 2 // 3x slower, classic typewriter feel
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -685,7 +684,7 @@ export const loadingScreen = {
|
||||
// ============================================================================
|
||||
|
||||
export const site = {
|
||||
title: `${user.username} | Portfolio`,
|
||||
title: `${user.displayname} | Portfolio`,
|
||||
description: `${user.title} - ${user.bio}`,
|
||||
keywords: ['developer', 'portfolio', 'programming', ...skills.languages, ...skills.frameworks],
|
||||
ogImage: '/og-image.png',
|
||||
@@ -706,31 +705,31 @@ export interface PageMeta {
|
||||
// Map route => meta. Use route paths as keys (e.g. '/', '/portfolio')
|
||||
export const pageMeta: Record<string, PageMeta> = {
|
||||
'/': {
|
||||
title: `${user.username} — Home`,
|
||||
title: `${user.displayname} — Home`,
|
||||
description: `Home — ${user.title} portfolio and projects.`,
|
||||
icon: 'mdi:home',
|
||||
keywords: ['home', 'portfolio', 'about']
|
||||
},
|
||||
'/portfolio': {
|
||||
title: `${user.username} — Portfolio`,
|
||||
title: `${user.displayname} — Portfolio`,
|
||||
description: 'Selected projects, highlights and case studies.',
|
||||
icon: 'mdi:folder-multiple',
|
||||
keywords: ['projects', 'portfolio', 'showcase']
|
||||
},
|
||||
'/models': {
|
||||
title: `${user.username} — 3D Models`,
|
||||
title: `${user.displayname} — 3D Models`,
|
||||
description: 'A curated collection of 3D models and previews.',
|
||||
icon: 'mdi:cube-outline',
|
||||
keywords: ['3d', 'models', 'glb', 'gltf']
|
||||
},
|
||||
'/hackathons': {
|
||||
title: `${user.username} — Hackathons`,
|
||||
'/projects': {
|
||||
title: `${user.displayname} — Projects`,
|
||||
description: 'Hackathon projects, demos and awards.',
|
||||
icon: 'mdi:trophy',
|
||||
keywords: ['hackathon', 'projects', 'events']
|
||||
},
|
||||
'/components': {
|
||||
title: `${user.username} — Components`,
|
||||
title: `${user.displayname} — Components`,
|
||||
description: 'Terminal UI components showcase and documentation.',
|
||||
icon: 'mdi:puzzle',
|
||||
keywords: ['components', 'ui', 'terminal', 'tui']
|
||||
@@ -757,7 +756,7 @@ export const navigation = [
|
||||
{ name: 'home', path: '/', icon: '~' },
|
||||
{ name: 'portfolio', path: '/portfolio', icon: '📁' },
|
||||
{ name: 'models', path: '/models', icon: '🎨' },
|
||||
{ name: 'hackathons', path: '/hackathons', icon: '🏆' },
|
||||
{ name: 'projects', path: '/projects', icon: '🏆' },
|
||||
// { name: 'components', path: '/components', icon: '🧩' },
|
||||
{ name: 'blog', path: 'https://blog.sirblob.co', icon: '📝', external: true }
|
||||
];
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { type ThemeColorMap, defaultColorMap } from '$lib/components/tui/utils';
|
||||
|
||||
// Import theme JSON files
|
||||
import archTheme from '$lib/assets/themes/arch.theme.json';
|
||||
import catppuccinTheme from '$lib/assets/themes/catppuccin.theme.json';
|
||||
|
||||
export type ColorTheme = 'arch' | 'catppuccin';
|
||||
export type Mode = 'dark' | 'light';
|
||||
|
||||
// Theme JSON structure
|
||||
export interface ThemeJson {
|
||||
name: string;
|
||||
icon: string;
|
||||
dark: {
|
||||
colors: Record<string, string>;
|
||||
colorMap: ThemeColorMap;
|
||||
};
|
||||
light: {
|
||||
colors: Record<string, string>;
|
||||
colorMap: ThemeColorMap;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
@@ -17,73 +36,50 @@ export interface ThemeColors {
|
||||
terminalPrompt: string;
|
||||
terminalUser: string;
|
||||
terminalPath: string;
|
||||
colorMap: ThemeColorMap;
|
||||
}
|
||||
|
||||
const themeColorsMap: Record<ColorTheme, { dark: ThemeColors; light: ThemeColors }> = {
|
||||
arch: {
|
||||
dark: {
|
||||
primary: '#1793d1',
|
||||
secondary: '#333333',
|
||||
accent: '#00ff00',
|
||||
background: '#0d1117',
|
||||
backgroundLight: '#161b22',
|
||||
text: '#c9d1d9',
|
||||
textMuted: '#8b949e',
|
||||
border: '#30363d',
|
||||
terminal: '#0d1117',
|
||||
terminalPrompt: '#1793d1',
|
||||
terminalUser: '#00ff00',
|
||||
terminalPath: '#1793d1'
|
||||
},
|
||||
light: {
|
||||
primary: '#1793d1',
|
||||
secondary: '#e0e0e0',
|
||||
accent: '#006600',
|
||||
background: '#f6f8fa',
|
||||
backgroundLight: '#ffffff',
|
||||
text: '#1a1a1a',
|
||||
textMuted: '#4a5568',
|
||||
border: '#d0d7de',
|
||||
terminal: '#ffffff',
|
||||
terminalPrompt: '#0f6ca0',
|
||||
terminalUser: '#006600',
|
||||
terminalPath: '#0f6ca0'
|
||||
}
|
||||
},
|
||||
catppuccin: {
|
||||
// Catppuccin Mocha (dark)
|
||||
dark: {
|
||||
primary: '#89b4fa', // Blue
|
||||
secondary: '#313244', // Surface 0
|
||||
accent: '#a6e3a1', // Green
|
||||
background: '#1e1e2e', // Base
|
||||
backgroundLight: '#313244', // Surface 0
|
||||
text: '#cdd6f4', // Text
|
||||
textMuted: '#a6adc8', // Subtext 0
|
||||
border: '#45475a', // Surface 1
|
||||
terminal: '#1e1e2e', // Base
|
||||
terminalPrompt: '#cba6f7', // Mauve
|
||||
terminalUser: '#a6e3a1', // Green
|
||||
terminalPath: '#89b4fa' // Blue
|
||||
},
|
||||
// Catppuccin Latte (light)
|
||||
light: {
|
||||
primary: '#1e66f5', // Blue
|
||||
secondary: '#ccd0da', // Surface 0
|
||||
accent: '#40a02b', // Green
|
||||
background: '#eff1f5', // Base
|
||||
backgroundLight: '#dce0e8', // Crust
|
||||
text: '#4c4f69', // Text
|
||||
textMuted: '#6c6f85', // Subtext 0
|
||||
border: '#bcc0cc', // Surface 1
|
||||
terminal: '#eff1f5', // Base
|
||||
terminalPrompt: '#8839ef', // Mauve
|
||||
terminalUser: '#40a02b', // Green
|
||||
terminalPath: '#1e66f5' // Blue
|
||||
}
|
||||
}
|
||||
// Load themes from JSON files
|
||||
const themes: Record<ColorTheme, ThemeJson> = {
|
||||
arch: archTheme as ThemeJson,
|
||||
catppuccin: catppuccinTheme as ThemeJson
|
||||
};
|
||||
|
||||
// Export themes for external access
|
||||
export { themes };
|
||||
|
||||
// Build theme colors from JSON
|
||||
function buildThemeColors(theme: ThemeJson, mode: Mode): ThemeColors {
|
||||
const modeData = theme[mode];
|
||||
return {
|
||||
primary: modeData.colors.primary,
|
||||
secondary: modeData.colors.secondary,
|
||||
accent: modeData.colors.accent,
|
||||
background: modeData.colors.background,
|
||||
backgroundLight: modeData.colors.backgroundLight,
|
||||
text: modeData.colors.text,
|
||||
textMuted: modeData.colors.textMuted,
|
||||
border: modeData.colors.border,
|
||||
terminal: modeData.colors.terminal,
|
||||
terminalPrompt: modeData.colors.terminalPrompt,
|
||||
terminalUser: modeData.colors.terminalUser,
|
||||
terminalPath: modeData.colors.terminalPath,
|
||||
colorMap: modeData.colorMap ?? defaultColorMap
|
||||
};
|
||||
}
|
||||
|
||||
// Build the theme colors map from JSON themes
|
||||
const themeColorsMap: Record<ColorTheme, { dark: ThemeColors; light: ThemeColors }> =
|
||||
Object.fromEntries(
|
||||
Object.entries(themes).map(([key, theme]) => [
|
||||
key,
|
||||
{
|
||||
dark: buildThemeColors(theme, 'dark'),
|
||||
light: buildThemeColors(theme, 'light')
|
||||
}
|
||||
])
|
||||
) as Record<ColorTheme, { dark: ThemeColors; light: ThemeColors }>;
|
||||
|
||||
function getInitialMode(): Mode {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem('mode');
|
||||
@@ -129,7 +125,10 @@ export function setColorTheme(theme: ColorTheme) {
|
||||
colorTheme.set(theme);
|
||||
}
|
||||
|
||||
export const themeOptions: { value: ColorTheme; label: string; icon: string }[] = [
|
||||
{ value: 'arch', label: 'Arch Linux', icon: '🐧' },
|
||||
{ value: 'catppuccin', label: 'Catppuccin', icon: '🐱' }
|
||||
];
|
||||
// Generate theme options from loaded themes
|
||||
export const themeOptions: { value: ColorTheme; label: string; icon: string }[] =
|
||||
Object.entries(themes).map(([key, theme]) => ({
|
||||
value: key as ColorTheme,
|
||||
label: theme.name,
|
||||
icon: theme.icon
|
||||
}));
|
||||
|
||||
@@ -12,17 +12,18 @@
|
||||
const lines: TerminalLine[] = [
|
||||
// neofetch style intro
|
||||
{ type: 'command', content: 'bash ~/startup.sh', delay: 300 },
|
||||
|
||||
// Color palette (moved below the art/stats block)
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'output', content: '(&red)███(&)(&orange)███(&)(&yellow)███(&)(&green)███(&)(&cyan)███(&)(&blue)███(&)(&magenta)███(&)(&pink)███(&)' },
|
||||
{ type: 'output', content: '(&dim,red)███(&)(&dim,orange)███(&)(&dim,yellow)███(&)(&dim,green)███(&)(&dim,cyan)███(&)(&dim,blue)███(&)(&dim,magenta)███(&)(&dim,pink)███(&)' },
|
||||
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'header', content: `Welcome to ${user.displayname}'s Portfolio` },
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'output', content: `(&muted)${user.bio}(&)` },
|
||||
{ type: 'blank', content: '' },
|
||||
|
||||
// Color palette (moved below the art/stats block)
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'output', content: '(&red)███(&)(&orange)███(&)(&yellow)███(&)(&green)███(&)(&cyan)███(&)(&blue)███(&)(&magenta)███(&)(&pink)███(&)' },
|
||||
{ type: 'output', content: '(&dim,red)███(&)(&dim,orange)███(&)(&dim,yellow)███(&)(&dim,green)███(&)(&dim,cyan)███(&)(&dim,blue)███(&)(&dim,magenta)███(&)(&dim,pink)███(&)' },
|
||||
|
||||
{ type: 'divider', content: 'NAVIGATION' },
|
||||
{ type: 'blank', content: '' },
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ code, pre, kbd, samp, .terminal, .mono, .prompt, .prompt-mini, .hero-title {
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
background: #1e1e2e; /* Fallback dark bg to prevent white flash on mobile */
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -58,6 +59,8 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow-x: hidden;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh; /* Dynamic viewport height for mobile */
|
||||
background: #1e1e2e; /* Fallback dark bg to prevent white flash on mobile */
|
||||
}
|
||||
|
||||
/* Terminal font for code elements */
|
||||
|
||||
@@ -22,17 +22,23 @@
|
||||
{ type: 'info', content: `(&accent)${user.title}(&)` },
|
||||
{ type: 'output', content: `(&muted)${user.bio}(&)` },
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
|
||||
{ type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true },
|
||||
{ type: 'link', href: "/portfolio#skills", content: `(&bg-orange,black)Skills(&)`, inline: true },
|
||||
{ type: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true },
|
||||
{ type: 'blank', content: '' },
|
||||
|
||||
{ type: 'divider', content: 'CONTACT' },
|
||||
{ type: 'divider', content: 'CONTACT', id: 'contact' },
|
||||
{ type: 'blank', content: '' },
|
||||
|
||||
// Contact buttons - dynamically generated from socials array
|
||||
...user.socials.map(social => ({
|
||||
type: 'button' as const,
|
||||
content: `${social.name}: ${social.link}`,
|
||||
content: `${social.name}`,
|
||||
icon: social.icon,
|
||||
style: 'primary' as const,
|
||||
href: social.link
|
||||
href: social.link,
|
||||
inline: true
|
||||
})),
|
||||
// {
|
||||
// type: 'button',
|
||||
@@ -43,7 +49,7 @@
|
||||
// },
|
||||
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'divider', content: 'SKILLS' },
|
||||
{ type: 'divider', content: 'SKILLS', id: 'skills' },
|
||||
{ type: 'blank', content: '' },
|
||||
|
||||
// Skills as TUI sections
|
||||
@@ -59,14 +65,11 @@
|
||||
{ type: 'info', content: '(&cyan,bold)▸ Databases(&)' },
|
||||
{ type: 'output', content: ' ' + skills.databases.map(s => `(&cyan)${s}(&)`).join(' (&muted)•(&) ') },
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'info', content: '(&yellow,bold)▸ Cloud(&)' },
|
||||
{ type: 'output', content: ' ' + skills.cloud.map(s => `(&orange)${s}(&)`).join(' (&muted)•(&) ') },
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'info', content: '(&accent,bold)▸ Interests(&)' },
|
||||
{ type: 'output', content: ' ' + skills.interests.map(s => `(&muted)${s}(&)`).join(' (&muted)•(&) ') },
|
||||
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'divider', content: 'PROJECTS' },
|
||||
{ type: 'divider', content: 'PROJECTS', id: 'projects' },
|
||||
{ type: 'blank', content: '' },
|
||||
|
||||
// Featured projects with buttons
|
||||
@@ -119,7 +122,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Portfolio | {user.name}</title>
|
||||
<title>Portfolio | {user.displayname}</title>
|
||||
<meta name="description" content={site.description} />
|
||||
</svelte:head>
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
import { user, sortedCards } from '$lib/config';
|
||||
import { getPageSpeedMultiplier, getPageAutoscroll } from '$lib';
|
||||
|
||||
const speed = getPageSpeedMultiplier('hackathons');
|
||||
const autoscroll = getPageAutoscroll('hackathons');
|
||||
const speed = getPageSpeedMultiplier('projects');
|
||||
const autoscroll = getPageAutoscroll('projects');
|
||||
|
||||
// Count stats
|
||||
const totalHackathons = sortedCards.length;
|
||||
@@ -14,12 +14,15 @@
|
||||
|
||||
// Build the terminal lines with card grid
|
||||
const lines: TerminalLine[] = [
|
||||
{ type: 'divider', content: 'PROJECTS' },
|
||||
{ type: 'command', content: 'ls ~/projects --grid' },
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'divider', content: 'HACKATHONS' },
|
||||
{ type: 'command', content: 'ls ~/hackathons --grid' },
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'header', content: `Hackathon Journey` },
|
||||
{ type: 'output', content: `(&muted)Total:(&) (&primary)${totalHackathons}(&) (&muted)| Awards:(&) (&yellow)${totalAwards}(&) (&muted)| Featured:(&) (&accent)${featuredCount}(&)` },
|
||||
{ type: 'blank', content: '' },
|
||||
// { type: 'divider', content: 'PROJECTS' },
|
||||
{ type: 'blank', content: '' },
|
||||
{ type: 'cardgrid', content: '', cards: sortedCards },
|
||||
{ type: 'blank', content: '' },
|
||||
@@ -28,16 +31,16 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Hackathons | {user.name}</title>
|
||||
<title>Projects | {user.name}</title>
|
||||
<meta name="description" content="Hackathon projects and achievements" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="hackathons-container">
|
||||
<TerminalTUI {lines} title="~/hackathons" interactive={true} {speed} {autoscroll} />
|
||||
<div class="projects-container">
|
||||
<TerminalTUI {lines} title="~/projects" interactive={true} {speed} {autoscroll} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hackathons-container {
|
||||
.projects-container {
|
||||
padding: 2rem 1rem;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
Reference in New Issue
Block a user