Website Redesign 7
33
.gitignore
vendored
@@ -1,8 +1,25 @@
|
||||
.svelte-kit/
|
||||
node_modules/
|
||||
bun.lockb
|
||||
build/
|
||||
.env
|
||||
package-lock.json
|
||||
|
||||
bun.lock
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
bun.lock
|
||||
|
||||
103
CustomTheme.ts
@@ -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
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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/
|
||||
69
package.json
@@ -1,40 +1,33 @@
|
||||
{
|
||||
"name": "btsw-api6",
|
||||
"version": "6.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "vite build && bun ./server/server.js",
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"server": "bun ./server/server.js"
|
||||
},
|
||||
"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:",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.1",
|
||||
"express": "^4.21.2",
|
||||
"ms": "^2.1.3"
|
||||
}
|
||||
"name": "website",
|
||||
"private": true,
|
||||
"version": "7.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --host",
|
||||
"build": "vite build",
|
||||
"server": "node build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.4.0",
|
||||
"@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": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"three": "^0.181.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { handler } from '../build/handler.js';
|
||||
import dotenv from 'dotenv';
|
||||
import http from 'http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const server = http.Server(app);
|
||||
|
||||
app.use(cors());
|
||||
|
||||
app.use(handler);
|
||||
|
||||
server.listen(process.env.PORT, () => {
|
||||
console.log('listening on port http://localhost:' + process.env.PORT);
|
||||
import { handler } from '../build/handler.js';
|
||||
import dotenv from 'dotenv';
|
||||
import http from 'http';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const server = http.Server(app);
|
||||
|
||||
app.use(cors());
|
||||
|
||||
app.use(handler);
|
||||
|
||||
server.listen(process.env.PORT, () => {
|
||||
console.log('listening on port http://localhost:' + process.env.PORT);
|
||||
});
|
||||
22
src/app.d.ts
vendored
@@ -1,9 +1,13 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Error {}
|
||||
// interface Platform {}
|
||||
}
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
23
src/app.html
@@ -1,12 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%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>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
53
src/lib/components/Background3D.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
609
src/lib/components/ModelViewer.svelte
Normal 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>
|
||||
@@ -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>
|
||||
476
src/lib/components/Navbar.svelte
Normal 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>
|
||||
504
src/lib/components/NavbarWaybar.svelte
Normal 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>
|
||||
101
src/lib/components/ParticleField.svelte
Normal 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}
|
||||
350
src/lib/components/Terminal.svelte
Normal 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>
|
||||
445
src/lib/components/TerminalPage.svelte
Normal 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>
|
||||
421
src/lib/components/TerminalTUI.svelte
Normal 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>
|
||||
119
src/lib/components/tui/TuiAccordion.svelte
Normal 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>
|
||||
306
src/lib/components/tui/TuiBody.svelte
Normal 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>
|
||||
79
src/lib/components/tui/TuiButton.svelte
Normal 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>
|
||||
118
src/lib/components/tui/TuiCard.svelte
Normal 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>
|
||||
325
src/lib/components/tui/TuiCardGrid.svelte
Normal 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>
|
||||
93
src/lib/components/tui/TuiFooter.svelte
Normal 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>
|
||||
64
src/lib/components/tui/TuiHeader.svelte
Normal 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>
|
||||
65
src/lib/components/tui/TuiLink.svelte
Normal 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>
|
||||
102
src/lib/components/tui/TuiProgress.svelte
Normal 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>
|
||||
96
src/lib/components/tui/TuiTable.svelte
Normal 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>
|
||||
167
src/lib/components/tui/TuiTooltip.svelte
Normal 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>
|
||||
55
src/lib/components/tui/types.ts
Normal 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;
|
||||
}
|
||||
185
src/lib/components/tui/utils.ts
Normal 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
@@ -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`.
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 127 KiB |
67
src/lib/index.ts
Normal 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
@@ -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: '🐱' }
|
||||
];
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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>
|
||||
|
||||
@@ -1,47 +1,111 @@
|
||||
<script lang="ts">
|
||||
import '../app.postcss';
|
||||
import { initializeStores, Modal } from '@skeletonlabs/skeleton';
|
||||
|
||||
import { AppShell } from '@skeletonlabs/skeleton';
|
||||
//import Footer from '$lib/components/Footer.svelte';
|
||||
import NavBar from '$lib/components/NavBar.svelte';
|
||||
|
||||
initializeStores();
|
||||
</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" />
|
||||
</svelte:head>
|
||||
|
||||
<Modal />
|
||||
<AppShell>
|
||||
<svelte:fragment slot="header">
|
||||
<NavBar />
|
||||
</svelte:fragment>
|
||||
<slot />
|
||||
<!-- <svelte:fragment slot="footer">
|
||||
<Footer />
|
||||
</svelte:fragment> -->
|
||||
</AppShell>
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
let { children } = $props();
|
||||
let mounted = $state(false);
|
||||
// don't bind the component; find the DOM element by its class
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
{#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>
|
||||
|
||||
@@ -1,76 +1,53 @@
|
||||
<script lang="ts">
|
||||
import BlobPFP from "$lib/components/BlobPFP.svelte";
|
||||
</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>
|
||||
|
||||
<style lang="postcss">
|
||||
.bts-h1 {
|
||||
@apply my-auto text-center;
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
.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>
|
||||
<script lang="ts">
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>{site.title}</title>
|
||||
<meta name="description" content={site.description} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="home-container">
|
||||
<TerminalTUI {lines} title="~" interactive={true} {speed} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.home-container {
|
||||
padding: 2rem 1rem;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
</style>
|
||||
|
||||
314
src/routes/components/+page.svelte
Normal 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>
|
||||
@@ -1,161 +1,43 @@
|
||||
<script lang="ts">
|
||||
import HackCard from "$lib/components/HackCard.svelte";
|
||||
|
||||
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 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
|
||||
}
|
||||
];
|
||||
|
||||
// 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>
|
||||
|
||||
<style lang="postcss">
|
||||
/* page-level tweaks */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
}
|
||||
</style>
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
const speed = getPageSpeedMultiplier('hackathons');
|
||||
|
||||
// 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! 🚀(&)` },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Hackathons | {user.name}</title>
|
||||
<meta name="description" content="Hackathon projects and achievements" />
|
||||
</svelte:head>
|
||||
|
||||
<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
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 { getModalStore } from '@skeletonlabs/skeleton';
|
||||
|
||||
const modalStore = getModalStore();
|
||||
|
||||
let container, row, modelViewer, modelViewerTitle, modelViewerBtn;
|
||||
|
||||
// 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";
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<section class="h-full mx-auto justify-center p-10">
|
||||
|
||||
</section>
|
||||
<script lang="ts">
|
||||
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';
|
||||
|
||||
const speed = getPageSpeedMultiplier('models');
|
||||
|
||||
// Available GLB files in static/models/
|
||||
const glbModels = [
|
||||
{ name: 'Bake Potato', path: '/models/BakePotato.glb' },
|
||||
{ name: 'Soda Can', path: '/models/Soda.glb' }
|
||||
];
|
||||
|
||||
let selectedModel = $state(glbModels[0]);
|
||||
let showViewer = $state(false);
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 22 KiB |
@@ -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 |
BIN
static/font/JetBrainsMono-SemiBold.ttf
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 196 KiB |
@@ -1,22 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
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,
|
||||
}
|
||||
};
|
||||
export default config;
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
kit: { adapter: adapter() }
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -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;
|
||||
@@ -1,19 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"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
|
||||
//
|
||||
// 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
|
||||
}
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// 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
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit(), purgeCss()]
|
||||
});
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
|
||||