Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70cd01019d | ||
|
|
c87e510ca9 | ||
|
|
0e6a764aab | ||
|
|
fe6c0f93fa | ||
|
|
111da8904c | ||
|
|
a52a703951 |
+16
-15
@@ -10,28 +10,29 @@
|
|||||||
"start": "vite build && bun server/server.js"
|
"start": "vite build && bun server/server.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.2",
|
"@sveltejs/adapter-node": "^5.5.7",
|
||||||
"@sveltejs/kit": "^2.50.1",
|
"@sveltejs/kit": "^2.68.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
"svelte": "^5.49.1",
|
"svelte": "^5.56.4",
|
||||||
"svelte-check": "^4.3.6",
|
"svelte-check": "^4.7.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.3.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/svelte": "^5.2.1",
|
"@iconify/svelte": "^5.2.2",
|
||||||
"@threlte/core": "^8.3.1",
|
"@threlte/core": "^8.5.16",
|
||||||
"@types/three": "^0.181.0",
|
"@types/three": "^0.185.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.26.4",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"hotkeys-js": "^4.0.0",
|
"hotkeys-js": "^4.0.4",
|
||||||
"play-dl": "^1.9.7",
|
"play-dl": "^1.9.7",
|
||||||
"three": "^0.181.2"
|
"svelte-qrcode": "^1.0.1",
|
||||||
|
"three": "^0.185.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
background-color: var(--scene-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-container :global(canvas) {
|
.scene-container :global(canvas) {
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
background: var(--terminal-bg);
|
background: var(--terminal-bg);
|
||||||
border: 2px solid var(--terminal-border);
|
border: 2px solid var(--terminal-border);
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: calc(100vh - var(--navbar-height) - 65px);
|
height: calc(100vh - var(--navbar-height));
|
||||||
max-height: calc(100vh - var(--navbar-height) - 65px);
|
max-height: calc(100vh - var(--navbar-height));
|
||||||
animation: tuiFadeIn 0.4s ease-out;
|
animation: tuiFadeIn 0.4s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
.tui-border-glow {
|
.tui-border-glow {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -2px;
|
inset: -2px;
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
45deg,
|
45deg,
|
||||||
var(--terminal-primary),
|
var(--terminal-primary),
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.tui-terminal {
|
.tui-terminal {
|
||||||
width: 95%;
|
width: 100%;
|
||||||
height: calc(100vh - var(--navbar-height) - 60px);
|
height: calc(100vh - var(--navbar-height));
|
||||||
max-height: calc(100vh - var(--navbar-height) - 60px);
|
max-height: calc(100vh - var(--navbar-height));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { Canvas } from '@threlte/core';
|
import { Canvas } from '@threlte/core';
|
||||||
import { T } from '@threlte/core';
|
import { T } from '@threlte/core';
|
||||||
import ParticleField from './ParticleField.svelte';
|
import ParticleField from './ParticleField.svelte';
|
||||||
@@ -8,9 +9,29 @@
|
|||||||
// Reactive theme colors
|
// Reactive theme colors
|
||||||
const bgColor = $derived($themeColors.background);
|
const bgColor = $derived($themeColors.background);
|
||||||
const primaryColor = $derived($themeColors.primary);
|
const primaryColor = $derived($themeColors.primary);
|
||||||
|
|
||||||
|
let webglAvailable = $state(false);
|
||||||
|
|
||||||
|
function isWebGLAvailable() {
|
||||||
|
try {
|
||||||
|
const testCanvas = document.createElement('canvas');
|
||||||
|
const context =
|
||||||
|
testCanvas.getContext('webgl2') ||
|
||||||
|
testCanvas.getContext('webgl') ||
|
||||||
|
testCanvas.getContext('experimental-webgl');
|
||||||
|
return !!(window.WebGLRenderingContext && context);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
webglAvailable = isWebGLAvailable();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="scene-container">
|
<div class="scene-container" style="--scene-bg: {bgColor};">
|
||||||
|
{#if webglAvailable}
|
||||||
<Canvas>
|
<Canvas>
|
||||||
<T.PerspectiveCamera
|
<T.PerspectiveCamera
|
||||||
makeDefault
|
makeDefault
|
||||||
@@ -36,4 +57,5 @@
|
|||||||
<!-- Background color -->
|
<!-- Background color -->
|
||||||
<T.Color args={[bgColor]} attach="background" />
|
<T.Color args={[bgColor]} attach="background" />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -385,8 +385,15 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (container) {
|
if (container) {
|
||||||
initScene();
|
try {
|
||||||
animate();
|
initScene();
|
||||||
|
animate();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("WebGL unavailable:", err);
|
||||||
|
loadError = "3D viewer unavailable (WebGL not supported)";
|
||||||
|
isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
window.addEventListener("keydown", handleKeydown);
|
window.addEventListener("keydown", handleKeydown);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { themeColors } from "$lib/stores/theme";
|
import { get } from "svelte/store";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import {
|
||||||
|
themeColors,
|
||||||
|
mode,
|
||||||
|
colorTheme,
|
||||||
|
toggleMode,
|
||||||
|
setColorTheme,
|
||||||
|
themeOptions,
|
||||||
|
} from "$lib/stores/theme";
|
||||||
import {
|
import {
|
||||||
terminalSettings,
|
terminalSettings,
|
||||||
|
user,
|
||||||
|
skills,
|
||||||
|
navigation,
|
||||||
type SpeedPreset,
|
type SpeedPreset,
|
||||||
speedPresets,
|
speedPresets,
|
||||||
} from "$lib/config";
|
} from "$lib/config";
|
||||||
|
import { runCommand, type CommandContext } from "./tui/terminal-commands";
|
||||||
import TuiHeader from "./tui/TuiHeader.svelte";
|
import TuiHeader from "./tui/TuiHeader.svelte";
|
||||||
import TuiBody from "./tui/TuiBody.svelte";
|
import TuiBody from "./tui/TuiBody.svelte";
|
||||||
import TuiFooter from "./tui/TuiFooter.svelte";
|
import TuiFooter from "./tui/TuiFooter.svelte";
|
||||||
@@ -39,6 +52,7 @@
|
|||||||
speed?: SpeedPreset | number;
|
speed?: SpeedPreset | number;
|
||||||
autoscroll?: boolean;
|
autoscroll?: boolean;
|
||||||
terminal?: TerminalAPI;
|
terminal?: TerminalAPI;
|
||||||
|
enableCommands?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -50,6 +64,7 @@
|
|||||||
speed = "normal",
|
speed = "normal",
|
||||||
autoscroll = true,
|
autoscroll = true,
|
||||||
terminal = $bindable(),
|
terminal = $bindable(),
|
||||||
|
enableCommands = true,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Calculate speed multiplier from preset or number
|
// Calculate speed multiplier from preset or number
|
||||||
@@ -223,6 +238,30 @@
|
|||||||
typeText,
|
typeText,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// COMMAND HANDLING
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
const commandContext: CommandContext = {
|
||||||
|
terminal: terminal!,
|
||||||
|
navigate: (path, external) => {
|
||||||
|
if (external) window.open(path, "_blank", "noopener,noreferrer");
|
||||||
|
else goto(path);
|
||||||
|
},
|
||||||
|
toggleMode,
|
||||||
|
setColorTheme: (name) => setColorTheme(name as any),
|
||||||
|
getMode: () => get(mode),
|
||||||
|
getColorTheme: () => get(colorTheme),
|
||||||
|
themeNames: themeOptions.map((option) => option.value),
|
||||||
|
user,
|
||||||
|
skills,
|
||||||
|
navigation,
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleCommand(input: string) {
|
||||||
|
runCommand(input, commandContext);
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// KEYBOARD HANDLING
|
// KEYBOARD HANDLING
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
@@ -306,6 +345,8 @@
|
|||||||
onHoverButton={(i) => (selectedIndex = i)}
|
onHoverButton={(i) => (selectedIndex = i)}
|
||||||
onLinkClick={handleLinkClick}
|
onLinkClick={handleLinkClick}
|
||||||
{terminalSettings}
|
{terminalSettings}
|
||||||
|
interactiveInput={enableCommands && interactive}
|
||||||
|
onCommand={handleCommand}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TuiFooter
|
<TuiFooter
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
onHoverButton: (idx: number) => void;
|
onHoverButton: (idx: number) => void;
|
||||||
onLinkClick: (idx: number) => void;
|
onLinkClick: (idx: number) => void;
|
||||||
terminalSettings: { showCursor: boolean };
|
terminalSettings: { showCursor: boolean };
|
||||||
|
interactiveInput?: boolean;
|
||||||
|
onCommand?: (command: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -27,8 +29,81 @@
|
|||||||
onHoverButton,
|
onHoverButton,
|
||||||
onLinkClick,
|
onLinkClick,
|
||||||
terminalSettings,
|
terminalSettings,
|
||||||
|
interactiveInput = false,
|
||||||
|
onCommand,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let inputEl = $state<HTMLInputElement>();
|
||||||
|
let commandValue = $state("");
|
||||||
|
let history = $state<string[]>([]);
|
||||||
|
let historyIndex = $state(-1);
|
||||||
|
let inputFocused = $state(false);
|
||||||
|
let caretPos = $state(0);
|
||||||
|
|
||||||
|
const beforeText = $derived(commandValue.slice(0, caretPos));
|
||||||
|
const cursorChar = $derived(commandValue.slice(caretPos, caretPos + 1) || " ");
|
||||||
|
const afterRest = $derived(commandValue.slice(caretPos + 1));
|
||||||
|
const afterFull = $derived(commandValue.slice(caretPos));
|
||||||
|
|
||||||
|
function syncCaret() {
|
||||||
|
caretPos = inputEl?.selectionStart ?? commandValue.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recall(value: string) {
|
||||||
|
commandValue = value;
|
||||||
|
caretPos = value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCommand() {
|
||||||
|
const value = commandValue;
|
||||||
|
commandValue = "";
|
||||||
|
caretPos = 0;
|
||||||
|
if (value.trim()) history = [...history, value];
|
||||||
|
historyIndex = -1;
|
||||||
|
onCommand?.(value);
|
||||||
|
queueMicrotask(() => inputEl?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInputKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
submitCommand();
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (history.length === 0) return;
|
||||||
|
const nextIndex =
|
||||||
|
historyIndex === -1
|
||||||
|
? history.length - 1
|
||||||
|
: Math.max(0, historyIndex - 1);
|
||||||
|
historyIndex = nextIndex;
|
||||||
|
recall(history[nextIndex]);
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (historyIndex === -1) return;
|
||||||
|
const nextIndex = historyIndex + 1;
|
||||||
|
if (nextIndex >= history.length) {
|
||||||
|
historyIndex = -1;
|
||||||
|
recall("");
|
||||||
|
} else {
|
||||||
|
historyIndex = nextIndex;
|
||||||
|
recall(history[nextIndex]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusCommandInput(event: MouseEvent) {
|
||||||
|
if (!interactiveInput) return;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.closest("a, button, input, textarea, .tui-button, .tui-link"))
|
||||||
|
return;
|
||||||
|
inputEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
// Group consecutive inline items together
|
// Group consecutive inline items together
|
||||||
type ProcessedGroup =
|
type ProcessedGroup =
|
||||||
| { kind: "single"; index: number; displayed: DisplayedLine }
|
| { kind: "single"; index: number; displayed: DisplayedLine }
|
||||||
@@ -76,7 +151,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tui-body" bind:this={ref}>
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div class="tui-body" bind:this={ref} onclick={focusCommandInput}>
|
||||||
{#each processedGroups as group, gi (gi)}
|
{#each processedGroups as group, gi (gi)}
|
||||||
{#if group.kind === "inline"}
|
{#if group.kind === "inline"}
|
||||||
<div class="tui-inline-group">
|
<div class="tui-inline-group">
|
||||||
@@ -127,7 +203,41 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
{#if interactiveInput}
|
||||||
|
{#if !isTyping}
|
||||||
|
<div class="tui-line prompt tui-command-line">
|
||||||
|
<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="tui-input-wrap">
|
||||||
|
<input
|
||||||
|
class="tui-command-input"
|
||||||
|
bind:this={inputEl}
|
||||||
|
bind:value={commandValue}
|
||||||
|
onkeydown={handleInputKeydown}
|
||||||
|
onkeyup={syncCaret}
|
||||||
|
oninput={syncCaret}
|
||||||
|
onclick={syncCaret}
|
||||||
|
onfocus={() => {
|
||||||
|
inputFocused = true;
|
||||||
|
syncCaret();
|
||||||
|
}}
|
||||||
|
onblur={() => (inputFocused = false)}
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
autocorrect="off"
|
||||||
|
aria-label="Terminal command input"
|
||||||
|
/><span class="tui-input-mirror" aria-hidden="true">{beforeText}{#if inputFocused}<span class="cursor-block blink">{cursorChar}</span>{afterRest}{:else}{afterFull}{/if}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
||||||
<div class="tui-line prompt">
|
<div class="tui-line prompt">
|
||||||
<span class="prompt">
|
<span class="prompt">
|
||||||
<span class="user">{user.username}</span><span class="at"
|
<span class="user">{user.username}</span><span class="at"
|
||||||
@@ -141,3 +251,63 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tui-command-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-command-input {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
color: transparent;
|
||||||
|
font: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-command-input:focus,
|
||||||
|
.tui-command-input:focus-visible {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tui-input-mirror {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--terminal-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-block {
|
||||||
|
display: inline-block;
|
||||||
|
height: 1.2em;
|
||||||
|
line-height: 1.2em;
|
||||||
|
color: var(--terminal-bg);
|
||||||
|
background: var(--terminal-accent, var(--terminal-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-block.blink {
|
||||||
|
animation: cursorBlink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import type { TerminalAPI, TerminalLine } from './types';
|
||||||
|
import { user } from '$lib/config';
|
||||||
|
|
||||||
|
export interface CommandContext {
|
||||||
|
terminal: TerminalAPI;
|
||||||
|
navigate: (path: string, external?: boolean) => void;
|
||||||
|
toggleMode: () => void;
|
||||||
|
setColorTheme: (name: string) => void;
|
||||||
|
getMode: () => string;
|
||||||
|
getColorTheme: () => string;
|
||||||
|
themeNames: string[];
|
||||||
|
user: typeof user;
|
||||||
|
skills: Record<string, string[]>;
|
||||||
|
navigation: Array<{ name: string; path: string; external?: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Command {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
usage?: string;
|
||||||
|
aliases?: string[];
|
||||||
|
hidden?: boolean;
|
||||||
|
run: (args: string[], ctx: CommandContext) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = (content: string): TerminalLine => ({ type: 'output', content });
|
||||||
|
const ok = (content: string): TerminalLine => ({ type: 'success', content });
|
||||||
|
const err = (content: string): TerminalLine => ({ type: 'error', content });
|
||||||
|
const info = (content: string): TerminalLine => ({ type: 'info', content });
|
||||||
|
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
|
||||||
|
|
||||||
|
const pageRoutes: Record<string, string> = {
|
||||||
|
home: '/',
|
||||||
|
about: '/about',
|
||||||
|
portfolio: '/portfolio',
|
||||||
|
projects: '/projects',
|
||||||
|
models: '/models',
|
||||||
|
components: '/components',
|
||||||
|
links: '/links'
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseCommands: Command[] = [
|
||||||
|
{
|
||||||
|
name: 'help',
|
||||||
|
aliases: ['h', '?'],
|
||||||
|
description: 'List all available commands',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
const rows = commands
|
||||||
|
.filter((command) => !command.hidden)
|
||||||
|
.map((command) => [
|
||||||
|
`(&primary,bold)${command.name}(&)` +
|
||||||
|
(command.aliases?.length ? ` (&muted)(${command.aliases.join(', ')})(&)` : ''),
|
||||||
|
command.description
|
||||||
|
]);
|
||||||
|
ctx.terminal.write({
|
||||||
|
type: 'table',
|
||||||
|
content: 'Available Commands',
|
||||||
|
tableHeaders: ['Command', 'Description'],
|
||||||
|
tableRows: rows,
|
||||||
|
style: 'accent'
|
||||||
|
} as TerminalLine);
|
||||||
|
ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cd projects(&), or (&accent)theme(&)'));
|
||||||
|
ctx.terminal.write(blank());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'clear',
|
||||||
|
aliases: ['cls'],
|
||||||
|
description: 'Clear the terminal',
|
||||||
|
run: (_args, ctx) => ctx.terminal.clear()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'whoami',
|
||||||
|
description: 'Show a short bio',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
ctx.terminal.writeLines([
|
||||||
|
out(`(&primary,bold)${ctx.user.name}(&) (&muted)— ${ctx.user.title}(&)`),
|
||||||
|
out(`(&muted)${ctx.user.bio}(&)`),
|
||||||
|
blank()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'neofetch',
|
||||||
|
aliases: ['banner'],
|
||||||
|
description: 'Display system info',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
ctx.terminal.writeLines([
|
||||||
|
out(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`),
|
||||||
|
out('(&muted)------------------------------(&)'),
|
||||||
|
out(`(&blue,bold)Name(&): ${ctx.user.name}`),
|
||||||
|
out(`(&blue,bold)Title(&): ${ctx.user.title}`),
|
||||||
|
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
|
||||||
|
out(`(&blue,bold)Shell(&): tui-terminal`),
|
||||||
|
out(`(&blue,bold)Languages(&): ${ctx.skills.languages.slice(0, 6).join(', ')}`),
|
||||||
|
blank()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'skills',
|
||||||
|
description: 'List skills by category',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
const order = ['languages', 'frameworks', 'tools', 'platforms', 'applications', 'databases', 'interests'];
|
||||||
|
const lines: TerminalLine[] = [];
|
||||||
|
for (const key of order) {
|
||||||
|
const values = ctx.skills[key];
|
||||||
|
if (!values?.length) continue;
|
||||||
|
lines.push(info(`(&blue,bold)${key}(&)`));
|
||||||
|
lines.push(out(' ' + values.map((value) => `(&cyan)${value}(&)`).join(' (&muted)•(&) ')));
|
||||||
|
}
|
||||||
|
lines.push(blank());
|
||||||
|
ctx.terminal.writeLines(lines);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'contact',
|
||||||
|
aliases: ['socials'],
|
||||||
|
description: 'Show social links',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
ctx.terminal.write(info('(&accent,bold)Find me online:(&)'));
|
||||||
|
ctx.terminal.writeLines(
|
||||||
|
ctx.user.socials.map((social) => ({
|
||||||
|
type: 'button',
|
||||||
|
content: social.name,
|
||||||
|
icon: social.icon,
|
||||||
|
href: social.link,
|
||||||
|
style: 'primary',
|
||||||
|
inline: true
|
||||||
|
})) as TerminalLine[]
|
||||||
|
);
|
||||||
|
ctx.terminal.write(blank());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'email',
|
||||||
|
description: 'Open a mail draft',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
ctx.terminal.write(ok(`Opening mail to (&accent)${ctx.user.email}(&)`));
|
||||||
|
ctx.navigate(`mailto:${ctx.user.email}`, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cd',
|
||||||
|
aliases: ['goto', 'open'],
|
||||||
|
description: 'Navigate to a page',
|
||||||
|
usage: 'cd <page>',
|
||||||
|
run: (args, ctx) => {
|
||||||
|
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||||
|
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
|
||||||
|
if (navItem) {
|
||||||
|
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||||
|
ctx.navigate(navItem.path, navItem.external);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pageRoutes[target] !== undefined) {
|
||||||
|
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||||
|
ctx.navigate(pageRoutes[target]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ls',
|
||||||
|
description: 'List the pages you can visit',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
const names = Object.keys(pageRoutes);
|
||||||
|
ctx.navigation.forEach((item) => {
|
||||||
|
if (item.external && !names.includes(item.name)) names.push(item.name);
|
||||||
|
});
|
||||||
|
ctx.terminal.write(out(names.map((name) => `(&blue)${name}(&)`).join(' ')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'theme',
|
||||||
|
description: 'List or set the color theme',
|
||||||
|
usage: 'theme [name]',
|
||||||
|
run: (args, ctx) => {
|
||||||
|
if (!args.length) {
|
||||||
|
ctx.terminal.write(out('Themes: ' + ctx.themeNames.map((name) => `(&blue)${name}(&)`).join(' ')));
|
||||||
|
ctx.terminal.write(info(`Current: (&accent)${ctx.getColorTheme()}(&)`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = args[0].toLowerCase();
|
||||||
|
if (ctx.themeNames.includes(name)) {
|
||||||
|
ctx.setColorTheme(name);
|
||||||
|
ctx.terminal.write(ok(`Theme set to (&accent)${name}(&)`));
|
||||||
|
} else {
|
||||||
|
ctx.terminal.write(err(`Unknown theme: ${name}. Available: ${ctx.themeNames.join(', ')}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mode',
|
||||||
|
description: 'Toggle or set dark/light mode',
|
||||||
|
usage: 'mode [dark|light]',
|
||||||
|
run: (args, ctx) => {
|
||||||
|
const current = ctx.getMode();
|
||||||
|
if (!args.length) {
|
||||||
|
ctx.toggleMode();
|
||||||
|
ctx.terminal.write(ok(`Switched to ${current === 'dark' ? 'light' : 'dark'} mode`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requested = args[0].toLowerCase();
|
||||||
|
if (requested === 'dark' || requested === 'light') {
|
||||||
|
if (requested !== current) ctx.toggleMode();
|
||||||
|
ctx.terminal.write(ok(`${requested} mode active`));
|
||||||
|
} else {
|
||||||
|
ctx.terminal.write(err('Usage: mode [dark|light]'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'echo',
|
||||||
|
description: 'Print text back',
|
||||||
|
usage: 'echo <text>',
|
||||||
|
run: (args, ctx) => ctx.terminal.write(out(args.join(' ')))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'date',
|
||||||
|
description: 'Show the current date and time',
|
||||||
|
run: (_args, ctx) => ctx.terminal.write(out(new Date().toString()))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sudo',
|
||||||
|
hidden: true,
|
||||||
|
description: 'Elevated privileges',
|
||||||
|
run: (_args, ctx) =>
|
||||||
|
ctx.terminal.write(
|
||||||
|
err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'blog',
|
||||||
|
hidden: true,
|
||||||
|
description: 'Open the blog',
|
||||||
|
run: (_args, ctx) => {
|
||||||
|
const blogItem = ctx.navigation.find((item) => item.name.toLowerCase() === 'blog');
|
||||||
|
if (blogItem) ctx.navigate(blogItem.path, blogItem.external);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const pageCommands: Command[] = Object.entries(pageRoutes).map(([name, path]) => ({
|
||||||
|
name,
|
||||||
|
description: `Go to the ${name} page`,
|
||||||
|
hidden: true,
|
||||||
|
run: (_args, ctx) => ctx.navigate(path)
|
||||||
|
}));
|
||||||
|
|
||||||
|
const socialCommands: Command[] = user.socials.map((social) => ({
|
||||||
|
name: social.name.toLowerCase(),
|
||||||
|
description: `Open ${social.name}`,
|
||||||
|
hidden: true,
|
||||||
|
run: (_args, ctx) => ctx.navigate(social.link, true)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const commands: Command[] = [...baseCommands, ...pageCommands, ...socialCommands];
|
||||||
|
|
||||||
|
function findCommand(name: string): Command | undefined {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
return commands.find(
|
||||||
|
(command) => command.name === lower || command.aliases?.some((alias) => alias.toLowerCase() === lower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCommand(input: string, ctx: CommandContext): void {
|
||||||
|
const raw = input.trim();
|
||||||
|
ctx.terminal.write({ type: 'command', content: raw });
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
const parts = raw.split(/\s+/);
|
||||||
|
const name = parts[0];
|
||||||
|
const args = parts.slice(1);
|
||||||
|
const command = findCommand(name);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
ctx.terminal.write(err(`command not found: (&bold)${name}(&) — type (&accent)help(&) for a list.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
command.run(args, ctx);
|
||||||
|
}
|
||||||
@@ -87,6 +87,12 @@ export function createKeyboardHandler(options: KeyboardHandlerOptions) {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
return function handleKeydown(event: KeyboardEvent): void {
|
return function handleKeydown(event: KeyboardEvent): void {
|
||||||
|
// Ignore key events originating from editable fields (e.g. command input)
|
||||||
|
const target = event.target as HTMLElement | null;
|
||||||
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip animation on Y key
|
// Skip animation on Y key
|
||||||
if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) {
|
if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface OpenSourceProject {
|
|||||||
|
|
||||||
|
|
||||||
export const openSourceProjects: OpenSourceProject[] = [
|
export const openSourceProjects: OpenSourceProject[] = [
|
||||||
|
{
|
||||||
|
name: 'TypstDrive',
|
||||||
|
description: 'TypstDrive is a self-hosted collaborative web editor for Typst.',
|
||||||
|
tech: ['Typst', 'Self-Hosted', 'Collaborative'],
|
||||||
|
github: 'https://github.com/SirBlobby/TypstDrive',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Pkit',
|
name: 'Pkit',
|
||||||
description: 'CLI toolkit and utilities (Rust project) — small developer-focused CLI.',
|
description: 'CLI toolkit and utilities (Rust project) — small developer-focused CLI.',
|
||||||
@@ -115,6 +121,50 @@ export const models: Model3D[] = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// EXPERIENCE
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface Experience {
|
||||||
|
role: string;
|
||||||
|
organization: string;
|
||||||
|
employmentType?: string;
|
||||||
|
location?: string;
|
||||||
|
period: string;
|
||||||
|
description?: string;
|
||||||
|
tech?: string[];
|
||||||
|
awards?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const experience: Experience[] = [
|
||||||
|
{
|
||||||
|
role: 'Research Assistant',
|
||||||
|
organization: 'College of Engineering and Computing',
|
||||||
|
employmentType: 'Part-time',
|
||||||
|
period: 'May 2026 – Present · 3 mos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'Software Engineering Lead',
|
||||||
|
organization: 'Raytheon Autonomous Vehicle Competition',
|
||||||
|
period: 'Dec 2025 – May 2026 · 6 mos',
|
||||||
|
description:
|
||||||
|
'Developing a collaborative autonomous system utilizing Python and ROS2 to coordinate communication and control as a UAV scouts and lands on a moving UGV. Engineering Gazebo simulation environments to validate computer vision algorithms for ArUco marker target identification and obstacle avoidance.',
|
||||||
|
tech: ['Python', 'ROS2'],
|
||||||
|
awards: ['1st Place Overall Winner']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'QA Automation Intern',
|
||||||
|
organization: 'Deltek',
|
||||||
|
employmentType: 'Internship',
|
||||||
|
location: 'Hybrid',
|
||||||
|
period: 'Jun 2025 – Aug 2025 · 3 mos',
|
||||||
|
description:
|
||||||
|
'Designed and implemented a workflow integrating AI into the QA automation pipeline, enhancing test efficiency for the GovWin IQ team. Researched Model Context Protocol (MCP) servers to improve automation, developed and published a public Selenium MCP package to NPM, enabling browser automation interactions with Agentic AI.',
|
||||||
|
tech: ['Artificial Intelligence (AI)', 'TypeScript', 'Selenium']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HACKATHONS
|
// HACKATHONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -137,6 +187,37 @@ export type Card = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cards: Card[] = [
|
export const cards: Card[] = [
|
||||||
|
{
|
||||||
|
image: "/hacks/hushmap.png",
|
||||||
|
title: "HushMap",
|
||||||
|
description:
|
||||||
|
"Architected a real-time campus noise and occupancy monitoring system utilizing SvelteKit, FastAPI, and MongoDB to help students locate quiet study spaces.",
|
||||||
|
link: "https://hushmap.study/",
|
||||||
|
hackathonName: "Bitcamp 2026",
|
||||||
|
university: "University of Maryland",
|
||||||
|
location: "College Park, MD",
|
||||||
|
year: "2026",
|
||||||
|
tags: ["Svelte", "YOLOv8", "FastAPI", "MongoDB"],
|
||||||
|
featured: true,
|
||||||
|
awards: [
|
||||||
|
{ track: "Best UI/UX Hack", place: "Winner" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: "/hacks/learningbuddy.png",
|
||||||
|
title: "LearningBuddy",
|
||||||
|
description:
|
||||||
|
"Engineered an ESP32-S3 IoT study companion in C++ with secure Wi-Fi provisioning, utilizing WebSockets and Socket.IO to stream low-latency raw audio for live lecture capture.",
|
||||||
|
hackathonName: "HaxFax x PatriotHacks 2026",
|
||||||
|
university: "George Mason University",
|
||||||
|
location: "Fairfax, VA",
|
||||||
|
year: "2026",
|
||||||
|
tags: ["C++", "WebSocket", "ESP32-S3", "Socket.IO"],
|
||||||
|
featured: true,
|
||||||
|
awards: [
|
||||||
|
{ track: "Best use of MongoDB", place: "Winner" }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
image: "/hacks/fooddecisive.png",
|
image: "/hacks/fooddecisive.png",
|
||||||
title: "Food Decisive",
|
title: "Food Decisive",
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ export {
|
|||||||
animations
|
animations
|
||||||
} from './theme';
|
} from './theme';
|
||||||
|
|
||||||
// Content: projects, models, hackathon cards
|
// Content: projects, models, experience, hackathon cards
|
||||||
export type { OpenSourceProject, PackageProject, Model3D, Card } from './content';
|
export type { OpenSourceProject, PackageProject, Model3D, Experience, Card } from './content';
|
||||||
export { openSourceProjects, packageProjects, models, cards, sortedCards } from './content';
|
export { openSourceProjects, packageProjects, models, experience, cards, sortedCards } from './content';
|
||||||
|
|
||||||
// Terminal settings, TUI styling, speed presets, model viewer, particles, shortcuts
|
// Terminal settings, TUI styling, speed presets, model viewer, particles, shortcuts
|
||||||
export type { SpeedPreset, TerminalSettings } from './terminal';
|
export type { SpeedPreset, TerminalSettings } from './terminal';
|
||||||
|
|||||||
@@ -75,5 +75,11 @@ export const pageMeta: Record<string, PageMeta> = {
|
|||||||
description: 'Terminal UI components showcase and documentation.',
|
description: 'Terminal UI components showcase and documentation.',
|
||||||
icon: 'mdi:puzzle',
|
icon: 'mdi:puzzle',
|
||||||
keywords: ['components', 'ui', 'terminal', 'tui']
|
keywords: ['components', 'ui', 'terminal', 'tui']
|
||||||
|
},
|
||||||
|
'/links': {
|
||||||
|
title: `${user.displayname} — Links`,
|
||||||
|
description: 'All my links with scannable QR codes.',
|
||||||
|
icon: 'mdi:link-variant',
|
||||||
|
keywords: ['links', 'qr', 'social', 'contact']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export const user = {
|
|||||||
// Social links - array of { name, icon (Iconify), link }
|
// Social links - array of { name, icon (Iconify), link }
|
||||||
socials: [
|
socials: [
|
||||||
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
|
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
|
||||||
{ name: 'Gitea', icon: 'simple-icons:gitea', link: 'https://git.sirblob.co/SirBlob' },
|
|
||||||
{ name: 'LinkedIn', icon: 'mdi:linkedin', link: 'https://www.linkedin.com/in/gmanjunatha/' },
|
{ name: 'LinkedIn', icon: 'mdi:linkedin', link: 'https://www.linkedin.com/in/gmanjunatha/' },
|
||||||
{ name: 'Devpost', icon: 'simple-icons:devpost', link: 'https://devpost.com/Sir_Blob_' },
|
{ name: 'Devpost', icon: 'simple-icons:devpost', link: 'https://devpost.com/Sir_Blob_' },
|
||||||
{ name: 'Discord', icon: 'ic:baseline-discord', link: 'https://discord.com/users/sir_blob_' }
|
{ name: 'Discord', icon: 'ic:baseline-discord', link: 'https://discord.com/users/sir_blob_' }
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
|
|
||||||
import type { TerminalLine } from '$lib/components/tui/types';
|
import type { TerminalLine } from '$lib/components/tui/types';
|
||||||
import { user, skills, openSourceProjects } from '$lib/config';
|
import { user, skills, openSourceProjects, experience } from '$lib/config';
|
||||||
|
|
||||||
|
const experienceElements: TerminalLine[] = [];
|
||||||
|
|
||||||
|
experience.forEach(exp => {
|
||||||
|
experienceElements.push(
|
||||||
|
{ type: 'header', content: `(&orange,bold)${exp.role}(&)` },
|
||||||
|
{ type: 'output', content: `(&primary)${exp.organization}(&)` + (exp.employmentType ? ` (&muted)· ${exp.employmentType}(&)` : '') },
|
||||||
|
{ type: 'output', content: `(&muted)${exp.period}(&)` + (exp.location ? ` (&muted)· ${exp.location}(&)` : '') },
|
||||||
|
...(exp.description ? [{ type: 'output' as const, content: `(&muted)${exp.description}(&)` }] : []),
|
||||||
|
...(exp.tech ? [{ type: 'output' as const, content: ' ' + exp.tech.map(t => `(&blue)${t}(&)`).join(' (&muted)•(&) ') }] : []),
|
||||||
|
...(exp.awards ? exp.awards.map(award => ({ type: 'success' as const, content: `(&success)${award}(&)` })) : []),
|
||||||
|
{ type: 'blank', content: '' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const openSourceElements:TerminalLine[] = [];
|
const openSourceElements:TerminalLine[] = [];
|
||||||
|
|
||||||
@@ -48,6 +62,7 @@ export const lines: TerminalLine[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
|
{ type: 'output', content: `(&primary, bold)Links >(&)`, inline: true },
|
||||||
{ type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true },
|
{ type: 'link', href: "/portfolio#contact", content: `(&bg-blue,black)Contact(&)`, inline: true },
|
||||||
|
{ type: 'link', href: "/portfolio#experience", content: `(&bg-magenta,black)Experience(&)`, inline: true },
|
||||||
{ type: 'link', href: "/portfolio#skills", content: `(&bg-orange,black)Skills(&)`, 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: 'link', href: "/portfolio#projects", content: `(&bg-green,black)Projects(&)`, inline: true },
|
||||||
]
|
]
|
||||||
@@ -63,6 +78,10 @@ export const lines: TerminalLine[] = [
|
|||||||
inline: true
|
inline: true
|
||||||
})),
|
})),
|
||||||
|
|
||||||
|
{ type: 'divider', content: 'EXPERIENCE', id: 'experience' },
|
||||||
|
|
||||||
|
...experienceElements,
|
||||||
|
|
||||||
{ type: 'divider', content: 'SKILLS', id: 'skills' },
|
{ type: 'divider', content: 'SKILLS', id: 'skills' },
|
||||||
|
|
||||||
// Skills as TUI sections
|
// Skills as TUI sections
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import './layout.css';
|
import './layout.css';
|
||||||
import Background3D from '$lib/components/Background3D.svelte';
|
|
||||||
import { themeColors, mode, colorTheme, toggleMode, setColorTheme, themeOptions } from '$lib/stores/theme';
|
import { themeColors, mode, colorTheme, toggleMode, setColorTheme, themeOptions } from '$lib/stores/theme';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { navigation } from '$lib/config';
|
import { navigation } from '$lib/config';
|
||||||
@@ -76,7 +75,6 @@
|
|||||||
<NavbarWaybar />
|
<NavbarWaybar />
|
||||||
<!-- <Navbar /> -->
|
<!-- <Navbar /> -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<Background3D />
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.home-container {
|
.home-container {
|
||||||
padding: 1rem 1rem;
|
padding: 0;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.about-container {
|
.about-container {
|
||||||
padding: 2rem 1rem;
|
padding: 0;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.components-container {
|
.components-container {
|
||||||
padding: 2rem 1rem;
|
padding: 0;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Icon from "@iconify/svelte";
|
||||||
|
import { user, navigation, pageMeta } from "$lib/config";
|
||||||
|
import { themeColors } from "$lib/stores/theme";
|
||||||
|
import TuiHeader from "$lib/components/tui/TuiHeader.svelte";
|
||||||
|
import TuiFooter from "$lib/components/tui/TuiFooter.svelte";
|
||||||
|
import "$lib/assets/css/terminal-tui.css";
|
||||||
|
import "$lib/assets/css/tui-body.css";
|
||||||
|
|
||||||
|
interface LinkItem {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
link: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blogLink = navigation.find((item) => item.name === "blog")?.path;
|
||||||
|
|
||||||
|
const links: LinkItem[] = [
|
||||||
|
...user.socials,
|
||||||
|
{ name: "Email", icon: "mdi:email", link: `mailto:${user.email}` },
|
||||||
|
...(blogLink ? [{ name: "Blog", icon: "mdi:rss", link: blogLink }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
let selected = $state<LinkItem | null>(null);
|
||||||
|
let QrCode = $state<any>(null);
|
||||||
|
let detailEl = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const module = await import("svelte-qrcode");
|
||||||
|
QrCode = module.default;
|
||||||
|
});
|
||||||
|
|
||||||
|
function select(item: LinkItem) {
|
||||||
|
selected = item;
|
||||||
|
queueMicrotask(() =>
|
||||||
|
detailEl?.scrollIntoView({ behavior: "smooth", block: "center" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyLink(link: string) {
|
||||||
|
return link
|
||||||
|
.replace(/^mailto:/, "")
|
||||||
|
.replace(/^https?:\/\//, "")
|
||||||
|
.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = pageMeta["/links"];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{meta?.title ?? `${user.displayname} — Links`}</title>
|
||||||
|
<meta name="description" content={meta?.description ?? "All my links"} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="links-container">
|
||||||
|
<div
|
||||||
|
class="tui-terminal"
|
||||||
|
style="
|
||||||
|
--terminal-bg: {$themeColors.terminal};
|
||||||
|
--terminal-text: {$themeColors.text};
|
||||||
|
--terminal-muted: {$themeColors.textMuted};
|
||||||
|
--terminal-border: {$themeColors.border};
|
||||||
|
--terminal-prompt: {$themeColors.terminalPrompt};
|
||||||
|
--terminal-user: {$themeColors.terminalUser};
|
||||||
|
--terminal-path: {$themeColors.terminalPath};
|
||||||
|
--terminal-primary: {$themeColors.primary};
|
||||||
|
--terminal-secondary: {$themeColors.secondary};
|
||||||
|
--terminal-accent: {$themeColors.accent};
|
||||||
|
--terminal-bg-light: {$themeColors.backgroundLight};
|
||||||
|
"
|
||||||
|
role="region"
|
||||||
|
aria-label="Links"
|
||||||
|
>
|
||||||
|
<div class="tui-border-glow"></div>
|
||||||
|
|
||||||
|
<div class="tui-content">
|
||||||
|
<TuiHeader title="~/links" interactive={false} hasButtons={false} />
|
||||||
|
|
||||||
|
<div class="tui-body">
|
||||||
|
<div class="links-body">
|
||||||
|
<div class="links-list">
|
||||||
|
<div class="list-label">
|
||||||
|
// click a link to generate a QR code
|
||||||
|
</div>
|
||||||
|
{#each links as item (item.name)}
|
||||||
|
<button
|
||||||
|
class="link-row"
|
||||||
|
class:active={selected?.name === item.name}
|
||||||
|
onclick={() => select(item)}
|
||||||
|
>
|
||||||
|
<Icon icon={item.icon} width="20" />
|
||||||
|
<span class="link-name">{item.name}</span>
|
||||||
|
<span class="link-host">{prettyLink(item.link)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links-detail" bind:this={detailEl}>
|
||||||
|
{#if selected}
|
||||||
|
<div class="qr-card">
|
||||||
|
{#if QrCode}
|
||||||
|
<QrCode
|
||||||
|
value={selected.link}
|
||||||
|
size="240"
|
||||||
|
background="#ffffff"
|
||||||
|
color="#11111b"
|
||||||
|
padding={4}
|
||||||
|
errorCorrection="M"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="qr-loading">Generating...</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="detail-name">
|
||||||
|
<Icon icon={selected.icon} width="18" />
|
||||||
|
<span>{selected.name}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
class="detail-link"
|
||||||
|
href={selected.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span>{prettyLink(selected.link)}</span>
|
||||||
|
<Icon icon="mdi:open-in-new" width="16" />
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class="detail-empty">
|
||||||
|
<Icon icon="mdi:qrcode-scan" width="48" />
|
||||||
|
<span>Select a link to generate a QR code</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TuiFooter
|
||||||
|
isTyping={false}
|
||||||
|
linesCount={links.length}
|
||||||
|
skipAnimation={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.links-container {
|
||||||
|
padding: 0;
|
||||||
|
min-height: calc(100vh - var(--navbar-height, 60px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 45%;
|
||||||
|
max-width: 460px;
|
||||||
|
padding-right: 1.25rem;
|
||||||
|
border-right: 1px solid var(--terminal-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-label {
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--terminal-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--terminal-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row:hover {
|
||||||
|
border-color: var(--terminal-primary);
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row.active {
|
||||||
|
border-color: var(--terminal-accent);
|
||||||
|
background: var(--terminal-bg-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-host {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-detail {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: min(280px, 80vw);
|
||||||
|
padding: 1rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card :global(img) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-loading {
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #11111b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border: 1px solid var(--terminal-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--terminal-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition:
|
||||||
|
background 0.15s ease,
|
||||||
|
color 0.15s ease;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link:hover {
|
||||||
|
background: var(--terminal-primary);
|
||||||
|
color: var(--terminal-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
color: var(--terminal-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.links-body {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-list {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
padding-right: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--terminal-border);
|
||||||
|
padding-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-row {
|
||||||
|
padding: 0.85rem 0.9rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-detail {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-link {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.portfolio-container {
|
.portfolio-container {
|
||||||
padding: 2rem 1rem;
|
padding: 0;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.projects-container {
|
.projects-container {
|
||||||
padding: 2rem 1rem;
|
padding: 0;
|
||||||
min-height: calc(100vh - 60px);
|
min-height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 263 KiB |
Reference in New Issue
Block a user