Website Redesign 7

This commit is contained in:
2025-11-28 02:40:12 +00:00
parent 9b9a201c3e
commit 96e2d0650c
72 changed files with 7504 additions and 1231 deletions

27
.gitignore vendored
View File

@@ -1,8 +1,25 @@
.svelte-kit/
node_modules/
bun.lockb
build/
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
package-lock.json
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
bun.lock

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

View File

@@ -1,103 +0,0 @@
import type { CustomThemeConfig } from '@skeletonlabs/tw-plugin';
export const myCustomTheme: CustomThemeConfig = {
name: 'my-custom-theme',
properties: {
// =~= Theme Properties =~=
"--theme-font-family-base": `Montserrat`,
"--theme-font-family-heading": `Montserrat`,
"--theme-font-color-base": "0 0 0",
"--theme-font-color-dark": "255 255 255",
"--theme-rounded-base": "4px",
"--theme-rounded-container": "8px",
"--theme-border-base": "1px",
// =~= Theme On-X Colors =~=
"--on-primary": "255 255 255",
"--on-secondary": "255 255 255",
"--on-tertiary": "0 0 0",
"--on-success": "0 0 0",
"--on-warning": "0 0 0",
"--on-error": "255 255 255",
"--on-surface": "255 255 255",
// =~= Theme Colors =~=
// primary | #2b273f
"--color-primary-50": "223 223 226", // #dfdfe2
"--color-primary-100": "213 212 217", // #d5d4d9
"--color-primary-200": "202 201 207", // #cac9cf
"--color-primary-300": "170 169 178", // #aaa9b2
"--color-primary-400": "107 104 121", // #6b6879
"--color-primary-500": "43 39 63", // #2b273f
"--color-primary-600": "39 35 57", // #272339
"--color-primary-700": "32 29 47", // #201d2f
"--color-primary-800": "26 23 38", // #1a1726
"--color-primary-900": "21 19 31", // #15131f
// secondary | #454545
"--color-secondary-50": "227 227 227", // #e3e3e3
"--color-secondary-100": "218 218 218", // #dadada
"--color-secondary-200": "209 209 209", // #d1d1d1
"--color-secondary-300": "181 181 181", // #b5b5b5
"--color-secondary-400": "125 125 125", // #7d7d7d
"--color-secondary-500": "69 69 69", // #454545
"--color-secondary-600": "62 62 62", // #3e3e3e
"--color-secondary-700": "52 52 52", // #343434
"--color-secondary-800": "41 41 41", // #292929
"--color-secondary-900": "34 34 34", // #222222
// tertiary | #0EA5E9
"--color-tertiary-50": "219 242 252", // #dbf2fc
"--color-tertiary-100": "207 237 251", // #cfedfb
"--color-tertiary-200": "195 233 250", // #c3e9fa
"--color-tertiary-300": "159 219 246", // #9fdbf6
"--color-tertiary-400": "86 192 240", // #56c0f0
"--color-tertiary-500": "14 165 233", // #0EA5E9
"--color-tertiary-600": "13 149 210", // #0d95d2
"--color-tertiary-700": "11 124 175", // #0b7caf
"--color-tertiary-800": "8 99 140", // #08638c
"--color-tertiary-900": "7 81 114", // #075172
// success | #00b336
"--color-success-50": "217 244 225", // #d9f4e1
"--color-success-100": "204 240 215", // #ccf0d7
"--color-success-200": "191 236 205", // #bfeccd
"--color-success-300": "153 225 175", // #99e1af
"--color-success-400": "77 202 114", // #4dca72
"--color-success-500": "0 179 54", // #00b336
"--color-success-600": "0 161 49", // #00a131
"--color-success-700": "0 134 41", // #008629
"--color-success-800": "0 107 32", // #006b20
"--color-success-900": "0 88 26", // #00581a
// warning | #EAB308
"--color-warning-50": "252 244 218", // #fcf4da
"--color-warning-100": "251 240 206", // #fbf0ce
"--color-warning-200": "250 236 193", // #faecc1
"--color-warning-300": "247 225 156", // #f7e19c
"--color-warning-400": "240 202 82", // #f0ca52
"--color-warning-500": "234 179 8", // #EAB308
"--color-warning-600": "211 161 7", // #d3a107
"--color-warning-700": "176 134 6", // #b08606
"--color-warning-800": "140 107 5", // #8c6b05
"--color-warning-900": "115 88 4", // #735804
// error | #db004d
"--color-error-50": "250 217 228", // #fad9e4
"--color-error-100": "248 204 219", // #f8ccdb
"--color-error-200": "246 191 211", // #f6bfd3
"--color-error-300": "241 153 184", // #f199b8
"--color-error-400": "230 77 130", // #e64d82
"--color-error-500": "219 0 77", // #db004d
"--color-error-600": "197 0 69", // #c50045
"--color-error-700": "164 0 58", // #a4003a
"--color-error-800": "131 0 46", // #83002e
"--color-error-900": "107 0 38", // #6b0026
// surface | #272835
"--color-surface-50": "223 223 225", // #dfdfe1
"--color-surface-100": "212 212 215", // #d4d4d7
"--color-surface-200": "201 201 205", // #c9c9cd
"--color-surface-300": "169 169 174", // #a9a9ae
"--color-surface-400": "104 105 114", // #686972
"--color-surface-500": "39 40 53", // #272835
"--color-surface-600": "35 36 48", // #232430
"--color-surface-700": "29 30 40", // #1d1e28
"--color-surface-800": "23 24 32", // #171820
"--color-surface-900": "19 20 26", // #13141a
}
}

387
README.md
View File

@@ -1,3 +1,386 @@
# Website
# Terminal Portfolio
An Arch Linux terminal-themed portfolio website with Hyprland-style TUI components, built with SvelteKit, Tailwind CSS, and Three.js.
![Terminal Portfolio](./static/og-image.png)
## Features
- 🖥️ **Hyprland-style TUI** - Terminal interface inspired by Textual Python TUI
- 🎨 **Theme Support** - Arch Linux and Catppuccin (Mocha/Latte) themes
- 🌓 **Dark/Light Mode** - Toggle between dark and light modes
- ⌨️ **Keyboard Navigation** - Navigate with arrow keys or vim-style j/k
- 🎮 **3D Model Viewer** - Interactive Three.js viewer for .glb models
-**Configurable Speed** - Per-page typing animation speed
- 📱 **Responsive** - Works on desktop and mobile
- 🎨 **Rich Text Formatting** - Colors, backgrounds, and text decorations
## Pages
- **Home** (`/`) - Neofetch-style intro with navigation
- **Portfolio** (`/portfolio`) - Skills, projects, and contact info
- **Models** (`/models`) - 3D model gallery with interactive viewer
- **Hackathons** (`/hackathons`) - Hackathon projects and achievements
## Configuration
Everything in the site is configurable from `src/lib/config.ts`. This file is the single source of truth for UI, layout, TUI behavior, colors, and per-page settings. Edit it to personalize the website to your needs.
### Main Config Sections
- `user`: Profile info (name, username, bio, social links)
- `skills`, `projects`, `models`, `hackathons`: Content arrays shown in pages
- `layout`: Sizes and page margins (navbar height, container width)
- `breakpoints`: Responsive breakpoints (mobile/tablet/desktop)
- `fonts`: Font stacks and weights
- `colorPalette`: Terminal and UI colors (semantic and base colors)
- `terminalButtons`: Terminal header buttons colors (close/minimize/maximize)
- `terminalSettings`: Typing presets, delays, cursor visibility, prompt style
- `tuiStyle`: Styling options for border, spacing, font sizes, buttons, etc.
- `tuiText`: Labels, hints, prefixes, and text labels for interactive elements
- `animations`: Animation durations and easing for UI interactions
- `scrollbar`: Scrollbar appearance settings
- `navbar`: Navbar sizes and theme button settings
- `modelViewer`: 3D viewer camera, lighting, and text strings
- `particles`: 3D background particles (count, opacity, motion)
- `loadingScreen`: Loading text and colors
- `keyboardShortcuts`: Map keyboard actions to keys
- `effects`: Misc effects like selection background and backdrop blur
- `pageMeta`: Per-route metadata (title, description, icon, keywords)
### Example: Key config snippets
```typescript
// Toggle theme keys and other shortcuts
export const keyboardShortcuts = {
skip: ['y', 'Y'], // Skip typing animation
toggleTheme: ['t', 'T'], // Toggle dark/light mode
navigateUp: ['ArrowUp', 'k'],
navigateDown: ['ArrowDown', 'j'],
select: ['Enter'],
};
// Terminal / typing
export const terminalSettings = {
baseTypeSpeed: 20,
minTypeSpeed: 5,
maxTypeSpeed: 50,
startDelay: 300,
lineDelay: 100,
showCursor: true,
promptStyle: 'full',
icon: '🐧',
scrollMargin: 80,
};
// Color palette (Catppuccin Mocha by default)
export const colorPalette = {
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#cba6f7',
cyan: '#94e2d5',
white: '#cdd6f4',
gray: '#6c7086',
error: '#f38ba8',
success: '#a6e3a1',
};
// TUI styling example
export const tuiStyle = {
borderRadius: 8,
borderWidth: 2,
width: '95%',
bodyPadding: '1rem 1.25rem 2rem 1.25rem',
buttonPadding: '0.5rem 0.75rem',
};
```
### How to customize
- Change any color in `colorPalette` to affect both inline coloring and semantic tokens like `(&primary)`/`(&error)`.
- Update `terminalSettings` to control typing speed, delays, and the TUI icon.
- Modify `tuiStyle` to adjust spacing, rounded corners, and button sizes.
- Use `keyboardShortcuts` to remap keys (e.g., toggle theme, skip animation).
- Per-page speeds are controlled by `pageSpeedSettings` (preset or numeric multiplier).
### Where to look for types & utilities
- `src/lib/components/tui/types.ts` — main TerminalLine types
- `src/lib/components/tui/utils.ts` — parsing utilities and style helpers
- `src/lib/stores/theme.ts` — theme store & `toggleMode()`
If you change a value in `config.ts`, the UI should pick it up on next reload. Some values (fonts, CSS variables) may also require adjusting CSS variables or Tailwind config.
## Speed Presets
| Preset | Effect |
|--------|--------|
| `instant` | No animation, appears immediately |
| `fast` | 3x faster than normal |
| `normal` | Default typing speed |
| `slow` | 2x slower than normal |
| `typewriter` | 3x slower, classic feel |
## Text Formatting
Use inline formatting with the `(&specs)text(&)` syntax:
### Colors
```typescript
// Basic colors
'(&red)Red text(&)'
'(&green)Green text(&)'
'(&blue)Blue text(&)'
'(&yellow)Yellow text(&)'
'(&magenta)Magenta text(&)'
'(&cyan)Cyan text(&)'
'(&orange)Orange text(&)'
'(&pink)Pink text(&)'
'(&gray)Gray text(&)'
'(&white)White text(&)'
// Semantic colors (theme-aware)
'(&primary)Primary color(&)'
'(&accent)Accent color(&)'
'(&muted)Muted text(&)'
'(&error)Error text(&)'
'(&success)Success text(&)'
'(&warning)Warning text(&)'
'(&info)Info text(&)'
// Custom hex colors
'(&#ff6b6b)Custom color(&)'
```
### Background Colors
Add `bg-` prefix to any color:
```typescript
'(&bg-red)Red background(&)'
'(&bg-blue,white)Blue bg with white text(&)'
'(&bg-surface)Surface background(&)'
'(&bg-#333333)Custom bg color(&)'
```
### Text Styles
```typescript
'(&bold)Bold text(&)'
'(&italic)Italic text(&)'
'(&dim)Dimmed text (60% opacity)(&)'
'(&underline)Underlined text(&)'
'(&strikethrough)Strikethrough text(&)'
'(&strike)Strikethrough shorthand(&)'
'(&overline)Overlined text(&)'
```
### Combining Styles
Combine multiple styles with commas:
```typescript
'(&bold,red)Bold red text(&)'
'(&italic,cyan,underline)Italic cyan underlined(&)'
'(&bg-blue,white,bold)Bold white on blue(&)'
'(&dim,strikethrough,gray)Dim gray strikethrough(&)'
```
## Line Types
### Basic Lines
```typescript
const lines: TerminalLine[] = [
{ type: 'command', content: 'ls -la' }, // With prompt prefix
{ type: 'output', content: 'File listing...' }, // Muted text
{ type: 'error', content: 'Error message' }, // Red with ✗ prefix
{ type: 'success', content: 'Success!' }, // Green with ✓ prefix
{ 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
];
```
### Button (Full-width interactive)
```typescript
{
type: 'button',
content: 'Click me',
icon: 'mdi:github', // Iconify icon
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)
// OR
action: () => doSomething(), // Custom action
}
```
### Link (Inline clickable text)
```typescript
{
type: 'link',
content: 'Visit GitHub',
icon: 'mdi:github', // Optional icon
style: 'accent', // Styling
href: 'https://github.com',
external: true, // Open in new tab (auto-detected for http/https)
}
```
### Image
```typescript
{
type: 'image',
content: 'Caption text', // Optional caption
image: '/path/to/image.png',
imageAlt: 'Alt text',
imageWidth: 300, // Max width in pixels
}
```
### Card
```typescript
{
type: 'card',
content: 'Card body text with (&bold)formatting(&) support',
cardTitle: 'Card Title', // Optional header
cardFooter: 'Footer text', // Optional footer
icon: 'mdi:star', // Optional header icon
image: '/path/to/image.png', // Optional card image
style: 'primary', // Border accent color
}
```
### Progress Bar
```typescript
{
type: 'progress',
content: 'Loading assets...', // Label above bar
progress: 75, // 0-100 percentage
progressLabel: '75%', // Custom label (defaults to percentage)
style: 'accent', // Bar color
}
```
### Accordion
```typescript
{
type: 'accordion',
content: '',
accordionItems: [
{ title: 'Section 1', content: 'Content for section 1 with (&cyan)colors(&)' },
{ title: 'Section 2', content: 'Content for section 2' },
],
accordionOpen: true, // First item open by default
style: 'primary', // Accent color
}
```
### Table
```typescript
{
type: 'table',
content: 'Table Title', // Optional title
tableHeaders: ['Name', 'Role', 'Status'],
tableRows: [
['Alice', 'Developer', '(&success)Active(&)'],
['Bob', 'Designer', '(&warning)Away(&)'],
],
style: 'accent', // Header color
}
```
### Tooltip
```typescript
{
type: 'tooltip',
content: 'Hover me', // Trigger text
tooltipText: 'This is helpful information!',
tooltipPosition: 'top', // top | bottom | left | right
style: 'info', // Tooltip border color
}
```
## TUI Components
### TerminalTUI
Main terminal component:
```svelte
<TerminalTUI
lines={lines}
title="~/directory"
interactive={true}
speed="normal"
onComplete={() => console.log('Done!')}
/>
```
### Component Structure
```
src/lib/components/
├── TerminalTUI.svelte # Main terminal container
└── tui/
├── types.ts # TypeScript types
├── utils.ts # Parsing & styling utilities
├── TuiHeader.svelte # Top status bar
├── TuiBody.svelte # Scrollable content area
├── TuiFooter.svelte # Bottom status bar
├── TuiButton.svelte # Full-width button
├── TuiLink.svelte # Inline clickable link
├── TuiCard.svelte # Card with header/body/footer
├── TuiProgress.svelte # Animated progress bar
├── TuiAccordion.svelte # Collapsible sections
├── TuiTable.svelte # Data table with headers
└── TuiTooltip.svelte # Hover tooltip
```
## 3D Models
Place `.glb` files in `/static/models/` and update the models page to display them.
## Tech Stack
- **Framework**: SvelteKit 2.x with Svelte 5 runes
- **Styling**: Tailwind CSS 4.x
- **3D**: Three.js with GLTFLoader
- **Icons**: @iconify/svelte
- **Font**: JetBrains Mono
## Development
```bash
# Install dependencies
bun install
# Start dev server
bun run dev
# Build for production
bun run build
# Preview production build
bun run preview
```
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `↑` / `k` | Navigate up |
| `↓` / `j` | Navigate down |
| `Enter` | Activate button |
| `Y` | Skip typing animation |
| `T` | Toggle dark/light mode |
https://dev.sirblob.co/

View File

@@ -1,40 +1,33 @@
{
"name": "btsw-api6",
"version": "6.2.0",
"name": "website",
"private": true,
"version": "7.0.0",
"type": "module",
"scripts": {
"start": "vite build && bun ./server/server.js",
"dev": "vite dev --host",
"build": "vite build",
"server": "bun ./server/server.js"
"server": "node build"
},
"devDependencies": {
"@skeletonlabs/skeleton": "2.10.2",
"@skeletonlabs/tw-plugin": "^0.4.1",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@tailwindcss/forms": "^0.5.10",
"@types/node": "22.7.5",
"autoprefixer": "10.4.20",
"postcss": "8.4.47",
"svelte": "^4.2.20",
"svelte-check": "^4.3.3",
"tailwindcss": "3.4.14",
"typescript": "^5.9.3",
"vite": "^5.4.21",
"vite-plugin-tailwind-purgecss": "0.3.3"
},
"type": "module",
"dependencies": {
"@floating-ui/dom": "^1.7.4",
"@iconify/json": "^2.2.403",
"@iconify/tailwind": "^1.2.0",
"@sveltejs/adapter-node": "^5.4.0",
"btsw-api6": "file:",
"@sveltejs/kit": "^2.49.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.17",
"svelte": "^5.45.2",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.2.4"
},
"dependencies": {
"@iconify/svelte": "^5.1.0",
"@threlte/core": "^8.3.0",
"@types/three": "^0.181.0",
"cors": "^2.8.5",
"dotenv": "^16.6.1",
"express": "^4.21.2",
"ms": "^2.1.3"
"dotenv": "^17.2.3",
"express": "^5.1.0",
"three": "^0.181.2"
}
}

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

12
src/app.d.ts vendored
View File

@@ -1,9 +1,13 @@
// See https://kit.svelte.dev/docs/types#app
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
// and what to do when importing types
declare namespace App {
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Error {}
// interface PageState {}
// interface Platform {}
}
}
export {};

View File

@@ -1,12 +1,11 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" data-theme="my-custom-theme">
<div style="display: contents" class="h-full overflow-hidden">%sveltekit.body%</div>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -1,25 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
html,
body {
@apply h-full overflow-hidden;
}
.insta-gradient {
@apply bg-gradient-to-r from-[#f09433] to-[#c90076];
}
.profile-gradient {
@apply bg-gradient-to-r from-[#10d3ff] to-[#008cac];
}
.projects-gradient {
@apply bg-gradient-to-r from-[#ffe299] to-[#ffb700];
}
.btn-no-bordeer {
@apply border-none;
}

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import { Canvas } from '@threlte/core';
import { T } from '@threlte/core';
import ParticleField from './ParticleField.svelte';
import { themeColors, mode } from '$lib/stores/theme';
import * as THREE from 'three';
let bgColor = $derived($mode === 'dark' ? $themeColors.background : $themeColors.background);
</script>
<div class="scene-container">
<Canvas>
<T.PerspectiveCamera
makeDefault
position={[0, 0, 8]}
fov={75}
near={0.1}
far={1000}
/>
<!-- Ambient light -->
<T.AmbientLight intensity={0.3} />
<!-- Directional light with theme color -->
<T.DirectionalLight
position={[5, 5, 5]}
intensity={0.5}
color={$themeColors.primary}
/>
<!-- Particle system -->
<ParticleField particleCount={600} />
<!-- Background color -->
<T.Color args={[bgColor]} attach="background" />
</Canvas>
</div>
<style>
.scene-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
pointer-events: none;
}
.scene-container :global(canvas) {
display: block;
}
</style>

View File

@@ -1,65 +0,0 @@
<script lang="ts">
// image source is configurable via prop; default points to a common images folder
export let src: string = '/src/lib/images/profile.png';
export let alt: string = 'Profile picture';
</script>
<figure class="mx-auto">
<section class="img-bg" />
<img src={src} alt={alt} class="fill-token rounded-full object-cover object-center" loading="lazy" />
</figure>
<style lang="postcss">
figure {
@apply flex relative flex-col;
}
figure img,
.img-bg {
@apply w-40 h-40 md:w-44 md:h-44 my-10;
}
.img-bg {
@apply absolute z-[-1] rounded-full blur-[50px] transition-all;
/* default (light mode): darker blue glow */
animation: pulse 5s cubic-bezier(0, 0, 0, 0.5) infinite,
glow-light 5s linear infinite;
}
/* In dark mode use the original multi-color glow */
:global(.dark) .img-bg {
animation: pulse 5s cubic-bezier(0, 0, 0, 0.5) infinite,
glow-dark 5s linear infinite;
}
@keyframes glow-dark {
0% {
@apply bg-primary-400/50;
}
33% {
@apply bg-secondary-400/50;
}
66% {
@apply bg-tertiary-400/50;
}
100% {
@apply bg-primary-400/50;
}
}
@keyframes glow-light {
/* darker blues for light mode */
0% {
@apply bg-primary-700/50;
}
33% {
@apply bg-primary-600/50;
}
66% {
@apply bg-primary-800/50;
}
100% {
@apply bg-primary-700/50;
}
}
@keyframes pulse {
50% {
transform: scale(1.5);
}
}
</style>

View File

@@ -1,49 +0,0 @@
<script lang="ts">
import { Avatar } from "@skeletonlabs/skeleton";
export let totalassets;
export let username;
export let bio;
export let avatar;
export let roles;
export let contributions;
// const popupHover = {
// event: 'hover',
// target: 'popupHover',
// placement: 'top'
// };
</script>
<div class="grid col card bg-initial card-hover overflow-hidden {$$restProps.class}">
<header class="mx-auto my-5">
<div class="relative inline-block">
<span class="badge-icon variant-filled-error absolute -top-0 -right-0 z-10 w-fit p-1">-{Math.round((contributions/totalassets)*100)}%</span>
<Avatar src={avatar} width="w-32" />
</div>
</header>
<div class="p-4 space-y-4">
<h3 class="h3" data-toc-ignore>{username}</h3>
<article>
{ #each roles as role}
<span class="badge variant-ghost-primary my-1 mx-1">{role}</span>
{/each}
<br><br>
<p>{bio}</p>
</article>
</div>
<hr class="opacity-50" />
<footer class="p-4 mx-auto flex justify-start items-center space-x-4">
<button type="button" class="chip variant-soft hover:variant-filled">
<span class="icon-[flowbite--user-circle-solid] profile-gradient"></span>
</button>
<button type="button" class="chip variant-soft hover:variant-filled">
<span class="icon-[flowbite--lightbulb-outline] projects-gradient"></span>
</button>
<button type="button" class="chip variant-soft hover:variant-filled">
<span class="icon-[lets-icons--insta] insta-gradient"></span>
</button>
</footer>
</div>

View File

@@ -1,11 +0,0 @@
<script lang="ts">
import { AppBar } from '@skeletonlabs/skeleton';
</script>
<AppBar class="flex justify-center items-center mx-auto p-4">
<div class="grid grid-cols-1">
<div class="grid mx-auto">
<span class="text-lg">Made with ❤️ by Sir Blob</span>
</div>
</div>
</AppBar>

View File

@@ -1,150 +0,0 @@
<script lang="ts">
export let title: string = "Project Title";
export let description: string =
"Short project description that explains what the project does in one or two lines.";
export let image: string = "";
export let link: string = "";
export let repo: string = "";
export let devpost: string = "";
export let hackathonName: string = "";
export let university: string = "";
export let location: string = "";
export let year: string = "";
export let tags: string[] = [];
export let featured: boolean = false;
export let awards: { track: string; place: string }[] = [];
export let liveWarning: boolean = false;
$: placeholderImage = `https://placehold.co/800x400/334155/94a3b8?text=${encodeURIComponent(title)}`;
$: displayImage = image || placeholderImage;
</script>
<article
class="card bg-surface-300 dark:bg-surface-700 rounded-xl overflow-hidden shadow-lg text-slate-900 dark:text-slate-100 flex flex-col relative"
>
<!-- Banner Image -->
<div class="w-full h-40 bg-slate-200 dark:bg-slate-800 overflow-hidden">
<img
src={displayImage}
alt={title}
class="w-full h-full object-cover"
loading="lazy"
/>
</div>
<!-- Content -->
<div class="p-6">
<!-- Title and Featured Badge -->
<div class="flex items-start justify-between gap-2">
<h3 class="text-xl md:text-2xl font-bold leading-tight">
{title}
</h3>
{#if featured}
<span
class="flex-shrink-0 inline-flex items-center text-xs font-semibold bg-amber-500 text-amber-900 px-2 py-1 rounded"
>Featured</span
>
{/if}
</div>
<!-- Metadata -->
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
{#if hackathonName}
<span class="font-semibold">{hackathonName}</span>
{/if}
{#if university}
<span>{university}</span>
{/if}
{#if location}
<span>{location}</span>
{/if}
{#if year}
<span>{year}</span>
{/if}
</div>
<!-- Awards Section -->
{#if awards && awards.length > 0}
<div class="mt-3 space-y-1">
{#each awards as award}
<div class="flex items-center gap-2 text-xs">
<span class="text-amber-600 dark:text-amber-400">🏆</span>
<span class="font-semibold text-slate-700 dark:text-slate-300">{award.place}</span>
<span class="text-slate-600 dark:text-slate-400"></span>
<span class="text-slate-600 dark:text-slate-400">{award.track}</span>
</div>
{/each}
</div>
{/if}
</div>
<!-- Description -->
<div class="px-6 pb-4 flex-grow">
<p class="text-sm text-slate-700 dark:text-slate-300">{description}</p>
{#if liveWarning && link}
<div class="mt-3 flex items-start gap-2 text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 p-2 rounded">
<span class="text-base">⚠️</span>
<span>Live site may not include all features from the original project.</span>
</div>
{/if}
</div>
<!-- Footer with tags and links -->
<div class="px-6 pb-6 pt-0 mt-auto">
<!-- Tags Row -->
{#if tags && tags.length > 0}
<div class="flex flex-wrap gap-2 mb-3">
{#each tags as tag}
<span class="chip variant-filled text-xs text-center whitespace-nowrap">{tag}</span>
{/each}
</div>
{/if}
<!-- Link Buttons Row -->
<div class="grid grid-cols-[repeat(auto-fit,minmax(80px,1fr))] gap-2">
{#if devpost}
<a
href={devpost}
target="_blank"
rel="noreferrer"
class="btn btn-sm variant-filled-primary w-full"
title="View on Devpost"
>
<span class="icon-[simple-icons--devpost]"></span>
<span class="ml-1">About</span>
</a>
{/if}
{#if repo}
<a
href={repo}
target="_blank"
rel="noreferrer"
class="btn btn-sm variant-ghost-secondary w-full"
>
<span class="icon-[simple-icons--github]"></span>
<span class="ml-1">Repo</span>
</a>
{/if}
{#if link}
<a
href={link}
target="_blank"
rel="noreferrer"
class="btn btn-sm variant-ghost-tertiary w-full">
<span class="ml-1">Website</span>
</a>
{/if}
</div>
</div>
</article>
<style lang="postcss">
article {
@apply w-full;
}
h3 {
word-break: break-word;
hyphens: auto;
}
</style>

View File

@@ -0,0 +1,609 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { themeColors } from '$lib/stores/theme';
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import Icon from '@iconify/svelte';
interface Props {
modelPath: string;
modelName?: string;
class?: string;
}
let { modelPath, modelName = 'Model', class: className = '' }: Props = $props();
let container: HTMLDivElement;
let scene: THREE.Scene;
let camera: THREE.PerspectiveCamera;
let renderer: THREE.WebGLRenderer;
let controls: OrbitControls;
let model: THREE.Group | null = null;
let animationId: number;
let sceneInitialized = false;
let isLoading = $state(true);
let loadError = $state<string | null>(null);
let isFullscreen = $state(false);
let autoRotate = $state(true);
let wireframe = $state(false);
let showGround = $state(true);
let lightIntensity = $state(1);
let showControls = $state(false);
let ground: THREE.Mesh | null = null;
// Watch for modelPath changes
$effect(() => {
if (modelPath && sceneInitialized) {
// Remove old model
if (model) {
scene.remove(model);
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.geometry.dispose();
if (Array.isArray(child.material)) {
child.material.forEach(m => m.dispose());
} else {
child.material.dispose();
}
}
});
model = null;
}
isLoading = true;
loadError = null;
loadModel();
}
});
function initScene() {
const width = container.clientWidth;
const height = container.clientHeight;
// Scene
scene = new THREE.Scene();
scene.background = null; // Transparent background
// Camera
camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000);
camera.position.set(0, 1, 3);
// Renderer
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance'
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.appendChild(renderer.domElement);
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.enablePan = false;
controls.minDistance = 1;
controls.maxDistance = 10;
controls.autoRotate = autoRotate;
controls.autoRotateSpeed = 2;
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight1 = new THREE.DirectionalLight(0xffffff, 1);
directionalLight1.position.set(5, 5, 5);
directionalLight1.castShadow = true;
directionalLight1.userData.baseIntensity = 1;
scene.add(directionalLight1);
const directionalLight2 = new THREE.DirectionalLight(0xffffff, 0.5);
directionalLight2.position.set(-5, 3, -5);
directionalLight2.userData.baseIntensity = 0.5;
scene.add(directionalLight2);
// Subtle rim light
const rimLight = new THREE.DirectionalLight(0x88ccff, 0.3);
rimLight.position.set(0, 3, -5);
rimLight.userData.baseIntensity = 0.3;
scene.add(rimLight);
// Ground plane (subtle)
const groundGeometry = new THREE.CircleGeometry(2, 32);
const groundMaterial = new THREE.MeshStandardMaterial({
color: 0x222222,
transparent: true,
opacity: 0.3
});
ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.5;
ground.receiveShadow = true;
scene.add(ground);
sceneInitialized = true;
loadModel();
}
function loadModel() {
const loader = new GLTFLoader();
loader.load(
modelPath,
(gltf) => {
model = gltf.scene;
// Center and scale the model
const box = new THREE.Box3().setFromObject(model);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
// Center the model
model.position.sub(center);
// Scale to fit
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 1.5 / maxDim;
model.scale.setScalar(scale);
// Enable shadows
model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
child.receiveShadow = true;
}
});
scene.add(model);
isLoading = false;
// Adjust camera based on model
camera.position.set(0, size.y * scale * 0.5, 2.5);
controls.target.set(0, 0, 0);
controls.update();
},
(progress) => {
// Loading progress
},
(error) => {
console.error('Error loading model:', error);
loadError = 'Failed to load model';
isLoading = false;
}
);
}
function animate() {
animationId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
function handleResize() {
if (!container || !camera || !renderer) return;
const width = container.clientWidth;
const height = container.clientHeight;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
}
function toggleAutoRotate() {
autoRotate = !autoRotate;
if (controls) {
controls.autoRotate = autoRotate;
}
}
function toggleFullscreen() {
isFullscreen = !isFullscreen;
setTimeout(handleResize, 100);
}
function resetCamera() {
if (!camera || !controls) return;
camera.position.set(0, 1, 3);
controls.target.set(0, 0, 0);
controls.update();
}
function toggleWireframe() {
wireframe = !wireframe;
if (model) {
model.traverse((child) => {
if (child instanceof THREE.Mesh && child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => m.wireframe = wireframe);
} else {
child.material.wireframe = wireframe;
}
}
});
}
}
function toggleGround() {
showGround = !showGround;
if (ground) {
ground.visible = showGround;
}
}
function adjustLighting(delta: number) {
lightIntensity = Math.max(0.2, Math.min(2, lightIntensity + delta));
if (scene) {
scene.traverse((child) => {
if (child instanceof THREE.DirectionalLight) {
child.intensity = child.userData.baseIntensity * lightIntensity;
} else if (child instanceof THREE.AmbientLight) {
child.intensity = 0.4 * lightIntensity;
}
});
}
}
function zoomIn() {
if (!camera || !controls) return;
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
camera.position.addScaledVector(direction, 0.5);
controls.update();
}
function zoomOut() {
if (!camera || !controls) return;
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
camera.position.addScaledVector(direction, -0.5);
controls.update();
}
function toggleControlsPanel() {
showControls = !showControls;
}
onMount(() => {
if (container) {
initScene();
animate();
window.addEventListener('resize', handleResize);
}
});
onDestroy(() => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (renderer) {
renderer.dispose();
}
window.removeEventListener('resize', handleResize);
});
</script>
<div
class="model-viewer {className}"
class:fullscreen={isFullscreen}
style="
--viewer-bg: {$themeColors.terminal};
--viewer-border: {$themeColors.border};
--viewer-text: {$themeColors.text};
--viewer-muted: {$themeColors.textMuted};
--viewer-primary: {$themeColors.primary};
--viewer-accent: {$themeColors.accent};
"
>
<!-- Header -->
<div class="viewer-header">
<div class="header-left">
<Icon icon="mdi:cube-outline" width="16" />
<span class="model-name">{modelName}</span>
</div>
<div class="header-controls">
<button
class="control-btn"
title={autoRotate ? 'Stop rotation' : 'Auto rotate'}
onclick={toggleAutoRotate}
class:active={autoRotate}
>
<Icon icon={autoRotate ? 'mdi:rotate-3d' : 'mdi:rotate-3d-variant'} width="16" />
</button>
<button
class="control-btn"
title="Reset camera"
onclick={resetCamera}
>
<Icon icon="mdi:camera-flip" width="16" />
</button>
<button
class="control-btn"
title="More controls"
onclick={toggleControlsPanel}
class:active={showControls}
>
<Icon icon="mdi:tune" width="16" />
</button>
<button
class="control-btn"
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
onclick={toggleFullscreen}
>
<Icon icon={isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'} width="16" />
</button>
</div>
</div>
<!-- Extended controls panel -->
{#if showControls}
<div class="controls-panel">
<div class="control-group">
<span class="control-label">View</span>
<div class="control-buttons">
<button
class="control-btn small"
title="Zoom in"
onclick={zoomIn}
>
<Icon icon="mdi:magnify-plus" width="14" />
</button>
<button
class="control-btn small"
title="Zoom out"
onclick={zoomOut}
>
<Icon icon="mdi:magnify-minus" width="14" />
</button>
</div>
</div>
<div class="control-group">
<span class="control-label">Lighting</span>
<div class="control-buttons">
<button
class="control-btn small"
title="Decrease brightness"
onclick={() => adjustLighting(-0.2)}
>
<Icon icon="mdi:brightness-5" width="14" />
</button>
<span class="control-value">{Math.round(lightIntensity * 100)}%</span>
<button
class="control-btn small"
title="Increase brightness"
onclick={() => adjustLighting(0.2)}
>
<Icon icon="mdi:brightness-7" width="14" />
</button>
</div>
</div>
<div class="control-group">
<span class="control-label">Display</span>
<div class="control-buttons">
<button
class="control-btn small"
title={wireframe ? 'Solid view' : 'Wireframe view'}
onclick={toggleWireframe}
class:active={wireframe}
>
<Icon icon={wireframe ? 'mdi:cube' : 'mdi:cube-outline'} width="14" />
</button>
<button
class="control-btn small"
title={showGround ? 'Hide ground' : 'Show ground'}
onclick={toggleGround}
class:active={showGround}
>
<Icon icon="mdi:checkerboard" width="14" />
</button>
</div>
</div>
</div>
{/if}
<!-- Canvas container -->
<div class="canvas-container" bind:this={container}>
{#if isLoading}
<div class="loading-overlay">
<Icon icon="mdi:loading" width="32" class="spin" />
<span>Loading model...</span>
</div>
{/if}
{#if loadError}
<div class="error-overlay">
<Icon icon="mdi:alert-circle" width="32" />
<span>{loadError}</span>
</div>
{/if}
</div>
<!-- Footer hints -->
<div class="viewer-footer">
<span class="hint">
<Icon icon="mdi:mouse" width="14" />
Drag to rotate
</span>
<span class="hint">
<Icon icon="mdi:magnify-plus-minus" width="14" />
Scroll to zoom
</span>
</div>
</div>
<style>
.model-viewer {
display: flex;
flex-direction: column;
background: var(--viewer-bg);
border: 1px solid var(--viewer-border);
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
}
.model-viewer.fullscreen {
position: fixed;
inset: 60px 0 0 0;
z-index: 100;
border-radius: 0;
border: none;
}
.viewer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--viewer-bg) 80%, black);
border-bottom: 1px solid var(--viewer-border);
}
.header-left {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--viewer-primary);
font-size: 0.85rem;
font-weight: 500;
}
.header-controls {
display: flex;
gap: 0.25rem;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--viewer-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.control-btn:hover {
background: var(--viewer-border);
color: var(--viewer-text);
border-color: var(--viewer-border);
}
.control-btn.active {
background: var(--viewer-primary);
color: var(--viewer-bg);
border-color: var(--viewer-primary);
}
.control-btn.small {
width: 24px;
height: 24px;
}
/* Extended controls panel */
.controls-panel {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--viewer-bg) 90%, black);
border-bottom: 1px solid var(--viewer-border);
}
.control-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-label {
font-size: 0.7rem;
color: var(--viewer-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.control-buttons {
display: flex;
align-items: center;
gap: 0.25rem;
}
.control-value {
font-size: 0.7rem;
color: var(--viewer-text);
min-width: 2.5rem;
text-align: center;
font-family: 'JetBrains Mono', monospace;
}
.canvas-container {
flex: 1;
min-height: 300px;
position: relative;
background: radial-gradient(
circle at center,
color-mix(in srgb, var(--viewer-primary) 5%, transparent),
transparent 70%
);
}
.canvas-container :global(canvas) {
display: block;
}
.loading-overlay,
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
color: var(--viewer-muted);
font-size: 0.9rem;
}
.error-overlay {
color: #f38ba8;
}
:global(.spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.viewer-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
padding: 0.5rem;
background: color-mix(in srgb, var(--viewer-bg) 80%, black);
border-top: 1px solid var(--viewer-border);
}
.hint {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--viewer-muted);
font-size: 0.75rem;
}
</style>

View File

@@ -1,87 +0,0 @@
<script lang="ts">
import { AppBar } from '@skeletonlabs/skeleton';
import { LightSwitch } from '@skeletonlabs/skeleton';
let logoElement: HTMLElement;
function onMobileMenuClick() {
const navbar = document.getElementById('navbar-default');
const button = document.querySelector('[data-collapse-toggle="navbar-default"]');
if (navbar) {
const expanded = navbar.getAttribute('aria-expanded') === 'true';
navbar.setAttribute('aria-expanded', String(!expanded));
button?.setAttribute('aria-expanded', String(!expanded));
navbar.style.display = expanded ? 'none' : 'block';
}
}
</script>
<!-- svelte-ignore a11y-missing-attribute -->
<AppBar class="bg-slate-400">
<svelte:fragment slot="lead">
<strong bind:this={logoElement} class="text-xl uppercase">
<a href="/">
<!-- svelte-ignore a11y-missing-attribute -->
<img src="/blob_nerd.png" class="rounded-md" style="width: 50px; height: auto;" alt="Logo" />
</a>
</strong>
</svelte:fragment>
<svelte:fragment slot="trail">
<button on:click={onMobileMenuClick} data-collapse-toggle="navbar-default" type="button" class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="navbar-default" aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15"/>
</svg>
</button>
<div class="hidden w-full md:block md:w-auto" id="navbar-default">
<ul class="font-medium flex flex-col p-4 md:p-0 mt-4 border md:flex-row md:space-x-6 rtl:space-x-reverse md:mt-0 md:border-0">
<li>
<a
class="btn hover:variant-ghost-surface center-nav"
href="/portfolio"
rel="noreferrer">
<strong>Portfolio</strong>
</a>
</li>
<li>
<a
class="btn hover:variant-ghost-surface center-nav"
href="/hackathons"
rel="noreferrer">
<strong>Hackathons</strong>
</a>
</li>
<!-- <li>
<a
class="btn hover:variant-ghost-surface center-nav"
href="/projects"
rel="noreferrer">
<strong>Projects</strong>
</a>
</li>
<li>
<a
class="btn hover:variant-ghost-surface center-nav"
href="/game"
rel="noreferrer">
<strong>Games</strong>
</a>
</li> -->
<li>
<a class="btn btn-sm">
<LightSwitch class="flex mx-auto items-center"/>
</a>
</li>
</ul>
</div>
</svelte:fragment>
</AppBar>
<style lang="postcss">
.center-nav {
@apply flex justify-center items-center;
}
</style>

View File

@@ -0,0 +1,476 @@
<script lang="ts">
import { mode, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
import { fly, fade } from 'svelte/transition';
import { user, navigation } from '$lib/config';
import Icon from '@iconify/svelte';
let themeDropdownOpen = $state(false);
let navExpanded = $state(false);
function handleThemeSelect(theme: ColorTheme) {
setColorTheme(theme);
themeDropdownOpen = false;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
themeDropdownOpen = false;
}
}
function getThemeIcon(theme: ColorTheme): string {
switch (theme) {
case 'arch': return 'mdi:arch';
case 'catppuccin': return 'solar:cat-bold';
default: return 'mdi:palette';
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<nav
class="navbar"
style="
--nav-bg: {$themeColors.backgroundLight};
--nav-border: {$themeColors.border};
--nav-text: {$themeColors.text};
--nav-primary: {$themeColors.primary};
--nav-accent: {$themeColors.accent};
--nav-muted: {$themeColors.textMuted};
"
>
<div class="nav-container">
<!-- Logo / Brand -->
<a href="/" class="nav-brand">
<span class="terminal-prompt">
<span class="user">{user.username}@{user.hostname}</span>
<span class="separator">:</span>
<span class="path">~</span>
<span class="symbol">$</span>
</span>
<span class="cursor"></span>
</a>
<!-- Mobile Menu Toggle -->
<button
class="mobile-toggle"
onclick={() => navExpanded = !navExpanded}
aria-label="Toggle navigation"
>
<span class="hamburger" class:open={navExpanded}></span>
</button>
<!-- Navigation Links -->
<div class="nav-links" class:expanded={navExpanded}>
{#each navigation as nav}
<a href={nav.path} class="nav-link" onclick={() => navExpanded = false}>
<span class="link-prefix">./</span>{nav.name}
</a>
{/each}
</div>
<!-- Theme Controls -->
<div class="nav-controls">
<!-- Theme Selector Dropdown -->
<div class="theme-selector">
<button
class="theme-button"
onclick={() => themeDropdownOpen = !themeDropdownOpen}
aria-expanded={themeDropdownOpen}
aria-haspopup="true"
>
<span class="theme-icon">
<Icon icon={getThemeIcon($colorTheme)} width="18" />
</span>
<span class="theme-label">{$colorTheme}</span>
<Icon icon="mdi:chevron-down" width="16" class="dropdown-arrow {themeDropdownOpen ? 'open' : ''}" />
</button>
{#if themeDropdownOpen}
<div
class="theme-dropdown"
transition:fly={{ y: -10, duration: 200 }}
>
{#each themeOptions as option}
<button
class="theme-option"
class:active={$colorTheme === option.value}
onclick={() => handleThemeSelect(option.value)}
>
<span class="option-icon">
<Icon icon={getThemeIcon(option.value)} width="18" />
</span>
<span class="option-label">{option.label}</span>
{#if $colorTheme === option.value}
<Icon icon="mdi:check" width="16" class="check-mark" />
{/if}
</button>
{/each}
</div>
{/if}
</div>
<!-- Dark/Light Mode Toggle -->
<button
class="mode-toggle"
onclick={toggleMode}
aria-label="Toggle dark/light mode"
title={$mode === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
>
{#if $mode === 'dark'}
<div transition:fade={{ duration: 150 }}>
<Icon icon="mdi:white-balance-sunny" width="20" />
</div>
{:else}
<div transition:fade={{ duration: 150 }}>
<Icon icon="mdi:moon-waning-crescent" width="20" />
</div>
{/if}
</button>
</div>
</div>
<!-- Backdrop for dropdown -->
{#if themeDropdownOpen}
<button
class="backdrop"
transition:fade={{ duration: 150 }}
onclick={() => themeDropdownOpen = false}
aria-label="Close dropdown"
></button>
{/if}
</nav>
<style>
.navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
background: var(--nav-bg);
border-bottom: 1px solid var(--nav-border);
backdrop-filter: blur(10px);
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
}
.nav-brand {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.9rem;
font-weight: 600;
}
.terminal-prompt {
display: flex;
align-items: center;
gap: 0;
}
.terminal-prompt .user {
color: var(--nav-accent);
}
.terminal-prompt .separator {
color: var(--nav-text);
}
.terminal-prompt .path {
color: var(--nav-primary);
}
.terminal-prompt .symbol {
color: var(--nav-text);
margin-left: 0.25rem;
}
.cursor {
width: 8px;
height: 1.2em;
background: var(--nav-primary);
animation: blink 1s step-end infinite;
margin-left: 0.25rem;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.mobile-toggle {
display: none;
background: none;
border: none;
padding: 0.5rem;
cursor: pointer;
}
.hamburger {
display: block;
width: 24px;
height: 2px;
background: var(--nav-text);
position: relative;
transition: all 0.3s ease;
}
.hamburger::before,
.hamburger::after {
content: '';
position: absolute;
width: 24px;
height: 2px;
background: var(--nav-text);
transition: all 0.3s ease;
}
.hamburger::before {
top: -7px;
}
.hamburger::after {
top: 7px;
}
.hamburger.open {
background: transparent;
}
.hamburger.open::before {
top: 0;
transform: rotate(45deg);
}
.hamburger.open::after {
top: 0;
transform: rotate(-45deg);
}
.nav-links {
display: flex;
align-items: center;
gap: 1.5rem;
flex: 1;
}
.nav-link {
color: var(--nav-text);
text-decoration: none;
font-size: 0.85rem;
padding: 0.5rem 0.75rem;
border-radius: 4px;
transition: all 0.2s ease;
display: flex;
align-items: center;
position: relative;
overflow: hidden;
}
.nav-link::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 0;
height: 2px;
background: var(--nav-primary);
transition: width 0.3s ease;
}
.nav-link:hover::before {
width: 100%;
}
.nav-link:hover {
color: var(--nav-primary);
}
.link-prefix {
color: var(--nav-muted);
margin-right: 2px;
}
.nav-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.theme-selector {
position: relative;
}
.theme-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: 1px solid var(--nav-border);
border-radius: 6px;
color: var(--nav-text);
cursor: pointer;
font-family: inherit;
font-size: 0.8rem;
transition: all 0.2s ease;
}
.theme-button:hover {
border-color: var(--nav-primary);
background: color-mix(in srgb, var(--nav-primary) 10%, transparent);
}
.theme-icon {
font-size: 1rem;
display: flex;
align-items: center;
}
.theme-label {
text-transform: capitalize;
}
:global(.dropdown-arrow) {
transition: transform 0.2s ease;
}
:global(.dropdown-arrow.open) {
transform: rotate(180deg);
}
.theme-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 180px;
background: var(--nav-bg);
border: 1px solid var(--nav-border);
border-radius: 8px;
padding: 0.5rem;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
z-index: 1001;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--nav-text);
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
text-align: left;
transition: all 0.2s ease;
}
.theme-option:hover {
background: color-mix(in srgb, var(--nav-primary) 15%, transparent);
}
.theme-option.active {
background: color-mix(in srgb, var(--nav-primary) 20%, transparent);
color: var(--nav-primary);
}
.option-icon {
font-size: 1.1rem;
display: flex;
align-items: center;
}
.option-label {
flex: 1;
}
:global(.check-mark) {
color: var(--nav-primary);
}
.mode-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: transparent;
border: 1px solid var(--nav-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--nav-text);
}
.mode-toggle:hover {
border-color: var(--nav-primary);
background: color-mix(in srgb, var(--nav-primary) 10%, transparent);
color: var(--nav-primary);
}
.backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 999;
border: none;
cursor: default;
}
@media (max-width: 768px) {
.nav-container {
flex-wrap: wrap;
padding: 0.75rem 1rem;
}
.mobile-toggle {
display: block;
order: 2;
}
.nav-links {
order: 4;
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
}
.nav-links.expanded {
max-height: 200px;
padding-top: 1rem;
}
.nav-controls {
order: 3;
margin-left: auto;
margin-right: 1rem;
}
.theme-label {
display: none;
}
.theme-button {
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,504 @@
<script lang="ts">
import { mode, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
import { page } from '$app/stores';
import { fly, fade } from 'svelte/transition';
import { user, navigation, colorPalette } from '$lib/config';
import Icon from '@iconify/svelte';
import { onMount, onDestroy } from 'svelte';
// State
let themeDropdownOpen = $state(false);
let currentTime = $state(new Date());
// Derived values (Svelte 5 runes)
let currentPath = $derived($page.url.pathname);
// Time update interval
let timeInterval: ReturnType<typeof setInterval>;
onMount(() => {
// Update time every second
timeInterval = setInterval(() => {
currentTime = new Date();
}, 1000);
});
onDestroy(() => {
if (timeInterval) clearInterval(timeInterval);
});
// Format time
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function formatDate(date: Date): string {
return date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
});
}
// Get current workspace/page index
function getWorkspaceIndex(path: string): number {
const idx = navigation.findIndex(n => n.path === path);
return idx >= 0 ? idx + 1 : 1;
}
// Battery icon based on level
function getBatteryIcon(level: number, charging: boolean): string {
if (charging) return 'mdi:battery-charging';
if (level > 90) return 'mdi:battery';
if (level > 70) return 'mdi:battery-80';
if (level > 50) return 'mdi:battery-60';
if (level > 30) return 'mdi:battery-40';
if (level > 10) return 'mdi:battery-20';
return 'mdi:battery-alert';
}
function handleThemeSelect(theme: ColorTheme) {
setColorTheme(theme);
themeDropdownOpen = false;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
themeDropdownOpen = false;
}
}
function getThemeIcon(theme: ColorTheme): string {
switch (theme) {
case 'arch': return 'mdi:arch';
case 'catppuccin': return 'solar:cat-bold';
default: return 'mdi:palette';
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<nav
class="waybar"
style="
--bar-bg: {$themeColors.background};
--bar-bg-module: {$themeColors.backgroundLight};
--bar-border: {$themeColors.border};
--bar-text: {$themeColors.text};
--bar-primary: {$themeColors.primary};
--bar-accent: {$themeColors.accent};
--bar-muted: {$themeColors.textMuted};
--bar-success: {colorPalette.success};
--bar-warning: {colorPalette.warning};
--bar-error: {colorPalette.error};
"
>
<!-- Left modules -->
<div class="bar-left">
<!-- Arch logo / Launcher -->
<a href="/" class="module launcher" title="Home">
<img src="/favicon.png" alt="Blob Icon" width="16" />
</a>
<!-- Workspaces -->
<div class="module workspaces">
{#each navigation as nav}
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
{#if nav.external}
<a
href={nav.path}
class="workspace external"
target="_blank"
rel="noopener noreferrer"
title={nav.name}
>
<Icon icon="mdi:open-in-new" width="12" />
<span class="ws-name">{nav.name}</span>
</a>
{:else}
<a
href={nav.path}
class="workspace"
class:active={isActive}
title={nav.name}
>
<span class="ws-name">{nav.name}</span>
</a>
{/if}
{/each}
</div>
<!-- Current window title -->
<div class="module window-title">
<span class="title-icon">
{#if currentPath === '/'}
<Icon icon="mdi:home" width="14" />
{:else if currentPath === '/portfolio'}
<Icon icon="mdi:folder-multiple" width="14" />
{:else if currentPath === '/models'}
<Icon icon="mdi:cube-outline" width="14" />
{:else if currentPath === '/hackathons'}
<Icon icon="mdi:trophy" width="14" />
{:else}
<Icon icon="mdi:file" width="14" />
{/if}
</span>
<span class="title-text">
{navigation.find(n => n.path === currentPath)?.name || 'page'}
</span>
</div>
</div>
<!-- Center modules -->
<div class="bar-center">
<div class="module clock">
<Icon icon="mdi:clock-outline" width="14" />
<span class="time">{formatTime(currentTime)}</span>
<span class="date">{formatDate(currentTime)}</span>
</div>
</div>
<!-- Right modules -->
<div class="bar-right">
<!-- (No system modules — minimal Waybar look) -->
<!-- Theme selector -->
<div class="module theme-selector">
<button
class="theme-trigger"
onclick={() => themeDropdownOpen = !themeDropdownOpen}
aria-expanded={themeDropdownOpen}
title="Theme: {$colorTheme}"
>
<Icon icon={getThemeIcon($colorTheme)} width="16" />
</button>
{#if themeDropdownOpen}
<div
class="theme-dropdown"
transition:fly={{ y: -10, duration: 150 }}
>
<div class="dropdown-header">Theme</div>
{#each themeOptions as option}
<button
class="theme-option"
class:active={$colorTheme === option.value}
onclick={() => handleThemeSelect(option.value)}
>
<Icon icon={getThemeIcon(option.value)} width="16" />
<span>{option.label}</span>
{#if $colorTheme === option.value}
<Icon icon="mdi:check" width="14" class="check" />
{/if}
</button>
{/each}
<div class="dropdown-divider"></div>
<div class="dropdown-header">Mode</div>
<button class="theme-option" onclick={toggleMode}>
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="16" />
<span>{$mode === 'dark' ? 'Light Mode' : 'Dark Mode'}</span>
</button>
</div>
{/if}
</div>
</div>
<!-- Backdrop -->
{#if themeDropdownOpen}
<button
class="backdrop"
transition:fade={{ duration: 100 }}
onclick={() => themeDropdownOpen = false}
aria-label="Close"
></button>
{/if}
</nav>
<style>
.waybar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
height: var(--navbar-height, 40px);
background: var(--bar-bg);
border-bottom: 1px solid var(--bar-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.5rem;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8rem;
gap: 0.5rem;
}
.bar-left,
.bar-center,
.bar-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.bar-left {
flex: 1;
}
.bar-right {
flex: 1;
justify-content: flex-end;
}
/* Modules */
.module {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.6rem;
background: var(--bar-bg-module);
border-radius: 4px;
color: var(--bar-text);
transition: all 0.15s ease;
}
.module:hover {
background: color-mix(in srgb, var(--bar-primary) 20%, var(--bar-bg-module));
}
/* module-group removed (minimal waybar look uses individual modules) */
/* Launcher */
.launcher {
color: var(--bar-primary);
padding: 0.35rem 0.5rem;
}
.launcher:hover {
color: var(--bar-accent);
}
/* Workspaces */
.workspaces {
display: flex;
gap: 0.25rem;
padding: 0.2rem 0.3rem;
}
.workspace {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.3rem 0.6rem;
background: transparent;
border-radius: 4px;
color: var(--bar-muted);
font-size: 0.75rem;
font-weight: 500;
transition: all 0.15s ease;
text-decoration: none;
white-space: nowrap;
}
.workspace:hover {
background: color-mix(in srgb, var(--bar-primary) 25%, transparent);
color: var(--bar-text);
}
.workspace.active {
background: var(--bar-primary);
color: var(--bar-bg);
font-weight: 600;
}
.workspace .ws-name {
font-size: 0.75rem;
}
.workspace.external {
color: var(--bar-muted);
font-size: 0.7rem;
}
.workspace.external:hover {
color: var(--bar-accent);
}
/* Window title */
.window-title {
color: var(--bar-muted);
font-size: 0.75rem;
max-width: 200px;
overflow: hidden;
}
.title-icon {
display: flex;
color: var(--bar-primary);
}
.title-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Clock */
.clock {
background: var(--bar-bg-module);
padding: 0.35rem 0.75rem;
}
.clock .time {
font-weight: 600;
color: var(--bar-text);
}
.clock .date {
color: var(--bar-muted);
font-size: 0.7rem;
margin-left: 0.5rem;
}
/* System modules removed */
/* Theme selector */
.theme-selector {
position: relative;
padding: 0;
background: transparent;
}
.theme-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
background: var(--bar-bg-module);
border: none;
border-radius: 4px;
color: var(--bar-text);
cursor: pointer;
transition: all 0.15s ease;
margin: 5px;
}
.theme-trigger:hover {
background: color-mix(in srgb, var(--bar-primary) 25%, var(--bar-bg-module));
color: var(--bar-primary);
}
.theme-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 160px;
background: var(--bar-bg);
border: 1px solid var(--bar-border);
border-radius: 8px;
padding: 0.4rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
z-index: 1001;
}
.dropdown-header {
padding: 0.4rem 0.6rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bar-muted);
}
.dropdown-divider {
height: 1px;
background: var(--bar-border);
margin: 0.3rem 0;
}
.theme-option {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.6rem;
background: transparent;
border: none;
border-radius: 4px;
color: var(--bar-text);
font-family: inherit;
font-size: 0.75rem;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.theme-option:hover {
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
}
.theme-option.active {
color: var(--bar-primary);
}
.theme-option span {
flex: 1;
}
:global(.check) {
color: var(--bar-primary);
}
/* Backdrop */
.backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 999;
border: none;
cursor: default;
}
/* Responsive */
@media (max-width: 768px) {
.waybar {
padding: 0 0.25rem;
}
.window-title {
display: none;
}
.clock .date {
display: none;
}
}
@media (max-width: 480px) {
.workspaces {
gap: 0.15rem;
}
.workspace {
padding: 0.25rem 0.4rem;
font-size: 0.65rem;
}
.workspace .ws-name {
font-size: 0.65rem;
}
}
</style>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { T, useTask } from '@threlte/core';
import { onMount } from 'svelte';
import * as THREE from 'three';
import { themeColors, mode } from '$lib/stores/theme';
interface Props {
particleCount?: number;
}
let { particleCount = 500 }: Props = $props();
let particlesGeometry: THREE.BufferGeometry;
let particlesMaterial: THREE.PointsMaterial;
let particles = $state<THREE.Points | null>(null);
let positions: Float32Array;
let velocities: Float32Array;
let time = 0;
// Create particles
function createParticles() {
positions = new Float32Array(particleCount * 3);
velocities = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {
const i3 = i * 3;
// Spread particles in a larger area
positions[i3] = (Math.random() - 0.5) * 20;
positions[i3 + 1] = (Math.random() - 0.5) * 20;
positions[i3 + 2] = (Math.random() - 0.5) * 20;
// Random velocities
velocities[i3] = (Math.random() - 0.5) * 0.01;
velocities[i3 + 1] = (Math.random() - 0.5) * 0.01;
velocities[i3 + 2] = (Math.random() - 0.5) * 0.01;
}
particlesGeometry = new THREE.BufferGeometry();
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
particlesMaterial = new THREE.PointsMaterial({
size: 0.05,
sizeAttenuation: true,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending,
depthWrite: false
});
particles = new THREE.Points(particlesGeometry, particlesMaterial);
}
onMount(() => {
createParticles();
});
// Update particle color based on theme
$effect(() => {
if (particlesMaterial) {
const color = new THREE.Color($themeColors.primary);
particlesMaterial.color = color;
particlesMaterial.opacity = $mode === 'dark' ? 0.6 : 0.4;
}
});
// Animation loop
useTask((delta) => {
if (!particles || !particlesGeometry) return;
time += delta;
const positionAttribute = particlesGeometry.getAttribute('position') as THREE.BufferAttribute;
const positionArray = positionAttribute.array as Float32Array;
for (let i = 0; i < particleCount; i++) {
const i3 = i * 3;
// Add some wave motion
positionArray[i3] += velocities[i3] + Math.sin(time + i * 0.1) * 0.001;
positionArray[i3 + 1] += velocities[i3 + 1] + Math.cos(time + i * 0.1) * 0.001;
positionArray[i3 + 2] += velocities[i3 + 2];
// Wrap around boundaries
if (positionArray[i3] > 10) positionArray[i3] = -10;
if (positionArray[i3] < -10) positionArray[i3] = 10;
if (positionArray[i3 + 1] > 10) positionArray[i3 + 1] = -10;
if (positionArray[i3 + 1] < -10) positionArray[i3 + 1] = 10;
if (positionArray[i3 + 2] > 10) positionArray[i3 + 2] = -10;
if (positionArray[i3 + 2] < -10) positionArray[i3 + 2] = 10;
}
positionAttribute.needsUpdate = true;
// Rotate the entire particle system slowly
particles.rotation.y += delta * 0.05;
particles.rotation.x += delta * 0.02;
});
</script>
{#if particles}
<T is={particles} />
{/if}

View File

@@ -0,0 +1,350 @@
<script lang="ts">
import { onMount } from 'svelte';
import { themeColors } from '$lib/stores/theme';
interface Props {
lines?: TerminalLine[];
typeSpeed?: number;
startDelay?: number;
showCursor?: boolean;
autoType?: boolean;
class?: string;
}
interface TerminalLine {
type: 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info';
content: string;
delay?: number;
}
let {
lines = [],
typeSpeed = 30,
startDelay = 500,
showCursor = true,
autoType = true,
class: className = ''
}: Props = $props();
let displayedLines = $state<{ line: TerminalLine; text: string; complete: boolean }[]>([]);
let currentLineIndex = $state(0);
let currentCharIndex = $state(0);
let isTyping = $state(false);
let terminalElement: HTMLDivElement;
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function typeText() {
if (!autoType || lines.length === 0) {
displayedLines = lines.map(line => ({ line, text: line.content, complete: true }));
return;
}
isTyping = true;
await sleep(startDelay);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
displayedLines = [...displayedLines, { line, text: '', complete: false }];
currentLineIndex = i;
if (line.delay) {
await sleep(line.delay);
}
// Type out the line character by character
for (let j = 0; j <= line.content.length; j++) {
currentCharIndex = j;
displayedLines[i] = {
line,
text: line.content.slice(0, j),
complete: j === line.content.length
};
// Scroll to bottom
if (terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
if (j < line.content.length) {
await sleep(line.type === 'output' ? typeSpeed / 3 : typeSpeed);
}
}
displayedLines[i].complete = true;
// Pause between lines
if (i < lines.length - 1) {
await sleep(150);
}
}
isTyping = false;
}
onMount(() => {
typeText();
});
function getLinePrefix(type: TerminalLine['type']): string {
switch (type) {
case 'command':
case 'prompt':
return '';
case 'error':
return '✗ ';
case 'success':
return '✓ ';
case 'info':
return ' ';
default:
return '';
}
}
</script>
<div
class="terminal {className}"
style="
--terminal-bg: {$themeColors.terminal};
--terminal-text: {$themeColors.text};
--terminal-muted: {$themeColors.textMuted};
--terminal-border: {$themeColors.border};
--terminal-prompt: {$themeColors.terminalPrompt};
--terminal-user: {$themeColors.terminalUser};
--terminal-path: {$themeColors.terminalPath};
--terminal-primary: {$themeColors.primary};
--terminal-accent: {$themeColors.accent};
"
bind:this={terminalElement}
>
<!-- Terminal Header -->
<div class="terminal-header">
<div class="terminal-buttons">
<span class="btn close"></span>
<span class="btn minimize"></span>
<span class="btn maximize"></span>
</div>
<div class="terminal-title">
<span class="terminal-icon">🐧</span>
<span>arch@terminal: ~</span>
</div>
<div class="terminal-spacer"></div>
</div>
<!-- Terminal Body -->
<div class="terminal-body">
{#each displayedLines as { line, text, complete }, i}
<div class="terminal-line {line.type}">
{#if line.type === 'command' || line.type === 'prompt'}
<span class="prompt">
<span class="user">user@arch</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
{/if}
<span class="content">
{getLinePrefix(line.type)}{text}
</span>
{#if showCursor && i === currentLineIndex && !complete && isTyping}
<span class="cursor"></span>
{/if}
</div>
{/each}
{#if showCursor && !isTyping && displayedLines.length > 0}
<div class="terminal-line prompt">
<span class="prompt">
<span class="user">user@arch</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
<span class="cursor"></span>
</div>
{/if}
</div>
</div>
<style>
.terminal {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: 12px;
overflow: hidden;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: terminalFadeIn 0.5s ease-out;
}
@keyframes terminalFadeIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.terminal-header {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
border-bottom: 1px solid var(--terminal-border);
}
.terminal-buttons {
display: flex;
gap: 8px;
}
.terminal-buttons .btn {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
transition: opacity 0.2s;
}
.terminal-buttons .btn:hover {
opacity: 0.8;
}
.terminal-buttons .close {
background: #ff5f56;
}
.terminal-buttons .minimize {
background: #ffbd2e;
}
.terminal-buttons .maximize {
background: #27ca40;
}
.terminal-title {
flex: 1;
text-align: center;
font-size: 0.8rem;
color: var(--terminal-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.terminal-icon {
font-size: 1rem;
}
.terminal-spacer {
width: 52px;
}
.terminal-body {
padding: 1rem 1.25rem;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.6;
}
.terminal-line {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.25rem;
animation: lineSlideIn 0.2s ease-out;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.prompt {
display: inline-flex;
margin-right: 0.5rem;
flex-shrink: 0;
}
.prompt .user {
color: var(--terminal-user);
}
.prompt .separator {
color: var(--terminal-text);
}
.prompt .path {
color: var(--terminal-path);
}
.prompt .symbol {
color: var(--terminal-text);
margin-left: 0.25rem;
}
.content {
color: var(--terminal-text);
word-break: break-word;
}
.terminal-line.output .content {
color: var(--terminal-muted);
}
.terminal-line.error .content {
color: #ff6b6b;
}
.terminal-line.success .content {
color: #51cf66;
}
.terminal-line.info .content {
color: var(--terminal-primary);
}
.cursor {
display: inline-block;
width: 8px;
height: 1.2em;
background: var(--terminal-primary);
animation: cursorBlink 1s step-end infinite;
margin-left: 2px;
vertical-align: middle;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Scrollbar styling */
.terminal-body::-webkit-scrollbar {
width: 8px;
}
.terminal-body::-webkit-scrollbar-track {
background: transparent;
}
.terminal-body::-webkit-scrollbar-thumb {
background: var(--terminal-border);
border-radius: 4px;
}
.terminal-body::-webkit-scrollbar-thumb:hover {
background: var(--terminal-muted);
}
</style>

View File

@@ -0,0 +1,445 @@
<script lang="ts">
import { onMount } from 'svelte';
import { themeColors } from '$lib/stores/theme';
import { user, terminalSettings } from '$lib/config';
import { calculateTypeSpeed } from '$lib';
export type LineType = 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'image' | 'blank' | 'header';
export interface TerminalLine {
type: LineType;
content: string;
delay?: number;
image?: string; // URL for image type
imageAlt?: string;
imageWidth?: number; // max width in pixels
}
interface Props {
lines?: TerminalLine[];
title?: string;
showHeader?: boolean;
class?: string;
onComplete?: () => void;
}
let {
lines = [],
title = 'terminal',
showHeader = true,
class: className = '',
onComplete
}: Props = $props();
let displayedLines = $state<{ line: TerminalLine; text: string; complete: boolean; showImage: boolean }[]>([]);
let currentLineIndex = $state(0);
let isTyping = $state(false);
let isComplete = $state(false);
let terminalElement: HTMLDivElement;
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function typeText() {
if (lines.length === 0) return;
isTyping = true;
await sleep(terminalSettings.startDelay);
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
displayedLines = [...displayedLines, { line, text: '', complete: false, showImage: false }];
currentLineIndex = i;
if (line.delay) {
await sleep(line.delay);
}
// Handle different line types
if (line.type === 'image') {
// For images, show immediately after a brief pause
await sleep(100);
displayedLines[i] = { line, text: line.content, complete: true, showImage: true };
} else if (line.type === 'blank') {
// Blank lines are instant
displayedLines[i] = { line, text: '', complete: true, showImage: false };
} else if (line.type === 'header') {
// Headers type out faster
const typeSpeed = 5;
for (let j = 0; j <= line.content.length; j++) {
displayedLines[i] = {
line,
text: line.content.slice(0, j),
complete: j === line.content.length,
showImage: false
};
if (j < line.content.length) await sleep(typeSpeed);
}
} else {
// Calculate typing speed based on content length
const typeSpeed = calculateTypeSpeed(line.content.length);
// Type out the line character by character
for (let j = 0; j <= line.content.length; j++) {
displayedLines[i] = {
line,
text: line.content.slice(0, j),
complete: j === line.content.length,
showImage: false
};
// Scroll to bottom
if (terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
if (j < line.content.length) {
await sleep(typeSpeed);
}
}
}
displayedLines[i].complete = true;
// Scroll to bottom after each line
if (terminalElement) {
terminalElement.scrollTop = terminalElement.scrollHeight;
}
// Pause between lines
if (i < lines.length - 1) {
await sleep(terminalSettings.lineDelay);
}
}
isTyping = false;
isComplete = true;
onComplete?.();
}
onMount(() => {
typeText();
});
function getLinePrefix(type: LineType): string {
switch (type) {
case 'error':
return '✗ ';
case 'success':
return '✓ ';
case 'info':
return ' ';
default:
return '';
}
}
function getPromptString(): string {
return `${user.username}@${user.hostname}`;
}
</script>
<div
class="terminal-page {className}"
style="
--terminal-bg: {$themeColors.terminal};
--terminal-text: {$themeColors.text};
--terminal-muted: {$themeColors.textMuted};
--terminal-border: {$themeColors.border};
--terminal-prompt: {$themeColors.terminalPrompt};
--terminal-user: {$themeColors.terminalUser};
--terminal-path: {$themeColors.terminalPath};
--terminal-primary: {$themeColors.primary};
--terminal-accent: {$themeColors.accent};
--terminal-bg-light: {$themeColors.backgroundLight};
"
bind:this={terminalElement}
>
{#if showHeader}
<!-- Terminal Header -->
<div class="terminal-header">
<div class="terminal-buttons">
<span class="btn close"></span>
<span class="btn minimize"></span>
<span class="btn maximize"></span>
</div>
<div class="terminal-title">
<span class="terminal-icon">🐧</span>
<span>{getPromptString()}: {title}</span>
</div>
<div class="terminal-spacer"></div>
</div>
{/if}
<!-- Terminal Body -->
<div class="terminal-body">
{#each displayedLines as { line, text, complete, showImage }, i}
<div class="terminal-line {line.type}" class:complete>
{#if line.type === 'command' || line.type === 'prompt'}
<span class="prompt">
<span class="user">{user.username}@{user.hostname}</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
{/if}
{#if line.type === 'image' && showImage}
<div class="terminal-image">
<img
src={line.image}
alt={line.imageAlt || 'Terminal 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">{text}</span>
{:else if line.type !== 'blank'}
<span class="content">
{getLinePrefix(line.type)}{text}
</span>
{/if}
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
<span class="cursor"></span>
{/if}
</div>
{/each}
{#if terminalSettings.showCursor && !isTyping && isComplete}
<div class="terminal-line prompt">
<span class="prompt">
<span class="user">{user.username}@{user.hostname}</span><span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
<span class="cursor"></span>
</div>
{/if}
</div>
</div>
<style>
.terminal-page {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: 12px;
overflow: hidden;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: terminalFadeIn 0.5s ease-out;
display: flex;
flex-direction: column;
height: 100%;
}
@keyframes terminalFadeIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.terminal-header {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
background: var(--terminal-bg-light);
border-bottom: 1px solid var(--terminal-border);
flex-shrink: 0;
}
.terminal-buttons {
display: flex;
gap: 8px;
}
.terminal-buttons .btn {
width: 12px;
height: 12px;
border-radius: 50%;
cursor: pointer;
transition: opacity 0.2s;
}
.terminal-buttons .btn:hover {
opacity: 0.8;
}
.terminal-buttons .close {
background: #ff5f56;
}
.terminal-buttons .minimize {
background: #ffbd2e;
}
.terminal-buttons .maximize {
background: #27ca40;
}
.terminal-title {
flex: 1;
text-align: center;
font-size: 0.8rem;
color: var(--terminal-muted);
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.terminal-icon {
font-size: 1rem;
}
.terminal-spacer {
width: 52px;
}
.terminal-body {
padding: 1rem 1.25rem;
flex: 1;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.6;
}
.terminal-line {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
margin-bottom: 0.25rem;
animation: lineSlideIn 0.2s ease-out;
min-height: 1.6em;
}
.terminal-line.blank {
min-height: 0.8em;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-10px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.prompt {
display: inline-flex;
margin-right: 0.5rem;
flex-shrink: 0;
}
.prompt .user {
color: var(--terminal-user);
}
.prompt .separator {
color: var(--terminal-text);
}
.prompt .path {
color: var(--terminal-path);
}
.prompt .symbol {
color: var(--terminal-text);
margin-left: 0.25rem;
}
.content {
color: var(--terminal-text);
word-break: break-word;
white-space: pre-wrap;
}
.terminal-line.output .content {
color: var(--terminal-muted);
}
.terminal-line.error .content {
color: #f38ba8;
}
.terminal-line.success .content {
color: #a6e3a1;
}
.terminal-line.info .content {
color: var(--terminal-primary);
}
.terminal-line.header .content {
color: var(--terminal-accent);
font-weight: 600;
font-size: 1rem;
}
.header-text {
color: var(--terminal-accent);
font-weight: 600;
}
.terminal-image {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
.terminal-image img {
border-radius: 8px;
border: 1px solid var(--terminal-border);
background: var(--terminal-bg-light);
object-fit: contain;
}
.image-caption {
color: var(--terminal-muted);
font-size: 0.8rem;
font-style: italic;
}
.cursor {
display: inline-block;
width: 8px;
height: 1.2em;
background: var(--terminal-primary);
animation: cursorBlink 1s step-end infinite;
margin-left: 2px;
vertical-align: middle;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Scrollbar styling */
.terminal-body::-webkit-scrollbar {
width: 8px;
}
.terminal-body::-webkit-scrollbar-track {
background: transparent;
}
.terminal-body::-webkit-scrollbar-thumb {
background: var(--terminal-border);
border-radius: 4px;
}
.terminal-body::-webkit-scrollbar-thumb:hover {
background: var(--terminal-muted);
}
</style>

View File

@@ -0,0 +1,421 @@
<script lang="ts">
import { onMount } from 'svelte';
import { themeColors } from '$lib/stores/theme';
import { toggleMode } from '$lib/stores/theme';
import { terminalSettings, type SpeedPreset, speedPresets } from '$lib/config';
import { calculateTypeSpeed } from '$lib';
import TuiHeader from './tui/TuiHeader.svelte';
import TuiBody from './tui/TuiBody.svelte';
import TuiFooter from './tui/TuiFooter.svelte';
import { parseColorText, getPlainText } from './tui/utils';
import type { TerminalLine, ParsedLine, DisplayedLine } from './tui/types';
interface Props {
lines?: TerminalLine[];
title?: string;
class?: string;
onComplete?: () => void;
interactive?: boolean;
speed?: SpeedPreset | number;
}
let {
lines = [],
title = 'terminal',
class: className = '',
onComplete,
interactive = true,
speed = 'normal'
}: Props = $props();
// Calculate speed multiplier from preset or number
const speedMultiplier = $derived(
typeof speed === 'number' ? speed : (speedPresets[speed] ?? 1)
);
// Pre-parse all lines upfront (segments + plain text)
const parsedLines = $derived<ParsedLine[]>(
lines.map(line => {
const segments = parseColorText(line.content);
return {
line,
segments,
plainText: getPlainText(segments)
};
})
);
let displayedLines = $state<DisplayedLine[]>([]);
let currentLineIndex = $state(0);
let isTyping = $state(false);
let isComplete = $state(false);
let selectedIndex = $state(-1);
let terminalElement: HTMLDivElement;
let bodyElement = $state<HTMLDivElement>();
// Autoscroll to bottom
function scrollToBottom() {
if (bodyElement) {
bodyElement.scrollTo({
top: bodyElement.scrollHeight,
behavior: 'smooth'
});
}
}
// Get all interactive button indices
let buttonIndices = $derived(
displayedLines
.map((item, i) => item.parsed.line.type === 'button' ? i : -1)
.filter(i => i !== -1)
);
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function typeText() {
if (parsedLines.length === 0) return;
isTyping = true;
// Apply speed multiplier to start delay
const startDelayMs = speedMultiplier === 0 ? 0 : terminalSettings.startDelay * speedMultiplier;
await sleep(startDelayMs);
if (skipRequested) return;
for (let i = 0; i < parsedLines.length; i++) {
if (skipRequested) return;
const parsed = parsedLines[i];
const line = parsed.line;
const plainLength = parsed.plainText.length;
displayedLines = [...displayedLines, { parsed, charIndex: 0, complete: false, showImage: false }];
currentLineIndex = i;
if (line.delay && speedMultiplier > 0) {
await sleep(line.delay * speedMultiplier);
if (skipRequested) return;
}
// Handle different line types
if (line.type === 'image') {
await sleep(speedMultiplier === 0 ? 0 : 100 * speedMultiplier);
if (skipRequested) return;
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: true };
scrollToBottom();
} else if (line.type === 'blank' || line.type === 'divider') {
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
} else if (line.type === 'button') {
// Buttons appear instantly
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
scrollToBottom();
} else if (speedMultiplier === 0) {
// Instant mode - no typing animation
displayedLines[i] = { parsed, charIndex: plainLength, complete: true, showImage: false };
if (i % 5 === 0) scrollToBottom();
} else if (line.type === 'header') {
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier * 0.25);
for (let j = 0; j <= plainLength; j++) {
if (skipRequested) return;
displayedLines[i] = {
parsed,
charIndex: j,
complete: j === plainLength,
showImage: false
};
if (j < plainLength) await sleep(typeSpeed);
}
scrollToBottom();
} else {
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier);
for (let j = 0; j <= plainLength; j++) {
if (skipRequested) return;
displayedLines[i] = {
parsed,
charIndex: j,
complete: j === plainLength,
showImage: false
};
if (j % 10 === 0) scrollToBottom();
if (j < plainLength) {
await sleep(typeSpeed);
}
}
}
if (skipRequested) return;
displayedLines[i].complete = true;
scrollToBottom();
if (i < parsedLines.length - 1 && speedMultiplier > 0) {
await sleep(terminalSettings.lineDelay * speedMultiplier);
}
}
isTyping = false;
isComplete = true;
if (interactive && buttonIndices.length > 0) {
selectedIndex = buttonIndices[0];
}
onComplete?.();
}
// Scroll selected button into view with margin for context
function scrollToSelected() {
if (bodyElement && selectedIndex >= 0) {
const buttons = bodyElement.querySelectorAll('.tui-button');
const selectedButton = Array.from(buttons).find((_, i) => {
const btnIndices = displayedLines
.map((item, idx) => item.parsed.line.type === 'button' ? idx : -1)
.filter(idx => idx !== -1);
return btnIndices[i] === selectedIndex;
}) as HTMLElement | undefined;
if (selectedButton && bodyElement) {
const containerRect = bodyElement.getBoundingClientRect();
const buttonRect = selectedButton.getBoundingClientRect();
const margin = 80;
if (buttonRect.top < containerRect.top + margin) {
const scrollAmount = buttonRect.top - containerRect.top - margin;
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
}
else if (buttonRect.bottom > containerRect.bottom - margin) {
const scrollAmount = buttonRect.bottom - containerRect.bottom + margin;
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
}
}
}
}
// Skip animation and show all content instantly
let skipRequested = $state(false);
function skipAnimation() {
if (!isTyping) return;
skipRequested = true;
displayedLines = parsedLines.map(parsed => ({
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: parsed.line.type === 'image'
}));
isTyping = false;
isComplete = true;
currentLineIndex = parsedLines.length - 1;
if (interactive && buttonIndices.length > 0) {
selectedIndex = buttonIndices[0];
}
scrollToBottom();
onComplete?.();
}
function handleKeydown(event: KeyboardEvent) {
// Toggle theme with T key
if (event.key === 't' || event.key === 'T') {
event.preventDefault();
toggleMode();
return;
}
if (isTyping && (event.key === 'y' || event.key === 'Y')) {
event.preventDefault();
skipAnimation();
return;
}
if (!interactive || !isComplete || buttonIndices.length === 0) return;
const currentButtonIdx = buttonIndices.indexOf(selectedIndex);
if (event.key === 'ArrowDown' || event.key === 'j') {
event.preventDefault();
const nextIdx = (currentButtonIdx + 1) % buttonIndices.length;
selectedIndex = buttonIndices[nextIdx];
scrollToSelected();
} else if (event.key === 'ArrowUp' || event.key === 'k') {
event.preventDefault();
const prevIdx = (currentButtonIdx - 1 + buttonIndices.length) % buttonIndices.length;
selectedIndex = buttonIndices[prevIdx];
scrollToSelected();
} else if (event.key === 'Enter') {
event.preventDefault();
const selectedLine = displayedLines[selectedIndex]?.parsed.line;
if (selectedLine?.action) {
selectedLine.action();
} else if (selectedLine?.href) {
const isExternal = selectedLine.external || selectedLine.href.startsWith('http://') || selectedLine.href.startsWith('https://');
if (isExternal) {
window.open(selectedLine.href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = selectedLine.href;
}
}
}
}
function handleButtonClick(index: number) {
selectedIndex = index;
const line = displayedLines[index]?.parsed.line;
if (line?.action) {
line.action();
} else if (line?.href) {
const isExternal = line.external || line.href.startsWith('http://') || line.href.startsWith('https://');
if (isExternal) {
window.open(line.href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = line.href;
}
}
}
function handleLinkClick(index: number) {
const line = displayedLines[index]?.parsed.line;
if (line?.action) {
line.action();
} else if (line?.href) {
const isExternal = line.external || line.href.startsWith('http://') || line.href.startsWith('https://');
if (isExternal) {
window.open(line.href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = line.href;
}
}
}
onMount(() => {
typeText();
});
</script>
<svelte:window on:keydown={handleKeydown} />
<div
class="tui-terminal {className}"
style="
--terminal-bg: {$themeColors.terminal};
--terminal-text: {$themeColors.text};
--terminal-muted: {$themeColors.textMuted};
--terminal-border: {$themeColors.border};
--terminal-prompt: {$themeColors.terminalPrompt};
--terminal-user: {$themeColors.terminalUser};
--terminal-path: {$themeColors.terminalPath};
--terminal-primary: {$themeColors.primary};
--terminal-accent: {$themeColors.accent};
--terminal-bg-light: {$themeColors.backgroundLight};
"
bind:this={terminalElement}
role="region"
aria-label="Terminal interface"
>
<!-- Hyprland-style border glow -->
<div class="tui-border-glow"></div>
<!-- TUI Content -->
<div class="tui-content">
<TuiHeader {title} {interactive} hasButtons={buttonIndices.length > 0} />
<!-- Main terminal area -->
<TuiBody bind:ref={bodyElement}
{displayedLines}
{currentLineIndex}
{isTyping}
{selectedIndex}
onButtonClick={handleButtonClick}
onHoverButton={(i) => selectedIndex = i}
onLinkClick={handleLinkClick}
terminalSettings={terminalSettings}
/>
<TuiFooter isTyping={isTyping} linesCount={displayedLines.length} skipAnimation={skipAnimation} />
</div>
</div>
<style>
.tui-terminal {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
background: var(--terminal-bg);
border: 2px solid var(--terminal-border);
border-radius: 8px;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
width: 95%;
margin: 0 auto;
height: calc(100vh - var(--navbar-height) - 80px);
max-height: calc(100vh - var(--navbar-height) - 80px);
animation: tuiFadeIn 0.4s ease-out;
}
.tui-terminal:focus-within .tui-border-glow {
opacity: 1;
}
@keyframes tuiFadeIn {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Hyprland-style animated border glow */
.tui-border-glow {
position: absolute;
inset: -2px;
border-radius: 10px;
background: linear-gradient(
45deg,
var(--terminal-primary),
var(--terminal-accent),
var(--terminal-primary),
var(--terminal-accent)
);
background-size: 400% 400%;
animation: borderGlow 8s ease infinite;
opacity: 0.5;
z-index: -1;
filter: blur(4px);
transition: opacity 0.3s ease;
}
@keyframes borderGlow {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.tui-content {
display: flex;
flex-direction: column;
height: 100%;
background: var(--terminal-bg);
position: relative;
z-index: 1;
}
/* Responsive */
@media (max-width: 768px) {
.tui-terminal {
width: 95%;
height: calc(100vh - var(--navbar-height) - 60px);
max-height: calc(100vh - var(--navbar-height) - 60px);
}
}
</style>

View File

@@ -0,0 +1,119 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
let openItems: Set<number> = new Set(line.accordionOpen ? [0] : []);
function toggleItem(index: number) {
if (openItems.has(index)) {
openItems.delete(index);
} else {
openItems.add(index);
}
openItems = openItems; // trigger reactivity
}
$: items = line.accordionItems || [{ title: line.content, content: '' }];
</script>
<div class="tui-accordion" style="--accordion-accent: {getButtonStyle(line.style)}">
{#each items as item, i}
{@const contentSegments = parseColorText(item.content)}
<div class="accordion-item" class:open={openItems.has(i)}>
<button class="accordion-header" on:click={() => toggleItem(i)}>
<Icon
icon={openItems.has(i) ? 'mdi:chevron-down' : 'mdi:chevron-right'}
width="16"
class="accordion-icon"
/>
<span class="accordion-title">{item.title}</span>
</button>
{#if openItems.has(i)}
<div class="accordion-content">
{#each contentSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
{/if}
</div>
{/each}
</div>
<style>
.tui-accordion {
margin: 0.5rem 0;
border: 1px solid var(--terminal-border);
border-radius: 6px;
overflow: hidden;
}
.accordion-item {
border-bottom: 1px solid var(--terminal-border);
}
.accordion-item:last-child {
border-bottom: none;
}
.accordion-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: transparent;
border: none;
color: var(--terminal-text);
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.accordion-header:hover {
background: rgba(255, 255, 255, 0.03);
}
.accordion-item.open .accordion-header {
background: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--terminal-border);
}
:global(.accordion-icon) {
color: var(--accordion-accent);
transition: transform 0.2s ease;
}
.accordion-title {
flex: 1;
}
.accordion-content {
padding: 0.75rem;
color: var(--terminal-muted);
font-size: 0.85rem;
line-height: 1.6;
white-space: pre-wrap;
animation: slideDown 0.2s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,306 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getSegmentStyle, getLinePrefix, getSegmentsUpToChar } from './utils';
import { user } from '$lib/config';
import TuiButton from './TuiButton.svelte';
import TuiLink from './TuiLink.svelte';
import TuiCard from './TuiCard.svelte';
import TuiProgress from './TuiProgress.svelte';
import TuiAccordion from './TuiAccordion.svelte';
import TuiTable from './TuiTable.svelte';
import TuiTooltip from './TuiTooltip.svelte';
import TuiCardGrid from './TuiCardGrid.svelte';
import type { DisplayedLine } from './types';
export let displayedLines: DisplayedLine[] = [];
export let currentLineIndex = 0;
export let isTyping = false;
export let selectedIndex = -1;
export let ref: HTMLDivElement | undefined;
export let onButtonClick: (idx: number) => void;
export let onHoverButton: (idx: number) => void;
export let onLinkClick: (idx: number) => void;
export let terminalSettings: any;
</script>
<div class="tui-body" bind:this={ref}>
{#each displayedLines as { parsed, charIndex, complete, showImage }, i}
{@const line = parsed.line}
{@const visibleSegments = getSegmentsUpToChar(parsed.segments, charIndex)}
{#if line.type === 'divider'}
<div class="tui-divider">
<span class="divider-line"></span>
{#if line.content}
<span class="divider-text">{line.content}</span>
{/if}
<span class="divider-line"></span>
</div>
{:else if line.type === 'button'}
<TuiButton {line} index={i} selected={selectedIndex === i} onClick={onButtonClick} onHover={onHoverButton} />
{:else if line.type === 'link'}
<div class="tui-line link">
<TuiLink {line} onClick={() => onLinkClick(i)} />
</div>
{:else if line.type === 'card'}
<TuiCard {line} />
{:else if line.type === 'cardgrid'}
<TuiCardGrid {line} />
{:else if line.type === 'progress'}
<TuiProgress {line} />
{:else if line.type === 'accordion'}
<TuiAccordion {line} />
{:else if line.type === 'table'}
<TuiTable {line} />
{:else if line.type === 'tooltip'}
<div class="tui-line">
<TuiTooltip {line} />
</div>
{:else}
<div class="tui-line {line.type}" class:complete>
{#if line.type === 'command' || line.type === 'prompt'}
<span class="prompt">
<span class="user">{user.username}</span><span class="at">@</span><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
{/if}
{#if line.type === 'image' && showImage}
<div class="tui-image">
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
{#if line.content}
<span class="image-caption">{line.content}</span>
{/if}
</div>
{:else if line.type === 'header'}
<span class="content header-text">
<Icon icon="mdi:pound" width="14" class="header-icon" />
{#each visibleSegments as segment}
{#if segment.icon}
<Icon icon={segment.icon} width="16" class="inline-icon" />
{:else if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
{:else if line.type !== 'blank'}
<span class="content">
{getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
</span>
{/if}
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
<span class="cursor"></span>
{/if}
</div>
{/if}
{/each}
{#if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
<div class="tui-line prompt">
<span class="prompt">
<span class="user">{user.username}</span><span class="at">@</span><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span class="symbol">$</span>
</span>
<span class="cursor"></span>
</div>
{/if}
</div>
<style>
.tui-body {
flex: 1;
padding: 1rem 1.25rem 2rem 1.25rem;
overflow-y: auto;
overflow-x: hidden;
font-size: 0.9rem;
line-height: 1.7;
min-height: 0;
}
/* Lines */
.tui-line {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
margin-bottom: 0.2rem;
animation: lineSlideIn 0.15s ease-out;
min-height: 1.7em;
}
.tui-line.blank {
min-height: 0.5em;
}
@keyframes lineSlideIn {
from {
opacity: 0;
transform: translateX(-5px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Prompt styling */
.prompt {
display: inline-flex;
margin-right: 0.5rem;
flex-shrink: 0;
}
.prompt .user {
color: var(--terminal-user);
}
.prompt .at {
color: var(--terminal-muted);
}
.prompt .host {
color: var(--terminal-accent);
}
.prompt .separator {
color: var(--terminal-muted);
}
.prompt .path {
color: var(--terminal-path);
}
.prompt .symbol {
color: var(--terminal-muted);
margin-left: 0.25rem;
}
/* Content */
.content {
color: var(--terminal-text);
word-break: break-word;
white-space: pre-wrap;
}
.tui-line.output .content {
color: var(--terminal-muted);
}
.tui-line.error .content {
color: #f38ba8;
}
.tui-line.success .content {
color: #a6e3a1;
}
.tui-line.info .content {
color: var(--terminal-primary);
}
.tui-line.warning .content {
color: #f9e2af;
}
.header-text {
color: var(--terminal-accent);
font-weight: 600;
font-size: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
:global(.header-icon) {
opacity: 0.7;
}
:global(.inline-icon) {
display: inline-block;
vertical-align: middle;
margin: 0 0.15em;
}
/* Divider */
.tui-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.75rem 0;
}
.divider-line {
flex: 1;
height: 1px;
background: linear-gradient(
to right,
transparent,
var(--terminal-border),
transparent
);
}
.divider-text {
color: var(--terminal-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
/* Images */
.tui-image {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem 0;
}
.tui-image img {
border-radius: 6px;
border: 1px solid var(--terminal-border);
background: var(--terminal-bg-light);
object-fit: contain;
}
.image-caption {
color: var(--terminal-muted);
font-size: 0.8rem;
font-style: italic;
}
/* Cursor */
.cursor {
display: inline-block;
width: 8px;
height: 1.2em;
background: var(--terminal-primary);
animation: cursorBlink 1s step-end infinite;
margin-left: 2px;
vertical-align: middle;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Scrollbar */
.tui-body::-webkit-scrollbar {
width: 6px;
}
.tui-body::-webkit-scrollbar-track {
background: transparent;
}
.tui-body::-webkit-scrollbar-thumb {
background: var(--terminal-border);
border-radius: 3px;
}
.tui-body::-webkit-scrollbar-thumb:hover {
background: var(--terminal-muted);
}
</style>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
export let index: number;
export let selected: boolean;
export let onClick: (idx: number) => void;
export let onHover: (idx: number) => void;
// Determine if this is an external link
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
</script>
<button
class="tui-button"
class:selected={selected}
style="--btn-color: {getButtonStyle(line.style)}"
on:click={() => onClick(index)}
on:mouseenter={() => onHover(index)}
>
<span class="btn-indicator">{selected ? '▶' : ' '}</span>
{#if line.icon}
<Icon icon={line.icon} width="16" />
{/if}
<span class="btn-text">{line.content}</span>
{#if line.href}
{#if isExternal}
<Icon icon="mdi:open-in-new" width="14" class="btn-arrow" />
{:else}
<Icon icon="mdi:chevron-right" width="16" class="btn-arrow" />
{/if}
{/if}
</button>
<style>
.tui-button {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
margin: 0.2rem 0;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--btn-color);
font-family: inherit;
font-size: 0.9rem;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.tui-button:hover,
.tui-button.selected {
background: color-mix(in srgb, var(--btn-color) 15%, transparent);
border-color: var(--btn-color);
}
.btn-indicator {
color: var(--btn-color);
font-size: 0.8rem;
width: 1rem;
}
.btn-text {
flex: 1;
}
:global(.btn-arrow) {
opacity: 0.5;
}
.tui-button.selected :global(.btn-arrow) {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
$: segments = parseColorText(line.content);
$: titleSegments = line.cardTitle ? parseColorText(line.cardTitle) : [];
$: footerSegments = line.cardFooter ? parseColorText(line.cardFooter) : [];
</script>
<div class="tui-card" style="--card-accent: {getButtonStyle(line.style)}">
{#if line.cardTitle}
<div class="card-header">
{#if line.icon}
<Icon icon={line.icon} width="16" class="card-icon" />
{/if}
<span class="card-title">
{#each titleSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
</div>
{/if}
<div class="card-body">
{#if line.image}
<img src={line.image} alt={line.imageAlt || ''} class="card-image" />
{/if}
<div class="card-content">
{#each segments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
</div>
{#if line.cardFooter}
<div class="card-footer">
{#each footerSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</div>
{/if}
</div>
<style>
.tui-card {
border: 1px solid var(--terminal-border);
border-radius: 6px;
background: color-mix(in srgb, var(--terminal-bg) 80%, var(--card-accent) 5%);
margin: 0.5rem 0;
overflow: hidden;
transition: border-color 0.2s ease;
}
.tui-card:hover {
border-color: var(--card-accent);
}
.card-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid var(--terminal-border);
background: rgba(0, 0, 0, 0.1);
}
:global(.card-icon) {
color: var(--card-accent);
}
.card-title {
font-weight: 600;
color: var(--terminal-text);
font-size: 0.85rem;
}
.card-body {
padding: 0.75rem;
}
.card-image {
width: 100%;
max-height: 200px;
object-fit: cover;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.card-content {
color: var(--terminal-muted);
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
}
.card-footer {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--terminal-border);
font-size: 0.75rem;
color: var(--terminal-muted);
background: rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,325 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { TerminalLine } from './types';
export let line: TerminalLine;
$: cards = line.cards || [];
</script>
<div class="tui-card-grid">
{#each cards as card}
<article class="tui-card" class:featured={card.featured}>
{#if card.image}
<div class="card-image">
<img src={card.image} alt={card.title} loading="lazy" />
{#if card.featured}
<span class="featured-badge">★ Featured</span>
{/if}
</div>
{:else if card.featured}
<div class="card-header-badge">
<span class="featured-badge">★ Featured</span>
</div>
{/if}
<div class="card-body">
<h3 class="card-title">{card.title}</h3>
{#if card.hackathonName}
<div class="card-meta">
<span class="meta-icon">🏛️</span>
<span class="hackathon-name">{card.hackathonName}</span>
{#if card.year}<span class="year">({card.year})</span>{/if}
</div>
{/if}
{#if card.university}
<div class="card-location">
<span class="meta-icon">📍</span>
{card.university}{card.location ? `, ${card.location}` : ''}
</div>
{/if}
{#if card.awards && card.awards.length > 0}
<div class="awards">
{#each card.awards as award}
<div class="award">
<span class="award-icon">🏆</span>
<span class="award-text">{award.place}{award.track}</span>
</div>
{/each}
</div>
{/if}
<p class="card-desc">{card.description}</p>
{#if card.tags && card.tags.length > 0}
<div class="tags">
{#each card.tags.slice(0, 5) as tag}
<span class="tag">{tag}</span>
{/each}
{#if card.tags.length > 5}
<span class="tag more">+{card.tags.length - 5}</span>
{/if}
</div>
{/if}
{#if card.liveWarning}
<div class="warning">⚠ Demo may be unavailable</div>
{/if}
<div class="card-actions">
{#if card.link}
<a href={card.link} target="_blank" rel="noopener noreferrer" class="action-btn primary">
<Icon icon="mdi:open-in-new" width="12" />
<span>Demo</span>
</a>
{/if}
{#if card.repo}
<a href={card.repo} target="_blank" rel="noopener noreferrer" class="action-btn">
<Icon icon="mdi:github" width="12" />
<span>Code</span>
</a>
{/if}
{#if card.devpost}
<a href={card.devpost} target="_blank" rel="noopener noreferrer" class="action-btn">
<Icon icon="mdi:rocket-launch" width="12" />
<span>Devpost</span>
</a>
{/if}
</div>
</div>
</article>
{/each}
</div>
<style>
.tui-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
padding: 0.5rem 0;
width: 100%;
}
.tui-card {
background: var(--terminal-bg);
border: 1px solid var(--terminal-border);
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
transition: all 0.2s ease;
font-family: 'JetBrains Mono', monospace;
}
.tui-card:hover {
border-color: var(--terminal-primary);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.tui-card.featured {
border-color: var(--terminal-accent);
}
.card-image {
position: relative;
width: 100%;
height: 140px;
overflow: hidden;
background: var(--terminal-bg-light);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.tui-card:hover .card-image img {
transform: scale(1.05);
}
.card-header-badge {
padding: 0.5rem 0.75rem 0;
}
.featured-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(0, 0, 0, 0.75);
color: var(--terminal-accent);
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
backdrop-filter: blur(4px);
}
.card-header-badge .featured-badge {
position: static;
display: inline-block;
}
.card-body {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
flex: 1;
}
.card-title {
margin: 0;
font-size: 0.9rem;
font-weight: 600;
color: var(--terminal-text);
line-height: 1.3;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--terminal-primary);
}
.meta-icon {
font-size: 0.75rem;
}
.hackathon-name {
font-weight: 500;
}
.year {
color: var(--terminal-muted);
}
.card-location {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.65rem;
color: var(--terminal-muted);
}
.awards {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin: 0.25rem 0;
}
.award {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
}
.award-icon {
font-size: 0.75rem;
}
.award-text {
color: #a6e3a1;
font-weight: 500;
}
.card-desc {
margin: 0;
font-size: 0.7rem;
color: var(--terminal-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: auto;
}
.tag {
background: color-mix(in srgb, var(--terminal-primary) 15%, transparent);
color: var(--terminal-primary);
padding: 0.1rem 0.35rem;
border-radius: 3px;
font-size: 0.55rem;
font-weight: 500;
text-transform: lowercase;
}
.tag.more {
background: color-mix(in srgb, var(--terminal-muted) 20%, transparent);
color: var(--terminal-muted);
}
.warning {
font-size: 0.6rem;
color: #f9e2af;
padding: 0.2rem 0.35rem;
background: rgba(249, 226, 175, 0.1);
border-radius: 3px;
width: fit-content;
}
.card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--terminal-border);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid var(--terminal-border);
border-radius: 4px;
color: var(--terminal-text);
font-size: 0.65rem;
font-weight: 500;
text-decoration: none;
transition: all 0.15s ease;
font-family: inherit;
}
.action-btn:hover {
background: var(--terminal-primary);
border-color: var(--terminal-primary);
color: var(--terminal-bg);
}
.action-btn.primary {
background: var(--terminal-primary);
border-color: var(--terminal-primary);
color: var(--terminal-bg);
}
.action-btn.primary:hover {
background: var(--terminal-accent);
border-color: var(--terminal-accent);
}
@media (max-width: 600px) {
.tui-card-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import Icon from '@iconify/svelte';
export let isTyping: boolean;
export let linesCount: number;
export let skipAnimation: () => void;
</script>
<div class="tui-statusbar bottom">
<span class="status-left">
<Icon icon="mdi:console" width="14" />
<span>TUI</span>
</span>
<span class="status-center">
{#if isTyping}
<span class="typing-indicator">Loading...</span>
{:else}
Ready
{/if}
</span>
<span class="status-right">
{#if isTyping}
<button class="skip-btn" on:click={skipAnimation}>
<Icon icon="mdi:skip-forward" width="12" />
<span>Skip (Y)</span>
</button>
{:else}
<span class="line-count">{linesCount} lines</span>
{/if}
</span>
</div>
<style>
.tui-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
background: var(--terminal-bg-light);
border-color: var(--terminal-border);
}
.tui-statusbar.bottom {
border-top: 1px solid var(--terminal-border);
}
.status-left, .status-right {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--terminal-muted);
}
.status-center {
color: var(--terminal-primary);
font-weight: 600;
}
.skip-btn {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.15rem 0.5rem;
background: var(--terminal-border);
border: 1px solid transparent;
border-radius: 3px;
color: var(--terminal-muted);
font-family: inherit;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s ease;
}
.skip-btn:hover {
background: var(--terminal-primary);
color: var(--terminal-bg);
border-color: var(--terminal-primary);
}
.typing-indicator {
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.line-count {
color: var(--terminal-muted);
}
</style>

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { user } from '$lib/config';
import type { SpeedPreset } from '$lib/config';
export let title = 'terminal';
export let interactive = true;
export let hasButtons = false;
</script>
<div class="tui-statusbar top">
<span class="status-left">
<Icon icon="mdi:arch" width="14" />
<span>{user.username}@{user.hostname}</span>
</span>
<span class="status-center">{title}</span>
<span class="status-right">
{#if interactive && hasButtons}
<span class="hint">↑↓ navigate</span>
<span class="hint">⏎ select</span>
{/if}
</span>
</div>
<style>
.tui-statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.75rem;
font-size: 0.75rem;
background: var(--terminal-bg-light);
border-color: var(--terminal-border);
}
.tui-statusbar.top {
border-bottom: 1px solid var(--terminal-border);
}
.status-left, .status-right {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--terminal-muted);
}
.status-center {
color: var(--terminal-primary);
font-weight: 600;
}
.hint {
padding: 0.1rem 0.4rem;
background: var(--terminal-border);
border-radius: 3px;
font-size: 0.7rem;
}
@media (max-width: 768px) {
.hint {
display: none;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { getButtonStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
export let onClick: () => void;
// Determine if this is an external link
$: isExternal = line.external || (line.href && (line.href.startsWith('http://') || line.href.startsWith('https://')));
</script>
<span class="tui-link" style="--link-color: {getButtonStyle(line.style)}">
{#if line.icon}
<Icon icon={line.icon} width="14" class="link-icon" />
{/if}
<button class="link-text" on:click={onClick}>
{line.content}
</button>
{#if isExternal}
<Icon icon="mdi:open-in-new" width="12" class="link-external" />
{/if}
</span>
<style>
.tui-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
:global(.link-icon) {
color: var(--link-color);
opacity: 0.8;
}
.link-text {
background: none;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: inherit;
color: var(--link-color);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
cursor: pointer;
transition: all 0.15s ease;
}
.link-text:hover {
text-decoration-style: solid;
filter: brightness(1.2);
}
:global(.link-external) {
color: var(--link-color);
opacity: 0.5;
}
.tui-link:hover :global(.link-external) {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { getButtonStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
$: progress = Math.min(100, Math.max(0, line.progress ?? 0));
$: label = line.progressLabel || `${progress}%`;
</script>
<div class="tui-progress" style="--progress-color: {getButtonStyle(line.style)}">
{#if line.content}
<div class="progress-label">{line.content}</div>
{/if}
<div class="progress-bar">
<div class="progress-fill" style="width: {progress}%">
<span class="progress-glow"></span>
</div>
<div class="progress-blocks">
{#each Array(20) as _, i}
<span class="block" class:filled={i < Math.floor(progress / 5)}></span>
{/each}
</div>
</div>
<div class="progress-value">{label}</div>
</div>
<style>
.tui-progress {
margin: 0.5rem 0;
font-size: 0.85rem;
}
.progress-label {
color: var(--terminal-text);
margin-bottom: 0.25rem;
}
.progress-bar {
position: relative;
height: 1.2rem;
background: var(--terminal-border);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, var(--progress-color), color-mix(in srgb, var(--progress-color) 80%, white 20%));
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-glow {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 20px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { opacity: 0; }
50% { opacity: 1; }
100% { opacity: 0; }
}
.progress-blocks {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 2px;
padding: 3px;
}
.block {
flex: 1;
background: rgba(0, 0, 0, 0.2);
border-radius: 1px;
transition: background 0.2s;
}
.block.filled {
background: transparent;
}
.progress-value {
text-align: right;
color: var(--progress-color);
font-size: 0.75rem;
margin-top: 0.25rem;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
$: headers = line.tableHeaders || [];
$: rows = line.tableRows || [];
</script>
<div class="tui-table-wrapper" style="--table-accent: {getButtonStyle(line.style)}">
{#if line.content}
<div class="table-title">{line.content}</div>
{/if}
<table class="tui-table">
{#if headers.length > 0}
<thead>
<tr>
{#each headers as header}
<th>{header}</th>
{/each}
</tr>
</thead>
{/if}
<tbody>
{#each rows as row, i}
<tr class:alt={i % 2 === 1}>
{#each row as cell}
{@const cellSegments = parseColorText(cell)}
<td>
{#each cellSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.tui-table-wrapper {
margin: 0.5rem 0;
border: 1px solid var(--terminal-border);
border-radius: 6px;
overflow: hidden;
}
.table-title {
padding: 0.5rem 0.75rem;
font-weight: 600;
font-size: 0.85rem;
color: var(--terminal-text);
background: rgba(0, 0, 0, 0.1);
border-bottom: 1px solid var(--terminal-border);
}
.tui-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
th {
padding: 0.5rem 0.75rem;
text-align: left;
font-weight: 600;
color: var(--table-accent);
background: rgba(0, 0, 0, 0.15);
border-bottom: 1px solid var(--terminal-border);
white-space: nowrap;
}
td {
padding: 0.4rem 0.75rem;
color: var(--terminal-text);
border-bottom: 1px solid var(--terminal-border);
}
tr:last-child td {
border-bottom: none;
}
tr.alt {
background: rgba(0, 0, 0, 0.03);
}
tr:hover {
background: color-mix(in srgb, var(--table-accent) 5%, transparent);
}
</style>

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import { getButtonStyle, parseColorText, getSegmentStyle } from './utils';
import type { TerminalLine } from './types';
export let line: TerminalLine;
let showTooltip = false;
let triggerEl: HTMLSpanElement;
let tooltipStyle = '';
$: contentSegments = parseColorText(line.content);
$: position = line.tooltipPosition || 'top';
function updateTooltipPosition() {
if (!triggerEl) return;
const rect = triggerEl.getBoundingClientRect();
const scrollY = window.scrollY;
const scrollX = window.scrollX;
let top = 0;
let left = 0;
switch (position) {
case 'top':
top = rect.top + scrollY - 8;
left = rect.left + scrollX + rect.width / 2;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-50%) translateY(-100%);`;
break;
case 'bottom':
top = rect.bottom + scrollY + 8;
left = rect.left + scrollX + rect.width / 2;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-50%);`;
break;
case 'left':
top = rect.top + scrollY + rect.height / 2;
left = rect.left + scrollX - 8;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateX(-100%) translateY(-50%);`;
break;
case 'right':
top = rect.top + scrollY + rect.height / 2;
left = rect.right + scrollX + 8;
tooltipStyle = `top: ${top}px; left: ${left}px; transform: translateY(-50%);`;
break;
}
}
function handleMouseEnter() {
updateTooltipPosition();
showTooltip = true;
}
function handleMouseLeave() {
showTooltip = false;
}
</script>
<span
class="tui-tooltip-trigger"
style="--tooltip-color: {getButtonStyle(line.style)}"
bind:this={triggerEl}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
role="button"
tabindex="0"
on:focus={handleMouseEnter}
on:blur={handleMouseLeave}
>
<span class="trigger-text">
{#each contentSegments as segment}
{#if getSegmentStyle(segment)}
<span style={getSegmentStyle(segment)}>{segment.text}</span>
{:else}
{segment.text}
{/if}
{/each}
</span>
<span class="tooltip-indicator">(?)</span>
</span>
{#if showTooltip && line.tooltipText}
<span class="tooltip {position}" style="{tooltipStyle} --tooltip-color: {getButtonStyle(line.style)}">
{line.tooltipText}
<span class="tooltip-arrow"></span>
</span>
{/if}
<style>
.tui-tooltip-trigger {
position: relative;
display: inline-flex;
align-items: center;
gap: 0.25rem;
cursor: help;
}
.trigger-text {
border-bottom: 1px dotted var(--tooltip-color);
}
.tooltip-indicator {
font-size: 0.7rem;
color: var(--tooltip-color);
opacity: 0.7;
}
.tooltip {
position: fixed;
z-index: 99999;
padding: 0.5rem 0.75rem;
background: var(--terminal-bg);
border: 1px solid var(--tooltip-color);
border-radius: 4px;
font-size: 0.8rem;
color: var(--terminal-text);
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
animation: tooltipFadeIn 0.15s ease-out;
pointer-events: none;
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Arrow styles - simplified for fixed positioning */
.tooltip-arrow {
position: absolute;
width: 8px;
height: 8px;
background: var(--terminal-bg);
border: 1px solid var(--tooltip-color);
border-top: none;
border-left: none;
}
.tooltip.top .tooltip-arrow {
bottom: -5px;
left: 50%;
transform: translateX(-50%) rotate(45deg);
}
.tooltip.bottom .tooltip-arrow {
top: -5px;
left: 50%;
transform: translateX(-50%) rotate(-135deg);
}
.tooltip.left .tooltip-arrow {
right: -5px;
top: 50%;
transform: translateY(-50%) rotate(-45deg);
}
.tooltip.right .tooltip-arrow {
left: -5px;
top: 50%;
transform: translateY(-50%) rotate(135deg);
}
</style>

View File

@@ -0,0 +1,55 @@
import type { TextSegment } from './utils';
import type { Card } from '$lib/config';
export type LineType =
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid';
export interface TerminalLine {
type: LineType;
content: string;
delay?: number;
image?: string;
imageAlt?: string;
imageWidth?: number;
// For button and link types
action?: () => void;
href?: string;
icon?: string;
external?: boolean; // Opens in new tab
// For styling
style?: 'primary' | 'secondary' | 'accent' | 'warning' | 'error';
// For card type
cardTitle?: string;
cardFooter?: string;
// For progress type
progress?: number; // 0-100
progressLabel?: string;
// For accordion type
accordionOpen?: boolean;
accordionItems?: { title: string; content: string }[];
// For table type
tableHeaders?: string[];
tableRows?: string[][];
// For tooltip type
tooltipText?: string;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
// For cardgrid type
cards?: Card[];
}
// Pre-parsed line with segments ready for rendering
export interface ParsedLine {
line: TerminalLine;
segments: TextSegment[];
plainText: string; // Plain text without color codes for length calculation
}
// Display state for a line during typing animation
export interface DisplayedLine {
parsed: ParsedLine;
charIndex: number; // How many plain-text characters to show
complete: boolean;
showImage: boolean;
}

View File

@@ -0,0 +1,185 @@
// Shared utilities used by TUI components
export interface TextSegment {
text: string;
color?: string;
background?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
overline?: boolean;
// For inline icons
icon?: string;
iconSize?: number;
// For inline clickable text
href?: string;
action?: () => void;
}
// Color map for text and background colors
export const colorMap: Record<string, string> = {
// Basic colors
'red': '#f38ba8',
'green': '#a6e3a1',
'yellow': '#f9e2af',
'blue': '#89b4fa',
'magenta': '#cba6f7',
'cyan': '#94e2d5',
'white': '#cdd6f4',
'gray': '#6c7086',
'orange': '#fab387',
'pink': '#f5c2e7',
'black': '#1e1e2e',
'surface': '#313244',
// Semantic colors
'primary': 'var(--terminal-primary)',
'accent': 'var(--terminal-accent)',
'muted': 'var(--terminal-muted)',
'error': '#f38ba8',
'success': '#a6e3a1',
'warning': '#f9e2af',
'info': '#89b4fa',
};
// Text style keywords
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
export function parseColorText(text: string): TextSegment[] {
const segments: TextSegment[] = [];
// Match both (&specs)content(&) and (&icon, iconName) patterns
const regex = /\(&([^)]+)\)(.*?)\(&\)|\(&icon,\s*([^)]+)\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ text: text.slice(lastIndex, match.index) });
}
// Check if this is an icon match (match[3] is the icon name)
if (match[3]) {
const iconName = match[3].trim();
segments.push({ text: '', icon: iconName });
lastIndex = match.index + match[0].length;
continue;
}
const specs = match[1].split(',').map(s => s.trim().toLowerCase());
const content = match[2];
const segment: TextSegment = { text: content };
for (const spec of specs) {
// Text styles
if (spec === 'bold') segment.bold = true;
else if (spec === 'dim') segment.dim = true;
else if (spec === 'italic') segment.italic = true;
else if (spec === 'underline') segment.underline = true;
else if (spec === 'strikethrough' || spec === 'strike') segment.strikethrough = true;
else if (spec === 'overline') segment.overline = true;
// Background color (bg-colorname or bg-#hex)
else if (spec.startsWith('bg-')) {
const bgColor = spec.slice(3);
if (colorMap[bgColor]) {
segment.background = colorMap[bgColor];
} else if (bgColor.startsWith('#')) {
segment.background = bgColor;
}
}
// Foreground color
else if (colorMap[spec] && !textStyles.includes(spec)) {
segment.color = colorMap[spec];
} else if (spec.startsWith('#')) {
segment.color = spec;
}
}
segments.push(segment);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ text: text.slice(lastIndex) });
}
if (segments.length === 0) {
segments.push({ text });
}
return segments;
}
// Get plain text from segments (for length calculation)
export function getPlainText(segments: TextSegment[]): string {
return segments.map(s => s.text).join('');
}
// Get segments up to a certain character count (for typing animation)
export function getSegmentsUpToChar(segments: TextSegment[], charCount: number): TextSegment[] {
const result: TextSegment[] = [];
let remaining = charCount;
for (const segment of segments) {
if (remaining <= 0) break;
if (segment.text.length <= remaining) {
result.push(segment);
remaining -= segment.text.length;
} else {
// Partial segment
result.push({ ...segment, text: segment.text.slice(0, remaining) });
remaining = 0;
}
}
return result;
}
export function getSegmentStyle(segment: TextSegment): string {
const styles: string[] = [];
if (segment.color) styles.push(`color: ${segment.color}`);
if (segment.background) styles.push(`background-color: ${segment.background}; padding: 0.1em 0.25em; border-radius: 3px`);
if (segment.bold) styles.push('font-weight: bold');
if (segment.dim) styles.push('opacity: 0.6');
if (segment.italic) styles.push('font-style: italic');
// Combine text decorations
const decorations: string[] = [];
if (segment.underline) decorations.push('underline');
if (segment.strikethrough) decorations.push('line-through');
if (segment.overline) decorations.push('overline');
if (decorations.length > 0) {
styles.push(`text-decoration: ${decorations.join(' ')}`);
}
return styles.join('; ');
}
export function getLinePrefix(type: string): string {
switch (type) {
case 'error':
return '✗ ';
case 'success':
return '✓ ';
case 'info':
return ' ';
default:
return '';
}
}
export function getButtonStyle(style?: string): string {
switch (style) {
case 'primary':
return 'var(--terminal-primary)';
case 'accent':
return 'var(--terminal-accent)';
case 'warning':
return '#f9e2af';
case 'error':
return '#f38ba8';
default:
return 'var(--terminal-text)';
}
}

764
src/lib/config.ts Normal file
View File

@@ -0,0 +1,764 @@
// ============================================================================
// USER PROFILE
// ============================================================================
export const user = {
name: 'Sir Blob',
username: 'sirblob',
hostname: 'engineering',
title: 'Engineering Student',
email: 'you@example.com',
location: 'San Francisco, CA',
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',
// Social links - array of { name, icon (Iconify), link }
socials: [
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
{ 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: 'Discord', icon: 'mdi:discord', link: 'https://discord.com/users/sir_blob_' }
]
};
// ============================================================================
// LAYOUT & DIMENSIONS
// ============================================================================
export const layout = {
// Navbar
navbarHeight: 60, // px
navbarMaxWidth: 1400, // px
navbarZIndex: 1000,
// Container
containerMaxWidth: 1200, // px
containerPadding: '1.5rem',
// Grid
gridGap: '1.5rem',
gridMinColumnWidth: 300, // px
// Page margins
pageBottomMargin: 80, // px (desktop)
pageMobileBottomMargin: 60, // px (mobile)
};
// ============================================================================
// RESPONSIVE BREAKPOINTS
// ============================================================================
export const breakpoints = {
mobile: 768, // px
tablet: 1024, // px
desktop: 1400, // px
};
// ============================================================================
// FONTS
// ============================================================================
export const fonts = {
mono: "'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace",
sans: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
monoWeight: 600,
};
// ============================================================================
// COLOR PALETTE
// ============================================================================
// These colors are used for terminal text formatting and UI elements
// Colors follow Catppuccin Mocha theme by default
export const colorPalette = {
// Basic colors
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#cba6f7',
cyan: '#94e2d5',
white: '#cdd6f4',
gray: '#6c7086',
orange: '#fab387',
pink: '#f5c2e7',
black: '#1e1e2e',
surface: '#313244',
// Semantic colors (map to basic colors by default)
error: '#f38ba8', // red
success: '#a6e3a1', // green
warning: '#f9e2af', // yellow
info: '#89b4fa', // blue
};
// ============================================================================
// TERMINAL WINDOW BUTTONS (traffic lights)
// ============================================================================
export const terminalButtons = {
close: '#ff5f56',
minimize: '#ffbd2e',
maximize: '#27ca40',
};
// ============================================================================
// SKILLS
// ============================================================================
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
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'],
interests: ['Open Source', '3D Graphics', 'CLI Tools', 'Game Dev']
};
// ============================================================================
// PROJECTS
// ============================================================================
export interface Project {
name: string;
description: string;
tech: string[];
github?: string;
live?: string;
image?: string;
featured?: boolean;
}
export const projects: Project[] = [
{
name: 'PokemonTCGAPI',
description: 'Official NPM package wrapper for the PokemonTCG API — utilities and helpers for working with Pokemon TCG data.',
tech: ['TypeScript', 'Node.js'],
live: 'https://www.npmjs.com/package/@bosstop/pokemontcgapi',
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
featured: false
},
{
name: 'MCSS TS API',
description: 'TypeScript API for MCServersSoft services, published to NPM.',
tech: ['TypeScript', 'Node.js'],
live: 'https://www.npmjs.com/package/@mcserversoft/mcss-api',
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
featured: false
},
{
name: 'MCP Selenium',
description: 'Selenium automation utilities for MCP workflows, packaged for reuse.',
tech: ['TypeScript', 'Selenium'],
live: 'https://www.npmjs.com/package/@sirblob/mcp-selenium',
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
featured: false
},
{
name: 'Pkit',
description: 'CLI toolkit and utilities (Rust project) — small developer-focused CLI.',
tech: ['Rust', 'CLI'],
github: 'https://github.com/dead-projects-inc/pkit-cli',
image: 'https://icons.veryicon.com/png/o/business/vscode-program-item-icon/rust-1.png',
featured: false
}
];
// ============================================================================
// 3D MODELS
// ============================================================================
export interface Model3D {
name: string;
description: string;
thumbnail: string;
modelUrl?: string; // URL to .glb/.gltf file for preview
downloadUrl?: string;
software: string[];
polyCount?: string;
featured?: boolean;
}
export const models: Model3D[] = [
{
name: 'Character Model',
description: 'A stylized character model with full rig',
thumbnail: '/models/character-thumb.png',
modelUrl: '/models/character.glb',
software: ['Blender', 'Substance Painter'],
polyCount: '15k tris',
featured: true
},
{
name: 'Environment Asset Pack',
description: 'Low-poly environment assets for game dev',
thumbnail: '/models/environment-thumb.png',
software: ['Blender'],
polyCount: '2k-5k tris',
featured: true
},
{
name: 'Weapon Collection',
description: 'Sci-fi weapon models with PBR textures',
thumbnail: '/models/weapons-thumb.png',
software: ['Blender', 'Substance Painter'],
downloadUrl: 'https://example.com/download'
}
];
// ============================================================================
// HACKATHONS
// ============================================================================
export type Card = {
title: string;
description: string;
image?: string;
link?: string;
repo?: string;
devpost?: string;
hackathonName?: string;
university?: string;
location?: string;
year?: string;
tags?: string[];
featured?: boolean;
awards?: { track: string; place: string }[];
liveWarning?: boolean;
};
export const cards: Card[] = [
{
image: "/hacks/fooddecisive.png",
title: "Food Decisive",
description:
"Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive",
link: "https://fooddecisive.sirblob.co/",
repo: "https://github.com/SirBlobby/VTHacks-12",
devpost: "https://devpost.com/software/food-decisive",
hackathonName: "VTHacks 12",
university: "Virginia Tech",
location: "Blacksburg, VA",
year: "2024",
tags: ["AI", "Llama3.1", "Svelte", "Node.js"],
featured: false,
liveWarning: true
},
{
image: "/hacks/carbin.png",
title: "Carbin",
description:
"Encourage student participation in responsible waste management with smart bins that guide proper disposal.",
repo: "https://github.com/SirBlobby/patriotHacks2024",
devpost: "https://devpost.com/software/carboniferous-akc4mj",
hackathonName: "PatriotHacks 2024",
university: "George Mason University",
location: "Fairfax, VA",
year: "2024",
tags: ["AI", "Azure", "CloudConvert", "Python", "React", "TypeScript", "API"],
featured: true,
awards: [
{ track: "Save the World", place: "2nd Place" },
{ track: "Microsoft X Cloudforce", place: "2nd Place" }
]
},
{
image: "/hacks/patsafe.png",
title: "PatSafe",
description:
"Bridging the gap between doctors and patients for seamless post-discharge care",
link: "https://hoya-hax2025.vercel.app/",
repo: "https://github.com/SirBlobby/HoyaHax2025",
devpost: "https://devpost.com/software/patsafe",
hackathonName: "HoyaHax 2025",
university: "Georgetown University",
location: "Washington, D.C.",
year: "2025",
tags: ["Javascript", "Next.js", "React", "TypeScript", "LangChain", "OpenAI"],
},
{
image:"https://placehold.co/800x400/334155/94a3b8?text=Fauxcall",
title: "Fauxcall",
description:
"This product is perfect for situations where you are walking at night and you feel unsafe from someone. Fauxcall lets users create a convincing fake phone call to deter potential attackers and provide a quick escape mechanism.",
link: "",
repo: "https://github.com/SirBlobby/HooHacks-12",
devpost: "https://devpost.com/software/fauxcall",
hackathonName: "HooHacks 2025",
university: "University of Virginia",
location: "Charlottesville, VA",
year: "2025",
tags: ["mongodb", "next.js", "python", "react", "sesame.com", "skeleton", "twilio"],
featured: false
},
{
image: "/hacks/drinkhappy.png",
title: "Drink Happy",
description:
"drinkhappy.tech is a gamified hydration wellness app that helps users track drinks, earn points for healthy choices, and compete with friends. It's powered by Gemini AI for smart drink recognition.",
link: "https://drinkhappy.tech",
repo: "https://github.com/SirBlobby/Bitcamp-2025",
devpost: "https://devpost.com/software/drink-happy",
hackathonName: "Bitcamp",
university: "University of Maryland",
location: "College Park, MD",
year: "2025",
tags: ["api", "auth0", "gemini", "github", "javascript", "mongodb", "nextjs", "react", "tailwind", "vercel"],
featured: false
},
{
image: "/hacks/roadcast.png",
title: "Roadcast",
description:
"Roadcast provides drivers with crash data and weather insights to choose safer routes home. The project combines historical crash data, weather feeds, and spatial analysis to surface hazardous areas and route-level safety recommendations.",
link: "https://roadcast.sirblob.co/",
repo: "https://github.com/SirBlobby/roadcast",
devpost: "https://devpost.com/software/roadcast",
hackathonName: "VTHacks 13",
university: "Virginia Tech",
location: "Blacksburg, VA",
year: "2025",
tags: ["react", "node.js", "python", "mongodb", "geospatial", "mapbox"],
featured: true,
liveWarning: true
}
];
// Sort cards to show featured first
export const sortedCards = [...cards].sort((a, b) => {
if (a.featured && !b.featured) return -1;
if (!a.featured && b.featured) return 1;
return 0;
});
// ============================================================================
// TERMINAL DISPLAY SETTINGS
// ============================================================================
export type SpeedPreset = 'instant' | 'fast' | 'normal' | 'slow' | 'typewriter';
export const terminalSettings = {
// Base typing speed in ms per character (will be adjusted by content length)
baseTypeSpeed: 20,
// Minimum typing speed (fastest)
minTypeSpeed: 5,
// Maximum typing speed (slowest)
maxTypeSpeed: 50,
// Delay before starting to type (ms)
startDelay: 300,
// Delay between lines (ms)
lineDelay: 100,
// Show cursor
showCursor: true,
// Prompt style
promptStyle: 'full' as 'full' | 'short' | 'minimal',
// Terminal icon (emoji or text)
icon: '🐧',
// Scroll margin when navigating buttons (px)
scrollMargin: 80,
};
// ============================================================================
// TUI (Terminal UI) STYLING
// ============================================================================
export const tuiStyle = {
// Border & container
borderRadius: 8, // px
borderWidth: 2, // px
width: '95%',
borderGlowOpacity: 0.5,
borderGlowBlur: 4, // px
// Header
headerPadding: '0.5rem 0.75rem',
headerFontSize: '0.8rem',
// Body
bodyPadding: '1rem 1.25rem 2rem 1.25rem',
bodyFontSize: '0.9rem',
lineHeight: 1.7,
lineMinHeight: '1.7em',
blankLineHeight: '0.5em',
// Divider
dividerMargin: '0.75rem 0',
// Images
imageBorderRadius: 6, // px
defaultImageWidth: 300, // px
// Cursor
cursorWidth: 8, // px
// Scrollbar
scrollbarWidth: 6, // px
// Footer/Statusbar
statusbarPadding: '0.4rem 0.75rem',
statusbarFontSize: '0.75rem',
hintFontSize: '0.7rem',
hintBorderRadius: 3, // px
// Buttons
buttonPadding: '0.5rem 0.75rem',
buttonMargin: '0.2rem 0',
buttonBorderRadius: 4, // px
buttonFontSize: '0.9rem',
buttonIndicatorFontSize: '0.8rem',
buttonIndicatorWidth: '1rem',
};
// ============================================================================
// TUI TEXT & INDICATORS
// ============================================================================
export const tuiText = {
// Status text
loading: 'Loading...',
ready: 'Ready',
skipButton: 'Skip (Y)',
tuiLabel: 'TUI',
// Keyboard hints
hints: {
navigate: '↑↓ navigate',
select: '⏎ select',
toggle: 'T theme',
},
// Line prefixes
prefixes: {
error: '✗ ',
success: '✓ ',
info: ' ',
warning: '⚠ ',
command: '$ ',
},
// Button indicators
buttonIndicator: {
selected: '▶',
unselected: ' ',
},
};
// ============================================================================
// COLOR FORMATTING
// ============================================================================
// Use (&color)text(&) syntax in terminal content for colored text
// Use (&icon, iconName) syntax for inline icons
//
// Available colors:
// red, green, yellow, blue, magenta, cyan, white, gray, orange, pink
// primary, accent, muted, error, success, warning, info
//
// Available styles:
// bold, dim, italic, underline
//
// Examples:
// "Hello (&red)world(&)!" -> red "world"
// "(&bold,green)Success(&): Task done" -> bold green "Success"
// "(&italic,cyan)Note(&): See docs" -> italic cyan "Note"
// "Status: (&primary)Active(&)" -> theme primary color
// "(&dim)This is muted text(&)" -> dimmed text
// "(&bold,underline,yellow)Warning(&)" -> bold underlined yellow
// "(&error)Failed(&) - (&success)Passed(&)" -> multiple colors
// "(&icon, mdi:github) GitHub" -> inline GitHub icon
// "Check (&icon, mdi:check) Done" -> inline check icon
// Per-page speed settings (override global settings)
// Use preset names or custom multiplier (0.1 = 10x faster, 2 = 2x slower)
export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
// Examples:
// 'home': 'fast', // Fast typing on home page
// 'portfolio': 'normal', // Normal speed
// 'models': 'instant', // No typing animation
// 'hackathons': 0.5, // Custom: 2x faster than normal
'home': 'fast',
'portfolio': 'fast',
'models': 'fast',
'hackathons': 'normal'
};
// 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
};
// ============================================================================
// ANIMATIONS
// ============================================================================
export const animations = {
// Terminal/TUI animations
terminalFadeIn: { duration: '0.4s', easing: 'ease-out' },
tuiFadeIn: { duration: '0.4s', easing: 'ease-out' },
lineSlideIn: { duration: '0.15s', easing: 'ease-out' },
cursorBlink: { duration: '1s' },
borderGlow: { duration: '8s', transition: '0.3s' },
// Button/interaction animations
buttonHover: { duration: '0.2s' },
buttonTransition: { duration: '0.15s' },
// Navigation animations
hamburgerTransition: { duration: '0.3s' },
navLinkTransition: { duration: '0.2s' },
linkUnderline: { duration: '0.3s' },
dropdown: { flyDuration: 200, fadeDuration: 150 }, // ms
// Model viewer
modelViewerTransition: { duration: '0.3s' },
spin: { duration: '1s' },
// General
pulse: { duration: '2s' },
float: { duration: '3s' },
};
// ============================================================================
// SCROLLBAR
// ============================================================================
export const scrollbar = {
width: 8, // px
borderRadius: 4, // px
trackColor: 'rgba(0, 0, 0, 0.1)',
thumbColor: 'rgba(128, 128, 128, 0.5)',
thumbHoverColor: 'rgba(128, 128, 128, 0.7)',
};
// ============================================================================
// NAVBAR
// ============================================================================
export const navbar = {
height: 60, // px - also update layout.navbarHeight if changing
maxWidth: 1400, // px
padding: '0.75rem 1.5rem',
gap: '2rem',
zIndex: 1000,
// Brand
brandFontSize: '0.9rem',
cursorWidth: 8, // px
// Hamburger menu
hamburgerWidth: 24, // px
hamburgerHeight: 2, // px
hamburgerSpacing: 7, // px
// Links
linksGap: '1.5rem',
linkFontSize: '0.85rem',
linkPadding: '0.5rem 0.75rem',
linkBorderRadius: 4, // px
linkUnderlineHeight: 2, // px
// Mode toggle
modeToggleSize: 40, // px
modeToggleBorderRadius: 8, // px
themeButtonBorderRadius: 6, // px
// Dropdown
dropdownMinWidth: 180, // px
dropdownBorderRadius: 8, // px
dropdownZIndex: 1001,
backdropZIndex: 999,
mobileExpandedHeight: 200, // px
};
// ============================================================================
// MODEL VIEWER (3D)
// ============================================================================
export const modelViewer = {
// Container
borderRadius: 8, // px
minHeight: 300, // px
headerPadding: '0.5rem 0.75rem',
nameFontSize: '0.85rem',
// Controls
controlButtonSize: 28, // px
controlButtonBorderRadius: 4, // px
fullscreenZIndex: 100,
fullscreenTopOffset: 60, // px (navbar height)
// Camera
cameraFov: 45,
cameraNear: 0.1,
cameraFar: 1000,
cameraZ: 2.5, // initial camera distance
// Controls behavior
controlsMinDistance: 1,
controlsMaxDistance: 10,
dampingFactor: 0.05,
autoRotateSpeed: 2,
// Model
modelScale: 1.5,
// Ground plane
groundColor: 0x222222,
groundOpacity: 0.3,
// Lighting
ambientIntensity: 0.6,
directionalIntensity: 0.8,
lightColor: 0xffffff,
rimLightColor: 0x88ccff,
// Text
loadingText: 'Loading model...',
errorText: 'Failed to load model',
hints: {
rotate: 'Drag to rotate',
zoom: 'Scroll to zoom',
},
// Timing
resizeTimeout: 100, // ms
};
// ============================================================================
// PARTICLES (3D Background)
// ============================================================================
export const particles = {
count: 600,
spreadArea: 20,
size: 0.05,
velocityRange: 0.01,
// Opacity
darkOpacity: 0.6,
lightOpacity: 0.4,
// Motion
waveAmplitude: 0.001,
waveFrequency: 0.1,
boundary: 10, // wrap boundary
rotationSpeedY: 0.05,
rotationSpeedX: 0.02,
};
// ============================================================================
// LOADING SCREEN
// ============================================================================
export const loadingScreen = {
background: '#0d1117',
textColor: '#c9d1d9',
cursorColor: '#1793d1',
text: "Blob's Portfolio",
};
// ============================================================================
// SITE METADATA
// ============================================================================
export const site = {
title: `${user.username} | Portfolio`,
description: `${user.title} - ${user.bio}`,
keywords: ['developer', 'portfolio', 'programming', ...skills.languages, ...skills.frameworks],
ogImage: '/og-image.png',
twitterHandle: '@yourusername'
};
// ============================================================================
// PAGE META (per-route)
// ============================================================================
export interface PageMeta {
title: string;
description?: string;
icon?: string; // Iconify id or path to an image (e.g. 'mdi:home' or '/icons/home.svg')
keywords?: string[];
ogImage?: string;
}
// Map route => meta. Use route paths as keys (e.g. '/', '/portfolio')
export const pageMeta: Record<string, PageMeta> = {
'/': {
title: `${user.username} — Home`,
description: `Home — ${user.title} portfolio and projects.`,
icon: 'mdi:home',
keywords: ['home', 'portfolio', 'about']
},
'/portfolio': {
title: `${user.username} — Portfolio`,
description: 'Selected projects, highlights and case studies.',
icon: 'mdi:folder-multiple',
keywords: ['projects', 'portfolio', 'showcase']
},
'/models': {
title: `${user.username} — 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`,
description: 'Hackathon projects, demos and awards.',
icon: 'mdi:trophy',
keywords: ['hackathon', 'projects', 'events']
},
'/components': {
title: `${user.username} — Components`,
description: 'Terminal UI components showcase and documentation.',
icon: 'mdi:puzzle',
keywords: ['components', 'ui', 'terminal', 'tui']
}
};
// ============================================================================
// KEYBOARD SHORTCUTS
// ============================================================================
export const keyboardShortcuts = {
skip: ['y', 'Y'], // Skip typing animation
toggleTheme: ['t', 'T'], // Toggle dark/light mode
navigateUp: ['ArrowUp', 'k'],
navigateDown: ['ArrowDown', 'j'],
select: ['Enter'],
};
// ============================================================================
// NAVIGATION
// ============================================================================
export const navigation = [
{ name: 'home', path: '/', icon: '~' },
{ name: 'portfolio', path: '/portfolio', icon: '📁' },
{ name: 'models', path: '/models', icon: '🎨' },
{ name: 'hackathons', path: '/hackathons', icon: '🏆' },
// { name: 'components', path: '/components', icon: '🧩' },
{ name: 'blog', path: 'https://blog.sirblob.co', icon: '📝', external: true }
];
// ============================================================================
// EFFECTS
// ============================================================================
export const effects = {
backdropBlur: 10, // px
selectionBackground: 'rgba(23, 147, 209, 0.3)',
};
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
// NOTE: Helper functions were moved to `src/lib/index.ts` to provide a central
// barrel and avoid duplication. Import helpers from `$lib` instead of `$lib/config`.

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

67
src/lib/index.ts Normal file
View File

@@ -0,0 +1,67 @@
// Central helper functions and re-exports
import { terminalSettings, pageSpeedSettings, speedPresets, user } from './config';
export type SpeedPreset = 'instant' | 'fast' | 'normal' | 'slow' | 'typewriter';
/**
* Calculate typing speed based on content length and speed multiplier
* Longer content types faster to maintain good UX
* @param contentLength - Length of the content to type
* @param speedMultiplier - Speed multiplier (0 = instant, 0.5 = 2x faster, 2 = 2x slower)
*/
export function calculateTypeSpeed(contentLength: number, speedMultiplier: number = 1): number {
// Instant mode - no delay
if (speedMultiplier === 0) return 0;
const { baseTypeSpeed, minTypeSpeed, maxTypeSpeed } = terminalSettings;
let speed: number;
// Shorter content = slower typing (more readable)
// Longer content = faster typing (less waiting)
if (contentLength < 20) speed = maxTypeSpeed;
else if (contentLength < 50) speed = baseTypeSpeed;
else if (contentLength < 100) speed = baseTypeSpeed * 0.7;
else if (contentLength < 200) speed = baseTypeSpeed * 0.5;
else speed = minTypeSpeed;
// Apply speed multiplier
return Math.max(0, Math.round(speed * speedMultiplier));
}
/**
* Get speed multiplier for a page
* @param pageName - Name of the page (e.g., 'home', 'portfolio')
*/
export function getPageSpeedMultiplier(pageName: string): number {
const setting = pageSpeedSettings[pageName];
if (setting === undefined) return 1; // Default to normal
if (typeof setting === 'number') {
return setting;
}
return speedPresets[setting] ?? 1;
}
/**
* Get the terminal prompt string
*/
export function getPrompt(path: string = '~'): string {
const { promptStyle } = terminalSettings;
switch (promptStyle) {
case 'full':
return `${user.username}@${user.hostname}:${path}$`;
case 'short':
return `${user.username}:${path}$`;
case 'minimal':
return '$';
default:
return '$';
}
}
// Barrel exports (convenience re-exports)
export { user, terminalSettings, pageSpeedSettings, speedPresets, pageMeta, site } from './config';

135
src/lib/stores/theme.ts Normal file
View File

@@ -0,0 +1,135 @@
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
export type ColorTheme = 'arch' | 'catppuccin';
export type Mode = 'dark' | 'light';
export interface ThemeColors {
primary: string;
secondary: string;
accent: string;
background: string;
backgroundLight: string;
text: string;
textMuted: string;
border: string;
terminal: string;
terminalPrompt: string;
terminalUser: string;
terminalPath: string;
}
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
}
}
};
function getInitialMode(): Mode {
if (browser) {
const stored = localStorage.getItem('mode');
if (stored === 'dark' || stored === 'light') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'dark';
}
function getInitialTheme(): ColorTheme {
if (browser) {
const stored = localStorage.getItem('colorTheme');
if (stored && stored in themeColorsMap) return stored as ColorTheme;
}
return 'arch';
}
export const mode = writable<Mode>(getInitialMode());
export const colorTheme = writable<ColorTheme>(getInitialTheme());
export const themeColors = derived([mode, colorTheme], ([$mode, $colorTheme]) => {
return themeColorsMap[$colorTheme][$mode];
});
// Subscribe to persist changes
if (browser) {
mode.subscribe((value) => {
localStorage.setItem('mode', value);
document.documentElement.setAttribute('data-mode', value);
});
colorTheme.subscribe((value) => {
localStorage.setItem('colorTheme', value);
document.documentElement.setAttribute('data-theme', value);
});
}
export function toggleMode() {
mode.update((m) => (m === 'dark' ? 'light' : 'dark'));
}
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: '🐱' }
];

View File

@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -1,3 +1,159 @@
<section class="container flex h-full p-8 mx-auto">
<h1 class="text-4xl font-bold h1">404 or 500 or idk :(</h1>
</section>
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalLine } from '$lib/components/tui/types';
import { user, site } from '$lib/config';
// Fun 404 messages
const notFoundMessages = [
"Looks like this page went on vacation... without telling anyone.",
"404: Page not found. It's probably hiding with the missing socks.",
"This page has mass, but it seems to have no class.",
"You've reached the edge of the internet. Turn back now.",
"The page you're looking for is in another castle.",
"Oops! This page took a wrong turn at /dev/null.",
"Error 404: Page went out for coffee and never came back.",
"This page was mass deleted from the matrix.",
"The server ate your page. Bad server!",
"Page not found. Have you tried turning it off and on again?",
"This URL is as empty as my coffee cup at 3am.",
"404: The page is a lie.",
"Segmentation fault (core dumped)... just kidding, it's a 404.",
"rm -rf /page... Oops, wrong command.",
"This page has been mass yeeted into the void.",
];
// Generic error messages
const errorMessages: Record<number, string[]> = {
500: [
"The server is having an existential crisis.",
"Something went wrong on our end. We're on it!",
"Internal Server Error: The hamsters powering the server need a break.",
],
503: [
"Service temporarily unavailable. Even servers need naps.",
"We're doing some maintenance. BRB!",
],
};
const status = $derived($page.status);
const errorMessage = $derived($page.error?.message || 'Something went wrong');
const pathname = $derived($page.url.pathname);
// Pick a random fun message based on status
function getFunMessage(): string {
if (status === 404) {
return notFoundMessages[Math.floor(Math.random() * notFoundMessages.length)];
}
const msgs = errorMessages[status];
if (msgs) {
return msgs[Math.floor(Math.random() * msgs.length)];
}
return "Something unexpected happened. Our code monkeys are investigating.";
}
// ASCII art for different errors (use ███ block characters)
function getAsciiArt(): string[] {
// Cat-shaped block art for 404
if (status === 404) {
return [
'(&pink) ███ ███ (&)',
'(&pink) █ █ █ █ █ █ █ (&)',
'(&pink) █ █ █ █ █ █ █ (&)',
'(&pink) ███████████ (&)',
'(&pink) █ █ █ █ (&)',
];
}
// Blocky error box for 5xx
if (status >= 500) {
return [
'(&error) █████ █████ (&)',
'(&error) █ █ █ █ █ (&)',
'(&error) █ █ █ █ █ █ █ (&)',
'(&error) █████████████ (&)',
];
}
// Generic small block banner
return [
'(&pink) ███ ███ ███ (&)',
'(&pink) █ █ █ █ (&)',
'(&pink) █ █ █ █ █ (&)',
'(&pink) █ █ (&)',
];
}
const funMessage = getFunMessage();
const asciiLines = getAsciiArt();
// Build the terminal lines for the error page (derived to capture reactive values)
const lines = $derived<TerminalLine[]>([
// Command that caused the error
{ type: 'command', content: `curl ${pathname}` },
{ type: 'blank', content: '' },
// ASCII art
...asciiLines.map(line => ({ type: 'output' as const, content: line })),
{ type: 'blank', content: '' },
// Error status (styled with leading X like the screenshot)
{ type: 'error', content: `(&error,bold)X Error ${status}: ${errorMessage}(&)` },
{ type: 'blank', content: '' },
// Fun message as a comment
{ type: 'output', content: `(&muted,italic)# ${funMessage}(&)` },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'SUGGESTIONS' },
{ type: 'blank', content: '' },
// Suggestions
{ type: 'command', content: 'cat suggestions.txt' },
{ type: 'info', content: 'Check if the URL is correct' },
{ type: 'info', content: 'Try refreshing the page' },
{ type: 'info', content: 'Contact me if the problem persists' },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'ACTIONS' },
{ type: 'blank', content: '' },
// Navigation buttons
{
type: 'button',
content: 'Go Home',
icon: 'mdi:home',
style: 'primary',
href: '/'
},
{
type: 'button',
content: 'Go Back',
icon: 'mdi:arrow-left',
style: 'accent',
action: () => history.back()
},
]);
</script>
<svelte:head>
<title>{status} | {user.username}</title>
<meta name="description" content="Error {status} - {errorMessage}" />
</svelte:head>
<div class="error-container">
<TerminalTUI
{lines}
title="error"
interactive={true}
speed="fast"
/>
</div>
<style>
.error-container {
padding: 2rem 1rem;
min-height: calc(100vh - 60px);
}
</style>

View File

@@ -1,47 +1,111 @@
<script lang="ts">
import '../app.postcss';
import { initializeStores, Modal } from '@skeletonlabs/skeleton';
import './layout.css';
import Navbar from '$lib/components/Navbar.svelte';
import Background3D from '$lib/components/Background3D.svelte';
import { themeColors, mode, colorTheme } from '$lib/stores/theme';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
import NavbarWaybar from '$lib/components/NavbarWaybar.svelte';
import { AppShell } from '@skeletonlabs/skeleton';
//import Footer from '$lib/components/Footer.svelte';
import NavBar from '$lib/components/NavBar.svelte';
let { children } = $props();
let mounted = $state(false);
// don't bind the component; find the DOM element by its class
initializeStores();
function updateNavbarHeight() {
const elem = document.querySelector('.navbar') as HTMLElement | null;
if (!elem) return;
const rect = elem.getBoundingClientRect();
document.documentElement.style.setProperty('--navbar-height', `${rect.height}px`);
}
onMount(() => {
mounted = true;
// Set initial theme attributes
if (browser) {
document.documentElement.setAttribute('data-mode', $mode);
document.documentElement.setAttribute('data-theme', $colorTheme);
}
// Update CSS var with actual navbar height
updateNavbarHeight();
window.addEventListener('resize', updateNavbarHeight);
});
$effect(() => updateNavbarHeight());
</script>
<svelte:head>
<!-- Primary meta tags -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sir Blob — Personal website & portfolio</title>
<link rel="icon" href="/blob_nerd.png" />
<meta name="description" content="Portfolio and projects by Sir Blob." />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Sir Blob — Personal website & portfolio" />
<meta property="og:description" content="Portfolio and projects by Sir Blob." />
<meta property="og:image" content="/blob_nerd.png" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Sir Blob — Personal website & portfolio" />
<meta name="twitter:description" content="Portfolio and projects by Sir Blob." />
<meta name="twitter:image" content="/blob_nerd.png" />
<!-- Fonts -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Montserrat:300,400,500,700&display=swap" />
<link rel="icon" href="/favicon.png" />
<title>Blob's Portfolio</title>
<meta name="description" content="Welcome to Blob's personal portfolio website, showcasing projects, skills, and more." />
</svelte:head>
<Modal />
<AppShell>
<svelte:fragment slot="header">
<NavBar />
</svelte:fragment>
<slot />
<!-- <svelte:fragment slot="footer">
<Footer />
</svelte:fragment> -->
</AppShell>
{#if mounted}
<div
class="app-wrapper"
style="
--app-bg: {$themeColors.background};
--app-text: {$themeColors.text};
--app-primary: {$themeColors.primary};
--app-accent: {$themeColors.accent};
--app-border: {$themeColors.border};
--app-muted: {$themeColors.textMuted};
background-color: {$themeColors.background};
color: {$themeColors.text};
"
>
<Background3D />
<NavbarWaybar />
<!-- <Navbar /> -->
<main class="main-content">
{@render children()}
</main>
</div>
{:else}
<!-- Loading state to prevent flash -->
<div class="loading-screen">
<div class="loading-content">
<span class="loading-text">Initializing terminal...</span>
<span class="loading-cursor"></span>
</div>
</div>
{/if}
<style>
.app-wrapper {
min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
}
.loading-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #0d1117;
color: #c9d1d9;
font-family: 'JetBrains Mono', monospace;
}
.loading-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.loading-text {
font-size: 1rem;
}
.loading-cursor {
width: 10px;
height: 1.2em;
background: #1793d1;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>

View File

@@ -1,76 +1,53 @@
<script lang="ts">
import BlobPFP from "$lib/components/BlobPFP.svelte";
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalLine } from '$lib/components/tui/types';
import { user, skills, site, navigation } from '$lib/config';
import { getPageSpeedMultiplier } from '$lib';
import { colorTheme } from '$lib/stores/theme';
const speed = getPageSpeedMultiplier('home');
// Build the terminal lines for the home page
const lines: TerminalLine[] = [
// neofetch style intro
{ type: 'command', content: 'bash ~/startup.sh', delay: 300 },
{ type: 'blank', content: '' },
{ type: 'header', content: `Welcome to ${user.name}'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: '' },
// Interactive navigation buttons
...navigation.map(nav => ({
type: 'button' as const,
content: nav.name,
icon: nav.icon === '📁' ? 'mdi:folder' : nav.icon === '🎨' ? 'mdi:palette' : 'mdi:trophy',
style: 'primary' as const,
href: nav.path
})),
];
</script>
<section class="grid container h-full mx-auto justify-center">
<div class="my-8 grid-cols-1 gap-2 items-center">
<div class="flex items-center">
<BlobPFP src="/blob_nerd.png" alt="Blob PFP" />
</div>
<div class="grid my-4 text-center ">
<h1 class="bts-h1">Sir Blob</h1>
<br>
<p class="h-fit text-2xl typewriter anim-typewriter">Projects, Games, API, and More!</p>
</div>
<div class="my-4 text-center flex-col">
<button type="button" class="mx-2 my-2 btn variant-ghost-tertiary">
<a
href="/portfolio"
rel="noreferrer">
Portfolio
</a>
</button>
<!-- <button type="button" class="mx-2 my-2 btn variant-ghost-warning">Projects</button>
<button type="button" class="mx-2 my-2 btn variant-ghost-success">TGS</button> -->
<button type="button" class="mx-2 my-2 btn variant-ghost-error">
<a
href="/hackathons"
rel="noreferrer">
Hackathons
</a>
</button>
<button type="button" class="mx-4 my-4 btn variant-ghost-secondary">
<a
href="https://github.com/SirBlobby"
target="_blank"
rel="noreferrer">
<span class="icon-[mdi--github] size-6">.</span>
</a>
</button>
</div>
</div>
</section>
<svelte:head>
<title>{site.title}</title>
<meta name="description" content={site.description} />
</svelte:head>
<style lang="postcss">
.bts-h1 {
@apply my-auto text-center;
font-size: 50px;
<div class="home-container">
<TerminalTUI {lines} title="~" interactive={true} {speed} />
</div>
<style>
.home-container {
padding: 2rem 1rem;
min-height: calc(100vh - 60px);
}
.typewriter {
margin-left: auto;
margin-right: auto;
margin-top: 10px;
margin-bottom: 10px;
width: 50%;
border-right: 3.5px solid rgba(255,255,255,.75);
text-align: center;
white-space: nowrap;
overflow: hidden;
}
.anim-typewriter {
animation: typewriter 3s steps(44) 1s 1 normal both, blinkTextCursor 500ms steps(44) infinite normal;
}
@keyframes typewriter {
from{width: 0;}
to{width: 16em;}
}
@keyframes blinkTextCursor {
from{border-right-color: rgba(230, 118, 14, 0.75);}
to{border-right-color: transparent;}
}
</style>

View File

@@ -0,0 +1,314 @@
<script lang="ts">
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalLine } from '$lib/components/tui/types';
import { user } from '$lib/config';
import { getPageSpeedMultiplier } from '$lib';
const speed = getPageSpeedMultiplier('portfolio');
// Sample data for examples
const sampleTableHeaders = ['Name', 'Type', 'Status'];
const sampleTableRows = [
['Button', 'Interactive', 'Ready'],
['Progress', 'Display', 'Active'],
['Card', 'Container', 'Ready']
];
const sampleAccordionItems = [
{ title: 'What is this?', content: 'A showcase of all TUI components available in this terminal.' },
{ title: 'How to use?', content: 'Copy the code examples and customize for your needs.' },
{ title: 'Can I add more?', content: 'Yes! Check the types.ts file for the full API.' }
];
// Build comprehensive component showcase
const lines: TerminalLine[] = [
// Header
{ type: 'command', content: 'cat ~/components/README.md' },
{ type: 'blank', content: '' },
{ type: 'header', content: '(&primary,bold)Terminal UI Components(&)' },
{ type: 'output', content: '(&muted)A comprehensive showcase of all available TUI components(&)' },
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// TEXT FORMATTING
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'TEXT FORMATTING' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Colors(&)' },
{ type: 'output', content: '(&red)red(&) (&green)green(&) (&yellow)yellow(&) (&blue)blue(&) (&magenta)magenta(&) (&cyan)cyan(&) (&orange)orange(&) (&pink)pink(&)' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Semantic Colors(&)' },
{ type: 'output', content: '(&primary)primary(&) (&accent)accent(&) (&muted)muted(&) (&error)error(&) (&success)success(&) (&warning)warning(&) (&info)info(&)' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Text Styles(&)' },
{ type: 'output', content: '(&bold)bold(&) (&dim)dim(&) (&italic)italic(&) (&underline)underline(&) (&strikethrough)strikethrough(&)' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Combined Styles(&)' },
{ type: 'output', content: '(&bold,red)bold red(&) (&italic,cyan)italic cyan(&) (&bold,underline,yellow)bold underline yellow(&)' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Inline Icons(&)' },
{ type: 'output', content: '(&icon, mdi:github) GitHub (&icon, mdi:twitter) Twitter (&icon, mdi:heart) Love (&icon, mdi:star) Star' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Background Colors(&)' },
{ type: 'output', content: '(&bg-surface)surface bg(&) (&bg-red)red bg(&) (&bg-blue)blue bg(&) (&bg-green)green bg(&)' },
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// LINE TYPES
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'LINE TYPES' },
{ type: 'blank', content: '' },
{ type: 'command', content: 'echo "This is a command line"' },
{ type: 'output', content: 'This is an output line' },
{ type: 'info', content: 'This is an info line' },
{ type: 'success', content: 'This is a success line' },
{ type: 'warning', content: 'This is a warning line' },
{ type: 'error', content: 'This is an error line' },
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// BUTTONS
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'BUTTONS' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Button Styles(&)' },
{
type: 'button',
content: 'Primary Button',
icon: 'mdi:check',
style: 'primary',
action: () => console.log('Primary clicked')
},
{
type: 'button',
content: 'Secondary Button',
icon: 'mdi:information',
style: 'secondary',
action: () => console.log('Secondary clicked')
},
{
type: 'button',
content: 'Accent Button',
icon: 'mdi:star',
style: 'accent',
action: () => console.log('Accent clicked')
},
{
type: 'button',
content: 'Warning Button',
icon: 'mdi:alert',
style: 'warning',
action: () => console.log('Warning clicked')
},
{
type: 'button',
content: 'Error Button',
icon: 'mdi:close-circle',
style: 'error',
action: () => console.log('Error clicked')
},
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Link Buttons(&)' },
{
type: 'button',
content: 'External Link (GitHub)',
icon: 'mdi:github',
style: 'primary',
href: 'https://github.com',
external: true
},
{
type: 'button',
content: 'Internal Link (Home)',
icon: 'mdi:home',
style: 'accent',
href: '/'
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// LINKS
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'LINKS' },
{ type: 'blank', content: '' },
{
type: 'link',
content: 'Click here to visit GitHub',
href: 'https://github.com',
icon: 'mdi:github',
external: true
},
{
type: 'link',
content: 'Go to portfolio page',
href: '/portfolio',
icon: 'mdi:folder'
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// PROGRESS BARS
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'PROGRESS BARS' },
{ type: 'blank', content: '' },
{
type: 'progress',
content: 'Loading...',
progress: 25,
progressLabel: 'Installing packages'
},
{
type: 'progress',
content: '',
progress: 50,
progressLabel: 'Building project'
},
{
type: 'progress',
content: '',
progress: 75,
progressLabel: 'Running tests'
},
{
type: 'progress',
content: '',
progress: 100,
progressLabel: 'Complete!'
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// IMAGES
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'IMAGES' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Image with Caption(&)' },
{
type: 'image',
content: 'User Avatar',
image: user.avatar,
imageAlt: 'Profile picture',
imageWidth: 100
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// CARDS
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'CARDS' },
{ type: 'blank', content: '' },
{
type: 'card',
content: 'This is card content. Cards can contain formatted text and are great for highlighting information.',
cardTitle: 'Card Title',
cardFooter: 'Card Footer'
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// TABLES
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'TABLES' },
{ type: 'blank', content: '' },
{
type: 'table',
content: '',
tableHeaders: sampleTableHeaders,
tableRows: sampleTableRows
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// ACCORDIONS
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'ACCORDIONS' },
{ type: 'blank', content: '' },
{
type: 'accordion',
content: '',
accordionItems: sampleAccordionItems,
accordionOpen: false
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// TOOLTIPS
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'TOOLTIPS' },
{ type: 'blank', content: '' },
{
type: 'tooltip',
content: 'Hover over me for more info!',
tooltipText: 'This is tooltip content that appears on hover.',
tooltipPosition: 'top'
},
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// DIVIDERS
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'DIVIDER STYLES' },
{ type: 'blank', content: '' },
{ type: 'output', content: '(&muted)Dividers are simple horizontal separators with optional text:(&)' },
{ type: 'divider', content: 'SECTION A' },
{ type: 'output', content: '(&muted)Content for section A...(&)' },
{ type: 'divider', content: 'SECTION B' },
{ type: 'output', content: '(&muted)Content for section B...(&)' },
{ type: 'divider', content: '' },
{ type: 'output', content: '(&muted)Empty divider above (no text)(&)' },
{ type: 'blank', content: '' },
// ═══════════════════════════════════════════════════════════════
// USAGE EXAMPLES
// ═══════════════════════════════════════════════════════════════
{ type: 'divider', content: 'USAGE' },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&blue,bold)Code Example(&)' },
{ type: 'output', content: "(&muted)// Text formatting(&)" },
{ type: 'output', content: "{ type: 'output', content: '(&green)colored text(&)' }" },
{ type: 'blank', content: '' },
{ type: 'output', content: "(&muted)// Button with action(&)" },
{ type: 'output', content: "{ type: 'button', content: 'Click', icon: 'mdi:check', style: 'primary', action: () => {} }" },
{ type: 'blank', content: '' },
{ type: 'output', content: "(&muted)// Progress bar(&)" },
{ type: 'output', content: "{ type: 'progress', progress: 75, progressLabel: 'Loading...' }" },
{ type: 'blank', content: '' },
// End
{ type: 'success', content: '(&success)Component showcase complete!(&)' }
];
</script>
<svelte:head>
<title>Components | {user.name}</title>
<meta name="description" content="Terminal UI Components Showcase" />
</svelte:head>
<div class="components-container">
<TerminalTUI {lines} title="~/components" interactive={true} {speed} />
</div>
<style>
.components-container {
padding: 2rem 1rem;
min-height: calc(100vh - 60px);
}
</style>

View File

@@ -1,161 +1,43 @@
<script lang="ts">
import HackCard from "$lib/components/HackCard.svelte";
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalLine } from '$lib/components/tui/types';
import { user, sortedCards } from '$lib/config';
import { getPageSpeedMultiplier } from '$lib';
type Card = {
title: string;
description: string;
image?: string;
link?: string;
repo?: string;
devpost?: string;
hackathonName?: string;
university?: string;
location?: string;
year?: string;
tags?: string[];
featured?: boolean;
awards?: { track: string; place: string }[];
liveWarning?: boolean;
};
const speed = getPageSpeedMultiplier('hackathons');
const cards: Card[] = [
{
image: "/hacks/fooddecisive.png",
title: "Food Decisive",
description:
"Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive",
link: "https://fooddecisive.sirblob.co/",
repo: "https://github.com/SirBlobby/VTHacks-12",
devpost: "https://devpost.com/software/food-decisive",
hackathonName: "VTHacks 12",
university: "Virginia Tech",
location: "Blacksburg, VA",
year: "2024",
tags: ["AI", "Llama3.1", "Svelte", "Node.js"],
featured: false,
liveWarning: true
},
{
image: "/hacks/carbin.png",
title: "Carbin",
description:
"Encourage student participation in responsible waste management with smart bins that guide proper disposal.",
repo: "https://github.com/SirBlobby/patriotHacks2024",
devpost: "https://devpost.com/software/carboniferous-akc4mj",
hackathonName: "PatriotHacks 2024",
university: "George Mason University",
location: "Fairfax, VA",
year: "2024",
tags: ["AI", "Azure", "CloudConvert", "Python", "React", "TypeScript", "API"],
featured: true,
awards: [
{ track: "Save the World", place: "2nd Place" },
{ track: "Microsoft X Cloudforce", place: "2nd Place" }
]
},
{
image: "/hacks/patsafe.png",
title: "PatSafe",
description:
"Bridging the gap between doctors and patients for seamless post-discharge care",
link: "https://hoya-hax2025.vercel.app/",
repo: "https://github.com/SirBlobby/HoyaHax2025",
devpost: "https://devpost.com/software/patsafe",
hackathonName: "HoyaHax 2025",
university: "Georgetown University",
location: "Washington, D.C.",
year: "2025",
tags: ["Javascript", "Next.js", "React", "TypeScript", "LangChain", "OpenAI"],
},
{
title: "Fauxcall",
description:
"This product is perfect for situations where you are walking at night and you feel unsafe from someone. Fauxcall lets users create a convincing fake phone call to deter potential attackers and provide a quick escape mechanism.",
link: "",
repo: "https://github.com/SirBlobby/HooHacks-12",
devpost: "https://devpost.com/software/fauxcall",
hackathonName: "HooHacks 2025",
university: "University of Virginia",
location: "Charlottesville, VA",
year: "2025",
tags: ["mongodb", "next.js", "python", "react", "sesame.com", "skeleton", "twilio"],
featured: false
},
{
image: "/hacks/drinkhappy.png",
title: "Drink Happy",
description:
"drinkhappy.tech is a gamified hydration wellness app that helps users track drinks, earn points for healthy choices, and compete with friends. It's powered by Gemini AI for smart drink recognition.",
link: "https://drinkhappy.tech",
repo: "https://github.com/SirBlobby/Bitcamp-2025",
devpost: "https://devpost.com/software/drink-happy",
hackathonName: "Bitcamp",
university: "University of Maryland",
location: "College Park, MD",
year: "2025",
tags: ["api", "auth0", "gemini", "github", "javascript", "mongodb", "nextjs", "react", "tailwind", "vercel"],
featured: false
},
{
image: "/hacks/roadcast.png",
title: "Roadcast",
description:
"Roadcast provides drivers with crash data and weather insights to choose safer routes home. The project combines historical crash data, weather feeds, and spatial analysis to surface hazardous areas and route-level safety recommendations.",
link: "https://roadcast.sirblob.co/",
repo: "https://github.com/SirBlobby/roadcast",
devpost: "https://devpost.com/software/roadcast",
hackathonName: "VTHacks 13",
university: "Virginia Tech",
location: "Blacksburg, VA",
year: "2025",
tags: ["react", "node.js", "python", "mongodb", "geospatial", "mapbox"],
featured: true,
liveWarning: true
}
// Count stats
const totalHackathons = sortedCards.length;
const totalAwards = sortedCards.filter(c => c.awards && c.awards.length > 0).length;
const featuredCount = sortedCards.filter(c => c.featured).length;
// Build the terminal lines with card grid
const lines: TerminalLine[] = [
{ 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: '' },
{ type: 'success', content: `(&success)Ready for the next hackathon! 🚀(&)` },
];
// Sort cards to show featured first
$: sortedCards = [...cards].sort((a, b) => {
if (a.featured && !b.featured) return -1;
if (!a.featured && b.featured) return 1;
return 0;
});
</script>
<section class="container mx-auto py-12 px-4">
<div class="grid p-4 w-full h-fit">
<main>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8 items-stretch"
>
{#each sortedCards as c}
<div class="h-full">
<HackCard
title={c.title}
description={c.description}
image={c.image}
link={c.link}
repo={c.repo}
devpost={c.devpost}
hackathonName={c.hackathonName}
university={c.university}
location={c.location}
year={c.year}
tags={c.tags}
featured={c.featured}
awards={c.awards}
liveWarning={c.liveWarning}
/>
</div>
{/each}
</div>
</main>
</div>
</section>
<svelte:head>
<title>Hackathons | {user.name}</title>
<meta name="description" content="Hackathon projects and achievements" />
</svelte:head>
<style lang="postcss">
/* page-level tweaks */
.container {
max-width: 1200px;
<div class="hackathons-container">
<TerminalTUI {lines} title="~/hackathons" interactive={true} {speed} />
</div>
<style>
.hackathons-container {
padding: 2rem 1rem;
min-height: calc(100vh - 60px);
}
</style>

272
src/routes/layout.css Normal file
View File

@@ -0,0 +1,272 @@
/* Font imports - must come first */
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/* CSS Reset and Base Styles */
:root {
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
/* Animation durations */
--transition-fast: 150ms;
--transition-normal: 250ms;
--transition-slow: 400ms;
/* Spacing */
--navbar-height: 60px;
}
/* Load local JetBrains Mono SemiBold from static folder so the site uses the exact semi-bold file */
@font-face {
font-family: 'JetBrains Mono';
src: url('/font/JetBrainsMono-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* Prefer the local semi-bold version when bold/600 weight is requested */
code, pre, kbd, samp, .terminal, .mono, .prompt, .prompt-mini, .hero-title {
font-family: 'JetBrains Mono', var(--font-mono);
font-weight: 600; /* Use the semi-bold file */
}
/* Ensure headings using monospace also render the semibold weight */
.heading-responsive, .hero-title, .terminal-title, .nav-brand {
font-family: 'JetBrains Mono', var(--font-mono);
font-weight: 600;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
min-height: 100vh;
}
/* Terminal font for code elements */
code, pre, kbd, samp, .terminal, .mono {
font-family: var(--font-mono);
}
/* Main content wrapper */
.main-content {
padding-top: var(--navbar-height);
min-height: 100vh;
position: relative;
z-index: 1;
}
/* Scrollbar styling - hidden but still scrollable */
::-webkit-scrollbar {
display: none;
}
/* For Firefox */
html {
scrollbar-width: none;
}
/* Keep scroll functionality */
body {
-ms-overflow-style: none; /* IE and Edge */
}
/* Selection styling */
::selection {
background: rgba(23, 147, 209, 0.3);
color: inherit;
}
/* Focus styles */
:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/* Links */
a {
color: inherit;
text-decoration: none;
transition: color var(--transition-fast) ease;
}
/* Buttons reset */
button {
font-family: inherit;
cursor: pointer;
}
/* Animation utilities */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes glow {
0%, 100% {
box-shadow: 0 0 5px currentColor, 0 0 10px currentColor;
}
50% {
box-shadow: 0 0 10px currentColor, 0 0 20px currentColor, 0 0 30px currentColor;
}
}
@keyframes typing {
from { width: 0; }
to { width: 100%; }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Utility classes */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
.animate-fade-in-up {
animation: fadeInUp 0.5s ease-out;
}
.animate-fade-in-down {
animation: fadeInDown 0.5s ease-out;
}
.animate-slide-in-left {
animation: slideInLeft 0.5s ease-out;
}
.animate-slide-in-right {
animation: slideInRight 0.5s ease-out;
}
.animate-pulse {
animation: pulse 2s ease-in-out infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-glow {
animation: glow 2s ease-in-out infinite;
}
/* Stagger animation delays */
.stagger-1 { animation-delay: 0.1s; }
.stagger-2 { animation-delay: 0.2s; }
.stagger-3 { animation-delay: 0.3s; }
.stagger-4 { animation-delay: 0.4s; }
.stagger-5 { animation-delay: 0.5s; }
/* Glass effect */
.glass {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Terminal window styling */
.terminal-window {
border-radius: 12px;
overflow: hidden;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
/* Container */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
/* Grid system */
.grid-terminal {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
/* Responsive text */
.text-responsive {
font-size: clamp(1rem, 2.5vw, 1.25rem);
}
.heading-responsive {
font-size: clamp(1.5rem, 5vw, 3rem);
}

View File

@@ -1,18 +0,0 @@
import fs from 'fs';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
let files = [];
const models = fs.readdirSync(`./static/models`).filter(file => file.endsWith('.glb'));
for(let model of models) {
let obj = {
path: "/models/" + model,
name: model.split('.')[0]
}
files.push(obj);
}
return { files };
}

View File

@@ -1,55 +1,194 @@
<svelte:head>
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>
</svelte:head>
<script lang="ts">
// @ts-nocheck
import { page } from '$app/stores';
import { onMount } from "svelte";
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalLine } from '$lib/components/tui/types';
import ModelViewer from '$lib/components/ModelViewer.svelte';
import { user } from '$lib/config';
import { getPageSpeedMultiplier } from '$lib';
import { themeColors } from '$lib/stores/theme';
import Icon from '@iconify/svelte';
import { getModalStore } from '@skeletonlabs/skeleton';
const speed = getPageSpeedMultiplier('models');
const modalStore = getModalStore();
// Available GLB files in static/models/
const glbModels = [
{ name: 'Bake Potato', path: '/models/BakePotato.glb' },
{ name: 'Soda Can', path: '/models/Soda.glb' }
];
let container, row, modelViewer, modelViewerTitle, modelViewerBtn;
let selectedModel = $state(glbModels[0]);
let showViewer = $state(false);
// const modal: ModalSettings = {
// type: 'alert',
// // Data
// title: 'Example Alert',
// body: 'This is an example modal.',
// image: 'https://i.imgur.com/WOgTG96.gif',
// };
// modalStore.trigger(modal);
onMount(() => {
modelViewerTitle = document.querySelector("#model-viewer-bts-title");
for(let i = 0; i < $page.data.files.length; i++) {
console.log($page.data.files[i]);
}
});
function download() {
modelViewerBtn.disabled = true;
modelViewerBtn.innerHTML = "Downloading...";
modelViewer.exportScene("glb").then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = modelViewerTitle.innerText + ".glb";
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
modelViewerBtn.disabled = false;
modelViewerBtn.innerHTML = "Download";
});
function selectModel(model: typeof glbModels[0]) {
selectedModel = model;
showViewer = true;
}
// Build the terminal lines for the models page
const lines: TerminalLine[] = [
{ type: 'command', content: 'ls ~/3d-models/' },
{ type: 'blank', content: '' },
{ type: 'header', content: '(&primary,bold)3D Models(&)' },
{ type: 'output', content: `(&muted)A collection of 3D models created for games, visualization, and fun.(&)` },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'VIEWER' },
{ type: 'blank', content: '' },
// Interactive model buttons
...glbModels.map(model => ({
type: 'button' as const,
content: model.name,
icon: 'mdi:cube-scan',
style: 'primary' as const,
action: () => selectModel(model)
})),
{ type: 'blank', content: '' },
{ type: 'success', content: `(&success)${glbModels.length} models available(&)` }
];
</script>
<svelte:head>
<title>3D Models | {user.name}</title>
<meta name="description" content="3D Models portfolio - Characters, Environments, Props and more" />
</svelte:head>
<section class="h-full mx-auto justify-center p-10">
<div class="models-page">
<div
class="models-layout"
class:viewer-open={showViewer}
style="--theme-bg: {$themeColors.background};"
>
<!-- Terminal Section -->
<div class="terminal-section">
<TerminalTUI {lines} title="~/3d-models" interactive={true} {speed} />
</div>
<!-- 3D Viewer Section -->
{#if showViewer}
<div class="viewer-section">
<div class="viewer-tabs">
{#each glbModels as model}
<button
class="viewer-tab"
class:active={selectedModel.path === model.path}
onclick={() => selectModel(model)}
>
<Icon icon="mdi:cube-outline" width="14" />
{model.name}
</button>
{/each}
<button class="viewer-tab close" onclick={() => showViewer = false}>
<Icon icon="mdi:close" width="14" />
</button>
</div>
<ModelViewer
modelPath={selectedModel.path}
modelName={selectedModel.name}
class="model-viewer-container"
/>
</div>
{/if}
</div>
</div>
<style>
.models-page {
min-height: calc(100vh - 60px);
padding: 2rem 1rem;
}
.models-layout {
display: flex;
gap: 1.5rem;
max-width: 1600px;
margin: 0 auto;
transition: all 0.3s ease;
}
.terminal-section {
flex: 1;
min-width: 0;
}
.viewer-section {
width: 500px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.viewer-tabs {
display: flex;
background: var(--theme-bg);
border-radius: 8px 8px 0 0;
overflow: hidden;
}
.viewer-tab {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.5rem 1rem;
background: transparent;
border: none;
color: inherit;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
cursor: pointer;
opacity: 0.6;
transition: all 0.2s ease;
}
.viewer-tab:hover {
opacity: 0.9;
background: rgba(255, 255, 255, 0.05);
}
.viewer-tab.active {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
.viewer-tab.close {
margin-left: auto;
opacity: 0.5;
}
.viewer-tab.close:hover {
opacity: 1;
color: #f38ba8;
}
:global(.model-viewer-container) {
flex: 1;
min-height: 400px;
border-radius: 0 0 8px 8px;
}
@media (max-width: 1024px) {
.models-layout {
flex-direction: column;
}
.viewer-section {
width: 100%;
order: -1;
}
}
</style>
</section>

View File

@@ -1,103 +1,134 @@
<script>
import { Avatar } from "@skeletonlabs/skeleton";
import RoleChips from "./RoleChip.svelte";
import ContactChip from "./ContactChip.svelte";
import LangChip from "./LangChip.svelte";
import ProjectCard from "./ProjectCard.svelte";
import OrgCard from "./OrgCard.svelte";
<script lang="ts">
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
import type { TerminalLine } from '$lib/components/tui/types';
import { user, skills, projects, site } from '$lib/config';
import { getPageSpeedMultiplier } from '$lib';
const speed = getPageSpeedMultiplier('portfolio');
// Build the terminal lines for the portfolio page
const lines: TerminalLine[] = [
// Header command
{ type: 'command', content: 'cat ~/about.md' },
{ type: 'blank', content: '' },
// Avatar image
{ type: 'image', content: '', image: user.avatar, imageAlt: user.name, imageWidth: 150 },
{ type: 'blank', content: '' },
// User info
{ type: 'header', content: `(&primary,bold)${user.name}(&)` },
{ type: 'info', content: `(&accent)${user.title}(&)` },
{ type: 'output', content: `(&muted)${user.bio}(&)` },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'CONTACT' },
{ type: 'blank', content: '' },
// Contact buttons - dynamically generated from socials array
...user.socials.map(social => ({
type: 'button' as const,
content: `${social.name}: ${social.link}`,
icon: social.icon,
style: 'primary' as const,
href: social.link
})),
// {
// type: 'button',
// content: `Email: ${user.email}`,
// icon: 'mdi:email',
// style: 'secondary',
// href: `mailto:${user.email}`
// },
{ type: 'blank', content: '' },
{ type: 'divider', content: 'SKILLS' },
{ type: 'blank', content: '' },
// Skills as TUI sections
{ type: 'info', content: '(&blue,bold)▸ Languages(&)' },
{ type: 'output', content: ' ' + skills.languages.map(s => `(&cyan)${s}(&)`).join(' (&muted)•(&) ') },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&magenta,bold)▸ Frameworks(&)' },
{ type: 'output', content: ' ' + skills.frameworks.map(s => `(&pink)${s}(&)`).join(' (&muted)•(&) ') },
{ type: 'blank', content: '' },
{ type: 'info', content: '(&green,bold)▸ Tools(&)' },
{ type: 'output', content: ' ' + skills.tools.map(s => `(&green)${s}(&)`).join(' (&muted)•(&) ') },
{ type: 'blank', content: '' },
{ 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: 'blank', content: '' },
// Featured projects with buttons
...projects.filter(p => p.featured).flatMap(project => [
{ type: 'header' as const, content: `(&primary,bold)${project.name}(&)` },
{ type: 'output' as const, content: `(&muted)${project.description}(&)` },
{ type: 'info' as const, content: `(&info)Tech: (&primary)${project.tech.join(', ')}(&)` },
...(project.github ? [{
type: 'button' as const,
content: 'View on GitHub',
icon: 'mdi:github',
style: 'accent' as const,
href: project.github
}] : []),
...(project.live ? [{
type: 'button' as const,
content: 'View Live Demo',
icon: 'mdi:open-in-new',
style: 'accent' as const,
href: project.live
}] : []),
{ type: 'blank' as const, content: '' }
]),
// Other projects
...projects.filter(p => !p.featured).flatMap(project => [
{ type: 'success' as const, content: `(&success)${project.name}(&)` },
{ type: 'output' as const, content: `(&muted)${project.description}(&)` },
{ type: 'info' as const, content: `(&info)Tech:(&) (&primary)${project.tech.join(', ')}(&)` },
...(project.github ? [{
type: 'button' as const,
content: 'View on GitHub',
icon: 'mdi:github',
style: 'accent' as const,
href: project.github
}] : []),
...(project.live ? [{
type: 'button' as const,
content: 'View Live',
icon: 'mdi:open-in-new',
style: 'accent' as const,
href: project.live
}] : []),
{ type: 'blank' as const, content: '' }
]),
// End
{ type: 'success', content: `(&success)Portfolio loaded successfully!(&)` }
];
</script>
<section class="h-full mx-auto justify-center p-5 container">
<div class="grid card p-4 w-full h-fit">
<div class="grid grid-cols-1 md:grid-cols-2 my-2 p-4 h-fit">
<div>
<div class="h-fit inline">
<h1 class="my-4 text-2xl font-bold">Sir Blob</h1>
<div class="my-4 h-fit inline font-bold">
<RoleChips role="College Student" />
<RoleChips role="USA" />
<RoleChips role="Computer Science" />
<RoleChips role="Engineering" />
</div>
<p class="my-4 text-lg">
Hi, I am Sir Blob a developer that loves making things.
My passion is using Computer Science with practical Engineering to bring “Forgotten” technology into the future.
Therefore, I do fun coding projects, Game Jams, and Hackathons.
I like to play video games, like Minecraft and Pokémon TCG Live.
</p>
<ContactChip link="https://github.com/GamerBoss101" icon="icon-[mdi--github]" />
<ContactChip link="https://devpost.com/Sir_Blob_" icon="icon-[simple-icons--devpost]" />
<ContactChip link="https://www.linkedin.com/in/gmanjunatha/" icon="icon-[mdi--linkedin]" />
</div>
</div>
<div class="h-fit">
<Avatar class="my-auto mx-auto" width="w-1/2" rounded="rounded-xl" src="https://avatars.githubusercontent.com/u/76974209?v=4" />
</div>
</div>
<div class="grid my-2 h-fit">
<h1 class="text-xl font-bold my-2">Programming Languages</h1>
<div class="inline my-4">
<LangChip icon="icon-[devicon--python]" />
<LangChip icon="icon-[devicon--javascript]" />
<LangChip icon="icon-[devicon--typescript]" />
<LangChip icon="icon-[devicon--c]" />
<LangChip icon="icon-[mdi--language-cpp]" />
<LangChip icon="icon-[devicon--java]" />
<LangChip icon="icon-[devicon--nodejs]" />
</div>
<h1 class="my-2 text-xl font-bold">Applications</h1>
<div class="inline my-4">
<LangChip icon="icon-[simple-icons--windows]" />
<LangChip icon="icon-[simple-icons--linux]" />
<LangChip icon="icon-[simple-icons--apple]" />
<LangChip icon="icon-[devicon--intellij]" />
<LangChip icon="icon-[devicon--vscode]" />
<LangChip icon="icon-[simple-icons--git]" />
<LangChip icon="icon-[logos--blender]"/>
<LangChip icon="icon-[devicon--godot]" />
</div>
<h1 class="my-2 text-xl font-bold">Frameworks</h1>
<div class="inline my-4">
<LangChip icon="icon-[devicon--arduino]" />
<LangChip icon="icon-[devicon--bootstrap]" />
<LangChip icon="icon-[devicon--tailwindcss]" />
<LangChip icon="icon-[devicon--discordjs]" />
<LangChip icon="icon-[devicon--react]" />
<LangChip icon="icon-[devicon--electron]" />
<LangChip icon="icon-[devicon--svelte]" />
<LangChip icon="icon-[devicon--mongodb]" />
</div>
<h1 class="text-xl font-bold my-2">Projects</h1>
<div class="grid grid-cols-1 my-4 lg:grid-cols-4 gap-4">
<ProjectCard
name="PokemonTCGAPI"
icon="https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png"
link="https://www.npmjs.com/package/@bosstop/pokemontcgapi"
/>
<ProjectCard
name="MCSS TS API"
icon="https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png"
link="https://www.npmjs.com/package/@mcserversoft/mcss-api"
/>
<ProjectCard
name="MCP Selenium"
icon="https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png"
link="https://www.npmjs.com/package/@sirblob/mcp-selenium"
/>
<ProjectCard
name="Pkit"
icon="https://icons.veryicon.com/png/o/business/vscode-program-item-icon/rust-1.png"
link="https://github.com/dead-projects-inc/pkit-cli"
/>
</div>
<!-- <h1 class="text-xl font-bold my-2">Organizations / Servers</h1>
<div class="grid grid-cols-1 my-4 md:grid-cols-4 gap-4">
<OrgCard
name="ASME"
icon="https://acc2022.a2c2.org/wp-content/uploads/sites/45/2021/03/asme-logo-300x178.png"
link=""
/>
</div> -->
</div>
</div>
</section>
<svelte:head>
<title>Portfolio | {user.name}</title>
<meta name="description" content={site.description} />
</svelte:head>
<div class="portfolio-container">
<TerminalTUI {lines} title="~/portfolio" interactive={true} {speed} />
</div>
<style>
.portfolio-container {
padding: 2rem 1rem;
min-height: calc(100vh - 60px);
}
</style>

View File

@@ -1,13 +0,0 @@
<script lang="ts">
export let link: string;
export let icon: string;
</script>
<span class="mx-2 my-2 w-fit btn variant-filled">
<a
href="{link}"
target="_blank"
rel="noreferrer">
<span class="{icon}">.</span>
</a>
</span>

View File

@@ -1,8 +0,0 @@
<script lang="ts">
export let icon: string;
export let content: string = "";
</script>
<span class="mx-2 my-2 w-fit btn variant-filled text-lg font-bold">
<span class="{icon}">{content}</span>
</span>

View File

@@ -1,27 +0,0 @@
<script lang="ts">
import { Avatar } from "@skeletonlabs/skeleton";
export let name: string;
export let icon: string;
export let link: string;
</script>
<div class="grid grid-cols-1 sm:grid-cols-2 card p-4 bg-card-alt">
<Avatar class="w-fit md:w-24" alt="{name} Icon" src="{icon}" />
<p class="my-auto mx-auto p-2 fit-text-in-div font-bold text-center text-white">
<a href="{link}">{name}</a>
</p>
</div>
<style lang="postcss">
.bg-card-alt {
background-color: #1a202c;
}
.fit-text-in-div {
padding: 0;
font-size: 125%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -1,25 +0,0 @@
<script lang="ts">
export let name: string;
export let icon: string;
export let link: string;
</script>
<div class="card bg-card-alt rounded-xl shadow-lg flex items-center gap-4 px-4 py-1 w-full max-w-sm mx-auto">
<img class="w-16 h-16 object-contain rounded-md" alt="{name} Icon" src="{icon}" />
<a class="font-bold text-white fit-text-in-div text-lg md:text-xl" href="{link}">{name}</a>
</div>
<style lang="postcss">
.bg-card-alt {
background-color: #1a202c;
}
.fit-text-in-div {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
max-width: 14rem;
margin: 0;
}
</style>

View File

@@ -1,5 +0,0 @@
<script lang="ts">
export let role: string;
</script>
<span class="font-bold mx-1 my-1 w-fit chip variant-filled">{role}</span>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="200.000000pt" height="200.000000pt" viewBox="0 0 200.000000 200.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,200.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M175 1608 c-3 -7 -6 -231 -7 -498 l-3 -485 -75 -3 c-44 -2 -78 -8
-82 -15 -14 -22 -8 -87 11 -126 11 -21 36 -52 56 -69 l36 -32 887 0 887 0 37
25 c20 13 45 44 57 68 24 49 28 119 8 135 -7 6 -44 12 -82 14 l-70 3 -5 495
-5 495 -823 3 c-653 2 -824 0 -827 -10z m1575 -528 l0 -460 -750 0 -750 0 0
460 0 460 750 0 750 0 0 -460z m-943 -560 c24 -18 41 -20 203 -20 163 0 179 2
203 20 25 20 38 20 367 18 l341 -3 -23 -35 -24 -35 -862 -3 c-581 -1 -869 1
-882 8 -11 6 -27 24 -35 40 l-16 30 352 0 c336 0 352 -1 376 -20z"/>
<path d="M1066 1308 c-8 -13 -69 -133 -136 -268 -78 -157 -119 -251 -115 -262
8 -20 32 -30 54 -23 9 3 75 122 149 270 110 221 131 269 122 285 -15 27 -56
26 -74 -2z"/>
<path d="M627 1161 c-53 -54 -97 -105 -97 -112 0 -8 47 -61 104 -118 84 -84
107 -102 124 -97 12 4 24 16 28 28 5 16 -9 36 -70 97 -42 42 -76 81 -76 86 0
6 34 44 75 85 75 74 86 95 63 118 -27 27 -55 11 -151 -87z"/>
<path d="M1187 1253 c-4 -3 -7 -18 -7 -32 0 -18 21 -47 75 -101 l75 -75 -75
-75 c-74 -74 -88 -103 -63 -128 7 -7 18 -12 26 -12 24 0 212 191 212 215 0 28
-188 215 -216 215 -11 0 -24 -3 -27 -7z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

View File

@@ -3,20 +3,10 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
extensions: ['.svelte'],
tsconfigFile: "./tsconfig.json",
compilerOptions: {
enableSourcemap: true,
},
preprocess: [vitePreprocess({
sourceMap: true
})],
kit: {
adapter: adapter()
},
csrf: {
origin: process.env.PUBLIC_HOST,
checkOrigin: false,
}
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
};
export default config;

View File

@@ -1,32 +0,0 @@
import { join } from 'path'
import type { Config } from 'tailwindcss'
import { skeleton } from '@skeletonlabs/tw-plugin'
import { myCustomTheme } from './CustomTheme';
import { addDynamicIconSelectors } from '@iconify/tailwind';
import forms from '@tailwindcss/forms';
export default {
darkMode: 'selector',
content: ['./src/**/*.{html,js,svelte,ts}', join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')],
theme: {
extend: {},
},
plugins: [
forms,
skeleton({
themes: {
custom: [
myCustomTheme
]
},
}),
addDynamicIconSelectors({
prefix: 'icon',
scale: 2,
iconSets: {},
customise: (content, name, prefix) => content
})
],
} satisfies Config;

View File

@@ -1,6 +1,7 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
@@ -11,9 +12,9 @@
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

View File

@@ -1,7 +1,7 @@
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit(), purgeCss()]
plugins: [tailwindcss(), sveltekit()]
});