Rewrite website as a Hyprland desktop
This commit is contained in:
1 parent
7c52779cf6
commit
3065ad6092
197 files changed
+5607
-17777
No files matched your search
@@ -0,0 +1,3 @@
|
|||||||
|
DISCORD_TOKEN=
|
||||||
|
DISCORD_USER_ID=
|
||||||
|
DISCORD_GUILD_ID=
|
||||||
@@ -24,4 +24,6 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
bun.lock
|
bun.lock
|
||||||
Update.md
|
Update.md
|
||||||
|
PROJECT.md
|
||||||
|
content/blog/
|
||||||
blogs/
|
blogs/
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,778 +1,149 @@
|
|||||||
# Terminal Portfolio
|
# Sir Blob's Desktop
|
||||||
|
|
||||||
An Arch Linux terminal-themed portfolio website with Hyprland-style TUI components, built with SvelteKit, Tailwind CSS, and Three.js.
|
A portfolio website that recreates my Arch Linux desktop (Hyprland, Quickshell bar, kitty terminals) in the browser. Built with SvelteKit 3, Svelte 5 runes and Tailwind CSS 4.
|
||||||
|
|
||||||
## Features
|
## Running
|
||||||
|
|
||||||
- Hyprland-style TUI - Terminal interface inspired by Textual Python TUI
|
```bash
|
||||||
- Interactive command line - Type commands directly into the terminal to navigate and run actions
|
bun install
|
||||||
- Theme Support - Arch, Catppuccin, Wintry, Rose, and Cerberus themes, each with dark/light variants
|
bun run dev
|
||||||
- Dark/Light Mode - Toggle between dark and light modes
|
bun run build
|
||||||
- Keyboard Navigation - Navigate with arrow keys or vim-style j/k
|
bun run check
|
||||||
- Native Blog - Markdown posts with tags, authors, syntax highlighting, search, pagination, and an RSS feed
|
|
||||||
- 3D Model Viewer - Interactive Three.js viewer for .glb models
|
|
||||||
- QR Links Page - Generate a scannable QR code for any of your links
|
|
||||||
- Configurable Speed - Per-page typing animation speed
|
|
||||||
- Responsive - Works on desktop and mobile
|
|
||||||
- Rich Text Formatting - Colors, backgrounds, text decorations, and sizeable inline icons
|
|
||||||
|
|
||||||
## Pages
|
|
||||||
|
|
||||||
- Home (`/`) - Neofetch-style intro with navigation
|
|
||||||
- About (`/about`) - Background and details
|
|
||||||
- Portfolio (`/portfolio`) - Profile, experience, skills, and contact info
|
|
||||||
- Projects (`/projects`) - Hub linking out to the three project categories below
|
|
||||||
- Open Source (`/projects/opensource`) - Projects built and maintained in the open
|
|
||||||
- Packages (`/projects/packages`) - Published packages and CLI tools
|
|
||||||
- Hackathons (`/projects/hackathons`) - Hackathon projects, awards, and stats
|
|
||||||
- Blog (`/blog`) - Native markdown blog with tags, search, pagination, and an RSS feed at `/blog/rss.xml`
|
|
||||||
- Models (`/models`) - 3D model gallery with interactive viewer
|
|
||||||
- Components (`/components`) - Showcase of all TUI components
|
|
||||||
- Links (`/links`) - All links with on-demand QR codes
|
|
||||||
|
|
||||||
The navbar exposes Home, About, Projects, and Blog. Portfolio, Models, Components, and Links are reachable directly by URL or with the terminal `cd` command (for example, `cd links`).
|
|
||||||
|
|
||||||
## Terminal Commands
|
|
||||||
|
|
||||||
Every terminal page has an interactive command line at the bottom. Click the terminal (or just start typing once focused), enter a command, and press Enter. Output is printed back into the terminal.
|
|
||||||
|
|
||||||
Use the Up/Down arrows to cycle through command history.
|
|
||||||
|
|
||||||
| Command | Aliases | Description |
|
|
||||||
|---------|---------|-------------|
|
|
||||||
| `help` | `h`, `?` | List all available commands |
|
|
||||||
| `clear` | `cls` | Clear the terminal |
|
|
||||||
| `whoami` | | Show a short bio |
|
|
||||||
| `neofetch` | `banner` | Display system info with profile picture |
|
|
||||||
| `skills` | | List skills by category |
|
|
||||||
| `contact` | `socials` | Show social links as buttons |
|
|
||||||
| `email` | | Show contact email |
|
|
||||||
| `cd <page>` | `goto`, `open` | Navigate to a page, including subpages (e.g. `cd projects/opensource`) |
|
|
||||||
| `ls` | `dir` | List the pages you can visit as a tree, with project subpages nested under `projects/` |
|
|
||||||
| `theme [name]` | | List or set the color theme |
|
|
||||||
| `mode [dark\|light]` | | Toggle or set dark/light mode |
|
|
||||||
| `echo <text>` | | Print text back |
|
|
||||||
| `cowsay <text>` | | Make a cow say something |
|
|
||||||
| `date` | | Show the current date and time (Eastern) |
|
|
||||||
|
|
||||||
Command definitions live in `src/lib/components/tui/terminal-commands.ts`. Add a new entry to the `commands` list to extend the command set. Pages and their subpages are declared once in the `pageTree` structure in that file, which both `ls` (tree rendering) and `cd` (nested path resolution) read from.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
The site configuration is modular - split into focused files in `src/lib/config/` for easier maintenance. You can still import everything from `$lib/config` for backward compatibility.
|
|
||||||
|
|
||||||
### Config File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/lib/config/
|
|
||||||
├── index.ts # Barrel export (re-exports all modules)
|
|
||||||
├── user.ts # User profile, socials, links, skills
|
|
||||||
├── layout.ts # Layout dimensions, breakpoints, fonts, navbar, scrollbar
|
|
||||||
├── theme.ts # Colors, animations, effects, loading screen
|
|
||||||
├── content.ts # Projects, 3D models, experience, hackathon cards
|
|
||||||
├── terminal.ts # Terminal settings, TUI styling, speed presets, shortcuts
|
|
||||||
├── navigation.ts # Navigation links, site metadata, page meta
|
|
||||||
└── blog.ts # Blog name and tagline shown on /blog
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Import Examples
|
## How the desktop works
|
||||||
|
|
||||||
```typescript
|
- The top bar mirrors the blob-shell Quickshell bar: chai menu (app launcher), workspaces 1-9, clock, Discord status and theme picker.
|
||||||
// Barrel import (backward compatible)
|
- Every page (`/about`, `/projects/hackathons`, ...) opens as a tiled window, so old resume links still work.
|
||||||
import { user, colorPalette, projects } from '$lib/config';
|
- Terminals open next to it and tile with the Hyprland dwindle layout.
|
||||||
|
- Desktop icons open a page, a terminal running a command, or an external link.
|
||||||
|
- On small screens the focused window goes fullscreen and open windows are listed as tabs.
|
||||||
|
|
||||||
// Direct imports (smaller bundles, faster builds)
|
### Keybinds
|
||||||
import { user, skills, links } from '$lib/config/user';
|
|
||||||
import { colorPalette, animations } from '$lib/config/theme';
|
|
||||||
import { openSourceProjects, cards, experience } from '$lib/config/content';
|
|
||||||
import { terminalSettings, keyboardShortcuts } from '$lib/config/terminal';
|
|
||||||
import { navigation, site, pageMeta } from '$lib/config/navigation';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Config Modules
|
| Keys | Action |
|
||||||
|
| --- | --- |
|
||||||
|
| Alt + Enter | Open a kitty terminal |
|
||||||
|
| Alt + Space | App launcher |
|
||||||
|
| Alt + Q | Close focused window |
|
||||||
|
| Alt + F | Fullscreen the page window |
|
||||||
|
| Alt + J / K | Focus next / previous window |
|
||||||
|
| Alt + 1-9 | Switch workspace |
|
||||||
|
| Alt + T | Next theme |
|
||||||
|
|
||||||
|
Alt stands in for Super, which browsers do not receive.
|
||||||
|
|
||||||
|
## Editing content
|
||||||
|
|
||||||
|
Everything you would normally change lives in `content/`. No code changes needed.
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
| --- | --- |
|
||||||
| `user.ts` | `user`, `skills`, `links` |
|
| `content/profile.json` | name (shown only on the portfolio page), bio, avatar, education, socials (also used by the links page), skills, interests |
|
||||||
| `layout.ts` | `layout`, `breakpoints`, `fonts`, `navbar`, `scrollbar` |
|
| `content/experience.json` | experience with highlights, tech, awards and links |
|
||||||
| `theme.ts` | `colorPalette`, `terminalButtons`, `loadingScreen`, `effects`, `animations` |
|
| `content/leadership.json` | leadership roles |
|
||||||
| `content.ts` | `openSourceProjects`, `packageProjects`, `models`, `experience`, `cards`, `sortedCards` + types |
|
| `content/about.json` | hobbies, games, shows, movies |
|
||||||
| `terminal.ts` | `terminalSettings`, `tuiStyle`, `tuiText`, `pageSpeedSettings`, `pageAutoscrollSettings`, `speedPresets`, `modelViewer`, `particles`, `keyboardShortcuts` |
|
| `content/projects/opensource.json` | open source projects |
|
||||||
| `navigation.ts` | `navigation`, `site`, `pageMeta` |
|
| `content/projects/packages.json` | published packages |
|
||||||
| `blog.ts` | `blogName`, `blogTagline` |
|
| `content/projects/hackathons.json` | hackathon projects, events and awards |
|
||||||
|
| `content/projects/research.json` | research roles and projects |
|
||||||
|
| `content/models.json` | 3D models (files go in `static/models/`) |
|
||||||
|
| `content/desktop.json` | default theme, workspaces, desktop icons, startup command |
|
||||||
|
| `content/blog.json` | blog name, tagline, posts per page |
|
||||||
|
| `content/blog/*.md` | blog posts |
|
||||||
|
|
||||||
### Example: Key config snippets
|
### Projects
|
||||||
|
|
||||||
```typescript
|
Every project in every category uses the same shape. Only `name`, `description`, `tags` and `links` are required.
|
||||||
// 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
|
```json
|
||||||
export const terminalSettings = {
|
{
|
||||||
baseTypeSpeed: 20,
|
"name": "HushMap",
|
||||||
minTypeSpeed: 5,
|
"description": "What it does.",
|
||||||
maxTypeSpeed: 50,
|
"image": "/hacks/hushmap.png",
|
||||||
startDelay: 300,
|
"tags": ["Svelte", "FastAPI"],
|
||||||
lineDelay: 100,
|
"featured": true,
|
||||||
showCursor: true,
|
"event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
|
||||||
promptStyle: 'full',
|
"awards": [{ "track": "Best UI/UX Hack", "place": "Winner" }],
|
||||||
scrollMargin: 80,
|
"demoMayBeOffline": false,
|
||||||
};
|
"version": "2.2.0",
|
||||||
|
"links": { "demo": "https://...", "code": "https://...", "devpost": "https://...", "package": "https://...", "article": "https://..." }
|
||||||
// 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',
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### How to Customize
|
Featured projects are listed first.
|
||||||
|
|
||||||
- Edit `config/user.ts` to update your profile, socials, links, and skills
|
### Desktop icons
|
||||||
- Edit `config/content.ts` to add projects, models, experience, or hackathon entries
|
|
||||||
- Edit `config/theme.ts` to change colors, animations, or loading screen
|
|
||||||
- Edit `config/terminal.ts` to adjust typing speed, TUI styling, or shortcuts
|
|
||||||
- Edit `config/layout.ts` to change dimensions, breakpoints, or navbar settings
|
|
||||||
- Edit `config/navigation.ts` to add/remove nav links or update page metadata
|
|
||||||
|
|
||||||
Changes take effect on next reload. Some values (fonts, CSS variables) may also require adjusting CSS or Tailwind config.
|
Each icon in `content/desktop.json` has a `label`, an Iconify `icon` and one `open` action:
|
||||||
|
|
||||||
### Where to Look for Types & Utilities
|
```json
|
||||||
|
{ "label": "about", "icon": "mdi:account", "open": { "page": "/about" } }
|
||||||
- `src/lib/config/` - All configuration modules
|
{ "label": "skills.sh", "icon": "mdi:script-text", "open": { "terminal": "skills" } }
|
||||||
- `src/lib/components/tui/types.ts` - TerminalLine types
|
{ "label": "github", "icon": "mdi:github", "open": { "url": "https://github.com/SirBlobby" } }
|
||||||
- `src/lib/components/tui/utils.ts` - Parsing utilities and style helpers
|
|
||||||
- `src/lib/components/tui/terminal-commands.ts` - Interactive command definitions
|
|
||||||
- `src/lib/stores/theme.ts` - Theme store & `toggleMode()`
|
|
||||||
- `src/lib/index.ts` - Helper functions (barrel export)
|
|
||||||
|
|
||||||
## Links Page
|
|
||||||
|
|
||||||
The `/links` page lists everything in the `links` array (a dedicated list, separate from `user.socials`) and renders a scannable QR code for whichever link is selected, plus a clickable button to open it.
|
|
||||||
|
|
||||||
Add or remove entries in `src/lib/config/user.ts`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const links: LinkItem[] = [
|
|
||||||
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
|
|
||||||
{ name: 'Email', icon: 'mdi:email', link: `mailto:${user.email}` },
|
|
||||||
// Add page-only links here without touching user.socials
|
|
||||||
];
|
|
||||||
```
|
```
|
||||||
|
|
||||||
QR codes are generated with the `svelte-qrcode` package, loaded on the client.
|
`{ "terminal": "" }` opens an empty terminal.
|
||||||
|
|
||||||
## Blog
|
### Blog posts
|
||||||
|
|
||||||
The `/blog` page is a native markdown blog — no external blogging platform required.
|
Add a markdown file to `content/blog/`. The file name is the URL slug.
|
||||||
|
|
||||||
### Writing a Post
|
|
||||||
|
|
||||||
Add a markdown file to the `blogs/` directory at the project root:
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
---
|
---
|
||||||
title: My Post Title
|
title: My Post Title
|
||||||
author: Your Name
|
author: Sir Blob
|
||||||
date: 2026-01-01
|
date: 2026-01-01
|
||||||
tags: meta, notes
|
tags: meta, notes
|
||||||
excerpt: A one line summary shown on the blog list and in the RSS feed.
|
excerpt: One line summary for the list and the RSS feed.
|
||||||
---
|
---
|
||||||
|
|
||||||
Post content goes here, written in markdown.
|
Post content in markdown.
|
||||||
```
|
```
|
||||||
|
|
||||||
Posts are picked up automatically via `import.meta.glob` in `src/lib/blog/posts.ts` — no manual registration needed. `author` and `date` are optional; `author` falls back to `user.displayname`.
|
The RSS feed is at `/blog/rss.xml`.
|
||||||
|
|
||||||
### Rendering
|
## Themes
|
||||||
|
|
||||||
`src/lib/blog/markdown.ts` is a small hand-written markdown-to-HTML renderer (headings, bold/italic, links, images, lists, blockquotes, and fenced code blocks) — no external markdown dependency. Fenced code blocks with a language tag (` ```js `, ` ```python `, etc.) get lightweight syntax highlighting via `src/lib/blog/highlight.ts`, and every code block gets a copy-to-clipboard button.
|
Themes live in `src/lib/theme/themes.ts` and use the same keys as a blomarchy `colors.toml`, so a theme can be copied over from the dotfiles. Each theme can set a `wallpaper` (from `static/wallpapers/`) and an `activeBorder` gradient. The default theme is set in `content/desktop.json`.
|
||||||
|
|
||||||
Dates are parsed and formatted in Eastern time via `src/lib/blog/date.ts`, anchored at noon UTC to avoid off-by-one day shifts from timezone conversion.
|
Shipped themes: Flats (default), Retro 82, Tokyo Night, Catppuccin, Arch Linux, Gruvbox, Nord, Kanagawa, Wintry, Rose, Cerberus, Catppuccin Latte, Rose Pine Dawn.
|
||||||
|
|
||||||
### Features
|
## Terminal commands
|
||||||
|
|
||||||
- Tags with click-to-filter on the blog list
|
`help`, `whoami`, `fastfetch`, `skills`, `contact`, `email`, `experience`, `projects [category]`, `ls`, `cd <page>`, `theme [name]`, `echo`, `cowsay`, `date`, `clear`, `exit`. Page names (`about`, `hackathons`, ...) and social names (`github`, `linkedin`, ...) also work as commands. Tab completes commands, pages after `cd`, themes after `theme` and categories after `projects`; when several match it lists them like bash. Up/Down walk history, Ctrl+L clears.
|
||||||
- Full-text search across title, excerpt, and tags
|
|
||||||
- Pagination (5 posts per page)
|
|
||||||
- Previous/next post navigation on each post page
|
|
||||||
- RSS feed at `/blog/rss.xml` (`src/routes/blog/rss.xml/+server.ts`)
|
|
||||||
|
|
||||||
## Speed Presets
|
Commands live in `src/lib/terminal/commands/`.
|
||||||
|
|
||||||
| Preset | Effect |
|
## Code layout
|
||||||
|--------|--------|
|
|
||||||
| `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
|
| Path | Contents |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/lib/content/` | typed loader for `content/` |
|
||||||
|
| `src/lib/blog/` | markdown renderer, frontmatter, highlighting, dates |
|
||||||
|
| `src/lib/theme/` | theme palettes, stylesheet generation, current theme state |
|
||||||
|
| `src/lib/desktop/` | window manager, dwindle tiling, keybinds, desktop actions |
|
||||||
|
| `src/lib/terminal/` | terminal output model and commands |
|
||||||
|
| `src/lib/site/pages.ts` | page list used by the terminal and launcher |
|
||||||
|
| `src/lib/components/shell/` | bar, workspaces, clock, theme menu, Discord status |
|
||||||
|
| `src/lib/server/discord.ts` | Discord bot and presence lookup |
|
||||||
|
| `src/lib/components/desktop/` | desktop, windows, icons, launcher |
|
||||||
|
| `src/lib/components/terminal/` | kitty terminal |
|
||||||
|
| `src/lib/components/ui/` | shared page pieces |
|
||||||
|
| `src/routes/` | pages |
|
||||||
|
|
||||||
Use inline formatting with the `(&specs)text(&)` syntax:
|
## Discord status
|
||||||
|
|
||||||
### Colors
|
The bar shows live Discord presence (status, avatar, guild tag, activities). A discord.js bot starts with the server (`src/hooks.server.ts`) and the bar polls `/api/discord` every 30 seconds. Copy `.env.example` to `.env` and fill in:
|
||||||
|
|
||||||
```typescript
|
| Variable | Value |
|
||||||
// Basic colors
|
| --- | --- |
|
||||||
'(&red)Red text(&)'
|
| `DISCORD_TOKEN` | bot token, with the Server Members and Presence intents enabled |
|
||||||
'(&green)Green text(&)'
|
| `DISCORD_USER_ID` | your user id |
|
||||||
'(&blue)Blue text(&)'
|
| `DISCORD_GUILD_ID` | a server both you and the bot are in |
|
||||||
'(&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)
|
Variables are declared in `src/env.ts`. Without them the widget shows your profile name as offline.
|
||||||
'(&primary)Primary color(&)'
|
|
||||||
'(&accent)Accent color(&)'
|
|
||||||
'(&muted)Muted text(&)'
|
|
||||||
'(&error)Error text(&)'
|
|
||||||
'(&success)Success text(&)'
|
|
||||||
'(&warning)Warning text(&)'
|
|
||||||
'(&info)Info text(&)'
|
|
||||||
|
|
||||||
// Custom hex colors
|
## Analytics
|
||||||
'(&#ff6b6b)Custom color(&)'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Background Colors
|
Umami is loaded in `src/app.html`.
|
||||||
|
|
||||||
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(&)'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Inline Icons
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'(&icon, mdi:github) GitHub' // Inline icon, default size
|
|
||||||
'(&icon, mdi:trophy, 32) Winner' // Inline icon with a custom size in pixels
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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 heading
|
|
||||||
{ type: 'blank', content: '' }, // Empty line
|
|
||||||
{ type: 'divider', content: 'SECTION', id: 'section' }, // Horizontal divider with anchor ID
|
|
||||||
];
|
|
||||||
```
|
|
||||||
|
|
||||||
### Line Properties
|
|
||||||
|
|
||||||
All line types support these optional properties:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
type: 'output',
|
|
||||||
content: 'Hello world',
|
|
||||||
id: 'my-section', // Anchor ID for URL hash scrolling (e.g., /page#my-section)
|
|
||||||
inline: true, // Render inline with adjacent inline elements
|
|
||||||
delay: 500, // Delay before this line appears (ms)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Button (Full-width interactive)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
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)
|
|
||||||
inline: true, // Render as compact inline button
|
|
||||||
// OR
|
|
||||||
action: () => doSomething(), // Custom action
|
|
||||||
border: false, // Disable default border (default: true)
|
|
||||||
flex: true, // Grow to fill available space in a row group (default: false)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Inline Elements
|
|
||||||
|
|
||||||
Multiple elements can be rendered on the same line using `inline: true`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// These will appear on the same line
|
|
||||||
{ type: 'output', content: 'Status:', inline: true },
|
|
||||||
{ type: 'success', content: 'Online', inline: true },
|
|
||||||
{ type: 'button', content: 'Refresh', icon: 'mdi:refresh', inline: true },
|
|
||||||
|
|
||||||
// Next line without inline breaks the group
|
|
||||||
{ type: 'blank', content: '' },
|
|
||||||
```
|
|
||||||
|
|
||||||
Supported inline types: `button`, `link`, `tooltip`, `progress`, `output`, `info`, `success`, `error`, `warning`
|
|
||||||
|
|
||||||
### Link (Inline clickable text)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
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
|
|
||||||
display: 'flex', // flex | grid | block (controls children layout)
|
|
||||||
children: [ // Optional nested elements
|
|
||||||
{ type: 'button', content: 'Action', style: 'primary' }
|
|
||||||
],
|
|
||||||
cardWidth: '1/2', // Width (string fraction, decimal, or px)
|
|
||||||
cardHeight: 300, // Height (string fraction, decimal, or px)
|
|
||||||
cardFloat: 'center', // start | center | end
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Group
|
|
||||||
|
|
||||||
Groups allow you to arrange multiple elements together with custom layout:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
content: '',
|
|
||||||
groupDirection: 'row', // row | column (default: row)
|
|
||||||
groupAlign: 'start', // start | center | end
|
|
||||||
groupGap: '1rem', // CSS gap value
|
|
||||||
groupExpand: true, // Expand children to fill width (default: false)
|
|
||||||
inline: true, // Render inline with other elements
|
|
||||||
children: [
|
|
||||||
{ type: 'output', content: 'Label:', inline: true },
|
|
||||||
{ type: 'button', content: 'Action', style: 'primary', inline: true },
|
|
||||||
{ type: 'link', content: 'More info', href: '/help', inline: true }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Groups support nested groups and all element types as children. Children are rendered using the same `TuiLine` component, ensuring consistent behavior.
|
|
||||||
|
|
||||||
## TUI Components
|
|
||||||
|
|
||||||
### TerminalTUI
|
|
||||||
|
|
||||||
Main terminal component:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<TerminalTUI
|
|
||||||
lines={lines}
|
|
||||||
title="~/directory"
|
|
||||||
interactive={true}
|
|
||||||
speed="normal"
|
|
||||||
autoscroll={true}
|
|
||||||
enableCommands={true}
|
|
||||||
onComplete={() => console.log('Done!')}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Props:
|
|
||||||
- `lines` - Array of TerminalLine objects
|
|
||||||
- `title` - Terminal window title
|
|
||||||
- `interactive` - Enable keyboard navigation
|
|
||||||
- `speed` - Typing speed preset or multiplier
|
|
||||||
- `autoscroll` - Auto-scroll as content types (default: true)
|
|
||||||
- `enableCommands` - Show the interactive command input (default: true)
|
|
||||||
- `onComplete` - Callback when typing animation finishes
|
|
||||||
|
|
||||||
### Anchor Scrolling
|
|
||||||
|
|
||||||
Add `id` to any line to create an anchor that can be linked to:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{ type: 'divider', content: 'SKILLS', id: 'skills' },
|
|
||||||
```
|
|
||||||
|
|
||||||
Then link to it with `/portfolio#skills` - the page will scroll to that section after typing completes.
|
|
||||||
|
|
||||||
## Terminal API
|
|
||||||
|
|
||||||
The `TerminalTUI` component exposes a reactive API for programmatic control via the `terminal` bindable prop.
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
|
|
||||||
import type { TerminalAPI, TerminalLine } from '$lib';
|
|
||||||
|
|
||||||
let terminal = $state<TerminalAPI>();
|
|
||||||
let lines = $state<TerminalLine[]>([
|
|
||||||
{ type: 'output', content: 'Hello world!' }
|
|
||||||
]);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TerminalTUI bind:terminal bind:lines title="~/demo" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Methods
|
|
||||||
|
|
||||||
#### Writing Lines
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `terminal.write(line)` | Append a single line |
|
|
||||||
| `terminal.writeLines(lines)` | Append multiple lines |
|
|
||||||
| `terminal.clear()` | Remove all lines |
|
|
||||||
| `terminal.setLines(lines)` | Replace all lines |
|
|
||||||
|
|
||||||
#### Updating Lines
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `terminal.update(index, updates)` | Update line by index with partial changes |
|
|
||||||
| `terminal.updateContent(index, content)` | Update just the content of a line |
|
|
||||||
| `terminal.updateById(id, updates)` | Update line by its `id` property |
|
|
||||||
|
|
||||||
#### Removing Lines
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `terminal.remove(index)` | Remove line at index |
|
|
||||||
| `terminal.removeRange(start, count)` | Remove a range of lines |
|
|
||||||
| `terminal.removeById(id)` | Remove line by its `id` property |
|
|
||||||
|
|
||||||
#### Inserting Lines
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `terminal.insert(index, line)` | Insert line at a specific index |
|
|
||||||
|
|
||||||
#### Reading State
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `terminal.getLine(index)` | Get line at index |
|
|
||||||
| `terminal.getLines()` | Get all lines (copy) |
|
|
||||||
| `terminal.getLineCount()` | Get number of lines |
|
|
||||||
| `terminal.findById(id)` | Find index of line by id |
|
|
||||||
| `terminal.isAnimating()` | Check if typing animation is active |
|
|
||||||
|
|
||||||
#### Navigation & Control
|
|
||||||
| Method | Description |
|
|
||||||
|--------|-------------|
|
|
||||||
| `terminal.scrollToBottom()` | Scroll to bottom of terminal |
|
|
||||||
| `terminal.scrollToLine(index)` | Scroll to specific line |
|
|
||||||
| `terminal.skip()` | Skip current typing animation |
|
|
||||||
| `terminal.restart()` | Restart typing animation from beginning |
|
|
||||||
|
|
||||||
### Example: Dynamic Updates
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<script lang="ts">
|
|
||||||
import TerminalTUI from '$lib/components/TerminalTUI.svelte';
|
|
||||||
import type { TerminalAPI } from '$lib';
|
|
||||||
|
|
||||||
let terminal = $state<TerminalAPI>();
|
|
||||||
let lines = $state([
|
|
||||||
{ type: 'output', content: 'Initializing...', id: 'status' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function runProcess() {
|
|
||||||
terminal?.updateById('status', {
|
|
||||||
type: 'info',
|
|
||||||
content: 'Processing...'
|
|
||||||
});
|
|
||||||
|
|
||||||
await delay(1000);
|
|
||||||
|
|
||||||
terminal?.write({
|
|
||||||
type: 'progress',
|
|
||||||
content: '',
|
|
||||||
progress: 50,
|
|
||||||
progressLabel: '50%'
|
|
||||||
});
|
|
||||||
|
|
||||||
await delay(1000);
|
|
||||||
|
|
||||||
terminal?.updateById('status', {
|
|
||||||
type: 'success',
|
|
||||||
content: 'Complete!'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<TerminalTUI bind:terminal bind:lines />
|
|
||||||
<button onclick={runProcess}>Start</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3D Model Viewer
|
|
||||||
|
|
||||||
The `ModelViewer` component provides an interactive Three.js viewer for `.glb` models.
|
|
||||||
|
|
||||||
### Features
|
|
||||||
- Mouse Controls: Drag to rotate, scroll to zoom
|
|
||||||
- Arrow Key Controls: Use arrow keys to orbit the camera (click viewer to focus first)
|
|
||||||
- Auto-rotate: Toggle automatic rotation
|
|
||||||
- Wireframe Mode: View model wireframe
|
|
||||||
- Adjustable Lighting: Increase/decrease scene brightness
|
|
||||||
- Fullscreen Mode: Expand to full viewport (press Escape to exit)
|
|
||||||
- Ground Plane: Optional shadow-receiving ground
|
|
||||||
- Graceful Fallback: Shows an error message when WebGL is unavailable instead of crashing
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<ModelViewer
|
|
||||||
modelPath="/models/my-model.glb"
|
|
||||||
modelName="My Model"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
Place `.glb` files in `/static/models/` and they'll be accessible at `/models/filename.glb`.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- Framework: SvelteKit 2.x with Svelte 5 runes
|
|
||||||
- Styling: Tailwind CSS 4.x
|
|
||||||
- 3D: Three.js with GLTFLoader
|
|
||||||
- QR codes: svelte-qrcode
|
|
||||||
- Icons: @iconify/svelte
|
|
||||||
- Font: JetBrains Mono
|
|
||||||
- Runtime: Bun
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Start dev server
|
|
||||||
bun run dev
|
|
||||||
|
|
||||||
# Build for production
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Type-check
|
|
||||||
bun run check
|
|
||||||
```
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| Up / k | Navigate up |
|
|
||||||
| Down / j | Navigate down |
|
|
||||||
| Enter | Activate button |
|
|
||||||
| Y | Skip typing animation |
|
|
||||||
| T | Toggle dark/light mode |
|
|
||||||
|
|
||||||
When the command input is focused, these keys type normally instead of triggering navigation.
|
|
||||||
|
|
||||||
### 3D Model Viewer
|
|
||||||
|
|
||||||
| Key | Action |
|
|
||||||
|-----|--------|
|
|
||||||
| Left | Rotate camera left |
|
|
||||||
| Right | Rotate camera right |
|
|
||||||
| Up | Rotate camera up |
|
|
||||||
| Down | Rotate camera down |
|
|
||||||
| Escape | Exit fullscreen |
|
|
||||||
|
|
||||||
## Theme System
|
|
||||||
|
|
||||||
Themes are defined as JSON files in `src/lib/assets/themes/`. Each theme contains colors for both dark and light modes. Shipped themes: Arch, Catppuccin, Wintry, Rose, and Cerberus (`src/lib/stores/theme.ts`).
|
|
||||||
|
|
||||||
### Theme File Structure
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Theme Name",
|
|
||||||
"icon": "arch",
|
|
||||||
"dark": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#89b4fa",
|
|
||||||
"secondary": "#313244",
|
|
||||||
"accent": "#a6e3a1",
|
|
||||||
"background": "#1e1e2e",
|
|
||||||
"backgroundLight": "#313244",
|
|
||||||
"text": "#cdd6f4",
|
|
||||||
"textMuted": "#a6adc8",
|
|
||||||
"border": "#45475a",
|
|
||||||
"terminal": "#1e1e2e",
|
|
||||||
"terminalPrompt": "#cba6f7",
|
|
||||||
"terminalUser": "#a6e3a1",
|
|
||||||
"terminalPath": "#89b4fa"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#f38ba8",
|
|
||||||
"green": "#a6e3a1",
|
|
||||||
"blue": "#89b4fa",
|
|
||||||
"primary": "var(--terminal-primary)",
|
|
||||||
"accent": "var(--terminal-accent)",
|
|
||||||
"muted": "var(--terminal-muted)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"light": {
|
|
||||||
"colors": { },
|
|
||||||
"colorMap": { }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding a New Theme
|
|
||||||
|
|
||||||
1. Create a new file: `src/lib/assets/themes/mytheme.theme.json`
|
|
||||||
2. Import it in `src/lib/stores/theme.ts`:
|
|
||||||
```typescript
|
|
||||||
import myTheme from '$lib/assets/themes/mytheme.theme.json';
|
|
||||||
```
|
|
||||||
3. Add it to the themes object:
|
|
||||||
```typescript
|
|
||||||
const themes: Record<ColorTheme, ThemeJson> = {
|
|
||||||
arch: archTheme,
|
|
||||||
catppuccin: catppuccinTheme,
|
|
||||||
mytheme: myTheme as ThemeJson
|
|
||||||
};
|
|
||||||
```
|
|
||||||
4. Update the `ColorTheme` type to include your theme name
|
|
||||||
|
|
||||||
### Theme-Specific Colors
|
|
||||||
|
|
||||||
Beyond the basic colors, themes include:
|
|
||||||
- `teal`, `sky`, `sapphire`, `lavender`
|
|
||||||
- `peach`, `maroon`, `mauve`
|
|
||||||
- `flamingo`, `rosewater`
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// These colors adapt to the current theme
|
|
||||||
'(&teal)Teal text(&)'
|
|
||||||
'(&lavender)Lavender text(&)'
|
|
||||||
'(&peach)Peach text(&)'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mobile Considerations
|
|
||||||
|
|
||||||
- Viewport height: Uses `100dvh` (dynamic viewport height) to properly handle mobile browser chrome
|
|
||||||
- Background color: A fallback dark background (`#1e1e2e`) is set on `html` and `body` to prevent white bars when the page content doesn't fill the viewport
|
|
||||||
- Overflow handling: Hidden horizontal scrollbar to prevent accidental horizontal scroll on mobile
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"hobbies": [
|
||||||
|
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
|
||||||
|
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
|
||||||
|
{ "name": "Robotics", "icon": "proicons:robot", "description": "Building and programming robots to solve problems is fun." }
|
||||||
|
],
|
||||||
|
"games": [
|
||||||
|
{ "name": "Minecraft", "image": "/img/minecraft.png", "url": "https://minecraft.net" },
|
||||||
|
{ "name": "Pokemon TCG Online", "image": "/img/pokemontcg.png", "url": "https://www.pokemon.com/us/pokemon-tcg" },
|
||||||
|
{ "name": "Fire Watch", "image": "/img/firewatch.png", "url": "https://firewatchgame.com" },
|
||||||
|
{ "name": "Expedition 33", "image": "/img/e33.png", "url": "https://expedition33.com" },
|
||||||
|
{ "name": "Mini Motorways", "image": "/img/mini.png", "url": "https://store.steampowered.com/app/1127500/Mini_Motorways/" },
|
||||||
|
{ "name": "Risk", "image": "/img/risk.png", "url": "https://store.steampowered.com/app/1128810/RISK_Global_Domination/" },
|
||||||
|
{ "name": "Slay the Spire", "image": "/img/slay_the_spire.png", "url": "https://store.steampowered.com/app/646570/Slay_the_Spire/" },
|
||||||
|
{ "name": "Slay the Spire 2", "image": "/img/slay_the_spire_2.png", "url": "https://store.steampowered.com/app/2868840/Slay_the_Spire_2/" }
|
||||||
|
],
|
||||||
|
"shows": [
|
||||||
|
{ "name": "Silo", "rating": 10, "url": "https://www.imdb.com/title/tt14688458/" },
|
||||||
|
{ "name": "D.P. (KDrama)", "rating": 10, "url": "https://www.imdb.com/title/tt13024974/" },
|
||||||
|
{ "name": "Andor", "rating": 9, "url": "https://www.imdb.com/title/tt9253284/" },
|
||||||
|
{ "name": "Severance", "rating": 9, "url": "https://www.imdb.com/title/tt11280740/" },
|
||||||
|
{ "name": "The Mandalorian", "rating": 7, "url": "https://www.imdb.com/title/tt8111088/" }
|
||||||
|
],
|
||||||
|
"movies": [
|
||||||
|
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
|
||||||
|
{ "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt1621800/" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "Finding Out",
|
||||||
|
"tagline": "My Engineering Blog",
|
||||||
|
"postsPerPage": 5
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"defaultTheme": "flats",
|
||||||
|
"workspaces": [
|
||||||
|
{ "number": 1, "name": "home", "path": "/" },
|
||||||
|
{ "number": 2, "name": "about", "path": "/about" },
|
||||||
|
{ "number": 3, "name": "projects", "path": "/projects" },
|
||||||
|
{ "number": 4, "name": "blog", "path": "/blog" },
|
||||||
|
{ "number": 5, "name": "links", "path": "/links" },
|
||||||
|
{ "number": 6, "name": "portfolio", "path": "/portfolio" },
|
||||||
|
{ "number": 7, "name": "models", "path": "/models" }
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{ "label": "about", "icon": "ph:user-circle-duotone", "open": { "page": "/about" } },
|
||||||
|
{ "label": "portfolio", "icon": "ph:briefcase-duotone", "open": { "page": "/portfolio" } },
|
||||||
|
{ "label": "projects", "icon": "ph:folder-open-duotone", "open": { "page": "/projects" } },
|
||||||
|
{ "label": "hackathons", "icon": "ph:trophy-duotone", "open": { "page": "/projects/hackathons" } },
|
||||||
|
{ "label": "blog", "icon": "ph:notebook-duotone", "open": { "page": "/blog" } },
|
||||||
|
{ "label": "models", "icon": "ph:cube-duotone", "open": { "page": "/models" } },
|
||||||
|
{ "label": "links", "icon": "ph:qr-code-duotone", "open": { "page": "/links" } },
|
||||||
|
{ "label": "kitty", "icon": "ph:terminal-window-duotone", "open": { "terminal": "" } },
|
||||||
|
{ "label": "whoami.sh", "icon": "ph:identification-card-duotone", "open": { "terminal": "whoami" } },
|
||||||
|
{ "label": "fastfetch.sh", "icon": "ph:cpu-duotone", "open": { "terminal": "fastfetch" } },
|
||||||
|
{ "label": "skills.sh", "icon": "ph:lightning-duotone", "open": { "terminal": "skills" } },
|
||||||
|
{ "label": "contact.sh", "icon": "ph:envelope-simple-duotone", "open": { "terminal": "contact" } },
|
||||||
|
{ "label": "github", "icon": "ph:github-logo-duotone", "open": { "url": "https://github.com/SirBlobby" } }
|
||||||
|
],
|
||||||
|
"startupCommand": "fastfetch"
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "Undergraduate Research Assistant, CREATE Lab",
|
||||||
|
"organization": "George Mason University, College of Engineering and Computing",
|
||||||
|
"period": "May 2026 – Present",
|
||||||
|
"highlights": [
|
||||||
|
"Built Astroswarm, a visual coding game that turns swarm control into a playable simulator, generating trial data at scale on which algorithms best defend a target.",
|
||||||
|
"Researching swarm robotics in Python and ROS for a research program on controlling robot fleets with one operator."
|
||||||
|
],
|
||||||
|
"tech": ["Python", "ROS", "Swarm Robotics"],
|
||||||
|
"links": [
|
||||||
|
{ "label": "Profile", "url": "https://create.autonomousrobotics.club/team/gagan-manjunatha" },
|
||||||
|
{ "label": "Astroswarm", "url": "https://astroswarm.autonomousrobotics.club/" },
|
||||||
|
{ "label": "GMU CEC News", "url": "https://cec.gmu.edu/news/2026-07/new-art-herding-robots" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "AI Summer Camp Instructor",
|
||||||
|
"organization": "ACCESS Academy",
|
||||||
|
"period": "Jul 2026 – Aug 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Taught the robotics section of an AI camp for 60 rising 9th to 12th graders, guiding teams to build robot kits and design algorithms that autonomously navigated a maze."
|
||||||
|
],
|
||||||
|
"tech": ["Robotics", "Teaching"],
|
||||||
|
"links": [{ "label": "Program", "url": "https://accessacademy.cehd.gmu.edu/ai-summer-camp" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Lead Software Engineer",
|
||||||
|
"organization": "Raytheon Autonomous Vehicle Competition",
|
||||||
|
"period": "Dec 2025 – May 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Beat four competing universities including Virginia Tech.",
|
||||||
|
"Led development of an autonomous system in Python and ROS2, enabling a UAV to scout and land on a moving UGV in an environment without GPS, validated against ArUco marker detection in Gazebo simulation."
|
||||||
|
],
|
||||||
|
"tech": ["Python", "ROS2", "Gazebo", "Computer Vision"],
|
||||||
|
"awards": ["1st Place Overall Winner"],
|
||||||
|
"links": [
|
||||||
|
{ "label": "GMU News", "url": "https://www.gmu.edu/news/2026-05/george-mason-students-fly-first-place-no-gps-no-grade-and-almost-no-problems" },
|
||||||
|
{ "label": "Video", "url": "https://www.youtube.com/watch?v=J9QkS8nLYy0" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "QA Automation Intern",
|
||||||
|
"organization": "Deltek",
|
||||||
|
"employmentType": "Internship · Hybrid",
|
||||||
|
"period": "Jun 2025 – Aug 2025",
|
||||||
|
"highlights": [
|
||||||
|
"Designed a workflow integrating AI into the QA automation pipeline for the GovWin IQ team.",
|
||||||
|
"Researched Model Context Protocol servers, publishing a public Selenium MCP package to NPM for agentic browser automation."
|
||||||
|
],
|
||||||
|
"tech": ["AI", "TypeScript", "Selenium", "MCP"],
|
||||||
|
"links": [{ "label": "NPM Package", "url": "https://www.npmjs.com/package/@sirblob/mcp-selenium" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{ "role": "Team Lead", "organization": "GMU RAVC/ARC Team", "url": "https://gmu.autonomousrobotics.club/" },
|
||||||
|
{ "role": "President", "organization": "GMU Minecraft Club", "url": "https://gmmc.sirblob.co/" },
|
||||||
|
{ "role": "Eagle Scout", "organization": "BSA" },
|
||||||
|
{ "role": "Volunteer", "organization": "Fairfax County CERT" }
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Bake Potato",
|
||||||
|
"description": "A fun baked-potato 3D model for demos and testing.",
|
||||||
|
"file": "/models/BakePotato.glb",
|
||||||
|
"software": ["Blender"],
|
||||||
|
"polyCount": "1k tris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Soda Can",
|
||||||
|
"description": "A realistic soda can GLB used for materials/tests.",
|
||||||
|
"file": "/models/Soda.glb",
|
||||||
|
"software": ["Blender"],
|
||||||
|
"polyCount": "800 tris"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "Gagan Manjunatha",
|
||||||
|
"displayName": "Sir Blob",
|
||||||
|
"username": "sirblob",
|
||||||
|
"hostname": "engineering",
|
||||||
|
"title": "Computer Science Student",
|
||||||
|
"email": "sirblob0@gmail.com",
|
||||||
|
"avatar": "/blob_nerd.png",
|
||||||
|
"bio": "An engineer who loves making things. I build fun coding projects, compete in hackathons and robotics competitions, and enjoy games like Minecraft and Pokémon TCG Live. I'm interested in Open Source, Robotics, Embedded Systems, and AI/ML.",
|
||||||
|
"tagline": "My passion is using Computer Science with practical Engineering to enhance technology for the future.",
|
||||||
|
"education": {
|
||||||
|
"school": "George Mason University",
|
||||||
|
"degree": "Bachelor of Science, Computer Science",
|
||||||
|
"graduation": "Expected June 2028"
|
||||||
|
},
|
||||||
|
"socials": [
|
||||||
|
{ "name": "GitHub", "icon": "mdi:github", "url": "https://github.com/SirBlobby" },
|
||||||
|
{ "name": "LinkedIn", "icon": "mdi:linkedin", "url": "https://www.linkedin.com/in/gmanjunatha/" },
|
||||||
|
{ "name": "Devpost", "icon": "simple-icons:devpost", "url": "https://devpost.com/Sir_Blob_" },
|
||||||
|
{ "name": "Discord", "icon": "ic:baseline-discord", "url": "https://discord.com/users/sir_blob_" },
|
||||||
|
{ "name": "Email", "icon": "mdi:email", "url": "mailto:sirblob0@gmail.com" }
|
||||||
|
],
|
||||||
|
"skills": {
|
||||||
|
"Languages": ["Python", "TypeScript/JavaScript", "C/C++", "Java", "Go", "Rust", "SQL"],
|
||||||
|
"Frameworks": ["React/Next.js", "SvelteKit", "Node/Express", "Flask", "Tailwind CSS", "ROS2", "Gazebo"],
|
||||||
|
"Tools & Data": ["Docker", "Git", "Linux/Windows Server", "PostgreSQL", "MySQL", "SQLite", "MongoDB", "Redis"]
|
||||||
|
},
|
||||||
|
"interests": ["Open Source", "Robotics", "Embedded Systems", "Game Development", "AI/ML"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "HushMap",
|
||||||
|
"description": "Architected a real-time campus noise and occupancy monitoring system utilizing SvelteKit, FastAPI, and MongoDB to help students locate quiet study spaces.",
|
||||||
|
"image": "/hacks/hushmap.png",
|
||||||
|
"tags": ["Svelte", "YOLOv8", "FastAPI", "MongoDB"],
|
||||||
|
"featured": true,
|
||||||
|
"event": { "name": "Bitcamp 2026", "university": "University of Maryland", "location": "College Park, MD", "year": "2026" },
|
||||||
|
"awards": [{ "track": "Best UI/UX Hack", "place": "Winner" }],
|
||||||
|
"links": { "demo": "https://hushmap.study/", "devpost": "https://devpost.com/software/hushmap-193nia" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "LearningBuddy",
|
||||||
|
"description": "ESP32-S3 IoT study companion in C++ that streams audio over WebSockets with low latency for live lecture capture, with transcription and MongoDB indexing behind a searchable \"Desk Pet\" dashboard.",
|
||||||
|
"image": "/hacks/learningbuddy.png",
|
||||||
|
"tags": ["C++", "ESP32-S3", "WebSocket", "MongoDB", "IoT"],
|
||||||
|
"featured": true,
|
||||||
|
"event": { "name": "HackFax x PatriotHacks 2026", "university": "George Mason University", "location": "Fairfax, VA", "year": "2026" },
|
||||||
|
"awards": [{ "track": "Best use of MongoDB", "place": "Winner" }],
|
||||||
|
"links": { "code": "https://github.com/SirBlobby/LearningBuddy", "devpost": "https://devpost.com/software/studybuddy-bc0i3j" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Carbin",
|
||||||
|
"description": "Encourage student participation in responsible waste management with smart bins that guide proper disposal.",
|
||||||
|
"image": "/hacks/carbin.png",
|
||||||
|
"tags": ["AI", "Azure", "CloudConvert", "Python", "React", "TypeScript", "API"],
|
||||||
|
"featured": true,
|
||||||
|
"event": { "name": "PatriotHacks 2024", "university": "George Mason University", "location": "Fairfax, VA", "year": "2024" },
|
||||||
|
"awards": [
|
||||||
|
{ "track": "Save the World", "place": "2nd Place" },
|
||||||
|
{ "track": "Microsoft X Cloudforce", "place": "2nd Place" }
|
||||||
|
],
|
||||||
|
"links": { "code": "https://github.com/SirBlobby/patriotHacks2024", "devpost": "https://devpost.com/software/carboniferous-akc4mj" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ethix",
|
||||||
|
"description": "A sustainability platform that helps users detect and report greenwashing, misleading environmental claims made by companies and products.",
|
||||||
|
"image": "/img/hoya26.png",
|
||||||
|
"tags": ["Svelte", "TypeScript", "Python", "Flask", "Tailwind", "Sustainability"],
|
||||||
|
"featured": true,
|
||||||
|
"event": { "name": "HoyaHax 2026", "university": "Georgetown University", "location": "Washington, D.C.", "year": "2026" },
|
||||||
|
"links": { "demo": "https://ethix.sirblob.co/", "code": "https://github.com/SirBlobby/Hoya26", "devpost": "https://devpost.com/software/ethix" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Food Decisive",
|
||||||
|
"description": "Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive.",
|
||||||
|
"image": "/hacks/fooddecisive.png",
|
||||||
|
"tags": ["AI", "Llama3.1", "Svelte", "Node.js"],
|
||||||
|
"demoMayBeOffline": true,
|
||||||
|
"event": { "name": "VTHacks 12", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2024" },
|
||||||
|
"links": { "demo": "https://fooddecisive.sirblob.co/", "code": "https://github.com/SirBlobby/VTHacks-12", "devpost": "https://devpost.com/software/food-decisive" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PatSafe",
|
||||||
|
"description": "Bridging the gap between doctors and patients for seamless post-discharge care.",
|
||||||
|
"image": "/hacks/patsafe.png",
|
||||||
|
"tags": ["Javascript", "Next.js", "React", "TypeScript", "LangChain", "OpenAI"],
|
||||||
|
"event": { "name": "HoyaHax 2025", "university": "Georgetown University", "location": "Washington, D.C.", "year": "2025" },
|
||||||
|
"links": { "demo": "https://hoya-hax2025.vercel.app/", "code": "https://github.com/SirBlobby/HoyaHax2025", "devpost": "https://devpost.com/software/patsafe" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "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.",
|
||||||
|
"tags": ["MongoDB", "Next.js", "Python", "React", "Sesame", "Skeleton", "Twilio"],
|
||||||
|
"event": { "name": "HooHacks 2025", "university": "University of Virginia", "location": "Charlottesville, VA", "year": "2025" },
|
||||||
|
"links": { "code": "https://github.com/SirBlobby/HooHacks-12", "devpost": "https://devpost.com/software/fauxcall" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "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.",
|
||||||
|
"image": "/hacks/drinkhappy.png",
|
||||||
|
"tags": ["API", "Auth0", "Gemini", "JavaScript", "MongoDB", "Next.js", "React", "Tailwind", "Vercel"],
|
||||||
|
"event": { "name": "Bitcamp 2025", "university": "University of Maryland", "location": "College Park, MD", "year": "2025" },
|
||||||
|
"links": { "demo": "https://drinkhappy.tech", "code": "https://github.com/SirBlobby/Bitcamp-2025", "devpost": "https://devpost.com/software/drink-happy" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "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.",
|
||||||
|
"image": "/hacks/roadcast.png",
|
||||||
|
"tags": ["React", "Node.js", "Python", "MongoDB", "Geospatial", "Mapbox"],
|
||||||
|
"demoMayBeOffline": true,
|
||||||
|
"event": { "name": "VTHacks 13", "university": "Virginia Tech", "location": "Blacksburg, VA", "year": "2025" },
|
||||||
|
"links": { "demo": "https://roadcast.sirblob.co/", "code": "https://github.com/SirBlobby/roadcast", "devpost": "https://devpost.com/software/roadcast" }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "TypstDrive",
|
||||||
|
"description": "Self hosted collaborative Typst editor with a Rust backend, SvelteKit frontend, real time multi user editing via Yjs, and a public render API.",
|
||||||
|
"version": "2.2.0",
|
||||||
|
"period": "2026 – Present",
|
||||||
|
"featured": true,
|
||||||
|
"tags": ["Rust", "SvelteKit", "Yjs", "Typst", "Self-Hosted"],
|
||||||
|
"image": "/projects/typstdrive.png",
|
||||||
|
"links": { "code": "https://github.com/SirBlobby/TypstDrive" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Typst Desktop",
|
||||||
|
"description": "Tauri desktop client for TypstDrive that keeps the same documents as local files and syncs them back with a three way merge.",
|
||||||
|
"version": "2.2.0",
|
||||||
|
"period": "2026 – Present",
|
||||||
|
"featured": true,
|
||||||
|
"tags": ["Tauri", "Rust", "Typst", "Offline Sync"],
|
||||||
|
"image": "/projects/typstdesktop.png",
|
||||||
|
"links": { "code": "https://github.com/SirBlobby/typst-desktop" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pkit",
|
||||||
|
"description": "CLI toolkit and utilities (Rust project), a small developer-focused CLI.",
|
||||||
|
"tags": ["Rust", "CLI"],
|
||||||
|
"image": "https://icons.veryicon.com/png/o/business/vscode-program-item-icon/rust-1.png",
|
||||||
|
"links": { "code": "https://github.com/dead-projects-inc/pkit-cli", "demo": "https://pkit.sirblob.co/" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OBookLLM",
|
||||||
|
"description": "OBookLLM is a powerful, self-hosted, offline-capable alternative to NotebookLLM.",
|
||||||
|
"tags": ["Python", "LLM", "AI", "NotebookLLM"],
|
||||||
|
"image": "https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg",
|
||||||
|
"links": { "code": "https://github.com/SirBlobby/OBookLLM" }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "MCP Selenium",
|
||||||
|
"description": "Selenium automation utilities for MCP workflows, packaged for reuse.",
|
||||||
|
"tags": ["TypeScript", "Selenium"],
|
||||||
|
"featured": true,
|
||||||
|
"links": { "package": "https://www.npmjs.com/package/@sirblob/mcp-selenium" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PokemonTCGAPI",
|
||||||
|
"description": "Unofficial NPM package wrapper for the PokemonTCG API.",
|
||||||
|
"tags": ["TypeScript", "Node.js"],
|
||||||
|
"links": { "package": "https://www.npmjs.com/package/@bosstop/pokemontcgapi" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MCSS TS API",
|
||||||
|
"description": "TypeScript API for MC Server Soft services, published to NPM.",
|
||||||
|
"tags": ["TypeScript", "Node.js"],
|
||||||
|
"links": { "package": "https://www.npmjs.com/package/@mcserversoft/mcss-api" }
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Astroswarm",
|
||||||
|
"organization": "CREATE Lab, George Mason University",
|
||||||
|
"period": "May 2026 – Present",
|
||||||
|
"description": "A visual coding game that turns swarm control into a playable simulator, generating trial data at scale on which algorithms best defend a target. Part of a research program on controlling robot fleets with one operator.",
|
||||||
|
"tags": ["Python", "ROS", "Swarm Robotics"],
|
||||||
|
"featured": true,
|
||||||
|
"links": {
|
||||||
|
"demo": "https://astroswarm.autonomousrobotics.club/",
|
||||||
|
"article": "https://cec.gmu.edu/news/2026-07/new-art-herding-robots"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+25
-23
@@ -1,38 +1,40 @@
|
|||||||
{
|
{
|
||||||
"name": "website",
|
"name": "new",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "7.0.0",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"server": "bun server/server.js",
|
"preview": "vite preview",
|
||||||
"start": "vite build && bun server/server.js"
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.5.7",
|
"@sveltejs/adapter-node": "^6.0.0",
|
||||||
"@sveltejs/kit": "^2.70.1",
|
"@sveltejs/kit": "^3.0.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"svelte": "^5.56.8",
|
"@types/node": "^24",
|
||||||
"svelte-check": "^4.7.4",
|
"svelte": "^5.57.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"svelte-check": "^4.6.0",
|
||||||
"typescript": "^5.9.3",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^7.3.6"
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.3.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@iconify/svelte": "^5.2.2",
|
"@iconify/svelte": "^5.2.3",
|
||||||
"@threlte/core": "^8.5.16",
|
"@types/three": "^0.185.4",
|
||||||
"@types/three": "^0.185.1",
|
|
||||||
"cors": "^2.8.6",
|
|
||||||
"discord.js": "^14.27.0",
|
"discord.js": "^14.27.0",
|
||||||
"dotenv": "^17.4.2",
|
|
||||||
"express": "^5.2.1",
|
|
||||||
"hotkeys-js": "^4.0.4",
|
|
||||||
"play-dl": "^1.9.7",
|
|
||||||
"svelte-qrcode": "^1.0.1",
|
"svelte-qrcode": "^1.0.1",
|
||||||
"three": "^0.185.1"
|
"three": "^0.185.1"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"#lib": "./src/lib/index.js",
|
||||||
|
"#lib/*": "./src/lib/*",
|
||||||
|
"#content/*": "./content/*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme) document.documentElement.dataset.theme = savedTheme;
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
<script defer src="https://umami.sirblob.co/script.js" data-website-id="5cf0aa1f-5585-4a8e-a7eb-b1348932e3aa"></script>
|
<script defer src="https://umami.sirblob.co/script.js" data-website-id="5cf0aa1f-5585-4a8e-a7eb-b1348932e3aa"></script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineEnvVars } from '@sveltejs/kit/env';
|
||||||
|
|
||||||
|
const optional = (value: string | undefined) => value || undefined;
|
||||||
|
|
||||||
|
export const variables = defineEnvVars({
|
||||||
|
DISCORD_TOKEN: {
|
||||||
|
schema: optional,
|
||||||
|
description: 'Discord bot token used to read presence'
|
||||||
|
},
|
||||||
|
DISCORD_USER_ID: {
|
||||||
|
schema: optional,
|
||||||
|
description: 'Discord user whose status is shown in the bar'
|
||||||
|
},
|
||||||
|
DISCORD_GUILD_ID: {
|
||||||
|
schema: optional,
|
||||||
|
description: 'Guild shared by the bot and the user, needed to read presence'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ServerInit } from '@sveltejs/kit';
|
||||||
|
import { getDiscordClient } from '#lib/server/discord';
|
||||||
|
|
||||||
|
export const init: ServerInit = () => {
|
||||||
|
getDiscordClient();
|
||||||
|
};
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
.scene-container {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
background-color: var(--scene-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-container :global(canvas) {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
/* TUI Components CSS Index */
|
|
||||||
/* This file can be imported to get all TUI component styles at once */
|
|
||||||
|
|
||||||
/* TUI Core Components */
|
|
||||||
@import './tui-header.css';
|
|
||||||
@import './tui-footer.css';
|
|
||||||
@import './tui-body.css';
|
|
||||||
|
|
||||||
/* TUI Display Components */
|
|
||||||
@import './tui-button.css';
|
|
||||||
@import './tui-link.css';
|
|
||||||
@import './tui-card.css';
|
|
||||||
@import './tui-card-grid.css';
|
|
||||||
@import './tui-progress.css';
|
|
||||||
@import './tui-accordion.css';
|
|
||||||
@import './tui-table.css';
|
|
||||||
@import './tui-tooltip.css';
|
|
||||||
|
|
||||||
/* TUI Form Components */
|
|
||||||
@import './tui-input.css';
|
|
||||||
@import './tui-textarea.css';
|
|
||||||
@import './tui-checkbox.css';
|
|
||||||
@import './tui-radio.css';
|
|
||||||
@import './tui-select.css';
|
|
||||||
@import './tui-toggle.css';
|
|
||||||
|
|
||||||
/* Main Components */
|
|
||||||
@import './terminal.css';
|
|
||||||
@import './terminal-tui.css';
|
|
||||||
@import './terminal-page.css';
|
|
||||||
@import './model-viewer.css';
|
|
||||||
@import './navbar.css';
|
|
||||||
@import './navbar-waybar.css';
|
|
||||||
@import './background-3d.css';
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
.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%
|
|
||||||
);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-container:focus {
|
|
||||||
box-shadow: inset 0 0 0 2px var(--viewer-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,808 +0,0 @@
|
|||||||
/* Waybar main container */
|
|
||||||
.waybar {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
height: var(--navbar-height);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
background: var(--bar-bg);
|
|
||||||
border-bottom: 1px solid var(--bar-border);
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bar sections */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Desktop-only items */
|
|
||||||
.desktop-only {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspaces.desktop-only,
|
|
||||||
.window-title.desktop-only {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.desktop-only,
|
|
||||||
.workspaces.desktop-only,
|
|
||||||
.window-title.desktop-only {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-left {
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar-center {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Module base style */
|
|
||||||
.module {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Launcher / Logo */
|
|
||||||
.launcher {
|
|
||||||
padding: 0.4rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.launcher:hover {
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile menu toggle button */
|
|
||||||
.mobile-menu-toggle {
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--bar-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-toggle:hover {
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
|
|
||||||
border-color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.mobile-menu-toggle {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Workspaces */
|
|
||||||
.workspaces {
|
|
||||||
gap: 0.15rem;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.20rem;
|
|
||||||
padding: 0.35rem 0.4rem;
|
|
||||||
color: var(--bar-muted);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace:hover {
|
|
||||||
color: var(--bar-text);
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace.active {
|
|
||||||
color: var(--bar-primary);
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace.external {
|
|
||||||
color: var(--bar-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace.external:hover {
|
|
||||||
background: color-mix(in srgb, var(--bar-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ws-name {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Window title */
|
|
||||||
.window-title {
|
|
||||||
color: var(--bar-text);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-icon {
|
|
||||||
color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-text {
|
|
||||||
color: var(--bar-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Clock */
|
|
||||||
.clock {
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
color: var(--bar-text);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date {
|
|
||||||
color: var(--bar-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Theme selector */
|
|
||||||
.theme-selector {
|
|
||||||
position: relative;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--bar-text);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-trigger:hover {
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
|
|
||||||
border-color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.5rem);
|
|
||||||
right: 0;
|
|
||||||
min-width: 200px;
|
|
||||||
background: var(--bar-bg);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.5rem;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-header {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
font-size: 0.65rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--bar-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--bar-border);
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
color: var(--bar-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
text-align: left;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.check) {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Backdrop */
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: transparent;
|
|
||||||
z-index: 997;
|
|
||||||
border: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile menu dropdown */
|
|
||||||
.mobile-menu {
|
|
||||||
position: fixed;
|
|
||||||
top: var(--navbar-height);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: var(--bar-bg);
|
|
||||||
border-bottom: 1px solid var(--bar-border);
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 1rem;
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-links {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-link {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
color: var(--bar-text);
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-link:hover {
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-link:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-link.active {
|
|
||||||
color: var(--bar-primary);
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-link.external {
|
|
||||||
color: var(--bar-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-menu-divider {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--bar-border);
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-theme-section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-section-header {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--bar-muted);
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-theme-options {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-theme-btn {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.75rem 0.5rem;
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--bar-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-theme-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
|
|
||||||
border-color: var(--bar-primary);
|
|
||||||
color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-theme-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-theme-btn.active {
|
|
||||||
color: var(--bar-primary);
|
|
||||||
border-color: var(--bar-primary);
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 20%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-mode-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--bar-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-mode-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
|
|
||||||
border-color: var(--bar-primary);
|
|
||||||
color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-mode-btn:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Battery styles */
|
|
||||||
.module.battery {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battery-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--bar-text);
|
|
||||||
cursor: default;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
transition: all 0.12s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battery-btn:hover {
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 12%, transparent);
|
|
||||||
border-color: var(--bar-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.battery-level {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bar-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.battery-alert {
|
|
||||||
color: var(--bar-warning);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Low battery visuals */
|
|
||||||
.battery-btn.low {
|
|
||||||
border-color: color-mix(in srgb, var(--bar-warning) 60%, var(--bar-border));
|
|
||||||
/* animation: battery-pulse 1.6s infinite ease-in-out; */
|
|
||||||
box-shadow: 0 0 0 0 rgba(0,0,0,0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes battery-pulse {
|
|
||||||
0% { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
|
|
||||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--bar-warning) 12%, transparent); }
|
|
||||||
100% { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Charging visuals (green) - overrides low state */
|
|
||||||
.battery-btn.charging {
|
|
||||||
border-color: color-mix(in srgb, var(--bar-success) 60%, var(--bar-border));
|
|
||||||
background: color-mix(in srgb, var(--bar-success) 8%, var(--bar-bg-module));
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.battery-level.charging {
|
|
||||||
color: var(--bar-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Discord status module */
|
|
||||||
.module.discord-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--bar-text);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-status .status-dot {
|
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--bar-muted);
|
|
||||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.04) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-status .status-dot.online { background: var(--bar-success); }
|
|
||||||
.discord-status .status-dot.idle { background: var(--bar-warning); }
|
|
||||||
.discord-status .status-dot.dnd { background: var(--bar-error); }
|
|
||||||
.discord-status .status-dot.offline { background: var(--bar-muted); opacity: 0.7; }
|
|
||||||
|
|
||||||
.discord-status .status-text {
|
|
||||||
color: var(--bar-text);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep the status on a single line and truncate if too long */
|
|
||||||
.module.discord-status {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-status .status-text {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
display: inline-block;
|
|
||||||
max-width: 9rem; /* desktop default */
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
/* tighter max width on mobile to keep everything on one line */
|
|
||||||
.discord-status .status-text {
|
|
||||||
max-width: 6rem;
|
|
||||||
}
|
|
||||||
.module.discord-status {
|
|
||||||
padding: 0.2rem 0.45rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Discord status button */
|
|
||||||
.discord-status-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-status-btn:hover {
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Discord Popover */
|
|
||||||
.module.discord-status {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-popover {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 0.5rem);
|
|
||||||
right: -0.5rem;
|
|
||||||
width: 320px;
|
|
||||||
background: var(--bar-bg);
|
|
||||||
border: 1px solid var(--bar-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 1002;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 8%, var(--bar-bg));
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-avatar {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--bar-muted);
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-avatar .avatar-img {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-status-dot {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 3px solid color-mix(in srgb, var(--bar-primary) 8%, var(--bar-bg));
|
|
||||||
background: var(--bar-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-status-dot.online { background: var(--bar-success); }
|
|
||||||
.popover-status-dot.idle { background: var(--bar-warning); }
|
|
||||||
.popover-status-dot.dnd { background: var(--bar-error); }
|
|
||||||
.popover-status-dot.offline { background: var(--bar-muted); opacity: 0.7; }
|
|
||||||
|
|
||||||
.popover-user-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.15rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-username {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--bar-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-username {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--bar-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
max-width: calc(100% - 92px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-clan-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0 0.35rem;
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 15%, transparent);
|
|
||||||
border: 1px solid color-mix(in srgb, var(--bar-primary) 30%, transparent);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bar-primary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .popover-clan-badge {
|
|
||||||
width: 36px;
|
|
||||||
height: 20px;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.popover-clan-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 22px; /* slightly taller to match larger text */
|
|
||||||
padding: 0 0.6rem;
|
|
||||||
background: color-mix(in srgb, var(--bar-primary) 12%, transparent);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9rem; /* a bit larger than the badge */
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--bar-primary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
line-height: 1;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover-clan-badge {
|
|
||||||
width: 15px; /* slightly smaller than tag text */
|
|
||||||
height: 15px;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
box-shadow: none;
|
|
||||||
margin-right: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Activities section */
|
|
||||||
.popover-activities {
|
|
||||||
padding: 0.75rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-card {
|
|
||||||
background: var(--bar-bg-module);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-type-label {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
color: var(--bar-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-content {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-images {
|
|
||||||
position: relative;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-large-image {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 8px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-small-image {
|
|
||||||
position: absolute;
|
|
||||||
bottom: -4px;
|
|
||||||
right: -4px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
border-radius: 50%;
|
|
||||||
border: 3px solid var(--bar-bg-module);
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
min-width: 0;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--bar-text);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-details {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--bar-muted);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No activity state */
|
|
||||||
.popover-no-activity {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
color: var(--bar-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile adjustments for popover */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.discord-popover {
|
|
||||||
position: fixed;
|
|
||||||
top: var(--navbar-height);
|
|
||||||
right: 0.5rem;
|
|
||||||
left: 0.5rem;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
.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: 0.5em;
|
|
||||||
height: 1.1em;
|
|
||||||
background: var(--nav-primary);
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
vertical-align: baseline;
|
|
||||||
transform: translateY(0.15em);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
.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: 0.5em;
|
|
||||||
height: 1.1em;
|
|
||||||
background: var(--terminal-primary);
|
|
||||||
animation: cursorBlink 1s step-end infinite;
|
|
||||||
margin-left: 2px;
|
|
||||||
vertical-align: baseline;
|
|
||||||
transform: translateY(0.15em);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
.tui-terminal {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
|
||||||
background: var(--terminal-bg);
|
|
||||||
border: 2px solid var(--terminal-border);
|
|
||||||
border-radius: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
height: calc(100vh - var(--navbar-height));
|
|
||||||
max-height: calc(100vh - var(--navbar-height));
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-terminal:focus-within .tui-border-glow {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hyprland-style animated border glow */
|
|
||||||
.tui-border-glow {
|
|
||||||
position: absolute;
|
|
||||||
inset: -2px;
|
|
||||||
border-radius: 0;
|
|
||||||
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: 100%;
|
|
||||||
height: calc(100vh - var(--navbar-height));
|
|
||||||
max-height: calc(100vh - var(--navbar-height));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
.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: 0.5em;
|
|
||||||
height: 1.1em;
|
|
||||||
background: var(--terminal-primary);
|
|
||||||
animation: cursorBlink 1s step-end infinite;
|
|
||||||
margin-left: 2px;
|
|
||||||
vertical-align: baseline;
|
|
||||||
transform: translateY(0.15em);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
.tui-accordion {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
border: 1px solid var(--terminal-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-accordion.inline {
|
|
||||||
display: inline-block;
|
|
||||||
width: auto;
|
|
||||||
min-width: 200px;
|
|
||||||
vertical-align: top;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,307 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline group wrapper - groups consecutive inline elements */
|
|
||||||
.tui-inline-group {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
animation: lineSlideIn 0.15s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* When inline group contains an image, allow text to wrap beside it */
|
|
||||||
.tui-inline-group:has(.inline-image) {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-inline-group:has(.inline-image) .inline-content,
|
|
||||||
.tui-inline-group:has(.inline-image) .tui-group {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-content {
|
|
||||||
display: inline;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-content.output {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-content.info {
|
|
||||||
color: var(--terminal-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-content.success {
|
|
||||||
color: #a6e3a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-content.error {
|
|
||||||
color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-content.warning {
|
|
||||||
color: #f9e2af;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lines */
|
|
||||||
.tui-line {
|
|
||||||
display: flex;
|
|
||||||
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.15em;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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: 700;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-text.inline-header {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
: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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline images */
|
|
||||||
.tui-image.inline-image {
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 0;
|
|
||||||
vertical-align: middle;
|
|
||||||
flex-shrink: 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: 0.5em;
|
|
||||||
height: 1.1em;
|
|
||||||
background: var(--terminal-primary);
|
|
||||||
background: var(--terminal-primary);
|
|
||||||
margin-left: 2px;
|
|
||||||
vertical-align: baseline;
|
|
||||||
transform: translateY(0.15em);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor.blink {
|
|
||||||
animation: cursorBlink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: stack inline groups vertically */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tui-inline-group {
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-inline-group:has(.inline-image) {
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep inline flow for specific containers */
|
|
||||||
.tui-card .tui-inline-group,
|
|
||||||
.tui-group .tui-inline-group,
|
|
||||||
.tui-accordion .tui-inline-group {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-inline-group .inline-image img {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TuiGroup inline on mobile should stack */
|
|
||||||
.tui-group.inline {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-group.inline .inline-image img {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-hidden {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
.tui-button {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
margin: 0.75rem 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--btn-color);
|
|
||||||
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.no-border {
|
|
||||||
border-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-button.flex-expand {
|
|
||||||
flex: 1;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline button styles */
|
|
||||||
.tui-button.inline {
|
|
||||||
width: auto;
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0.35rem 0.6rem;
|
|
||||||
border: 1px solid color-mix(in srgb, var(--btn-color) 40%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-button.inline .btn-indicator {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-button:hover,
|
|
||||||
.tui-button.selected {
|
|
||||||
background: color-mix(in srgb, var(--btn-color) 15%, transparent);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.inline-icon) {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 0 0.15em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: inline buttons become full-width */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tui-button.inline {
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
.tui-card-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-card-grid.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
width: auto;
|
|
||||||
gap: 0.5rem;
|
|
||||||
vertical-align: top;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-card-grid .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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
.tui-card {
|
|
||||||
border: 1px solid var(--terminal-border);
|
|
||||||
border-left: 3px solid var(--card-color);
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 50%, var(--terminal-bg-light));
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-card.inline {
|
|
||||||
display: inline-block;
|
|
||||||
width: auto;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
color: var(--card-color);
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-content {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer {
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
border-top: 1px dashed var(--terminal-border);
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: inline cards become full-width */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tui-card.inline {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
.tui-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0.35rem 0;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 0 0.75rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox:hover:not(.disabled) {
|
|
||||||
background: color-mix(in srgb, var(--checkbox-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox:focus-visible {
|
|
||||||
outline: 1px solid var(--checkbox-color);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-box {
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.checked .checkbox-box,
|
|
||||||
.checkbox-box.indeterminate {
|
|
||||||
color: var(--checkbox-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.checkbox-icon) {
|
|
||||||
color: var(--checkbox-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.disabled .checkbox-label {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
.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);
|
|
||||||
gap: 1rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-statusbar.top {
|
|
||||||
border-bottom: 1px solid var(--terminal-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-center {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
color: var(--terminal-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint {
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
background: var(--terminal-border);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-host {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hint {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-host {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-center {
|
|
||||||
position: static;
|
|
||||||
transform: none;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
.tui-input {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--input-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrapper:focus-within {
|
|
||||||
border-color: var(--input-color);
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--input-color) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input.error .input-wrapper {
|
|
||||||
border-color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input.disabled .input-wrapper {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-prompt {
|
|
||||||
color: var(--input-color);
|
|
||||||
font-weight: bold;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-affix {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-affix.prefix {
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-affix.suffix {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
color: #f38ba8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.inline-icon) {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 0 0.15em;
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
.tui-progress {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline progress styles */
|
|
||||||
.tui-progress.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress.inline .progress-label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress.inline .progress-bar {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 80px;
|
|
||||||
height: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress.inline .progress-value {
|
|
||||||
margin-top: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.inline-icon) {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 0 0.15em;
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
.tui-radio-group {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-radio-group.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.75rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-radio-group.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--radio-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-options.horizontal {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option:hover:not(.option-disabled) {
|
|
||||||
background: color-mix(in srgb, var(--radio-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option:focus-visible {
|
|
||||||
outline: 1px solid var(--radio-color);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option.option-disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-symbol {
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option.selected .radio-symbol {
|
|
||||||
color: var(--radio-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.option-icon) {
|
|
||||||
color: var(--radio-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option.option-disabled .option-label {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
.tui-select {
|
|
||||||
position: relative;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.open .select-trigger,
|
|
||||||
.select-trigger:focus-visible {
|
|
||||||
border-color: var(--select-color);
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--select-color) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.error .select-trigger {
|
|
||||||
border-color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.disabled .select-trigger {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-prompt {
|
|
||||||
color: var(--select-color);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-value {
|
|
||||||
flex: 1;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-value.placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-arrow {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
background: var(--terminal-bg);
|
|
||||||
border: 1px solid var(--select-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 100;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-search {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-search input {
|
|
||||||
flex: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-search input::placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-options {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option.highlighted {
|
|
||||||
background: color-mix(in srgb, var(--select-color) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option.selected {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option.option-disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.option-icon) {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.check-icon) {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-empty {
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
color: #f38ba8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
.tui-table-wrapper {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
border: 1px solid var(--terminal-border);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-table-wrapper.inline {
|
|
||||||
display: inline-block;
|
|
||||||
width: auto;
|
|
||||||
vertical-align: top;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
.tui-textarea {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--input-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-wrapper {
|
|
||||||
display: flex;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.focused .textarea-wrapper {
|
|
||||||
border-color: var(--input-color);
|
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--input-color) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.error .textarea-wrapper {
|
|
||||||
border-color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.disabled .textarea-wrapper {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-numbers {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 60%, black);
|
|
||||||
border-right: 1px solid var(--terminal-muted);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-num {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: right;
|
|
||||||
min-width: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: 1.5;
|
|
||||||
resize: vertical;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea::placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: #f38ba8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count.warning {
|
|
||||||
color: #f9e2af;
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
.tui-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin: 0.35rem 0;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 0 0.75rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle:hover:not(.disabled) {
|
|
||||||
background: color-mix(in srgb, var(--toggle-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle:focus-visible {
|
|
||||||
outline: 1px solid var(--toggle-color);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.toggle-icon) {
|
|
||||||
color: var(--toggle-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.checked .toggle-track {
|
|
||||||
border-color: var(--toggle-color);
|
|
||||||
background: color-mix(in srgb, var(--toggle-color) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-off-label,
|
|
||||||
.toggle-on-label {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
min-width: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle:not(.checked) .toggle-off-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.checked .toggle-on-label {
|
|
||||||
color: var(--toggle-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.5rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-knob {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
transform: translateX(-0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.checked .toggle-knob {
|
|
||||||
color: var(--toggle-color);
|
|
||||||
transform: translateX(0.5rem);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Arch Linux",
|
|
||||||
"icon": "🐧",
|
|
||||||
"dark": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#1793d1",
|
|
||||||
"secondary": "#21262d",
|
|
||||||
"accent": "#23d18b",
|
|
||||||
"background": "#0d1117",
|
|
||||||
"backgroundLight": "#161b22",
|
|
||||||
"text": "#c9d1d9",
|
|
||||||
"textMuted": "#8b949e",
|
|
||||||
"border": "#484f58",
|
|
||||||
"terminal": "#0d1117",
|
|
||||||
"terminalPrompt": "#1793d1",
|
|
||||||
"terminalUser": "#23d18b",
|
|
||||||
"terminalPath": "#1793d1"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#ff7b72",
|
|
||||||
"green": "#3fb950",
|
|
||||||
"yellow": "#d29922",
|
|
||||||
"blue": "#58a6ff",
|
|
||||||
"magenta": "#bc8cff",
|
|
||||||
"cyan": "#39c5cf",
|
|
||||||
"white": "#f0f6fc",
|
|
||||||
"gray": "#6e7681",
|
|
||||||
"orange": "#ffa657",
|
|
||||||
"pink": "#ff79c6",
|
|
||||||
"black": "#484f58",
|
|
||||||
"surface": "#21262d",
|
|
||||||
"teal": "#39c5cf",
|
|
||||||
"sky": "#58a6ff",
|
|
||||||
"sapphire": "#1793d1",
|
|
||||||
"lavender": "#bc8cff",
|
|
||||||
"peach": "#ffa657",
|
|
||||||
"maroon": "#ff7b72",
|
|
||||||
"mauve": "#bc8cff",
|
|
||||||
"flamingo": "#e06c75",
|
|
||||||
"rosewater": "#e8b4b8",
|
|
||||||
"error": "#ff7b72",
|
|
||||||
"success": "#3fb950",
|
|
||||||
"warning": "#d29922",
|
|
||||||
"info": "#1793d1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"light": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#1793d1",
|
|
||||||
"secondary": "#ddf4ff",
|
|
||||||
"accent": "#0969da",
|
|
||||||
"background": "#ffffff",
|
|
||||||
"backgroundLight": "#f6f8fa",
|
|
||||||
"text": "#24292f",
|
|
||||||
"textMuted": "#57606a",
|
|
||||||
"border": "#d0d7de",
|
|
||||||
"terminal": "#ffffff",
|
|
||||||
"terminalPrompt": "#1793d1",
|
|
||||||
"terminalUser": "#0969da",
|
|
||||||
"terminalPath": "#1793d1"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#cf222e",
|
|
||||||
"green": "#1a7f37",
|
|
||||||
"yellow": "#9a6700",
|
|
||||||
"blue": "#0969da",
|
|
||||||
"magenta": "#8250df",
|
|
||||||
"cyan": "#1b7c83",
|
|
||||||
"white": "#24292f",
|
|
||||||
"gray": "#6e7781",
|
|
||||||
"orange": "#bc4c00",
|
|
||||||
"pink": "#bf3989",
|
|
||||||
"black": "#24292f",
|
|
||||||
"surface": "#f6f8fa",
|
|
||||||
"teal": "#1b7c83",
|
|
||||||
"sky": "#0969da",
|
|
||||||
"sapphire": "#1793d1",
|
|
||||||
"lavender": "#8250df",
|
|
||||||
"peach": "#bc4c00",
|
|
||||||
"maroon": "#cf222e",
|
|
||||||
"mauve": "#8250df",
|
|
||||||
"flamingo": "#bf3989",
|
|
||||||
"rosewater": "#d0d7de",
|
|
||||||
"error": "#cf222e",
|
|
||||||
"success": "#1a7f37",
|
|
||||||
"warning": "#9a6700",
|
|
||||||
"info": "#0969da"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Catppuccin",
|
|
||||||
"icon": "🐱",
|
|
||||||
"dark": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#89b4fa",
|
|
||||||
"secondary": "#585b70",
|
|
||||||
"accent": "#a6e3a1",
|
|
||||||
"background": "#1e1e2e",
|
|
||||||
"backgroundLight": "#313244",
|
|
||||||
"text": "#cdd6f4",
|
|
||||||
"textMuted": "#a6adc8",
|
|
||||||
"border": "#6c7086",
|
|
||||||
"terminal": "#1e1e2e",
|
|
||||||
"terminalPrompt": "#cba6f7",
|
|
||||||
"terminalUser": "#a6e3a1",
|
|
||||||
"terminalPath": "#89b4fa"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#f38ba8",
|
|
||||||
"green": "#a6e3a1",
|
|
||||||
"yellow": "#f9e2af",
|
|
||||||
"blue": "#89b4fa",
|
|
||||||
"magenta": "#cba6f7",
|
|
||||||
"cyan": "#94e2d5",
|
|
||||||
"white": "#cdd6f4",
|
|
||||||
"gray": "#6c7086",
|
|
||||||
"orange": "#fab387",
|
|
||||||
"pink": "#f5c2e7",
|
|
||||||
"black": "#45475a",
|
|
||||||
"surface": "#313244",
|
|
||||||
"teal": "#94e2d5",
|
|
||||||
"sky": "#89dceb",
|
|
||||||
"sapphire": "#74c7ec",
|
|
||||||
"lavender": "#b4befe",
|
|
||||||
"peach": "#fab387",
|
|
||||||
"maroon": "#eba0ac",
|
|
||||||
"mauve": "#cba6f7",
|
|
||||||
"flamingo": "#f2cdcd",
|
|
||||||
"rosewater": "#f5e0dc",
|
|
||||||
"error": "#f38ba8",
|
|
||||||
"success": "#a6e3a1",
|
|
||||||
"warning": "#f9e2af",
|
|
||||||
"info": "#89b4fa"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"light": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#1e66f5",
|
|
||||||
"secondary": "#acb0be",
|
|
||||||
"accent": "#40a02b",
|
|
||||||
"background": "#eff1f5",
|
|
||||||
"backgroundLight": "#e6e9ef",
|
|
||||||
"text": "#4c4f69",
|
|
||||||
"textMuted": "#6c6f85",
|
|
||||||
"border": "#9ca0b0",
|
|
||||||
"terminal": "#eff1f5",
|
|
||||||
"terminalPrompt": "#8839ef",
|
|
||||||
"terminalUser": "#40a02b",
|
|
||||||
"terminalPath": "#1e66f5"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#d20f39",
|
|
||||||
"green": "#40a02b",
|
|
||||||
"yellow": "#df8e1d",
|
|
||||||
"blue": "#1e66f5",
|
|
||||||
"magenta": "#8839ef",
|
|
||||||
"cyan": "#179299",
|
|
||||||
"white": "#4c4f69",
|
|
||||||
"gray": "#9ca0b0",
|
|
||||||
"orange": "#fe640b",
|
|
||||||
"pink": "#ea76cb",
|
|
||||||
"black": "#bcc0cc",
|
|
||||||
"surface": "#ccd0da",
|
|
||||||
"teal": "#179299",
|
|
||||||
"sky": "#04a5e5",
|
|
||||||
"sapphire": "#209fb5",
|
|
||||||
"lavender": "#7287fd",
|
|
||||||
"peach": "#fe640b",
|
|
||||||
"maroon": "#e64553",
|
|
||||||
"mauve": "#8839ef",
|
|
||||||
"flamingo": "#dd7878",
|
|
||||||
"rosewater": "#dc8a78",
|
|
||||||
"error": "#d20f39",
|
|
||||||
"success": "#40a02b",
|
|
||||||
"warning": "#df8e1d",
|
|
||||||
"info": "#1e66f5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Cerberus",
|
|
||||||
"icon": "🐕",
|
|
||||||
"dark": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#818cf8",
|
|
||||||
"secondary": "#e879f9",
|
|
||||||
"accent": "#2dd4bf",
|
|
||||||
"background": "#171717",
|
|
||||||
"backgroundLight": "#262626",
|
|
||||||
"text": "#f5f5f5",
|
|
||||||
"textMuted": "#a3a3a3",
|
|
||||||
"border": "#404040",
|
|
||||||
"terminal": "#171717",
|
|
||||||
"terminalPrompt": "#818cf8",
|
|
||||||
"terminalUser": "#2dd4bf",
|
|
||||||
"terminalPath": "#e879f9"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#ef4444",
|
|
||||||
"green": "#2dd4bf",
|
|
||||||
"yellow": "#fbbf24",
|
|
||||||
"blue": "#818cf8",
|
|
||||||
"magenta": "#e879f9",
|
|
||||||
"cyan": "#67e8f9",
|
|
||||||
"white": "#f5f5f5",
|
|
||||||
"gray": "#737373",
|
|
||||||
"orange": "#fb923c",
|
|
||||||
"pink": "#f472b6",
|
|
||||||
"black": "#404040",
|
|
||||||
"surface": "#262626",
|
|
||||||
"teal": "#2dd4bf",
|
|
||||||
"sky": "#38bdf8",
|
|
||||||
"sapphire": "#3b82f6",
|
|
||||||
"lavender": "#a78bfa",
|
|
||||||
"peach": "#fdba74",
|
|
||||||
"maroon": "#f43f5e",
|
|
||||||
"mauve": "#c084fc",
|
|
||||||
"flamingo": "#f472b6",
|
|
||||||
"rosewater": "#fb7185",
|
|
||||||
"error": "#ef4444",
|
|
||||||
"success": "#2dd4bf",
|
|
||||||
"warning": "#fbbf24",
|
|
||||||
"info": "#818cf8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"light": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#4f46e5",
|
|
||||||
"secondary": "#c026d3",
|
|
||||||
"accent": "#0d9488",
|
|
||||||
"background": "#ffffff",
|
|
||||||
"backgroundLight": "#f5f5f5",
|
|
||||||
"text": "#171717",
|
|
||||||
"textMuted": "#525252",
|
|
||||||
"border": "#d4d4d4",
|
|
||||||
"terminal": "#ffffff",
|
|
||||||
"terminalPrompt": "#4f46e5",
|
|
||||||
"terminalUser": "#0d9488",
|
|
||||||
"terminalPath": "#c026d3"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#dc2626",
|
|
||||||
"green": "#0f766e",
|
|
||||||
"yellow": "#d97706",
|
|
||||||
"blue": "#4f46e5",
|
|
||||||
"magenta": "#c026d3",
|
|
||||||
"cyan": "#0891b2",
|
|
||||||
"white": "#171717",
|
|
||||||
"gray": "#525252",
|
|
||||||
"orange": "#ea580c",
|
|
||||||
"pink": "#db2777",
|
|
||||||
"black": "#262626",
|
|
||||||
"surface": "#f5f5f5",
|
|
||||||
"teal": "#0f766e",
|
|
||||||
"sky": "#0284c7",
|
|
||||||
"sapphire": "#1d4ed8",
|
|
||||||
"lavender": "#7e22ce",
|
|
||||||
"peach": "#f97316",
|
|
||||||
"maroon": "#e11d48",
|
|
||||||
"mauve": "#9333ea",
|
|
||||||
"flamingo": "#be185d",
|
|
||||||
"rosewater": "#e11d48",
|
|
||||||
"error": "#dc2626",
|
|
||||||
"success": "#0f766e",
|
|
||||||
"warning": "#d97706",
|
|
||||||
"info": "#4f46e5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Rose",
|
|
||||||
"icon": "🌹",
|
|
||||||
"dark": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#fb7185",
|
|
||||||
"secondary": "#c084fc",
|
|
||||||
"accent": "#2dd4bf",
|
|
||||||
"background": "#1f181b",
|
|
||||||
"backgroundLight": "#2e2327",
|
|
||||||
"text": "#ffe4e6",
|
|
||||||
"textMuted": "#fda4af",
|
|
||||||
"border": "#5f484f",
|
|
||||||
"terminal": "#1f181b",
|
|
||||||
"terminalPrompt": "#fb7185",
|
|
||||||
"terminalUser": "#2dd4bf",
|
|
||||||
"terminalPath": "#c084fc"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#f43f5e",
|
|
||||||
"green": "#34d399",
|
|
||||||
"yellow": "#fde047",
|
|
||||||
"blue": "#60a5fa",
|
|
||||||
"magenta": "#e879f9",
|
|
||||||
"cyan": "#67e8f9",
|
|
||||||
"white": "#ffe4e6",
|
|
||||||
"gray": "#886e75",
|
|
||||||
"orange": "#fb923c",
|
|
||||||
"pink": "#fb7185",
|
|
||||||
"black": "#5f484f",
|
|
||||||
"surface": "#2e2327",
|
|
||||||
"teal": "#2dd4bf",
|
|
||||||
"sky": "#7dd3fc",
|
|
||||||
"sapphire": "#3b82f6",
|
|
||||||
"lavender": "#c084fc",
|
|
||||||
"peach": "#fdba74",
|
|
||||||
"maroon": "#be123c",
|
|
||||||
"mauve": "#d8b4fe",
|
|
||||||
"flamingo": "#f472b6",
|
|
||||||
"rosewater": "#fecdd3",
|
|
||||||
"error": "#f43f5e",
|
|
||||||
"success": "#34d399",
|
|
||||||
"warning": "#fde047",
|
|
||||||
"info": "#60a5fa"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"light": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#be123c",
|
|
||||||
"secondary": "#7e22ce",
|
|
||||||
"accent": "#0f766e",
|
|
||||||
"background": "#fff1f2",
|
|
||||||
"backgroundLight": "#ffe4e6",
|
|
||||||
"text": "#4c0519",
|
|
||||||
"textMuted": "#9f1239",
|
|
||||||
"border": "#fecdd3",
|
|
||||||
"terminal": "#fff1f2",
|
|
||||||
"terminalPrompt": "#be123c",
|
|
||||||
"terminalUser": "#0f766e",
|
|
||||||
"terminalPath": "#7e22ce"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#e11d48",
|
|
||||||
"green": "#059669",
|
|
||||||
"yellow": "#d97706",
|
|
||||||
"blue": "#2563eb",
|
|
||||||
"magenta": "#9333ea",
|
|
||||||
"cyan": "#0891b2",
|
|
||||||
"white": "#4c0519",
|
|
||||||
"gray": "#9f1239",
|
|
||||||
"orange": "#ea580c",
|
|
||||||
"pink": "#db2777",
|
|
||||||
"black": "#881337",
|
|
||||||
"surface": "#ffe4e6",
|
|
||||||
"teal": "#0f766e",
|
|
||||||
"sky": "#0284c7",
|
|
||||||
"sapphire": "#1d4ed8",
|
|
||||||
"lavender": "#7e22ce",
|
|
||||||
"peach": "#f97316",
|
|
||||||
"maroon": "#881337",
|
|
||||||
"mauve": "#a855f7",
|
|
||||||
"flamingo": "#be185d",
|
|
||||||
"rosewater": "#e11d48",
|
|
||||||
"error": "#e11d48",
|
|
||||||
"success": "#059669",
|
|
||||||
"warning": "#d97706",
|
|
||||||
"info": "#2563eb"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Wintry",
|
|
||||||
"icon": "❄️",
|
|
||||||
"dark": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#7dd3fc",
|
|
||||||
"secondary": "#334155",
|
|
||||||
"accent": "#2dd4bf",
|
|
||||||
"background": "#0f172a",
|
|
||||||
"backgroundLight": "#1e293b",
|
|
||||||
"text": "#f1f5f9",
|
|
||||||
"textMuted": "#94a3b8",
|
|
||||||
"border": "#475569",
|
|
||||||
"terminal": "#0f172a",
|
|
||||||
"terminalPrompt": "#7dd3fc",
|
|
||||||
"terminalUser": "#2dd4bf",
|
|
||||||
"terminalPath": "#818cf8"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#f43f5e",
|
|
||||||
"green": "#4ade80",
|
|
||||||
"yellow": "#fde047",
|
|
||||||
"blue": "#60a5fa",
|
|
||||||
"magenta": "#c084fc",
|
|
||||||
"cyan": "#67e8f9",
|
|
||||||
"white": "#f1f5f9",
|
|
||||||
"gray": "#64748b",
|
|
||||||
"orange": "#fb923c",
|
|
||||||
"pink": "#f472b6",
|
|
||||||
"black": "#334155",
|
|
||||||
"surface": "#1e293b",
|
|
||||||
"teal": "#2dd4bf",
|
|
||||||
"sky": "#7dd3fc",
|
|
||||||
"sapphire": "#3b82f6",
|
|
||||||
"lavender": "#a78bfa",
|
|
||||||
"peach": "#fdba74",
|
|
||||||
"maroon": "#fda4af",
|
|
||||||
"mauve": "#d8b4fe",
|
|
||||||
"flamingo": "#f9a8d4",
|
|
||||||
"rosewater": "#fecdd3",
|
|
||||||
"error": "#f43f5e",
|
|
||||||
"success": "#4ade80",
|
|
||||||
"warning": "#fde047",
|
|
||||||
"info": "#60a5fa"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"light": {
|
|
||||||
"colors": {
|
|
||||||
"primary": "#0284c7",
|
|
||||||
"secondary": "#e2e8f0",
|
|
||||||
"accent": "#0d9488",
|
|
||||||
"background": "#f8fafc",
|
|
||||||
"backgroundLight": "#ffffff",
|
|
||||||
"text": "#0f172a",
|
|
||||||
"textMuted": "#64748b",
|
|
||||||
"border": "#cbd5e1",
|
|
||||||
"terminal": "#ffffff",
|
|
||||||
"terminalPrompt": "#0284c7",
|
|
||||||
"terminalUser": "#0d9488",
|
|
||||||
"terminalPath": "#4f46e5"
|
|
||||||
},
|
|
||||||
"colorMap": {
|
|
||||||
"red": "#e11d48",
|
|
||||||
"green": "#16a34a",
|
|
||||||
"yellow": "#d97706",
|
|
||||||
"blue": "#2563eb",
|
|
||||||
"magenta": "#9333ea",
|
|
||||||
"cyan": "#0891b2",
|
|
||||||
"white": "#0f172a",
|
|
||||||
"gray": "#64748b",
|
|
||||||
"orange": "#ea580c",
|
|
||||||
"pink": "#db2777",
|
|
||||||
"black": "#1e293b",
|
|
||||||
"surface": "#e2e8f0",
|
|
||||||
"teal": "#0d9488",
|
|
||||||
"sky": "#0284c7",
|
|
||||||
"sapphire": "#1d4ed8",
|
|
||||||
"lavender": "#7c3aed",
|
|
||||||
"peach": "#f97316",
|
|
||||||
"maroon": "#be123c",
|
|
||||||
"mauve": "#a855f7",
|
|
||||||
"flamingo": "#be185d",
|
|
||||||
"rosewater": "#e11d48",
|
|
||||||
"error": "#e11d48",
|
|
||||||
"success": "#16a34a",
|
|
||||||
"warning": "#d97706",
|
|
||||||
"info": "#2563eb"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+22
-24
@@ -1,19 +1,27 @@
|
|||||||
import { user } from '$lib/config';
|
import { profile } from '#lib/content';
|
||||||
import { parseFrontmatter } from './frontmatter';
|
import { parseFrontmatter } from './frontmatter';
|
||||||
import { renderMarkdown } from './markdown';
|
import { renderMarkdown } from './markdown';
|
||||||
|
|
||||||
export interface BlogPost {
|
export interface BlogPostSummary {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
author: string;
|
author: string;
|
||||||
date: string;
|
date: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
excerpt: string;
|
excerpt: string;
|
||||||
html: string;
|
|
||||||
readingTime: number;
|
readingTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const postFiles = import.meta.glob('/blogs/*.md', {
|
export interface BlogPost extends BlogPostSummary {
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlogPostLink {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postFiles = import.meta.glob('/content/blog/*.md', {
|
||||||
eager: true,
|
eager: true,
|
||||||
query: '?raw',
|
query: '?raw',
|
||||||
import: 'default'
|
import: 'default'
|
||||||
@@ -24,8 +32,8 @@ function slugFromPath(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function estimateReadingTime(content: string): number {
|
function estimateReadingTime(content: string): number {
|
||||||
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
||||||
return Math.max(1, Math.round(words / 200));
|
return Math.max(1, Math.round(wordCount / 200));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPost(path: string, raw: string): BlogPost {
|
function buildPost(path: string, raw: string): BlogPost {
|
||||||
@@ -35,7 +43,7 @@ function buildPost(path: string, raw: string): BlogPost {
|
|||||||
return {
|
return {
|
||||||
slug,
|
slug,
|
||||||
title: (data.title as string) || slug,
|
title: (data.title as string) || slug,
|
||||||
author: (data.author as string) || user.displayname,
|
author: (data.author as string) || profile.displayName,
|
||||||
date: (data.date as string) || '',
|
date: (data.date as string) || '',
|
||||||
tags: (data.tags as string[]) || [],
|
tags: (data.tags as string[]) || [],
|
||||||
excerpt: (data.excerpt as string) || '',
|
excerpt: (data.excerpt as string) || '',
|
||||||
@@ -46,27 +54,21 @@ function buildPost(path: string, raw: string): BlogPost {
|
|||||||
|
|
||||||
const allPosts: BlogPost[] = Object.entries(postFiles)
|
const allPosts: BlogPost[] = Object.entries(postFiles)
|
||||||
.map(([path, raw]) => buildPost(path, raw))
|
.map(([path, raw]) => buildPost(path, raw))
|
||||||
.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
|
.sort((first, second) => second.date.localeCompare(first.date));
|
||||||
|
|
||||||
export function getAllPosts(): BlogPost[] {
|
export function getAllPosts(): BlogPost[] {
|
||||||
return allPosts;
|
return allPosts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPostSummaries(): BlogPostSummary[] {
|
||||||
|
return allPosts.map(({ html, ...summary }) => summary);
|
||||||
|
}
|
||||||
|
|
||||||
export function getPostBySlug(slug: string): BlogPost | undefined {
|
export function getPostBySlug(slug: string): BlogPost | undefined {
|
||||||
return allPosts.find((post) => post.slug === slug);
|
return allPosts.find((post) => post.slug === slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlogPostNav {
|
export function getAdjacentPosts(slug: string): { previous: BlogPostLink | null; next: BlogPostLink | null } {
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdjacentPosts {
|
|
||||||
previous: BlogPostNav | null;
|
|
||||||
next: BlogPostNav | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdjacentPosts(slug: string): AdjacentPosts {
|
|
||||||
const index = allPosts.findIndex((post) => post.slug === slug);
|
const index = allPosts.findIndex((post) => post.slug === slug);
|
||||||
if (index === -1) return { previous: null, next: null };
|
if (index === -1) return { previous: null, next: null };
|
||||||
|
|
||||||
@@ -80,9 +82,5 @@ export function getAdjacentPosts(slug: string): AdjacentPosts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAllTags(): string[] {
|
export function getAllTags(): string[] {
|
||||||
const tags = new Set<string>();
|
return [...new Set(allPosts.flatMap((post) => post.tags))].sort();
|
||||||
for (const post of allPosts) {
|
|
||||||
for (const tag of post.tags) tags.add(tag);
|
|
||||||
}
|
|
||||||
return Array.from(tags).sort();
|
|
||||||
}
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { Canvas } from '@threlte/core';
|
|
||||||
import { T } from '@threlte/core';
|
|
||||||
import ParticleField from './ParticleField.svelte';
|
|
||||||
import { themeColors, mode } from '$lib/stores/theme';
|
|
||||||
import '$lib/assets/css/background-3d.css';
|
|
||||||
|
|
||||||
// Reactive theme colors
|
|
||||||
const bgColor = $derived($themeColors.background);
|
|
||||||
const primaryColor = $derived($themeColors.primary);
|
|
||||||
|
|
||||||
let webglAvailable = $state(false);
|
|
||||||
|
|
||||||
function isWebGLAvailable() {
|
|
||||||
try {
|
|
||||||
const testCanvas = document.createElement('canvas');
|
|
||||||
const context =
|
|
||||||
testCanvas.getContext('webgl2') ||
|
|
||||||
testCanvas.getContext('webgl') ||
|
|
||||||
testCanvas.getContext('experimental-webgl');
|
|
||||||
return !!(window.WebGLRenderingContext && context);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
webglAvailable = isWebGLAvailable();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="scene-container" style="--scene-bg: {bgColor};">
|
|
||||||
{#if webglAvailable}
|
|
||||||
<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={primaryColor}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Particle system -->
|
|
||||||
<ParticleField particleCount={600} />
|
|
||||||
|
|
||||||
<!-- Background color -->
|
|
||||||
<T.Color args={[bgColor]} attach="background" />
|
|
||||||
</Canvas>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from "svelte";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import "$lib/assets/css/model-viewer.css";
|
|
||||||
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 showGrid = $state(false);
|
|
||||||
let showAxes = $state(false);
|
|
||||||
let lightIntensity = $state(1);
|
|
||||||
let rotationSpeed = $state(2);
|
|
||||||
let showControls = $state(false);
|
|
||||||
|
|
||||||
let ground: THREE.Mesh | null = null;
|
|
||||||
let gridHelper: THREE.GridHelper | null = null;
|
|
||||||
let axesHelper: THREE.AxesHelper | 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;
|
|
||||||
ground.receiveShadow = true;
|
|
||||||
scene.add(ground);
|
|
||||||
|
|
||||||
// Grid Helper
|
|
||||||
gridHelper = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
|
|
||||||
gridHelper.position.y = -0.49; // Slightly above ground to avoid z-fighting
|
|
||||||
gridHelper.visible = showGrid;
|
|
||||||
scene.add(gridHelper);
|
|
||||||
|
|
||||||
// Axes Helper
|
|
||||||
axesHelper = new THREE.AxesHelper(2);
|
|
||||||
axesHelper.visible = showAxes;
|
|
||||||
scene.add(axesHelper);
|
|
||||||
|
|
||||||
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 toggleGrid() {
|
|
||||||
showGrid = !showGrid;
|
|
||||||
if (gridHelper) {
|
|
||||||
gridHelper.visible = showGrid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAxes() {
|
|
||||||
showAxes = !showAxes;
|
|
||||||
if (axesHelper) {
|
|
||||||
axesHelper.visible = showAxes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustRotationSpeed(delta: number) {
|
|
||||||
rotationSpeed = Math.max(0, Math.min(10, rotationSpeed + delta));
|
|
||||||
if (controls) {
|
|
||||||
controls.autoRotateSpeed = rotationSpeed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 rotateCamera(direction: "left" | "right" | "up" | "down") {
|
|
||||||
if (!controls) return;
|
|
||||||
|
|
||||||
const rotateSpeed = 0.1;
|
|
||||||
const spherical = new THREE.Spherical();
|
|
||||||
const offset = new THREE.Vector3();
|
|
||||||
|
|
||||||
// Get current camera position relative to target
|
|
||||||
offset.copy(camera.position).sub(controls.target);
|
|
||||||
spherical.setFromVector3(offset);
|
|
||||||
|
|
||||||
switch (direction) {
|
|
||||||
case "left":
|
|
||||||
spherical.theta += rotateSpeed;
|
|
||||||
break;
|
|
||||||
case "right":
|
|
||||||
spherical.theta -= rotateSpeed;
|
|
||||||
break;
|
|
||||||
case "up":
|
|
||||||
spherical.phi = Math.max(0.1, spherical.phi - rotateSpeed);
|
|
||||||
break;
|
|
||||||
case "down":
|
|
||||||
spherical.phi = Math.min(
|
|
||||||
Math.PI - 0.1,
|
|
||||||
spherical.phi + rotateSpeed,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
offset.setFromSpherical(spherical);
|
|
||||||
camera.position.copy(controls.target).add(offset);
|
|
||||||
controls.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
|
||||||
// Only handle if this component is focused or in fullscreen
|
|
||||||
if (!container?.contains(document.activeElement) && !isFullscreen)
|
|
||||||
return;
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case "ArrowLeft":
|
|
||||||
event.preventDefault();
|
|
||||||
rotateCamera("left");
|
|
||||||
break;
|
|
||||||
case "ArrowRight":
|
|
||||||
event.preventDefault();
|
|
||||||
rotateCamera("right");
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
event.preventDefault();
|
|
||||||
rotateCamera("up");
|
|
||||||
break;
|
|
||||||
case "ArrowDown":
|
|
||||||
event.preventDefault();
|
|
||||||
rotateCamera("down");
|
|
||||||
break;
|
|
||||||
case "Escape":
|
|
||||||
if (isFullscreen) {
|
|
||||||
isFullscreen = false;
|
|
||||||
setTimeout(handleResize, 100);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleControlsPanel() {
|
|
||||||
showControls = !showControls;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (container) {
|
|
||||||
try {
|
|
||||||
initScene();
|
|
||||||
animate();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("WebGL unavailable:", err);
|
|
||||||
loadError = "3D viewer unavailable (WebGL not supported)";
|
|
||||||
isLoading = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
window.addEventListener("keydown", handleKeydown);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (animationId) {
|
|
||||||
cancelAnimationFrame(animationId);
|
|
||||||
}
|
|
||||||
if (renderer) {
|
|
||||||
renderer.dispose();
|
|
||||||
}
|
|
||||||
window.removeEventListener("resize", handleResize);
|
|
||||||
window.removeEventListener("keydown", handleKeydown);
|
|
||||||
});
|
|
||||||
</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>
|
|
||||||
<button
|
|
||||||
class="control-btn small"
|
|
||||||
title={showGrid ? "Hide grid" : "Show grid"}
|
|
||||||
onclick={toggleGrid}
|
|
||||||
class:active={showGrid}
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:grid" width="14" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="control-btn small"
|
|
||||||
title={showAxes ? "Hide axes" : "Show axes"}
|
|
||||||
onclick={toggleAxes}
|
|
||||||
class:active={showAxes}
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:axis-arrow" width="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="control-group">
|
|
||||||
<span class="control-label">Rotation</span>
|
|
||||||
<div class="control-buttons">
|
|
||||||
<button
|
|
||||||
class="control-btn small"
|
|
||||||
title="Slower rotation"
|
|
||||||
onclick={() => adjustRotationSpeed(-0.5)}
|
|
||||||
disabled={!autoRotate}
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:minus" width="14" />
|
|
||||||
</button>
|
|
||||||
<span class="control-value">{rotationSpeed.toFixed(1)}</span
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="control-btn small"
|
|
||||||
title="Faster rotation"
|
|
||||||
onclick={() => adjustRotationSpeed(0.5)}
|
|
||||||
disabled={!autoRotate}
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:plus" width="14" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Canvas container -->
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
||||||
<div
|
|
||||||
class="canvas-container"
|
|
||||||
bind:this={container}
|
|
||||||
tabindex="0"
|
|
||||||
role="application"
|
|
||||||
aria-label="3D model viewer - use arrow keys to rotate"
|
|
||||||
>
|
|
||||||
{#if isLoading}
|
|
||||||
<div class="loading-overlay">
|
|
||||||
<Icon icon="mdi:loading" width="32" class="spin" />
|
|
||||||
<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>
|
|
||||||
<span class="hint">
|
|
||||||
<Icon icon="mdi:arrow-all" width="14" />
|
|
||||||
Arrow keys
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<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';
|
|
||||||
import '$lib/assets/css/navbar.css';
|
|
||||||
|
|
||||||
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>
|
|
||||||
@@ -1,532 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { mode, getThemeIcon, colorTheme, toggleMode, setColorTheme, themeOptions, themeColors, type ColorTheme } from '$lib/stores/theme';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { fly, fade, slide } from 'svelte/transition';
|
|
||||||
import { user, navigation } from '$lib/config';
|
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import '$lib/assets/css/navbar-waybar.css';
|
|
||||||
|
|
||||||
// State
|
|
||||||
let themeDropdownOpen = $state(false);
|
|
||||||
let mobileMenuOpen = $state(false);
|
|
||||||
let currentTime = $state(new Date());
|
|
||||||
|
|
||||||
// Discord presence from layout data
|
|
||||||
let discordStatus = $state('offline');
|
|
||||||
let discordDisplayName: string | null = $state(null);
|
|
||||||
let discordActivities: any[] = $state([]);
|
|
||||||
let discordAvatarUrl: string | null = $state(null);
|
|
||||||
let discordGuildTag: string | null = $state(null);
|
|
||||||
let guildTagBadgeImage: string | null = $state(null);
|
|
||||||
let discordPopoverOpen = $state(false);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
discordStatus = $page.data?.status ?? 'offline';
|
|
||||||
discordDisplayName = $page.data?.displayName ?? null;
|
|
||||||
discordAvatarUrl = $page.data?.avatarUrl ?? null;
|
|
||||||
discordGuildTag = $page.data?.guildTag ?? null;
|
|
||||||
guildTagBadgeImage = $page.data?.guildTagBadgeImage ?? null;
|
|
||||||
// Filter out Custom Status (type 4)
|
|
||||||
discordActivities = ($page.data?.activities ?? []).filter((a: any) => a.type !== 4);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Derived: first displayable activity (if any)
|
|
||||||
let currentActivity = $derived(discordActivities.length > 0 ? discordActivities[0] : null);
|
|
||||||
|
|
||||||
// Battery state (computed from EST time)
|
|
||||||
let batteryLevel = $state(50);
|
|
||||||
let batteryCharging = $state(false);
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
|
|
||||||
// Compute battery using America/New_York timezone (DST-aware)
|
|
||||||
function zoneEpochFromMs(ms: number, timeZone: string) {
|
|
||||||
const date = new Date(ms);
|
|
||||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
|
||||||
timeZone,
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
});
|
|
||||||
const parts = fmt.formatToParts(date).reduce((acc: any, p: any) => {
|
|
||||||
acc[p.type] = p.value;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const iso = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}Z`;
|
|
||||||
return Date.parse(iso);
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeBatteryFromMs(ms: number) {
|
|
||||||
const dischargeMs = 5 * 3600 * 1000; // 5 hours
|
|
||||||
const chargeMs = 1 * 3600 * 1000; // 1 hour
|
|
||||||
const cycleMs = dischargeMs + chargeMs; // 6 hours
|
|
||||||
// position in cycle using America/New_York wall-clock epoch (DST-aware)
|
|
||||||
const estMs = zoneEpochFromMs(ms, 'America/New_York');
|
|
||||||
let pos = estMs % cycleMs;
|
|
||||||
if (pos < 0) pos += cycleMs;
|
|
||||||
if (pos < dischargeMs) {
|
|
||||||
// discharging: 100 -> 0
|
|
||||||
const pct = 100 - (pos / dischargeMs) * 100;
|
|
||||||
return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: false };
|
|
||||||
} else {
|
|
||||||
// charging: 0 -> 100
|
|
||||||
const chargePos = pos - dischargeMs;
|
|
||||||
const pct = (chargePos / chargeMs) * 100;
|
|
||||||
return { level: Math.max(0, Math.min(100, Math.round(pct))), charging: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { level, charging } = computeBatteryFromMs(Date.now());
|
|
||||||
batteryLevel = level;
|
|
||||||
batteryCharging = charging;
|
|
||||||
}, 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;
|
|
||||||
mobileMenuOpen = false;
|
|
||||||
discordPopoverOpen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDiscordPopover() {
|
|
||||||
discordPopoverOpen = !discordPopoverOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivityTypeLabel(type: number): string {
|
|
||||||
switch (type) {
|
|
||||||
case 0: return 'Playing';
|
|
||||||
case 1: return 'Streaming';
|
|
||||||
case 2: return 'Listening to';
|
|
||||||
case 3: return 'Watching';
|
|
||||||
case 5: return 'Competing in';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMobileMenu() {
|
|
||||||
mobileMenuOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
</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: {$themeColors.colorMap.success};
|
|
||||||
--bar-warning: {$themeColors.colorMap.warning};
|
|
||||||
--bar-error: {$themeColors.colorMap.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> -->
|
|
||||||
|
|
||||||
<!-- Mobile menu toggle -->
|
|
||||||
<button
|
|
||||||
class="mobile-menu-toggle"
|
|
||||||
onclick={() => mobileMenuOpen = !mobileMenuOpen}
|
|
||||||
aria-expanded={mobileMenuOpen}
|
|
||||||
aria-label="Toggle navigation menu"
|
|
||||||
>
|
|
||||||
<Icon icon={mobileMenuOpen ? 'mdi:close' : 'mdi:menu'} width="20" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Workspaces (desktop) -->
|
|
||||||
<div class="module workspaces desktop-only">
|
|
||||||
{#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 (desktop) -->
|
|
||||||
<div class="module window-title desktop-only">
|
|
||||||
<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 === '/projects'}
|
|
||||||
<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) -->
|
|
||||||
|
|
||||||
<!-- Discord status with popover -->
|
|
||||||
<div class="module discord-status">
|
|
||||||
<button
|
|
||||||
class="discord-status-btn"
|
|
||||||
onclick={toggleDiscordPopover}
|
|
||||||
title={discordDisplayName ? `${discordDisplayName} — ${discordStatus}` : `Discord: ${discordStatus}`}
|
|
||||||
aria-expanded={discordPopoverOpen}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="status-dot"
|
|
||||||
class:online={discordStatus === 'online'}
|
|
||||||
class:idle={discordStatus === 'idle'}
|
|
||||||
class:dnd={discordStatus === 'dnd'}
|
|
||||||
class:offline={discordStatus === 'offline'}
|
|
||||||
></span>
|
|
||||||
{#if discordDisplayName}
|
|
||||||
<span class="status-text">{discordDisplayName}</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{#if discordPopoverOpen}
|
|
||||||
<div
|
|
||||||
class="discord-popover"
|
|
||||||
transition:fly={{ y: -10, duration: 150 }}
|
|
||||||
>
|
|
||||||
<!-- Header with avatar and name -->
|
|
||||||
<div class="popover-header">
|
|
||||||
<div class="popover-avatar">
|
|
||||||
{#if discordAvatarUrl}
|
|
||||||
<img src={discordAvatarUrl} alt="" class="avatar-img" />
|
|
||||||
{:else}
|
|
||||||
<Icon icon="mdi:account-circle" width="48" />
|
|
||||||
{/if}
|
|
||||||
<span
|
|
||||||
class="popover-status-dot"
|
|
||||||
class:online={discordStatus === 'online'}
|
|
||||||
class:idle={discordStatus === 'idle'}
|
|
||||||
class:dnd={discordStatus === 'dnd'}
|
|
||||||
class:offline={discordStatus === 'offline'}
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
<div class="popover-user-info">
|
|
||||||
<div class="popover-name-row">
|
|
||||||
<span class="popover-username">{discordDisplayName ?? 'Unknown'}</span>
|
|
||||||
{#if discordGuildTag}
|
|
||||||
<span class="popover-clan-tag">
|
|
||||||
<img src={guildTagBadgeImage} alt={discordGuildTag ?? 'Guild'} class="popover-clan-badge" />
|
|
||||||
{discordGuildTag}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<span class="popover-status-label">{discordStatus}</span>
|
|
||||||
</div>
|
|
||||||
<button class="popover-close" onclick={() => discordPopoverOpen = false} aria-label="Close">
|
|
||||||
<Icon icon="mdi:close" width="16" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Divider -->
|
|
||||||
<div class="popover-divider"></div>
|
|
||||||
|
|
||||||
<!-- Activities -->
|
|
||||||
{#if discordActivities.length > 0}
|
|
||||||
<div class="popover-activities">
|
|
||||||
{#each discordActivities as activity}
|
|
||||||
<div class="activity-card">
|
|
||||||
<div class="activity-type-label">{getActivityTypeLabel(activity.type)}</div>
|
|
||||||
<div class="activity-content">
|
|
||||||
{#if activity.assets?.large_image || activity.app_icon}
|
|
||||||
<div class="activity-images">
|
|
||||||
<img
|
|
||||||
src={activity.assets?.large_image ?? activity.app_icon}
|
|
||||||
alt=""
|
|
||||||
class="activity-large-image"
|
|
||||||
/>
|
|
||||||
{#if activity.assets?.small_image}
|
|
||||||
<img
|
|
||||||
src={activity.assets.small_image}
|
|
||||||
alt=""
|
|
||||||
class="activity-small-image"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="activity-info">
|
|
||||||
<span class="activity-name">{activity.name}</span>
|
|
||||||
{#if activity.details}
|
|
||||||
<span class="activity-details">{activity.details}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="popover-no-activity">
|
|
||||||
<Icon icon="mdi:sleep" width="24" />
|
|
||||||
<span>No current activity</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Battery module (EST-based simulated battery cycle) -->
|
|
||||||
<div class="module battery" title="Battery">
|
|
||||||
<button
|
|
||||||
class="battery-btn"
|
|
||||||
aria-label={`Battery ${batteryLevel}% ${batteryCharging ? 'charging' : 'discharging'}`}
|
|
||||||
class:low={batteryLevel < 20 && !batteryCharging}
|
|
||||||
class:charging={batteryCharging}
|
|
||||||
>
|
|
||||||
<Icon icon={getBatteryIcon(batteryLevel, batteryCharging)} width="16" />
|
|
||||||
<span class="battery-level" class:battery-alert={batteryLevel < 20 && !batteryCharging} class:charging={batteryCharging}>{batteryLevel}%</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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, i}
|
|
||||||
<button
|
|
||||||
class="theme-option"
|
|
||||||
class:active={$colorTheme === option.value}
|
|
||||||
onclick={() => handleThemeSelect(option.value)}
|
|
||||||
>
|
|
||||||
<Icon icon={getThemeIcon(option.value)} width="16" />
|
|
||||||
<span class="theme-label">
|
|
||||||
{option.label} <span class="theme-number">[{i+1}]</span>
|
|
||||||
</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'} [T]</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Backdrop for dropdowns -->
|
|
||||||
{#if themeDropdownOpen || discordPopoverOpen}
|
|
||||||
<button
|
|
||||||
class="backdrop"
|
|
||||||
transition:fade={{ duration: 100 }}
|
|
||||||
onclick={() => { themeDropdownOpen = false; discordPopoverOpen = false; }}
|
|
||||||
aria-label="Close"
|
|
||||||
></button>
|
|
||||||
{/if}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Mobile menu backdrop (separate, behind the menu) -->
|
|
||||||
{#if mobileMenuOpen}
|
|
||||||
<button
|
|
||||||
class="mobile-backdrop"
|
|
||||||
transition:fade={{ duration: 100 }}
|
|
||||||
onclick={() => { mobileMenuOpen = false; }}
|
|
||||||
aria-label="Close menu"
|
|
||||||
style="
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
top: var(--navbar-height);
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
z-index: 997;
|
|
||||||
border: none;
|
|
||||||
cursor: default;
|
|
||||||
"
|
|
||||||
></button>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Mobile menu dropdown -->
|
|
||||||
{#if mobileMenuOpen}
|
|
||||||
<div
|
|
||||||
class="mobile-menu"
|
|
||||||
transition:slide={{ duration: 200 }}
|
|
||||||
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};
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="mobile-nav-links">
|
|
||||||
{#each navigation as nav}
|
|
||||||
{@const isActive = currentPath === nav.path || (nav.path !== '/' && currentPath.startsWith(nav.path))}
|
|
||||||
{#if nav.external}
|
|
||||||
<a
|
|
||||||
href={nav.path}
|
|
||||||
class="mobile-nav-link external"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onclick={closeMobileMenu}
|
|
||||||
>
|
|
||||||
<Icon icon="mdi:open-in-new" width="16" />
|
|
||||||
<span>{nav.name}</span>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<a
|
|
||||||
href={nav.path}
|
|
||||||
class="mobile-nav-link"
|
|
||||||
class:active={isActive}
|
|
||||||
onclick={closeMobileMenu}
|
|
||||||
>
|
|
||||||
{#if nav.path === '/'}
|
|
||||||
<Icon icon="mdi:home" width="16" />
|
|
||||||
{:else if nav.path === '/portfolio'}
|
|
||||||
<Icon icon="mdi:folder-multiple" width="16" />
|
|
||||||
{:else if nav.path === '/models'}
|
|
||||||
<Icon icon="mdi:cube-outline" width="16" />
|
|
||||||
{:else if nav.path === '/projects'}
|
|
||||||
<Icon icon="mdi:trophy" width="16" />
|
|
||||||
{:else}
|
|
||||||
<Icon icon="mdi:file" width="16" />
|
|
||||||
{/if}
|
|
||||||
<span>{nav.name}</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mobile-menu-divider"></div>
|
|
||||||
|
|
||||||
<div class="mobile-theme-section">
|
|
||||||
<div class="mobile-section-header">Theme</div>
|
|
||||||
<div class="mobile-theme-options">
|
|
||||||
{#each themeOptions as option, i}
|
|
||||||
<button
|
|
||||||
class="mobile-theme-btn"
|
|
||||||
class:active={$colorTheme === option.value}
|
|
||||||
onclick={() => { handleThemeSelect(option.value); closeMobileMenu(); }}
|
|
||||||
>
|
|
||||||
<Icon icon={getThemeIcon(option.value)} width="18" />
|
|
||||||
<span class="theme-label">{option.label} <span class="theme-number">[{i+1}]</span></span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<button class="mobile-mode-btn" onclick={() => { toggleMode(); closeMobileMenu(); }}>
|
|
||||||
<Icon icon={$mode === 'dark' ? 'mdi:weather-sunny' : 'mdi:weather-night'} width="18" />
|
|
||||||
<span>{$mode === 'dark' ? 'Switch to Light' : 'Switch to Dark'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
<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;
|
|
||||||
|
|
||||||
// Reactive theme values - use accent or a darker color for light mode visibility
|
|
||||||
const currentMode = $derived($mode);
|
|
||||||
const particleColor = $derived(
|
|
||||||
currentMode === 'dark'
|
|
||||||
? $themeColors.primary
|
|
||||||
: $themeColors.accent || $themeColors.primary
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
color: new THREE.Color(particleColor),
|
|
||||||
sizeAttenuation: true,
|
|
||||||
transparent: true,
|
|
||||||
opacity: currentMode === 'dark' ? 0.6 : 0.8,
|
|
||||||
blending: currentMode === 'dark' ? THREE.AdditiveBlending : THREE.NormalBlending,
|
|
||||||
depthWrite: false
|
|
||||||
});
|
|
||||||
|
|
||||||
particles = new THREE.Points(particlesGeometry, particlesMaterial);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
createParticles();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update particle color and opacity based on theme
|
|
||||||
$effect(() => {
|
|
||||||
if (particlesMaterial) {
|
|
||||||
particlesMaterial.color.set(particleColor);
|
|
||||||
particlesMaterial.opacity = currentMode === 'dark' ? 0.6 : 0.8;
|
|
||||||
particlesMaterial.blending = currentMode === 'dark' ? THREE.AdditiveBlending : THREE.NormalBlending;
|
|
||||||
particlesMaterial.needsUpdate = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { themeColors } from '$lib/stores/theme';
|
|
||||||
import '$lib/assets/css/terminal.css';
|
|
||||||
|
|
||||||
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-secondary: {$themeColors.secondary};
|
|
||||||
--terminal-accent: {$themeColors.accent};
|
|
||||||
--terminal-bg-light: {$themeColors.backgroundLight};
|
|
||||||
"
|
|
||||||
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>
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { themeColors } from '$lib/stores/theme';
|
|
||||||
import { user, terminalSettings } from '$lib/config';
|
|
||||||
import { calculateTypeSpeed } from '$lib';
|
|
||||||
import '$lib/assets/css/terminal-page.css';
|
|
||||||
|
|
||||||
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-secondary: {$themeColors.secondary};
|
|
||||||
--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>
|
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { get } from "svelte/store";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import {
|
|
||||||
themeColors,
|
|
||||||
mode,
|
|
||||||
colorTheme,
|
|
||||||
toggleMode,
|
|
||||||
setColorTheme,
|
|
||||||
themeOptions,
|
|
||||||
} from "$lib/stores/theme";
|
|
||||||
import {
|
|
||||||
terminalSettings,
|
|
||||||
user,
|
|
||||||
skills,
|
|
||||||
navigation,
|
|
||||||
type SpeedPreset,
|
|
||||||
speedPresets,
|
|
||||||
} from "$lib/config";
|
|
||||||
import { runCommand, type CommandContext } from "./tui/terminal-commands";
|
|
||||||
import TuiHeader from "./tui/TuiHeader.svelte";
|
|
||||||
import TuiBody from "./tui/TuiBody.svelte";
|
|
||||||
import TuiFooter from "./tui/TuiFooter.svelte";
|
|
||||||
import "$lib/assets/css/terminal-tui.css";
|
|
||||||
|
|
||||||
// Import extracted modules
|
|
||||||
import { parseLine, createTerminalAPI } from "./tui/terminal-api";
|
|
||||||
import {
|
|
||||||
runTypingAnimation,
|
|
||||||
skipTypingAnimation,
|
|
||||||
} from "./tui/terminal-typing";
|
|
||||||
import {
|
|
||||||
createKeyboardHandler,
|
|
||||||
handleNavigation,
|
|
||||||
scrollToHash,
|
|
||||||
} from "./tui/terminal-keyboard";
|
|
||||||
|
|
||||||
import type {
|
|
||||||
TerminalLine,
|
|
||||||
ParsedLine,
|
|
||||||
DisplayedLine,
|
|
||||||
TerminalAPI,
|
|
||||||
} from "./tui/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
lines?: TerminalLine[];
|
|
||||||
title?: string;
|
|
||||||
class?: string;
|
|
||||||
onComplete?: () => void;
|
|
||||||
interactive?: boolean;
|
|
||||||
speed?: SpeedPreset | number;
|
|
||||||
autoscroll?: boolean;
|
|
||||||
terminal?: TerminalAPI;
|
|
||||||
enableCommands?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
lines = $bindable([]),
|
|
||||||
title = "terminal",
|
|
||||||
class: className = "",
|
|
||||||
onComplete,
|
|
||||||
interactive = true,
|
|
||||||
speed = "normal",
|
|
||||||
autoscroll = true,
|
|
||||||
terminal = $bindable(),
|
|
||||||
enableCommands = true,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Calculate speed multiplier from preset or number
|
|
||||||
const speedMultiplier = $derived(
|
|
||||||
typeof speed === "number" ? speed : (speedPresets[speed] ?? 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get colorMap from current theme
|
|
||||||
const colorMap = $derived($themeColors.colorMap);
|
|
||||||
|
|
||||||
// Pre-parse all lines upfront (segments + plain text)
|
|
||||||
const parsedLines = $derived<ParsedLine[]>(
|
|
||||||
lines.map((line) => parseLine(line, colorMap)),
|
|
||||||
);
|
|
||||||
|
|
||||||
let displayedLines = $state<DisplayedLine[]>([]);
|
|
||||||
let currentLineIndex = $state(0);
|
|
||||||
let isTyping = $state(false);
|
|
||||||
let isComplete = $state(false);
|
|
||||||
let selectedIndex = $state(-1);
|
|
||||||
let skipRequested = $state(false);
|
|
||||||
let terminalElement: HTMLDivElement;
|
|
||||||
let bodyElement = $state<HTMLDivElement>();
|
|
||||||
|
|
||||||
// Track colorMap identity to detect theme/mode changes
|
|
||||||
let lastColorMapId = $state("");
|
|
||||||
|
|
||||||
// When colorMap changes (theme/mode toggle), update displayedLines with new parsed segments
|
|
||||||
$effect(() => {
|
|
||||||
// Create a simple identity string from a few colorMap values
|
|
||||||
const colorMapId = `${colorMap.red}-${colorMap.text}-${colorMap.primary}`;
|
|
||||||
|
|
||||||
// Only update if colorMap actually changed and animation is complete
|
|
||||||
if (
|
|
||||||
colorMapId !== lastColorMapId &&
|
|
||||||
isComplete &&
|
|
||||||
displayedLines.length > 0
|
|
||||||
) {
|
|
||||||
// Store current showImage states before updating
|
|
||||||
const showImageStates = displayedLines.map((d) => d.showImage);
|
|
||||||
|
|
||||||
// Update with new parsed content
|
|
||||||
displayedLines = parsedLines.map((parsed, i) => ({
|
|
||||||
parsed,
|
|
||||||
charIndex: parsed.plainText.length,
|
|
||||||
complete: true,
|
|
||||||
showImage: showImageStates[i] ?? parsed.line.type === "image",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
lastColorMapId = colorMapId;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper to check if a line or its children contain buttons
|
|
||||||
function hasButtons(line: TerminalLine): boolean {
|
|
||||||
if (line.type === "button") return true;
|
|
||||||
if (line.type === "group" && line.children) {
|
|
||||||
return line.children.some((child) => hasButtons(child));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all interactive button indices (including buttons nested in groups)
|
|
||||||
const buttonIndices = $derived(
|
|
||||||
displayedLines
|
|
||||||
.map((item, i) => (hasButtons(item.parsed.line) ? i : -1))
|
|
||||||
.filter((i) => i !== -1),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// TYPING ANIMATION
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
function typeText() {
|
|
||||||
runTypingAnimation({
|
|
||||||
parsedLines,
|
|
||||||
speedMultiplier,
|
|
||||||
terminalSettings,
|
|
||||||
buttonIndices,
|
|
||||||
interactive,
|
|
||||||
autoscroll,
|
|
||||||
getBodyElement: () => bodyElement,
|
|
||||||
getState: () => ({
|
|
||||||
displayedLines,
|
|
||||||
currentLineIndex,
|
|
||||||
isTyping,
|
|
||||||
isComplete,
|
|
||||||
skipRequested,
|
|
||||||
selectedIndex,
|
|
||||||
}),
|
|
||||||
setState: (updates) => {
|
|
||||||
if (updates.displayedLines !== undefined)
|
|
||||||
displayedLines = updates.displayedLines;
|
|
||||||
if (updates.currentLineIndex !== undefined)
|
|
||||||
currentLineIndex = updates.currentLineIndex;
|
|
||||||
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
|
|
||||||
if (updates.isComplete !== undefined)
|
|
||||||
isComplete = updates.isComplete;
|
|
||||||
if (updates.skipRequested !== undefined)
|
|
||||||
skipRequested = updates.skipRequested;
|
|
||||||
if (updates.selectedIndex !== undefined)
|
|
||||||
selectedIndex = updates.selectedIndex;
|
|
||||||
},
|
|
||||||
onComplete,
|
|
||||||
scrollToHash: () => scrollToHash(bodyElement),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function skipAnimation() {
|
|
||||||
skipTypingAnimation(
|
|
||||||
parsedLines,
|
|
||||||
buttonIndices,
|
|
||||||
interactive,
|
|
||||||
() => ({
|
|
||||||
displayedLines,
|
|
||||||
currentLineIndex,
|
|
||||||
isTyping,
|
|
||||||
isComplete,
|
|
||||||
skipRequested,
|
|
||||||
selectedIndex,
|
|
||||||
}),
|
|
||||||
(updates) => {
|
|
||||||
if (updates.displayedLines !== undefined)
|
|
||||||
displayedLines = updates.displayedLines;
|
|
||||||
if (updates.currentLineIndex !== undefined)
|
|
||||||
currentLineIndex = updates.currentLineIndex;
|
|
||||||
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
|
|
||||||
if (updates.isComplete !== undefined)
|
|
||||||
isComplete = updates.isComplete;
|
|
||||||
if (updates.skipRequested !== undefined)
|
|
||||||
skipRequested = updates.skipRequested;
|
|
||||||
if (updates.selectedIndex !== undefined)
|
|
||||||
selectedIndex = updates.selectedIndex;
|
|
||||||
},
|
|
||||||
() => bodyElement,
|
|
||||||
autoscroll,
|
|
||||||
onComplete,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// TERMINAL API
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
terminal = createTerminalAPI({
|
|
||||||
getState: () => ({
|
|
||||||
lines,
|
|
||||||
displayedLines,
|
|
||||||
isTyping,
|
|
||||||
isComplete,
|
|
||||||
currentLineIndex,
|
|
||||||
selectedIndex,
|
|
||||||
skipRequested,
|
|
||||||
}),
|
|
||||||
setState: (updates) => {
|
|
||||||
if (updates.lines !== undefined) lines = updates.lines;
|
|
||||||
if (updates.displayedLines !== undefined)
|
|
||||||
displayedLines = updates.displayedLines;
|
|
||||||
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
|
|
||||||
if (updates.isComplete !== undefined)
|
|
||||||
isComplete = updates.isComplete;
|
|
||||||
if (updates.currentLineIndex !== undefined)
|
|
||||||
currentLineIndex = updates.currentLineIndex;
|
|
||||||
if (updates.selectedIndex !== undefined)
|
|
||||||
selectedIndex = updates.selectedIndex;
|
|
||||||
if (updates.skipRequested !== undefined)
|
|
||||||
skipRequested = updates.skipRequested;
|
|
||||||
},
|
|
||||||
getColorMap: () => colorMap,
|
|
||||||
getBodyElement: () => bodyElement,
|
|
||||||
typeText,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// COMMAND HANDLING
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
const commandContext: CommandContext = {
|
|
||||||
terminal: terminal!,
|
|
||||||
navigate: (path, external) => {
|
|
||||||
if (external) window.open(path, "_blank", "noopener,noreferrer");
|
|
||||||
else goto(path);
|
|
||||||
},
|
|
||||||
toggleMode,
|
|
||||||
setColorTheme: (name) => setColorTheme(name as any),
|
|
||||||
getMode: () => get(mode),
|
|
||||||
getColorTheme: () => get(colorTheme),
|
|
||||||
themeNames: themeOptions.map((option) => option.value),
|
|
||||||
user,
|
|
||||||
skills,
|
|
||||||
navigation,
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleCommand(input: string) {
|
|
||||||
runCommand(input, commandContext);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// KEYBOARD HANDLING
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
const handleKeydown = createKeyboardHandler({
|
|
||||||
getIsTyping: () => isTyping,
|
|
||||||
getIsComplete: () => isComplete,
|
|
||||||
getInteractive: () => interactive,
|
|
||||||
getButtonIndices: () => buttonIndices,
|
|
||||||
getSelectedIndex: () => selectedIndex,
|
|
||||||
setSelectedIndex: (index) => {
|
|
||||||
selectedIndex = index;
|
|
||||||
},
|
|
||||||
getDisplayedLines: () => displayedLines,
|
|
||||||
getBodyElement: () => bodyElement,
|
|
||||||
skipAnimation,
|
|
||||||
scrollMargin: terminalSettings.scrollMargin,
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleButtonClick(index: number, line?: TerminalLine) {
|
|
||||||
if (line) {
|
|
||||||
// If line is provided (e.g. from nested group), use it directly
|
|
||||||
// We don't update selectedIndex because it might not map to a top-level line
|
|
||||||
handleNavigation(line as any);
|
|
||||||
} else {
|
|
||||||
// Fallback to top-level index lookup
|
|
||||||
selectedIndex = index;
|
|
||||||
handleNavigation(displayedLines[index]?.parsed.line as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleLinkClick(index: number) {
|
|
||||||
handleNavigation(displayedLines[index]?.parsed.line as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
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-secondary: {$themeColors.secondary};
|
|
||||||
--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}
|
|
||||||
interactiveInput={enableCommands && interactive}
|
|
||||||
onCommand={handleCommand}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TuiFooter
|
|
||||||
{isTyping}
|
|
||||||
linesCount={displayedLines.length}
|
|
||||||
{skipAnimation}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { html }: { html: string } = $props();
|
||||||
|
|
||||||
|
let contentElement = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
function copyCode(code: string, button: HTMLButtonElement) {
|
||||||
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
|
button.textContent = 'copied';
|
||||||
|
setTimeout(() => (button.textContent = 'copy'), 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
html;
|
||||||
|
contentElement?.querySelectorAll('pre').forEach((block) => {
|
||||||
|
const code = block.querySelector('code');
|
||||||
|
if (!code || block.querySelector('.copy-button')) return;
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'copy-button';
|
||||||
|
button.textContent = 'copy';
|
||||||
|
button.addEventListener('click', () => copyCode(code.textContent ?? '', button));
|
||||||
|
block.appendChild(button);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="post-content" bind:this={contentElement}>
|
||||||
|
{@html html}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.post-content {
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(h1),
|
||||||
|
.post-content :global(h2),
|
||||||
|
.post-content :global(h3) {
|
||||||
|
margin: 1.75rem 0 0.75rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(h2::before) {
|
||||||
|
content: '## ';
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(p) {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(a) {
|
||||||
|
color: var(--blue);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(code) {
|
||||||
|
padding: 0.1rem 0.35rem;
|
||||||
|
background: var(--lighter-background);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(pre) {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--dark-background);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.copy-button) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.copy-button:hover) {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-keyword) {
|
||||||
|
color: var(--magenta);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-string) {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-comment) {
|
||||||
|
color: var(--muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(.tok-number) {
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(blockquote) {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.25rem 0 0.25rem 1rem;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(ul),
|
||||||
|
.post-content :global(ol) {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(ul) {
|
||||||
|
list-style: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content :global(hr) {
|
||||||
|
margin: 1.75rem 0;
|
||||||
|
border: none;
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { dwindleLayout, fullscreenLayout, tileStyle, type TileRect } from '#lib/desktop/tiling';
|
||||||
|
import { pageWindowId, windowManager } from '#lib/desktop/windows.svelte';
|
||||||
|
import { closePage } from '#lib/desktop/actions';
|
||||||
|
import Terminal from '#lib/components/terminal/Terminal.svelte';
|
||||||
|
import DesktopIcons from './DesktopIcons.svelte';
|
||||||
|
import Window from './Window.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
pageOpen,
|
||||||
|
pageTitle,
|
||||||
|
pageIcon,
|
||||||
|
pageKey,
|
||||||
|
children
|
||||||
|
}: { pageOpen: boolean; pageTitle: string; pageIcon: string; pageKey: string; children: Snippet } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const gapInPixels = 10;
|
||||||
|
const narrowWidth = 768;
|
||||||
|
|
||||||
|
let areaWidth = $state(1280);
|
||||||
|
let areaHeight = $state(720);
|
||||||
|
|
||||||
|
const isNarrow = $derived(areaWidth < narrowWidth);
|
||||||
|
const openWindowIds = $derived([...(pageOpen ? [pageWindowId] : []), ...windowManager.terminals.map((terminal) => terminal.id)]);
|
||||||
|
|
||||||
|
const layout = $derived.by(() => {
|
||||||
|
const rects = new Map<string, TileRect>();
|
||||||
|
const soloId = windowManager.maximizedId ?? (isNarrow ? windowManager.focusedId : null);
|
||||||
|
if (soloId && openWindowIds.includes(soloId)) {
|
||||||
|
rects.set(soloId, fullscreenLayout());
|
||||||
|
return rects;
|
||||||
|
}
|
||||||
|
dwindleLayout(openWindowIds.length, areaWidth, areaHeight).forEach((rect, index) => rects.set(openWindowIds[index], rect));
|
||||||
|
return rects;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (openWindowIds.length && !openWindowIds.includes(windowManager.focusedId)) {
|
||||||
|
windowManager.focus(openWindowIds.at(-1)!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function styleFor(id: string): string {
|
||||||
|
const rect = layout.get(id);
|
||||||
|
return rect ? tileStyle(rect, gapInPixels) : 'display: none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="desktop" class:narrow={isNarrow}>
|
||||||
|
{#if isNarrow && openWindowIds.length > 1}
|
||||||
|
<nav class="window-tabs" aria-label="Open windows">
|
||||||
|
{#each openWindowIds as id (id)}
|
||||||
|
<button class:active={windowManager.focusedId === id} onclick={() => windowManager.focus(id)}>
|
||||||
|
{id === pageWindowId ? pageTitle : id}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="icons-layer">
|
||||||
|
<DesktopIcons />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
||||||
|
{#if pageOpen}
|
||||||
|
<Window
|
||||||
|
title="kitty"
|
||||||
|
path={pageTitle}
|
||||||
|
icon={pageIcon}
|
||||||
|
focused={windowManager.focusedId === pageWindowId}
|
||||||
|
tileStyle={styleFor(pageWindowId)}
|
||||||
|
scrollKey={pageKey}
|
||||||
|
onfocus={() => windowManager.focus(pageWindowId)}
|
||||||
|
onclose={closePage}
|
||||||
|
>
|
||||||
|
{@render children()}
|
||||||
|
</Window>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each windowManager.terminals as terminal (terminal.id)}
|
||||||
|
<Window
|
||||||
|
title="kitty"
|
||||||
|
path="~"
|
||||||
|
focused={windowManager.focusedId === terminal.id}
|
||||||
|
tileStyle={styleFor(terminal.id)}
|
||||||
|
onfocus={() => windowManager.focus(terminal.id)}
|
||||||
|
onclose={() => windowManager.closeTerminal(terminal.id)}
|
||||||
|
>
|
||||||
|
<Terminal
|
||||||
|
startupCommand={terminal.startupCommand}
|
||||||
|
focused={windowManager.focusedId === terminal.id}
|
||||||
|
onexit={() => windowManager.closeTerminal(terminal.id)}
|
||||||
|
/>
|
||||||
|
</Window>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.desktop {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: calc(var(--gaps-out) - 4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--gaps-out) - 4px);
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiling-area {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiling-area > :global(.window) {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop.narrow {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-tabs {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 4px 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-tabs button {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.15rem 0.6rem;
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--border-inactive);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-tabs button.active {
|
||||||
|
border-color: var(--border-active-solid);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.icons-layer :global(.desktop-icons) {
|
||||||
|
grid-auto-flow: row;
|
||||||
|
grid-template-columns: repeat(auto-fill, 112px);
|
||||||
|
grid-template-rows: none;
|
||||||
|
grid-auto-rows: 104px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { desktop } from '#lib/content';
|
||||||
|
import { runDesktopAction } from '#lib/desktop/actions';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="desktop-icons" aria-label="Desktop">
|
||||||
|
{#each desktop.icons as item (item.label)}
|
||||||
|
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
|
||||||
|
<span class="icon-box">
|
||||||
|
<Icon icon={item.icon} width="36" />
|
||||||
|
</span>
|
||||||
|
<span class="icon-label">{item.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.desktop-icons {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-template-rows: repeat(auto-fill, 104px);
|
||||||
|
grid-auto-columns: 112px;
|
||||||
|
gap: 6px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-icon:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-box {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
background: var(--background);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
transition:
|
||||||
|
background 0.12s,
|
||||||
|
color 0.12s,
|
||||||
|
transform 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-icon:hover .icon-box,
|
||||||
|
.desktop-icon:focus-visible .icon-box {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-icon:active .icon-box {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-label {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: color-mix(in srgb, var(--background) 88%, transparent);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-icon:hover .icon-label,
|
||||||
|
.desktop-icon:focus-visible .icon-label {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { desktop, type DesktopAction } from '#lib/content';
|
||||||
|
import { allSitePages } from '#lib/site/pages';
|
||||||
|
import { runDesktopAction } from '#lib/desktop/actions';
|
||||||
|
import { windowManager } from '#lib/desktop/windows.svelte';
|
||||||
|
|
||||||
|
interface LauncherEntry {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
icon: string;
|
||||||
|
open: DesktopAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageEntries: LauncherEntry[] = allSitePages.map((page) => ({
|
||||||
|
label: page.title,
|
||||||
|
detail: page.path,
|
||||||
|
icon: page.icon,
|
||||||
|
open: { page: page.path }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const iconEntries: LauncherEntry[] = desktop.icons
|
||||||
|
.filter((item) => !('page' in item.open))
|
||||||
|
.map((item) => ({
|
||||||
|
label: item.label,
|
||||||
|
detail: 'terminal' in item.open ? `kitty ${item.open.terminal}`.trim() : 'url' in item.open ? item.open.url : '',
|
||||||
|
icon: item.icon,
|
||||||
|
open: item.open
|
||||||
|
}));
|
||||||
|
|
||||||
|
const entries = [...pageEntries, ...iconEntries];
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let selectedIndex = $state(0);
|
||||||
|
let searchElement = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
const filteredEntries = $derived(
|
||||||
|
entries.filter((entry) => `${entry.label} ${entry.detail}`.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
query;
|
||||||
|
selectedIndex = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
searchElement?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
windowManager.launcherOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function launch(entry: LauncherEntry | undefined) {
|
||||||
|
if (entry) runDesktopAction(entry.open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') close();
|
||||||
|
else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.min(filteredEntries.length - 1, selectedIndex + 1);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
selectedIndex = Math.max(0, selectedIndex - 1);
|
||||||
|
} else if (event.key === 'Enter') launch(filteredEntries[selectedIndex]);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class="backdrop" aria-label="Close launcher" onclick={close}></button>
|
||||||
|
<div class="launcher" role="dialog" aria-label="Launcher">
|
||||||
|
<div class="launcher-surface">
|
||||||
|
<label class="search">
|
||||||
|
<Icon icon="mdi:magnify" width="20" />
|
||||||
|
<input bind:this={searchElement} bind:value={query} onkeydown={handleKeydown} placeholder="Search apps and pages" />
|
||||||
|
</label>
|
||||||
|
<ul class="results">
|
||||||
|
{#each filteredEntries as entry, index (entry.label + entry.detail)}
|
||||||
|
<li>
|
||||||
|
<button class="result" class:selected={index === selectedIndex} onclick={() => launch(entry)} onmouseenter={() => (selectedIndex = index)}>
|
||||||
|
<Icon icon={entry.icon} width="20" />
|
||||||
|
<span class="result-label">{entry.label}</span>
|
||||||
|
<span class="result-detail">{entry.detail}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{:else}
|
||||||
|
<li class="empty">No matches</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
background: color-mix(in srgb, var(--dark-background) 40%, transparent);
|
||||||
|
border: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher {
|
||||||
|
position: fixed;
|
||||||
|
top: 18vh;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 70;
|
||||||
|
width: min(640px, calc(100vw - 24px));
|
||||||
|
padding: var(--border-size);
|
||||||
|
background: var(--border-active);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.launcher-surface {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
max-height: 50vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.4rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result.selected {
|
||||||
|
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-detail,
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
path = '',
|
||||||
|
icon = 'ph:terminal-window-duotone',
|
||||||
|
focused,
|
||||||
|
tileStyle,
|
||||||
|
scrollKey = '',
|
||||||
|
onfocus,
|
||||||
|
onclose,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
path?: string;
|
||||||
|
icon?: string;
|
||||||
|
focused: boolean;
|
||||||
|
tileStyle: string;
|
||||||
|
scrollKey?: string;
|
||||||
|
onfocus: () => void;
|
||||||
|
onclose: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let contentElement = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
scrollKey;
|
||||||
|
if (contentElement) contentElement.scrollTop = 0;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="window"
|
||||||
|
class:focused
|
||||||
|
style={tileStyle}
|
||||||
|
aria-label="{title} {path}"
|
||||||
|
onpointerdown={onfocus}
|
||||||
|
onfocusin={onfocus}
|
||||||
|
>
|
||||||
|
<div class="window-surface">
|
||||||
|
<header class="title-bar">
|
||||||
|
<span class="title">
|
||||||
|
<span class="app-name"><Icon {icon} width="15" />{title}</span>
|
||||||
|
{#if path}
|
||||||
|
<span class="path">{path}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<button class="close-button" title="Close (Alt+Q)" aria-label="Close {title} {path}" onclick={onclose}>
|
||||||
|
<Icon icon="mdi:close" width="15" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="window-content" bind:this={contentElement}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.window {
|
||||||
|
position: absolute;
|
||||||
|
padding: var(--border-size);
|
||||||
|
background: var(--border-inactive);
|
||||||
|
animation: window-pop-in 0.2s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
transition:
|
||||||
|
left 0.25s cubic-bezier(0.23, 1, 0.32, 1),
|
||||||
|
top 0.25s cubic-bezier(0.23, 1, 0.32, 1),
|
||||||
|
width 0.25s cubic-bezier(0.23, 1, 0.32, 1),
|
||||||
|
height 0.25s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window.focused {
|
||||||
|
background: var(--border-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-surface {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 0.3rem 0 0.8rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focused .app-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focused .path {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
display: grid;
|
||||||
|
flex-shrink: 0;
|
||||||
|
place-items: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button:hover {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import type { ModelScene } from '#lib/models/model-scene';
|
||||||
|
|
||||||
|
let { modelUrl, modelName }: { modelUrl: string; modelName: string } = $props();
|
||||||
|
|
||||||
|
let container = $state<HTMLDivElement>();
|
||||||
|
let modelScene = $state<ModelScene | null>(null);
|
||||||
|
let status = $state<'loading' | 'ready' | 'error' | 'unsupported'>('loading');
|
||||||
|
let autoRotate = $state(true);
|
||||||
|
let wireframe = $state(false);
|
||||||
|
let brightness = $state(0.6);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
let disposed = false;
|
||||||
|
let resizeObserver: ResizeObserver | undefined;
|
||||||
|
|
||||||
|
import('#lib/models/model-scene')
|
||||||
|
.then(({ ModelScene }) => {
|
||||||
|
if (disposed || !container) return;
|
||||||
|
modelScene = new ModelScene(container);
|
||||||
|
resizeObserver = new ResizeObserver(() => modelScene?.resize());
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
})
|
||||||
|
.catch(() => (status = 'unsupported'));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
modelScene?.dispose();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!modelScene) return;
|
||||||
|
status = 'loading';
|
||||||
|
wireframe = false;
|
||||||
|
modelScene
|
||||||
|
.load(modelUrl)
|
||||||
|
.then(() => (status = 'ready'))
|
||||||
|
.catch(() => (status = 'error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => modelScene?.setAutoRotate(autoRotate));
|
||||||
|
$effect(() => modelScene?.setWireframe(wireframe));
|
||||||
|
$effect(() => modelScene?.setBrightness(brightness));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="viewer">
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="model-name">{modelName}</span>
|
||||||
|
<div class="controls">
|
||||||
|
<button class:active={autoRotate} title="Auto rotate" onclick={() => (autoRotate = !autoRotate)}>
|
||||||
|
<Icon icon="mdi:rotate-3d-variant" width="16" />
|
||||||
|
</button>
|
||||||
|
<button class:active={wireframe} title="Wireframe" onclick={() => (wireframe = !wireframe)}>
|
||||||
|
<Icon icon="mdi:cube-outline" width="16" />
|
||||||
|
</button>
|
||||||
|
<button title="Darker" onclick={() => (brightness = Math.max(0, brightness - 0.2))}>
|
||||||
|
<Icon icon="mdi:brightness-5" width="16" />
|
||||||
|
</button>
|
||||||
|
<button title="Brighter" onclick={() => (brightness = Math.min(3, brightness + 0.2))}>
|
||||||
|
<Icon icon="mdi:brightness-7" width="16" />
|
||||||
|
</button>
|
||||||
|
<button title="Reset camera" onclick={() => modelScene?.resetCamera()}>
|
||||||
|
<Icon icon="mdi:camera-retake" width="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="canvas-area" bind:this={container} tabindex="0" role="application" aria-label="3D view of {modelName}">
|
||||||
|
{#if status !== 'ready'}
|
||||||
|
<div class="status muted">
|
||||||
|
{#if status === 'loading'}
|
||||||
|
loading model...
|
||||||
|
{:else if status === 'error'}
|
||||||
|
failed to load model
|
||||||
|
{:else}
|
||||||
|
WebGL is not available in this browser
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="hints muted">drag to rotate · scroll to zoom · arrow keys after clicking</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button.active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area {
|
||||||
|
position: relative;
|
||||||
|
height: 380px;
|
||||||
|
background: radial-gradient(circle, var(--lighter-background), var(--dark-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-area :global(canvas) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hints {
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import type { Project, ProjectCategory } from '#lib/content';
|
||||||
|
import LinkButton from '#lib/components/ui/LinkButton.svelte';
|
||||||
|
import Tag from '#lib/components/ui/Tag.svelte';
|
||||||
|
|
||||||
|
let { project, category }: { project: Project; category: ProjectCategory } = $props();
|
||||||
|
|
||||||
|
const linkButtons = $derived(
|
||||||
|
[
|
||||||
|
{ href: project.links.demo, label: 'View Demo', icon: 'mdi:open-in-new' },
|
||||||
|
{ href: project.links.package, label: 'View Package', icon: 'mdi:package-variant' },
|
||||||
|
{ href: project.links.code, label: 'View Code', icon: 'mdi:github' },
|
||||||
|
{ href: project.links.devpost, label: 'Devpost', icon: 'simple-icons:devpost' },
|
||||||
|
{ href: project.links.article, label: 'Article', icon: 'mdi:newspaper-variant-outline' }
|
||||||
|
].filter((button) => button.href)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<article class="project-entry" class:has-image={!!project.image}>
|
||||||
|
<h2 class="project-title">{project.name}</h2>
|
||||||
|
|
||||||
|
{#if project.image}
|
||||||
|
<img class="project-image" src={project.image} alt={project.name} loading="lazy" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="project-body">
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge category"><Icon icon={category.icon} width="15" /> {category.title}</span>
|
||||||
|
{#if project.featured}
|
||||||
|
<span class="badge featured">Featured</span>
|
||||||
|
{/if}
|
||||||
|
{#if project.version}
|
||||||
|
<span class="badge version">v{project.version}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if project.event}
|
||||||
|
<div class="event">
|
||||||
|
{project.event.name}{#if project.event.year}<span class="muted"> ({project.event.year})</span>{/if}
|
||||||
|
</div>
|
||||||
|
{#if project.event.university}
|
||||||
|
<div class="muted">{project.event.university}{project.event.location ? `, ${project.event.location}` : ''}</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if project.organization}
|
||||||
|
<div class="event">{project.organization}</div>
|
||||||
|
{/if}
|
||||||
|
{#if project.period}
|
||||||
|
<div class="muted">{project.period}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if project.description}
|
||||||
|
<p>{project.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each project.awards ?? [] as award (award.track)}
|
||||||
|
<div class="award"><Icon icon="mdi:trophy" width="16" /> {award.place} - {award.track}</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if project.tags.length}
|
||||||
|
<div class="tags">
|
||||||
|
{#each project.tags as tag, index (tag)}
|
||||||
|
<Tag label={tag} colorIndex={index} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if project.demoMayBeOffline}
|
||||||
|
<div class="warning"><Icon icon="mdi:alert" width="16" /> Demo may be unavailable</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if linkButtons.length}
|
||||||
|
<div class="buttons">
|
||||||
|
{#each linkButtons as button (button.label)}
|
||||||
|
<LinkButton href={button.href!} icon={button.icon}>{button.label}</LinkButton>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.project-entry {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
padding: 1rem 0 1.5rem;
|
||||||
|
border-top: 1px dashed color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-entry.has-image {
|
||||||
|
grid-template-columns: minmax(180px, 320px) 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-title {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-image {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: var(--dark-background);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges,
|
||||||
|
.tags,
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
color: var(--background);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.featured {
|
||||||
|
background: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
background: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.award {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--orange);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 700px) {
|
||||||
|
.project-entry.has-image {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { projectCategories } from '#lib/content';
|
||||||
|
|
||||||
|
const chipColors = ['magenta', 'orange', 'green', 'cyan'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="project-nav">
|
||||||
|
<a href="/projects" class:active={page.url.pathname === '/projects'} style="--chip-color: var(--accent)">~/projects</a>
|
||||||
|
{#each projectCategories as category, index (category.id)}
|
||||||
|
<a
|
||||||
|
href="/projects/{category.id}"
|
||||||
|
class:active={page.url.pathname === `/projects/${category.id}`}
|
||||||
|
style="--chip-color: var(--{chipColors[index % chipColors.length]})">{category.title}</a
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.project-nav {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
padding: 0.05rem 0.55rem;
|
||||||
|
border: 1px solid var(--chip-color);
|
||||||
|
color: var(--chip-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover,
|
||||||
|
a.active {
|
||||||
|
background: var(--chip-color);
|
||||||
|
color: var(--background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { windowManager } from '#lib/desktop/windows.svelte';
|
||||||
|
import ChaiIcon from './ChaiIcon.svelte';
|
||||||
|
import Clock from './Clock.svelte';
|
||||||
|
import Workspaces from './Workspaces.svelte';
|
||||||
|
import ThemeMenu from './ThemeMenu.svelte';
|
||||||
|
import DiscordStatus from './DiscordStatus.svelte';
|
||||||
|
import Battery from './Battery.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="bar">
|
||||||
|
<div class="bar-section left">
|
||||||
|
<button class="tray-button" title="Apps (Alt+Space)" onclick={() => (windowManager.launcherOpen = true)}>
|
||||||
|
<ChaiIcon size={18} />
|
||||||
|
</button>
|
||||||
|
<Workspaces />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="clock-button" title="Open a terminal (Alt+Enter)" onclick={() => windowManager.openTerminal()}>
|
||||||
|
<Clock />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="bar-section right">
|
||||||
|
<DiscordStatus />
|
||||||
|
<Battery />
|
||||||
|
<ThemeMenu />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
height: var(--bar-height);
|
||||||
|
padding: 0 8px;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-button {
|
||||||
|
height: var(--bar-height);
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clock-button:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar :global(.tray-button) {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: var(--bar-height);
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar :global(.tray-button:hover) {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bar {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { batteryAt, batteryIcon, type BatteryState } from '#lib/desktop/battery';
|
||||||
|
|
||||||
|
let battery = $state<BatteryState | null>(null);
|
||||||
|
|
||||||
|
const isLow = $derived(!!battery && battery.level < 20 && !battery.charging);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
battery = batteryAt(new Date());
|
||||||
|
const timer = setInterval(() => (battery = batteryAt(new Date())), 10000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if battery}
|
||||||
|
<span
|
||||||
|
class="battery"
|
||||||
|
class:low={isLow}
|
||||||
|
class:charging={battery.charging}
|
||||||
|
title="Battery {battery.level}% {battery.charging ? '(charging)' : ''}"
|
||||||
|
>
|
||||||
|
<Icon icon={batteryIcon(battery)} width="18" />
|
||||||
|
<span class="level">{battery.level}%</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.battery {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.charging {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.low {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.level {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { size = 14 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
|
||||||
|
<rect x="8.6" y="1.6" width="1.3" height="4.6" rx="0.65" transform="rotate(-12 9.25 3.9)" />
|
||||||
|
<rect x="12.4" y="2.4" width="1.3" height="4.6" rx="0.65" transform="rotate(12 13.05 4.7)" />
|
||||||
|
<path d="M17.2 11 a3.4 3.4 0 0 1 0 6 v-1.7 a1.7 1.7 0 0 0 0 -2.6 z" />
|
||||||
|
<path d="M4 9 h14 l-2.5 9.2 a1.6 1.6 0 0 1 -1.55 1.2 h-5.9 a1.6 1.6 0 0 1 -1.55 -1.2 z" />
|
||||||
|
<rect x="3" y="20.4" width="18" height="2" rx="1" />
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let now = $state<Date | null>(null);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
now = new Date();
|
||||||
|
const timer = setInterval(() => (now = new Date()), 15000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatClock(date: Date): string {
|
||||||
|
const weekday = date.toLocaleDateString('en-US', { weekday: 'long' });
|
||||||
|
const day = date.toLocaleDateString('en-US', { day: '2-digit' });
|
||||||
|
const month = date.toLocaleDateString('en-US', { month: 'long' });
|
||||||
|
const time = date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
return `${weekday} ${day} ${month} ${time}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="clock full">{now ? formatClock(now) : ''}</span>
|
||||||
|
<span class="clock short">{now ? formatClock(now).split(' ').slice(1).join(' ') : ''}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.clock {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.short {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { activityLabels, type DiscordPresence } from '#lib/discord/types';
|
||||||
|
|
||||||
|
let { presence, onclose }: { presence: DiscordPresence; onclose: () => void } = $props();
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onclose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<button class="backdrop" aria-label="Close Discord status" onclick={onclose}></button>
|
||||||
|
<div class="popover" role="dialog" aria-label="Discord status">
|
||||||
|
<div class="popover-surface">
|
||||||
|
<header class="profile">
|
||||||
|
<div class="avatar">
|
||||||
|
{#if presence.avatarUrl}
|
||||||
|
<img src={presence.avatarUrl} alt="" />
|
||||||
|
{:else}
|
||||||
|
<Icon icon="mdi:account-circle" width="52" />
|
||||||
|
{/if}
|
||||||
|
<span class="avatar-dot {presence.status}"></span>
|
||||||
|
</div>
|
||||||
|
<div class="identity">
|
||||||
|
<div class="name-row">
|
||||||
|
<span class="name">{presence.displayName}</span>
|
||||||
|
{#if presence.guildTag}
|
||||||
|
<span class="guild-tag">
|
||||||
|
{#if presence.guildTagBadgeUrl}
|
||||||
|
<img src={presence.guildTagBadgeUrl} alt="" />
|
||||||
|
{/if}
|
||||||
|
{presence.guildTag}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="status-label">{presence.status}</span>
|
||||||
|
</div>
|
||||||
|
<button class="close" aria-label="Close" onclick={onclose}>
|
||||||
|
<Icon icon="mdi:close" width="18" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if presence.activities.length}
|
||||||
|
<div class="activities">
|
||||||
|
{#each presence.activities as activity, index (index)}
|
||||||
|
<div class="activity">
|
||||||
|
<div class="activity-label">{activityLabels[activity.type] ?? ''}</div>
|
||||||
|
<div class="activity-body">
|
||||||
|
{#if activity.largeImage}
|
||||||
|
<div class="activity-images">
|
||||||
|
<img class="large-image" src={activity.largeImage} alt="" />
|
||||||
|
{#if activity.smallImage}
|
||||||
|
<img class="small-image" src={activity.smallImage} alt="" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="activity-text">
|
||||||
|
<span class="activity-name">{activity.name}</span>
|
||||||
|
{#if activity.details}
|
||||||
|
<span class="activity-details">{activity.details}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="no-activity">
|
||||||
|
<Icon icon="mdi:sleep" width="28" />
|
||||||
|
<span>No current activity</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
width: 340px;
|
||||||
|
padding: var(--border-size);
|
||||||
|
background: var(--border-active);
|
||||||
|
animation: window-pop-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-surface {
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.9rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 8%, var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar img {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-dot {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
border: 3px solid color-mix(in srgb, var(--accent) 8%, var(--background));
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-dot.online {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-dot.idle {
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-dot.dnd {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guild-tag img {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-label {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 0.15rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activities {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--lighter-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-label {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-body {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-images {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large-image {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-image {
|
||||||
|
position: absolute;
|
||||||
|
right: -5px;
|
||||||
|
bottom: -5px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 3px solid var(--lighter-background);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-name,
|
||||||
|
.activity-details {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-name {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-details {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-activity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.popover {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--bar-height) + 6px);
|
||||||
|
right: 8px;
|
||||||
|
left: 8px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { profile } from '#lib/content';
|
||||||
|
import { offlinePresence, type DiscordPresence } from '#lib/discord/types';
|
||||||
|
import DiscordPopover from './DiscordPopover.svelte';
|
||||||
|
|
||||||
|
const refreshInterval = 30000;
|
||||||
|
|
||||||
|
let presence = $state<DiscordPresence>(offlinePresence);
|
||||||
|
let open = $state(false);
|
||||||
|
|
||||||
|
const displayName = $derived(presence.displayName ?? profile.displayName);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/discord');
|
||||||
|
if (response.ok) presence = await response.json();
|
||||||
|
} catch {
|
||||||
|
presence = offlinePresence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
refresh();
|
||||||
|
const timer = setInterval(refresh, refreshInterval);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="discord-status">
|
||||||
|
<button
|
||||||
|
class="status-button"
|
||||||
|
title="{displayName} - {presence.status}"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={() => (open = !open)}
|
||||||
|
>
|
||||||
|
<span class="status-dot {presence.status}"></span>
|
||||||
|
<span class="status-name">{displayName}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<DiscordPopover presence={{ ...presence, displayName }} onclose={() => (open = false)} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.discord-status {
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
height: calc(var(--bar-height) - 8px);
|
||||||
|
padding: 0 0.6rem;
|
||||||
|
background: var(--lighter-background);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-name {
|
||||||
|
max-width: 9rem;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
background: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.idle {
|
||||||
|
background: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.dnd {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.status-name {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { title, onclose, children }: { title: string; onclose: () => void; children: Snippet } = $props();
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onclose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<button class="backdrop" aria-label="Close {title}" onclick={onclose}></button>
|
||||||
|
<div class="popup" role="dialog" aria-label={title}>
|
||||||
|
<div class="popup-title">{title}</div>
|
||||||
|
<div class="popup-body">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
width: 320px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--border-size);
|
||||||
|
background: var(--border-active);
|
||||||
|
animation: window-pop-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-title {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { themes } from '#lib/theme/themes';
|
||||||
|
import { themeState } from '#lib/theme/theme.svelte';
|
||||||
|
import PopupCard from './PopupCard.svelte';
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="theme-menu">
|
||||||
|
<button class="tray-button" title="Theme" onclick={() => (open = !open)}>
|
||||||
|
<Icon icon="mdi:palette-outline" width="18" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<PopupCard title="Themes" onclose={() => (open = false)}>
|
||||||
|
{#each themes as theme (theme.id)}
|
||||||
|
<button
|
||||||
|
class="theme-option"
|
||||||
|
class:selected={theme.id === themeState.currentId}
|
||||||
|
onclick={() => themeState.set(theme.id)}
|
||||||
|
>
|
||||||
|
<span class="swatches">
|
||||||
|
{#each [theme.colors.background, theme.colors.accent, theme.colors.red, theme.colors.green, theme.colors.foreground] as swatch, index (index)}
|
||||||
|
<span class="swatch" style="background: {swatch}"></span>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
<span class="theme-name">{theme.name}</span>
|
||||||
|
<span class="theme-mode">{theme.mode}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</PopupCard>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.theme-menu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-option.selected {
|
||||||
|
background: color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatches {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 12px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-mode {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { desktop } from '#lib/content';
|
||||||
|
import { activeWorkspace, openPage } from '#lib/desktop/actions';
|
||||||
|
|
||||||
|
const workspaceSlots = [1, 2, 3, 4, 5, 6, 7, 8, 9];
|
||||||
|
|
||||||
|
const currentWorkspace = $derived(activeWorkspace(page.url.pathname));
|
||||||
|
|
||||||
|
function workspaceFor(number: number) {
|
||||||
|
return desktop.workspaces.find((workspace) => workspace.number === number);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="workspaces">
|
||||||
|
{#each workspaceSlots as number (number)}
|
||||||
|
{@const workspace = workspaceFor(number)}
|
||||||
|
{@const isFocused = currentWorkspace?.number === number}
|
||||||
|
<button
|
||||||
|
class="workspace"
|
||||||
|
class:occupied={!!workspace}
|
||||||
|
title={workspace?.name}
|
||||||
|
disabled={!workspace}
|
||||||
|
onclick={() => workspace && openPage(workspace.path)}
|
||||||
|
>
|
||||||
|
{#if isFocused}
|
||||||
|
<span class="focused-marker"></span>
|
||||||
|
{:else}
|
||||||
|
{number}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.workspaces {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 26px;
|
||||||
|
height: var(--bar-height);
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace.occupied {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace.occupied:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.workspace:not(.occupied) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.focused-marker {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { profile } from '#lib/content';
|
||||||
|
import { themeState } from '#lib/theme/theme.svelte';
|
||||||
|
|
||||||
|
const launchDate = new Date('2020-01-01T00:00:00Z');
|
||||||
|
const uptimeDays = Math.floor((Date.now() - launchDate.getTime()) / 86400000);
|
||||||
|
const paletteColors = ['red', 'yellow', 'orange', 'green', 'cyan', 'blue', 'magenta', 'accent'];
|
||||||
|
|
||||||
|
const rows = $derived([
|
||||||
|
['OS', 'Arch Linux x86_64'],
|
||||||
|
['Host', profile.hostname],
|
||||||
|
['WM', 'Hyprland (web)'],
|
||||||
|
['Shell', 'blob-shell'],
|
||||||
|
['Terminal', 'kitty'],
|
||||||
|
['Theme', themeState.current.name],
|
||||||
|
['Name', profile.displayName],
|
||||||
|
['Title', profile.title],
|
||||||
|
['Uptime', `${uptimeDays} days`],
|
||||||
|
['School', profile.education.school],
|
||||||
|
['Languages', (profile.skills.Languages ?? []).slice(0, 5).join(', ')]
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="fetch">
|
||||||
|
<img class="avatar" src={profile.avatar} alt={profile.displayName} />
|
||||||
|
<div class="info">
|
||||||
|
<div class="heading">
|
||||||
|
<span class="user">{profile.username}</span><span class="at">@</span><span class="host">{profile.hostname}</span>
|
||||||
|
</div>
|
||||||
|
<div class="separator">{'-'.repeat(profile.username.length + profile.hostname.length + 1)}</div>
|
||||||
|
{#each rows as [label, value] (label)}
|
||||||
|
<div class="row"><span class="label">{label}</span>: {value}</div>
|
||||||
|
{/each}
|
||||||
|
<div class="palette">
|
||||||
|
{#each paletteColors as color (color)}
|
||||||
|
<span class="block" style="background: var(--{color})"></span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.fetch {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: min(240px, 40%);
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user,
|
||||||
|
.host {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.at,
|
||||||
|
.separator {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
color: var(--blue);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.palette {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Span } from '#lib/terminal/output';
|
||||||
|
import { isExternalUrl, openUrl } from '#lib/desktop/actions';
|
||||||
|
|
||||||
|
let { spans }: { spans: Span[] } = $props();
|
||||||
|
|
||||||
|
function toneStyle(item: Span): string {
|
||||||
|
return item.tone ? `color: var(--${item.tone})` : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="output-line">
|
||||||
|
{#each spans as item, index (index)}
|
||||||
|
{#if item.href}
|
||||||
|
<a
|
||||||
|
href={item.href}
|
||||||
|
class:bold={item.bold}
|
||||||
|
style={toneStyle(item)}
|
||||||
|
target={isExternalUrl(item.href) ? '_blank' : undefined}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onclick={(event) => {
|
||||||
|
if (isExternalUrl(item.href!)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
openUrl(item.href!);
|
||||||
|
}}>{item.text}</a
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class:bold={item.bold} style={toneStyle(item)}>{item.text}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.output-line {
|
||||||
|
min-height: 1.5em;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
background: var(--selection);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, tick } from 'svelte';
|
||||||
|
import { profile } from '#lib/content';
|
||||||
|
import { openPage, openUrl } from '#lib/desktop/actions';
|
||||||
|
import { themeState } from '#lib/theme/theme.svelte';
|
||||||
|
import { runCommand, type CommandContext } from '#lib/terminal/commands';
|
||||||
|
import { complete } from '#lib/terminal/completion';
|
||||||
|
import { line, span, type OutputBlock } from '#lib/terminal/output';
|
||||||
|
import OutputLine from './OutputLine.svelte';
|
||||||
|
import Fetch from './Fetch.svelte';
|
||||||
|
|
||||||
|
let { startupCommand = '', focused, onexit }: { startupCommand?: string; focused: boolean; onexit: () => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
const typingDelay = 35;
|
||||||
|
|
||||||
|
let blocks = $state<OutputBlock[]>([]);
|
||||||
|
let input = $state('');
|
||||||
|
let isTyping = $state(false);
|
||||||
|
let history: string[] = [];
|
||||||
|
let historyIndex = 0;
|
||||||
|
let inputElement = $state<HTMLInputElement>();
|
||||||
|
let screenElement = $state<HTMLDivElement>();
|
||||||
|
|
||||||
|
const context: CommandContext = {
|
||||||
|
print: (...newBlocks) => blocks.push(...newBlocks),
|
||||||
|
clear: () => (blocks = []),
|
||||||
|
exit: onexit,
|
||||||
|
openPage,
|
||||||
|
openUrl,
|
||||||
|
setTheme: (themeId) => themeState.set(themeId),
|
||||||
|
currentThemeId: () => themeState.currentId
|
||||||
|
};
|
||||||
|
|
||||||
|
async function execute(command: string) {
|
||||||
|
blocks.push({ kind: 'prompt', command });
|
||||||
|
if (command.trim()) history.push(command);
|
||||||
|
historyIndex = history.length;
|
||||||
|
runCommand(command, context);
|
||||||
|
await tick();
|
||||||
|
screenElement?.scrollTo({ top: screenElement.scrollHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function typeOut(command: string) {
|
||||||
|
isTyping = true;
|
||||||
|
for (const character of command) {
|
||||||
|
input += character;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, typingDelay));
|
||||||
|
}
|
||||||
|
input = '';
|
||||||
|
isTyping = false;
|
||||||
|
await execute(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showCompletion() {
|
||||||
|
const result = complete(input);
|
||||||
|
input = result.input;
|
||||||
|
if (result.options.length === 0) return;
|
||||||
|
blocks.push({ kind: 'prompt', command: input }, line(span(result.options.join(' '), 'cyan')));
|
||||||
|
await tick();
|
||||||
|
screenElement?.scrollTo({ top: screenElement.scrollHeight });
|
||||||
|
}
|
||||||
|
|
||||||
|
function recallHistory(step: number) {
|
||||||
|
if (history.length === 0) return;
|
||||||
|
historyIndex = Math.min(history.length, Math.max(0, historyIndex + step));
|
||||||
|
input = history[historyIndex] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (isTyping) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
const command = input;
|
||||||
|
input = '';
|
||||||
|
execute(command);
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
recallHistory(-1);
|
||||||
|
} else if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
recallHistory(1);
|
||||||
|
} else if (event.key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
showCompletion();
|
||||||
|
} else if (event.key === 'l' && event.ctrlKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
context.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusInput() {
|
||||||
|
if (!window.getSelection()?.toString()) inputElement?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (focused) inputElement?.focus({ preventScroll: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (startupCommand) typeOut(startupCommand);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="terminal" bind:this={screenElement} onclick={focusInput} role="presentation">
|
||||||
|
{#each blocks as block, index (index)}
|
||||||
|
{#if block.kind === 'prompt'}
|
||||||
|
<div class="prompt-line">
|
||||||
|
<span class="prompt">{profile.username}@{profile.hostname}</span>
|
||||||
|
<span class="path">~</span>
|
||||||
|
<span class="symbol">$</span>
|
||||||
|
<span>{block.command}</span>
|
||||||
|
</div>
|
||||||
|
{:else if block.kind === 'fetch'}
|
||||||
|
<Fetch />
|
||||||
|
{:else}
|
||||||
|
<OutputLine spans={block.spans} />
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<label class="prompt-line input-line">
|
||||||
|
<span class="prompt">{profile.username}@{profile.hostname}</span>
|
||||||
|
<span class="path">~</span>
|
||||||
|
<span class="symbol">$</span>
|
||||||
|
<span class="input-wrapper">
|
||||||
|
<input
|
||||||
|
bind:this={inputElement}
|
||||||
|
bind:value={input}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
aria-label="Terminal input"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.terminal {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem 1.1rem 1.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5ch;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
color: var(--blue);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--foreground);
|
||||||
|
font: inherit;
|
||||||
|
caret-color: var(--foreground);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { AccordionLine } from "./types";
|
|
||||||
import TuiLine from "./TuiLine.svelte";
|
|
||||||
import "$lib/assets/css/tui-accordion.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: AccordionLine;
|
|
||||||
inline?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { line, inline = false }: Props = $props();
|
|
||||||
|
|
||||||
let openItems = $state(
|
|
||||||
new Set(untrack(() => line.accordionOpen) ? [0] : []),
|
|
||||||
);
|
|
||||||
|
|
||||||
function toggleItem(index: number) {
|
|
||||||
const newSet = new Set(openItems);
|
|
||||||
if (newSet.has(index)) {
|
|
||||||
newSet.delete(index);
|
|
||||||
} else {
|
|
||||||
newSet.add(index);
|
|
||||||
}
|
|
||||||
openItems = newSet;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = $derived(
|
|
||||||
line.accordionItems || [{ title: line.content, content: "" }],
|
|
||||||
);
|
|
||||||
const isSingleItemMode = $derived(
|
|
||||||
!line.accordionItems && line.children && line.children.length > 0,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-accordion"
|
|
||||||
class:inline
|
|
||||||
style="--accordion-accent: {getButtonStyle(line.style)}"
|
|
||||||
>
|
|
||||||
{#each items as item, i}
|
|
||||||
{@const contentSegments = parseColorText(
|
|
||||||
item.content,
|
|
||||||
$themeColors.colorMap,
|
|
||||||
)}
|
|
||||||
<div class="accordion-item" class:open={openItems.has(i)}>
|
|
||||||
<button class="accordion-header" onclick={() => 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}
|
|
||||||
|
|
||||||
{#if isSingleItemMode && line.children}
|
|
||||||
<div
|
|
||||||
class="accordion-children"
|
|
||||||
class:flex={line.display === "flex"}
|
|
||||||
class:grid={line.display === "grid"}
|
|
||||||
>
|
|
||||||
{#each line.children as child, k}
|
|
||||||
<TuiLine
|
|
||||||
line={child}
|
|
||||||
index={k}
|
|
||||||
segments={parseColorText(child.content, $themeColors.colorMap)}
|
|
||||||
complete={true}
|
|
||||||
showImage={true}
|
|
||||||
selectedIndex={-1}
|
|
||||||
inline={false}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-children {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-children.flex {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion-children.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideDown {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getSegmentsUpToChar } from "./utils";
|
|
||||||
import { user } from "$lib/config";
|
|
||||||
import TuiLine from "./TuiLine.svelte";
|
|
||||||
import type { DisplayedLine, TerminalLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-body.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
displayedLines: DisplayedLine[];
|
|
||||||
currentLineIndex?: number;
|
|
||||||
isTyping?: boolean;
|
|
||||||
selectedIndex?: number;
|
|
||||||
ref?: HTMLDivElement | undefined;
|
|
||||||
onButtonClick: (idx: number, line?: TerminalLine) => void;
|
|
||||||
onHoverButton: (idx: number) => void;
|
|
||||||
onLinkClick: (idx: number) => void;
|
|
||||||
terminalSettings: { showCursor: boolean };
|
|
||||||
interactiveInput?: boolean;
|
|
||||||
onCommand?: (command: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
displayedLines = [],
|
|
||||||
currentLineIndex = 0,
|
|
||||||
isTyping = false,
|
|
||||||
selectedIndex = -1,
|
|
||||||
ref = $bindable(undefined),
|
|
||||||
onButtonClick,
|
|
||||||
onHoverButton,
|
|
||||||
onLinkClick,
|
|
||||||
terminalSettings,
|
|
||||||
interactiveInput = false,
|
|
||||||
onCommand,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let inputEl = $state<HTMLInputElement>();
|
|
||||||
let commandValue = $state("");
|
|
||||||
let history = $state<string[]>([]);
|
|
||||||
let historyIndex = $state(-1);
|
|
||||||
let inputFocused = $state(false);
|
|
||||||
let caretPos = $state(0);
|
|
||||||
|
|
||||||
const beforeText = $derived(commandValue.slice(0, caretPos));
|
|
||||||
const cursorChar = $derived(commandValue.slice(caretPos, caretPos + 1) || " ");
|
|
||||||
const afterRest = $derived(commandValue.slice(caretPos + 1));
|
|
||||||
const afterFull = $derived(commandValue.slice(caretPos));
|
|
||||||
|
|
||||||
function syncCaret() {
|
|
||||||
caretPos = inputEl?.selectionStart ?? commandValue.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function recall(value: string) {
|
|
||||||
commandValue = value;
|
|
||||||
caretPos = value.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
function submitCommand() {
|
|
||||||
const value = commandValue;
|
|
||||||
commandValue = "";
|
|
||||||
caretPos = 0;
|
|
||||||
if (value.trim()) history = [...history, value];
|
|
||||||
historyIndex = -1;
|
|
||||||
onCommand?.(value);
|
|
||||||
queueMicrotask(() => inputEl?.focus());
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputKeydown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
submitCommand();
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (history.length === 0) return;
|
|
||||||
const nextIndex =
|
|
||||||
historyIndex === -1
|
|
||||||
? history.length - 1
|
|
||||||
: Math.max(0, historyIndex - 1);
|
|
||||||
historyIndex = nextIndex;
|
|
||||||
recall(history[nextIndex]);
|
|
||||||
} else if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
if (historyIndex === -1) return;
|
|
||||||
const nextIndex = historyIndex + 1;
|
|
||||||
if (nextIndex >= history.length) {
|
|
||||||
historyIndex = -1;
|
|
||||||
recall("");
|
|
||||||
} else {
|
|
||||||
historyIndex = nextIndex;
|
|
||||||
recall(history[nextIndex]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function focusCommandInput(event: MouseEvent) {
|
|
||||||
if (!interactiveInput) return;
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target.closest("a, button, input, textarea, .tui-button, .tui-link"))
|
|
||||||
return;
|
|
||||||
inputEl?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group consecutive inline items together
|
|
||||||
type ProcessedGroup =
|
|
||||||
| { kind: "single"; index: number; displayed: DisplayedLine }
|
|
||||||
| {
|
|
||||||
kind: "inline";
|
|
||||||
items: Array<{ index: number; displayed: DisplayedLine }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const processedGroups = $derived.by(() => {
|
|
||||||
const groups: ProcessedGroup[] = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < displayedLines.length) {
|
|
||||||
const displayed = displayedLines[i];
|
|
||||||
const isInline =
|
|
||||||
displayed.parsed.line.inline ||
|
|
||||||
displayed.parsed.line.display === "inline";
|
|
||||||
|
|
||||||
if (isInline) {
|
|
||||||
const inlineItems: Array<{
|
|
||||||
index: number;
|
|
||||||
displayed: DisplayedLine;
|
|
||||||
}> = [];
|
|
||||||
// Collect consecutive inline items
|
|
||||||
while (i < displayedLines.length) {
|
|
||||||
const nextLine = displayedLines[i].parsed.line;
|
|
||||||
const nextIsInline =
|
|
||||||
nextLine.inline || nextLine.display === "inline";
|
|
||||||
|
|
||||||
if (!nextIsInline) break;
|
|
||||||
|
|
||||||
inlineItems.push({
|
|
||||||
index: i,
|
|
||||||
displayed: displayedLines[i],
|
|
||||||
});
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
groups.push({ kind: "inline", items: inlineItems });
|
|
||||||
} else {
|
|
||||||
groups.push({ kind: "single", index: i, displayed });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
||||||
<div class="tui-body" bind:this={ref} onclick={focusCommandInput}>
|
|
||||||
{#each processedGroups as group, gi (gi)}
|
|
||||||
{#if group.kind === "inline"}
|
|
||||||
<div class="tui-inline-group">
|
|
||||||
{#each group.items as item (item.index)}
|
|
||||||
<TuiLine
|
|
||||||
line={item.displayed.parsed.line}
|
|
||||||
index={item.index}
|
|
||||||
segments={getSegmentsUpToChar(
|
|
||||||
item.displayed.parsed.segments,
|
|
||||||
item.displayed.charIndex,
|
|
||||||
)}
|
|
||||||
complete={item.displayed.complete}
|
|
||||||
showImage={item.displayed.showImage}
|
|
||||||
{selectedIndex}
|
|
||||||
inline={true}
|
|
||||||
showCursor={terminalSettings.showCursor &&
|
|
||||||
item.index === currentLineIndex &&
|
|
||||||
!item.displayed.complete &&
|
|
||||||
isTyping &&
|
|
||||||
item.displayed.parsed.line.type !== "image"}
|
|
||||||
{onButtonClick}
|
|
||||||
{onHoverButton}
|
|
||||||
{onLinkClick}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<TuiLine
|
|
||||||
line={group.displayed.parsed.line}
|
|
||||||
index={group.index}
|
|
||||||
segments={getSegmentsUpToChar(
|
|
||||||
group.displayed.parsed.segments,
|
|
||||||
group.displayed.charIndex,
|
|
||||||
)}
|
|
||||||
complete={group.displayed.complete}
|
|
||||||
showImage={group.displayed.showImage}
|
|
||||||
{selectedIndex}
|
|
||||||
inline={false}
|
|
||||||
showCursor={terminalSettings.showCursor &&
|
|
||||||
group.index === currentLineIndex &&
|
|
||||||
!group.displayed.complete &&
|
|
||||||
isTyping &&
|
|
||||||
group.displayed.parsed.line.type !== "image"}
|
|
||||||
{onButtonClick}
|
|
||||||
{onHoverButton}
|
|
||||||
{onLinkClick}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#if interactiveInput}
|
|
||||||
{#if !isTyping}
|
|
||||||
<div class="tui-line prompt tui-command-line">
|
|
||||||
<span class="prompt">
|
|
||||||
<span class="user">{user.username}</span><span class="at"
|
|
||||||
>@</span
|
|
||||||
><span class="host">{user.hostname}</span>
|
|
||||||
<span class="separator">:</span><span class="path">~</span><span
|
|
||||||
class="symbol">$</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<span class="tui-input-wrap">
|
|
||||||
<input
|
|
||||||
class="tui-command-input"
|
|
||||||
bind:this={inputEl}
|
|
||||||
bind:value={commandValue}
|
|
||||||
onkeydown={handleInputKeydown}
|
|
||||||
onkeyup={syncCaret}
|
|
||||||
oninput={syncCaret}
|
|
||||||
onclick={syncCaret}
|
|
||||||
onfocus={() => {
|
|
||||||
inputFocused = true;
|
|
||||||
syncCaret();
|
|
||||||
}}
|
|
||||||
onblur={() => (inputFocused = false)}
|
|
||||||
spellcheck="false"
|
|
||||||
autocomplete="off"
|
|
||||||
autocapitalize="off"
|
|
||||||
autocorrect="off"
|
|
||||||
aria-label="Terminal command input"
|
|
||||||
/><span class="tui-input-mirror" aria-hidden="true">{beforeText}{#if inputFocused}<span class="cursor-block blink">{cursorChar}</span>{afterRest}{:else}{afterFull}{/if}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
|
|
||||||
<div class="tui-line prompt">
|
|
||||||
<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 blink"></span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-command-line {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input-wrap {
|
|
||||||
position: relative;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
margin-left: 0.4rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-command-input {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
color: transparent;
|
|
||||||
font: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
caret-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-command-input:focus,
|
|
||||||
.tui-command-input:focus-visible {
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input-mirror {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
white-space: pre;
|
|
||||||
overflow: hidden;
|
|
||||||
pointer-events: none;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-block {
|
|
||||||
display: inline-block;
|
|
||||||
height: 1.2em;
|
|
||||||
line-height: 1.2em;
|
|
||||||
color: var(--terminal-bg);
|
|
||||||
background: var(--terminal-accent, var(--terminal-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.cursor-block.blink {
|
|
||||||
animation: cursorBlink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { ButtonLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-button.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: ButtonLine;
|
|
||||||
index: number;
|
|
||||||
selected: boolean;
|
|
||||||
onClick: (idx: number, line?: ButtonLine) => void;
|
|
||||||
onHover: (idx: number) => void;
|
|
||||||
inline?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
index,
|
|
||||||
selected,
|
|
||||||
onClick,
|
|
||||||
onHover,
|
|
||||||
inline = false,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Determine if this is an external link
|
|
||||||
const isExternal = $derived(
|
|
||||||
line.external ||
|
|
||||||
(line.href &&
|
|
||||||
(line.href.startsWith("http://") ||
|
|
||||||
line.href.startsWith("https://"))),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse color formatting in content using theme colorMap
|
|
||||||
const segments = $derived(
|
|
||||||
parseColorText(line.content, $themeColors.colorMap),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="tui-button"
|
|
||||||
class:selected
|
|
||||||
class:inline
|
|
||||||
class:no-border={line.border === false}
|
|
||||||
class:flex-expand={line.flex}
|
|
||||||
style="--btn-color: {getButtonStyle(line.style)}"
|
|
||||||
onclick={() => onClick(index, line)}
|
|
||||||
onmouseenter={() => onHover(index)}
|
|
||||||
data-href={line.href || ""}
|
|
||||||
data-external={isExternal ? "true" : "false"}
|
|
||||||
>
|
|
||||||
<span class="btn-indicator">{selected ? "▶" : " "}</span>
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="16" />
|
|
||||||
{/if}
|
|
||||||
<span class="btn-text" style:text-align={line.textStyle || "left"}>
|
|
||||||
{#each segments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{#if line.href}
|
|
||||||
{#if isExternal}
|
|
||||||
<Icon icon="mdi:open-in-new" width="14" class="btn-arrow" />
|
|
||||||
{:else}
|
|
||||||
<Icon icon="mdi:chevron-right" width="16" class="btn-arrow" />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import {
|
|
||||||
getButtonStyle,
|
|
||||||
parseColorText,
|
|
||||||
getSegmentStyle,
|
|
||||||
parseDimension,
|
|
||||||
} from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { CardLine, TerminalLine } from "./types";
|
|
||||||
import TuiLine from "./TuiLine.svelte";
|
|
||||||
import "$lib/assets/css/tui-card.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: CardLine;
|
|
||||||
inline?: boolean;
|
|
||||||
onButtonClick?: (idx: number, line?: any) => void;
|
|
||||||
onHoverButton?: (idx: number) => void;
|
|
||||||
onLinkClick?: (idx: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
onButtonClick = () => {},
|
|
||||||
onHoverButton = () => {},
|
|
||||||
onLinkClick = () => {},
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const segments = $derived(parseColorText(line.content, $themeColors.colorMap));
|
|
||||||
const titleSegments = $derived(
|
|
||||||
line.cardTitle ? parseColorText(line.cardTitle, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
const footerSegments = $derived(
|
|
||||||
line.cardFooter ? parseColorText(line.cardFooter, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cardStyle = $derived(
|
|
||||||
[
|
|
||||||
`--card-color: ${getButtonStyle(line.style)}`,
|
|
||||||
line.cardWidth ? `width: ${parseDimension(line.cardWidth)}` : "",
|
|
||||||
line.cardHeight ? `height: ${parseDimension(line.cardHeight)}` : "",
|
|
||||||
line.cardFloat === "start" ? "margin-right: auto" : "",
|
|
||||||
line.cardFloat === "center" ? "margin-inline: auto" : "",
|
|
||||||
line.cardFloat === "end" ? "margin-left: auto" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("; "),
|
|
||||||
);
|
|
||||||
const processedChildren = $derived.by(() => {
|
|
||||||
if (!line.children) return [];
|
|
||||||
const groups: Array<
|
|
||||||
| { kind: "single"; index: number; line: TerminalLine }
|
|
||||||
| {
|
|
||||||
kind: "inline";
|
|
||||||
items: Array<{ index: number; line: TerminalLine }>;
|
|
||||||
}
|
|
||||||
> = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < line.children.length) {
|
|
||||||
const child = line.children[i];
|
|
||||||
const isInline = child.inline || child.display === "inline";
|
|
||||||
|
|
||||||
if (isInline) {
|
|
||||||
const inlineItems: Array<{
|
|
||||||
index: number;
|
|
||||||
line: TerminalLine;
|
|
||||||
}> = [];
|
|
||||||
while (i < line.children.length) {
|
|
||||||
const nextChild = line.children[i];
|
|
||||||
const nextIsInline =
|
|
||||||
nextChild.inline || nextChild.display === "inline";
|
|
||||||
if (!nextIsInline) break;
|
|
||||||
|
|
||||||
inlineItems.push({ index: i, line: nextChild });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
groups.push({ kind: "inline", items: inlineItems });
|
|
||||||
} else {
|
|
||||||
groups.push({ kind: "single", index: i, line: child });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="tui-card" class:inline style={cardStyle}>
|
|
||||||
{#if line.cardTitle}
|
|
||||||
<div class="card-header">
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="16" class="card-icon" />
|
|
||||||
{/if}
|
|
||||||
<span class="card-title">
|
|
||||||
{#if line.link}
|
|
||||||
<a
|
|
||||||
href={line.link}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="card-title-link"
|
|
||||||
>
|
|
||||||
{#each titleSegments as segment}
|
|
||||||
{#if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
{#each titleSegments as segment}
|
|
||||||
{#if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</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>
|
|
||||||
{#if line.children && line.children.length > 0}
|
|
||||||
<div
|
|
||||||
class="card-children"
|
|
||||||
class:flex={line.display === "flex"}
|
|
||||||
class:grid={line.display === "grid"}
|
|
||||||
>
|
|
||||||
{#each processedChildren as group}
|
|
||||||
{#if group.kind === "inline"}
|
|
||||||
<div class="tui-inline-group">
|
|
||||||
{#each group.items as item}
|
|
||||||
<TuiLine
|
|
||||||
line={item.line}
|
|
||||||
index={item.index}
|
|
||||||
segments={parseColorText(item.line.content, $themeColors.colorMap)}
|
|
||||||
complete={true}
|
|
||||||
showImage={true}
|
|
||||||
selectedIndex={-1}
|
|
||||||
inline={true}
|
|
||||||
{onButtonClick}
|
|
||||||
{onHoverButton}
|
|
||||||
{onLinkClick}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<TuiLine
|
|
||||||
line={group.line}
|
|
||||||
index={group.index}
|
|
||||||
segments={parseColorText(group.line.content, $themeColors.colorMap)}
|
|
||||||
complete={true}
|
|
||||||
showImage={true}
|
|
||||||
selectedIndex={-1}
|
|
||||||
inline={false}
|
|
||||||
{onButtonClick}
|
|
||||||
{onHoverButton}
|
|
||||||
{onLinkClick}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</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-title-link {
|
|
||||||
color: var(--card-accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-style: dotted;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-title-link:hover {
|
|
||||||
text-decoration-style: solid;
|
|
||||||
filter: brightness(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-children {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-children.flex {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-children.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(
|
|
||||||
auto-fill,
|
|
||||||
minmax(max(200px, calc((100% - 5rem) / 6)), 1fr)
|
|
||||||
);
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { CardGridLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-card-grid.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: CardGridLine;
|
|
||||||
inline?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { line, inline = false }: Props = $props();
|
|
||||||
|
|
||||||
const cards = $derived(line.cards || []);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-card-grid"
|
|
||||||
class:inline
|
|
||||||
style="--card-warning: {$themeColors.colorMap.warning};"
|
|
||||||
>
|
|
||||||
{#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" />
|
|
||||||
</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 as tag}
|
|
||||||
<span class="tag">{tag}</span>
|
|
||||||
{/each}
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--card-warning, #b8860b);
|
|
||||||
padding: 0.3rem 0.6rem;
|
|
||||||
background: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--card-warning, #b8860b) 8%,
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
border: 1px solid
|
|
||||||
color-mix(in srgb, var(--card-warning, #b8860b) 20%, transparent);
|
|
||||||
border-radius: 6px;
|
|
||||||
width: fit-content;
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02) inset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.warning:before {
|
|
||||||
content: "⚠";
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { CheckboxLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-checkbox.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: CheckboxLine;
|
|
||||||
inline?: boolean;
|
|
||||||
checked?: boolean;
|
|
||||||
onchange?: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
checked = $bindable(false),
|
|
||||||
onchange,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const labelSegments = $derived(
|
|
||||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
const isDisabled = $derived(line.inputDisabled || false);
|
|
||||||
const indeterminate = $derived(line.checkboxIndeterminate || false);
|
|
||||||
|
|
||||||
function handleChange() {
|
|
||||||
if (isDisabled) return;
|
|
||||||
checked = !checked;
|
|
||||||
onchange?.(checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === " " || e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleChange();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCheckboxSymbol(
|
|
||||||
checked: boolean,
|
|
||||||
indeterminate: boolean,
|
|
||||||
): string {
|
|
||||||
if (indeterminate) return "[-]";
|
|
||||||
return checked ? "[✓]" : "[ ]";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-checkbox"
|
|
||||||
class:inline
|
|
||||||
class:checked
|
|
||||||
class:disabled={isDisabled}
|
|
||||||
style="--checkbox-color: {getButtonStyle(line.style)}"
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={indeterminate ? "mixed" : checked}
|
|
||||||
aria-disabled={isDisabled}
|
|
||||||
tabindex={isDisabled ? -1 : 0}
|
|
||||||
onclick={handleChange}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
>
|
|
||||||
<span class="checkbox-box" class:indeterminate>
|
|
||||||
{getCheckboxSymbol(checked, indeterminate)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="14" class="checkbox-icon" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if line.content}
|
|
||||||
<span class="checkbox-label">
|
|
||||||
{#each labelSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-checkbox {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0.35rem 0;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 0 0.75rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox:hover:not(.disabled) {
|
|
||||||
background: color-mix(in srgb, var(--checkbox-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox:focus-visible {
|
|
||||||
outline: 1px solid var(--checkbox-color);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-box {
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.checked .checkbox-box,
|
|
||||||
.checkbox-box.indeterminate {
|
|
||||||
color: var(--checkbox-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.checkbox-icon) {
|
|
||||||
color: var(--checkbox-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.checkbox-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-checkbox.disabled .checkbox-label {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: inline checkboxes become full-width */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tui-checkbox.inline {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.35rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import "$lib/assets/css/tui-footer.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
isTyping: boolean;
|
|
||||||
linesCount: number;
|
|
||||||
skipAnimation: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { isTyping, linesCount, skipAnimation }: Props = $props();
|
|
||||||
</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" onclick={skipAnimation}>
|
|
||||||
<Icon icon="mdi:skip-forward" width="12" />
|
|
||||||
<span>Skip (Y)</span>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="line-count">{linesCount} lines</span>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { parseColorText, getPlainText } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import TuiLine from "./TuiLine.svelte";
|
|
||||||
import type { GroupLine, TerminalLine } from "./types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: GroupLine;
|
|
||||||
inline?: boolean;
|
|
||||||
onButtonClick?: (idx: number, line?: TerminalLine) => void;
|
|
||||||
onHoverButton?: (idx: number) => void;
|
|
||||||
onLinkClick?: (idx: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
onButtonClick = () => {},
|
|
||||||
onHoverButton = () => {},
|
|
||||||
onLinkClick = () => {},
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Get colorMap from current theme
|
|
||||||
const colorMap = $derived($themeColors.colorMap);
|
|
||||||
|
|
||||||
// Parse children with current colorMap
|
|
||||||
const parsedChildren = $derived(
|
|
||||||
(line.children || []).map((child) => {
|
|
||||||
const segments = parseColorText(child.content, colorMap);
|
|
||||||
return { line: child, segments, plainText: getPlainText(segments) };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group consecutive inline elements
|
|
||||||
const processedChildren = $derived.by(() => {
|
|
||||||
const groups: Array<
|
|
||||||
| { kind: "single"; index: number; line: TerminalLine }
|
|
||||||
| {
|
|
||||||
kind: "inline";
|
|
||||||
items: Array<{ index: number; line: TerminalLine }>;
|
|
||||||
}
|
|
||||||
> = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < parsedChildren.length) {
|
|
||||||
const child = parsedChildren[i];
|
|
||||||
const isInline = child.line.inline || child.line.display === "inline";
|
|
||||||
|
|
||||||
if (isInline) {
|
|
||||||
const inlineItems: Array<{
|
|
||||||
index: number;
|
|
||||||
line: TerminalLine;
|
|
||||||
}> = [];
|
|
||||||
while (i < parsedChildren.length) {
|
|
||||||
const nextChild = parsedChildren[i];
|
|
||||||
const nextIsInline =
|
|
||||||
nextChild.line.inline || nextChild.line.display === "inline";
|
|
||||||
if (!nextIsInline) break;
|
|
||||||
|
|
||||||
inlineItems.push({
|
|
||||||
index: i,
|
|
||||||
line: nextChild.line,
|
|
||||||
});
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
groups.push({ kind: "inline", items: inlineItems });
|
|
||||||
} else {
|
|
||||||
groups.push({ kind: "single", index: i, line: child.line });
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Style for the group container
|
|
||||||
const groupStyle = $derived(() => {
|
|
||||||
const styles: string[] = [];
|
|
||||||
// Only apply flex styles if not grid or block
|
|
||||||
if (line.display !== "grid" && line.display !== "block") {
|
|
||||||
if (line.groupDirection === "column")
|
|
||||||
styles.push("flex-direction: column");
|
|
||||||
if (line.groupAlign) {
|
|
||||||
const alignMap: Record<string, string> = {
|
|
||||||
start: "flex-start",
|
|
||||||
center: "center",
|
|
||||||
end: "flex-end",
|
|
||||||
stretch: "stretch",
|
|
||||||
};
|
|
||||||
styles.push(
|
|
||||||
`align-items: ${alignMap[line.groupAlign] || "flex-start"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (line.groupGap) styles.push(`gap: ${line.groupGap}`);
|
|
||||||
return styles.join("; ");
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-group"
|
|
||||||
class:inline
|
|
||||||
class:grid={line.display === "grid"}
|
|
||||||
class:block={line.display === "block"}
|
|
||||||
class:expand={line.groupExpand}
|
|
||||||
style={groupStyle()}
|
|
||||||
id={line.id}
|
|
||||||
>
|
|
||||||
{#each processedChildren as group}
|
|
||||||
{#if group.kind === "inline"}
|
|
||||||
<div class="tui-inline-group">
|
|
||||||
{#each group.items as item}
|
|
||||||
<TuiLine
|
|
||||||
line={item.line}
|
|
||||||
index={item.index}
|
|
||||||
segments={parseColorText(item.line.content, colorMap)}
|
|
||||||
complete={true}
|
|
||||||
showImage={item.line.type === "image"}
|
|
||||||
selectedIndex={-1}
|
|
||||||
inline={true}
|
|
||||||
{onButtonClick}
|
|
||||||
{onHoverButton}
|
|
||||||
{onLinkClick}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<TuiLine
|
|
||||||
line={group.line}
|
|
||||||
index={group.index}
|
|
||||||
segments={parseColorText(group.line.content, colorMap)}
|
|
||||||
complete={true}
|
|
||||||
showImage={group.line.type === "image"}
|
|
||||||
selectedIndex={-1}
|
|
||||||
inline={false}
|
|
||||||
{onButtonClick}
|
|
||||||
{onHoverButton}
|
|
||||||
{onLinkClick}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 0.75rem;
|
|
||||||
animation: lineSlideIn 0.15s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-group.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-group.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(
|
|
||||||
auto-fill,
|
|
||||||
minmax(max(200px, calc((100% - 3.75rem) / 5)), 1fr)
|
|
||||||
);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-group.block {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-group.expand > :global(*),
|
|
||||||
.tui-group.expand > :global(*) > :global(*) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-group.expand :global(.tui-button) {
|
|
||||||
flex: 1;
|
|
||||||
width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes lineSlideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-5px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
/* .tui-group.inline removed to allow inline flow on mobile */
|
|
||||||
|
|
||||||
.tui-group.grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { user } from "$lib/config";
|
|
||||||
import {
|
|
||||||
colorTheme,
|
|
||||||
getThemeIcon,
|
|
||||||
type ColorTheme,
|
|
||||||
} from "$lib/stores/theme";
|
|
||||||
import "$lib/assets/css/tui-header.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title?: string;
|
|
||||||
interactive?: boolean;
|
|
||||||
hasButtons?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
title = "terminal",
|
|
||||||
interactive = true,
|
|
||||||
hasButtons = false,
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="tui-statusbar top">
|
|
||||||
<span class="status-left">
|
|
||||||
<Icon icon={getThemeIcon($colorTheme)} width="14" />
|
|
||||||
<span class="user-host">{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>
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { InputLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-input.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: InputLine;
|
|
||||||
inline?: boolean;
|
|
||||||
value?: string;
|
|
||||||
oninput?: (value: string) => void;
|
|
||||||
onchange?: (value: string) => void;
|
|
||||||
onfocus?: () => void;
|
|
||||||
onblur?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
value = $bindable(""),
|
|
||||||
oninput,
|
|
||||||
onchange,
|
|
||||||
onfocus,
|
|
||||||
onblur,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const labelSegments = $derived(
|
|
||||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
const placeholder = $derived(line.inputPlaceholder || "");
|
|
||||||
const inputType = $derived(line.inputType || "text");
|
|
||||||
const isDisabled = $derived(line.inputDisabled || false);
|
|
||||||
const hasError = $derived(line.inputError);
|
|
||||||
const errorMessage = $derived(line.inputErrorMessage || "");
|
|
||||||
const prefix = $derived(line.inputPrefix || "");
|
|
||||||
const suffix = $derived(line.inputSuffix || "");
|
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
value = target.value;
|
|
||||||
oninput?.(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement;
|
|
||||||
onchange?.(target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus() {
|
|
||||||
onfocus?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
onblur?.();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-input"
|
|
||||||
class:inline
|
|
||||||
class:error={hasError}
|
|
||||||
class:disabled={isDisabled}
|
|
||||||
style="--input-color: {getButtonStyle(line.style)}"
|
|
||||||
>
|
|
||||||
{#if line.content}
|
|
||||||
<label class="input-label">
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
|
||||||
{/if}
|
|
||||||
{#each labelSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="input-wrapper">
|
|
||||||
<span class="input-prompt">❯</span>
|
|
||||||
{#if prefix}
|
|
||||||
<span class="input-affix prefix">{prefix}</span>
|
|
||||||
{/if}
|
|
||||||
<input
|
|
||||||
type={inputType}
|
|
||||||
{placeholder}
|
|
||||||
bind:value
|
|
||||||
disabled={isDisabled}
|
|
||||||
oninput={handleInput}
|
|
||||||
onchange={handleChange}
|
|
||||||
onfocus={handleFocus}
|
|
||||||
onblur={handleBlur}
|
|
||||||
/>
|
|
||||||
{#if suffix}
|
|
||||||
<span class="input-affix suffix">{suffix}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if hasError && errorMessage}
|
|
||||||
<div class="input-error">
|
|
||||||
<Icon icon="mdi:alert-circle" width="12" />
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-input {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--input-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-wrapper:focus-within {
|
|
||||||
border-color: var(--input-color);
|
|
||||||
box-shadow: 0 0 0 1px
|
|
||||||
color-mix(in srgb, var(--input-color) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input.error .input-wrapper {
|
|
||||||
border-color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-input.disabled .input-wrapper {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-prompt {
|
|
||||||
color: var(--input-color);
|
|
||||||
font-weight: bold;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-affix {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-affix.prefix {
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-affix.suffix {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
input::placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
color: #f38ba8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: inline inputs become full-width */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tui-input.inline {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getSegmentStyle, getLinePrefix } from "./utils";
|
|
||||||
import { handleNavigation } from "./terminal-keyboard";
|
|
||||||
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 TuiInput from "./TuiInput.svelte";
|
|
||||||
import TuiTextarea from "./TuiTextarea.svelte";
|
|
||||||
import TuiCheckbox from "./TuiCheckbox.svelte";
|
|
||||||
import TuiRadio from "./TuiRadio.svelte";
|
|
||||||
import TuiSelect from "./TuiSelect.svelte";
|
|
||||||
import TuiToggle from "./TuiToggle.svelte";
|
|
||||||
import TuiGroup from "./TuiGroup.svelte";
|
|
||||||
import type { TerminalLine, TextSegment } from "./types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: TerminalLine;
|
|
||||||
index: number;
|
|
||||||
segments: TextSegment[];
|
|
||||||
complete?: boolean;
|
|
||||||
showImage?: boolean;
|
|
||||||
selectedIndex?: number;
|
|
||||||
inline?: boolean;
|
|
||||||
showCursor?: boolean;
|
|
||||||
onButtonClick?: (idx: number, line?: TerminalLine) => void;
|
|
||||||
onHoverButton?: (idx: number) => void;
|
|
||||||
onLinkClick?: (idx: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
index,
|
|
||||||
segments,
|
|
||||||
complete = true,
|
|
||||||
showImage = false,
|
|
||||||
selectedIndex = -1,
|
|
||||||
inline = false,
|
|
||||||
showCursor = false,
|
|
||||||
onButtonClick = () => {},
|
|
||||||
onHoverButton = () => {},
|
|
||||||
onLinkClick = () => {},
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Component types that have their own wrapper
|
|
||||||
const componentTypes = new Set([
|
|
||||||
"button",
|
|
||||||
"card",
|
|
||||||
"cardgrid",
|
|
||||||
"progress",
|
|
||||||
"accordion",
|
|
||||||
"table",
|
|
||||||
"input",
|
|
||||||
"textarea",
|
|
||||||
"checkbox",
|
|
||||||
"radio",
|
|
||||||
"select",
|
|
||||||
"toggle",
|
|
||||||
"group",
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Types that need special handling
|
|
||||||
const isComponent = $derived(componentTypes.has(line.type));
|
|
||||||
const isBlank = $derived(line.type === "blank");
|
|
||||||
const isDivider = $derived(line.type === "divider");
|
|
||||||
const isImage = $derived(line.type === "image");
|
|
||||||
const isPrompt = $derived(
|
|
||||||
line.type === "command" || line.type === "prompt",
|
|
||||||
);
|
|
||||||
const isHeader = $derived(line.type === "header");
|
|
||||||
const isLink = $derived(line.type === "link");
|
|
||||||
const isTooltip = $derived(line.type === "tooltip");
|
|
||||||
|
|
||||||
const blankHeight = $derived(
|
|
||||||
line.type === "blank" && line.height !== undefined
|
|
||||||
? typeof line.height === "number"
|
|
||||||
? `${line.height}px`
|
|
||||||
: line.height
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class:mobile-hidden={line.mobile === false} style="display: contents">
|
|
||||||
{#if isBlank}
|
|
||||||
{#if inline}<span
|
|
||||||
class="inline-blank"
|
|
||||||
style={blankHeight ? `width: ${blankHeight}` : undefined}
|
|
||||||
></span>{:else}<div
|
|
||||||
class="tui-line blank"
|
|
||||||
style={blankHeight
|
|
||||||
? `height: ${blankHeight}; min-height: ${blankHeight};`
|
|
||||||
: undefined}
|
|
||||||
></div>{/if}
|
|
||||||
{:else if isDivider}
|
|
||||||
<div class="tui-divider" id={line.id}>
|
|
||||||
<span class="divider-line"></span>
|
|
||||||
{#if line.content}<span class="divider-text">{line.content}</span>{/if}
|
|
||||||
<span class="divider-line"></span>
|
|
||||||
</div>
|
|
||||||
{:else if line.type === "button"}
|
|
||||||
<TuiButton
|
|
||||||
{line}
|
|
||||||
{index}
|
|
||||||
selected={selectedIndex === index}
|
|
||||||
onClick={onButtonClick}
|
|
||||||
onHover={onHoverButton}
|
|
||||||
{inline}
|
|
||||||
/>
|
|
||||||
{:else if line.type === "link"}
|
|
||||||
{#if inline}
|
|
||||||
<TuiLink {line} onClick={() => handleNavigation(line)} />
|
|
||||||
{:else}
|
|
||||||
<div class="tui-line link">
|
|
||||||
<TuiLink {line} onClick={() => onLinkClick(index)} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if line.type === "tooltip"}
|
|
||||||
{#if inline}<TuiTooltip {line} />{:else}<div class="tui-line">
|
|
||||||
<TuiTooltip {line} />
|
|
||||||
</div>{/if}
|
|
||||||
{:else if line.type === "card"}
|
|
||||||
<TuiCard {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
|
|
||||||
{:else if line.type === "cardgrid"}
|
|
||||||
<TuiCardGrid {line} {inline} />
|
|
||||||
{:else if line.type === "progress"}
|
|
||||||
<TuiProgress {line} {inline} />
|
|
||||||
{:else if line.type === "accordion"}
|
|
||||||
<TuiAccordion {line} {inline} />
|
|
||||||
{:else if line.type === "table"}
|
|
||||||
<TuiTable {line} {inline} />
|
|
||||||
{:else if line.type === "input"}
|
|
||||||
<TuiInput {line} {inline} />
|
|
||||||
{:else if line.type === "textarea"}
|
|
||||||
<TuiTextarea {line} {inline} />
|
|
||||||
{:else if line.type === "checkbox"}
|
|
||||||
<TuiCheckbox {line} {inline} />
|
|
||||||
{:else if line.type === "radio"}
|
|
||||||
<TuiRadio {line} {inline} />
|
|
||||||
{:else if line.type === "select"}
|
|
||||||
<TuiSelect {line} {inline} />
|
|
||||||
{:else if line.type === "toggle"}
|
|
||||||
<TuiToggle {line} {inline} />
|
|
||||||
{:else if line.type === "group"}
|
|
||||||
<TuiGroup {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
|
|
||||||
{:else if line.type === "image" && showImage}
|
|
||||||
<div class="tui-image" class:inline-image={inline}>
|
|
||||||
<img
|
|
||||||
src={line.image}
|
|
||||||
alt={line.imageAlt || "Image"}
|
|
||||||
style="width: {line.imageWidth + "px" || "100%"}"
|
|
||||||
/>
|
|
||||||
{#if line.content}<span class="image-caption">{line.content}</span>{/if}
|
|
||||||
</div>
|
|
||||||
{:else if line.type === "header"}
|
|
||||||
{#if inline}
|
|
||||||
<span class="header-text inline-header">
|
|
||||||
<Icon icon="mdi:pound" width="20" class="header-icon" />
|
|
||||||
{#each segments as seg}{#if seg.icon}<Icon
|
|
||||||
icon={seg.icon}
|
|
||||||
width={seg.iconSize ?? 20}
|
|
||||||
class="inline-icon"
|
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
|
||||||
>{:else}{seg.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<div class="tui-line header" class:complete id={line.id}>
|
|
||||||
<span class="content header-text">
|
|
||||||
<Icon icon="mdi:pound" width="25" class="header-icon" />
|
|
||||||
{#each segments as seg}{#if seg.icon}<Icon
|
|
||||||
icon={seg.icon}
|
|
||||||
width={seg.iconSize ?? 25}
|
|
||||||
class="inline-icon"
|
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
|
||||||
>{:else}{seg.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{#if showCursor}<span class="cursor"></span>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if line.type === "command" || line.type === "prompt"}
|
|
||||||
{#if inline}
|
|
||||||
<span class="inline-content {line.type}">
|
|
||||||
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
|
||||||
icon={seg.icon}
|
|
||||||
width={seg.iconSize ?? 14}
|
|
||||||
class="inline-icon"
|
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
|
||||||
>{:else}{seg.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<div class="tui-line {line.type}" class:complete id={line.id}>
|
|
||||||
<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="content">
|
|
||||||
{getLinePrefix(
|
|
||||||
line.type,
|
|
||||||
)}{#each segments as seg}{#if seg.icon}<Icon
|
|
||||||
icon={seg.icon}
|
|
||||||
width={seg.iconSize ?? 14}
|
|
||||||
class="inline-icon"
|
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
|
||||||
>{:else}{seg.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{#if showCursor}<span class="cursor"></span>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if !isImage}
|
|
||||||
{#if inline}
|
|
||||||
<span class="inline-content {line.type}">
|
|
||||||
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon
|
|
||||||
icon={seg.icon}
|
|
||||||
width={seg.iconSize ?? 14}
|
|
||||||
class="inline-icon"
|
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
|
||||||
>{:else}{seg.text}{/if}{/each}
|
|
||||||
{#if showCursor}<span class="cursor"></span>{/if}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<div class="tui-line {line.type}" class:complete id={line.id}>
|
|
||||||
<span class="content">
|
|
||||||
{getLinePrefix(
|
|
||||||
line.type,
|
|
||||||
)}{#each segments as seg}{#if seg.icon}<Icon
|
|
||||||
icon={seg.icon}
|
|
||||||
width={seg.iconSize ?? 14}
|
|
||||||
class="inline-icon"
|
|
||||||
/>{:else if getSegmentStyle(seg)}<span
|
|
||||||
style={getSegmentStyle(seg)}>{seg.text}</span
|
|
||||||
>{:else}{seg.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{#if showCursor}<span class="cursor"></span>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { LinkLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-link.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: LinkLine;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { line, onClick }: Props = $props();
|
|
||||||
|
|
||||||
// Determine if this is an external link
|
|
||||||
const isExternal = $derived(
|
|
||||||
line.external ||
|
|
||||||
(line.href &&
|
|
||||||
(line.href.startsWith("http://") ||
|
|
||||||
line.href.startsWith("https://"))),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Parse color formatting in content using theme colorMap
|
|
||||||
const segments = $derived(
|
|
||||||
parseColorText(line.content, $themeColors.colorMap),
|
|
||||||
);
|
|
||||||
</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" onclick={onClick}>
|
|
||||||
{#each segments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</button>
|
|
||||||
{#if isExternal}
|
|
||||||
<Icon icon="mdi:open-in-new" width="12" class="link-external" />
|
|
||||||
{/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;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.inline-icon) {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 0 0.15em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { ProgressLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-progress.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: ProgressLine;
|
|
||||||
inline?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { line, inline = false }: Props = $props();
|
|
||||||
|
|
||||||
let containerWidth = $state(0);
|
|
||||||
const charWidth = 9.6; // Approximate width of a character in monospace font (adjust as needed)
|
|
||||||
|
|
||||||
const progress = $derived(Math.min(100, Math.max(0, line.progress ?? 0)));
|
|
||||||
const label = $derived(line.progressLabel || `${progress}%`);
|
|
||||||
const contentSegments = $derived(
|
|
||||||
parseColorText(line.content, $themeColors.colorMap),
|
|
||||||
);
|
|
||||||
const labelSegments = $derived(
|
|
||||||
parseColorText(label, $themeColors.colorMap),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate how many characters fit in the bar
|
|
||||||
const charCapacity = $derived(
|
|
||||||
Math.max(10, Math.floor((containerWidth - 30) / charWidth)),
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-progress"
|
|
||||||
class:inline
|
|
||||||
class:text-style={line.progressStyle === "text"}
|
|
||||||
style="--progress-color: {getButtonStyle(line.style)}"
|
|
||||||
bind:clientWidth={containerWidth}
|
|
||||||
>
|
|
||||||
{#if line.content}
|
|
||||||
<div class="progress-label">
|
|
||||||
{#each contentSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if line.progressStyle === "text"}
|
|
||||||
<div class="progress-text-bar">
|
|
||||||
<span class="bracket">[</span>
|
|
||||||
<span class="bar-content">
|
|
||||||
{#each Array(charCapacity) as _, i}
|
|
||||||
<span class="char"
|
|
||||||
>{i < Math.floor((progress / 100) * charCapacity)
|
|
||||||
? "#"
|
|
||||||
: "."}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
<span class="bracket">]</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="progress-bar"
|
|
||||||
class:continuous={line.progressStyle === "continuous"}
|
|
||||||
>
|
|
||||||
<div class="progress-fill" style="width: {progress}%">
|
|
||||||
<span class="progress-glow"></span>
|
|
||||||
</div>
|
|
||||||
{#if line.progressStyle !== "continuous"}
|
|
||||||
<div class="progress-blocks">
|
|
||||||
{#each Array(20) as _, i}
|
|
||||||
<span
|
|
||||||
class="block"
|
|
||||||
class:filled={i < Math.floor(progress / 5)}
|
|
||||||
></span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="progress-value">
|
|
||||||
{#each labelSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 12} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-progress {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Inline progress styles */
|
|
||||||
.tui-progress.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin: 0;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress.inline .progress-label {
|
|
||||||
margin-bottom: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress.inline .progress-bar {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 80px;
|
|
||||||
height: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress.inline .progress-value {
|
|
||||||
margin-top: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.inline-icon) {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
margin: 0 0.15em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: inline progress becomes full-width */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tui-progress.inline {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress.inline .progress-bar {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Continuous Style */
|
|
||||||
.progress-bar.continuous .progress-blocks {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text Style */
|
|
||||||
.progress-text-bar {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--terminal-dim);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-text-bar .char {
|
|
||||||
color: var(--terminal-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-progress:not(.text-style) .progress-text-bar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-text-bar .bracket {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { RadioLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-radio.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: RadioLine;
|
|
||||||
inline?: boolean;
|
|
||||||
value?: string;
|
|
||||||
onchange?: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
value = $bindable(""),
|
|
||||||
onchange,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const labelSegments = $derived(
|
|
||||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
const options = $derived(line.radioOptions || []);
|
|
||||||
const isDisabled = $derived(line.inputDisabled || false);
|
|
||||||
const isHorizontal = $derived(line.radioHorizontal || false);
|
|
||||||
|
|
||||||
function handleSelect(optionValue: string) {
|
|
||||||
if (isDisabled) return;
|
|
||||||
value = optionValue;
|
|
||||||
onchange?.(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent, optionValue: string) {
|
|
||||||
if (e.key === " " || e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSelect(optionValue);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRadioSymbol(selected: boolean): string {
|
|
||||||
return selected ? "(●)" : "( )";
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-radio-group"
|
|
||||||
class:inline
|
|
||||||
class:disabled={isDisabled}
|
|
||||||
style="--radio-color: {getButtonStyle(line.style)}"
|
|
||||||
role="radiogroup"
|
|
||||||
>
|
|
||||||
{#if line.content}
|
|
||||||
<div class="radio-label">
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
|
||||||
{/if}
|
|
||||||
{#each labelSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="radio-options" class:horizontal={isHorizontal}>
|
|
||||||
{#each options as option}
|
|
||||||
{@const isSelected = value === option.value}
|
|
||||||
{@const optionSegments = parseColorText(
|
|
||||||
option.label,
|
|
||||||
$themeColors.colorMap,
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
class="radio-option"
|
|
||||||
class:selected={isSelected}
|
|
||||||
class:option-disabled={option.disabled}
|
|
||||||
role="radio"
|
|
||||||
aria-checked={isSelected}
|
|
||||||
aria-disabled={isDisabled || option.disabled}
|
|
||||||
tabindex={isDisabled || option.disabled ? -1 : 0}
|
|
||||||
onclick={() => !option.disabled && handleSelect(option.value)}
|
|
||||||
onkeydown={(e) =>
|
|
||||||
!option.disabled && handleKeydown(e, option.value)}
|
|
||||||
>
|
|
||||||
<span class="radio-symbol">
|
|
||||||
{getRadioSymbol(isSelected)}
|
|
||||||
</span>
|
|
||||||
{#if option.icon}
|
|
||||||
<Icon icon={option.icon} width="14" class="option-icon" />
|
|
||||||
{/if}
|
|
||||||
<span class="option-label">
|
|
||||||
{#each optionSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon
|
|
||||||
icon={segment.icon}
|
|
||||||
width={segment.iconSize ?? 14}
|
|
||||||
class="inline-icon"
|
|
||||||
/>
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}
|
|
||||||
>{segment.text}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-radio-group {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-radio-group.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.75rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-radio-group.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--radio-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-options.horizontal {
|
|
||||||
flex-direction: row;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option:hover:not(.option-disabled) {
|
|
||||||
background: color-mix(in srgb, var(--radio-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option:focus-visible {
|
|
||||||
outline: 1px solid var(--radio-color);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option.option-disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-symbol {
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option.selected .radio-symbol {
|
|
||||||
color: var(--radio-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.option-icon) {
|
|
||||||
color: var(--radio-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.radio-option.option-disabled .option-label {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { SelectLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-select.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: SelectLine;
|
|
||||||
inline?: boolean;
|
|
||||||
value?: string;
|
|
||||||
onchange?: (value: string) => void;
|
|
||||||
onfocus?: () => void;
|
|
||||||
onblur?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
value = $bindable(""),
|
|
||||||
onchange,
|
|
||||||
onfocus,
|
|
||||||
onblur,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const labelSegments = $derived(
|
|
||||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
const options = $derived(line.selectOptions || []);
|
|
||||||
const placeholder = $derived(
|
|
||||||
line.inputPlaceholder || "Select an option...",
|
|
||||||
);
|
|
||||||
const isDisabled = $derived(line.inputDisabled || false);
|
|
||||||
const hasError = $derived(line.inputError);
|
|
||||||
const errorMessage = $derived(line.inputErrorMessage || "");
|
|
||||||
const searchable = $derived(line.selectSearchable || false);
|
|
||||||
|
|
||||||
let isOpen = $state(false);
|
|
||||||
let searchQuery = $state("");
|
|
||||||
let highlightedIndex = $state(0);
|
|
||||||
let selectRef: HTMLDivElement | undefined = $state();
|
|
||||||
|
|
||||||
const filteredOptions = $derived(
|
|
||||||
searchable && searchQuery
|
|
||||||
? options.filter(
|
|
||||||
(opt) =>
|
|
||||||
opt.label
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()) ||
|
|
||||||
opt.value
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(searchQuery.toLowerCase()),
|
|
||||||
)
|
|
||||||
: options,
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedOption = $derived(options.find((opt) => opt.value === value));
|
|
||||||
const displayValue = $derived(selectedOption?.label || "");
|
|
||||||
|
|
||||||
function handleToggle() {
|
|
||||||
if (isDisabled) return;
|
|
||||||
isOpen = !isOpen;
|
|
||||||
if (isOpen) {
|
|
||||||
highlightedIndex = 0;
|
|
||||||
searchQuery = "";
|
|
||||||
onfocus?.();
|
|
||||||
} else {
|
|
||||||
onblur?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelect(optionValue: string) {
|
|
||||||
if (isDisabled) return;
|
|
||||||
value = optionValue;
|
|
||||||
isOpen = false;
|
|
||||||
searchQuery = "";
|
|
||||||
onchange?.(value);
|
|
||||||
onblur?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (isDisabled) return;
|
|
||||||
|
|
||||||
switch (e.key) {
|
|
||||||
case "Enter":
|
|
||||||
case " ":
|
|
||||||
e.preventDefault();
|
|
||||||
if (isOpen && filteredOptions[highlightedIndex]) {
|
|
||||||
handleSelect(filteredOptions[highlightedIndex].value);
|
|
||||||
} else {
|
|
||||||
handleToggle();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "ArrowDown":
|
|
||||||
e.preventDefault();
|
|
||||||
if (!isOpen) {
|
|
||||||
isOpen = true;
|
|
||||||
} else {
|
|
||||||
highlightedIndex = Math.min(
|
|
||||||
highlightedIndex + 1,
|
|
||||||
filteredOptions.length - 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
e.preventDefault();
|
|
||||||
if (isOpen) {
|
|
||||||
highlightedIndex = Math.max(highlightedIndex - 1, 0);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "Escape":
|
|
||||||
isOpen = false;
|
|
||||||
searchQuery = "";
|
|
||||||
break;
|
|
||||||
case "Home":
|
|
||||||
if (isOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
highlightedIndex = 0;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "End":
|
|
||||||
if (isOpen) {
|
|
||||||
e.preventDefault();
|
|
||||||
highlightedIndex = filteredOptions.length - 1;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (selectRef && !selectRef.contains(e.target as Node)) {
|
|
||||||
isOpen = false;
|
|
||||||
searchQuery = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearchInput(e: Event) {
|
|
||||||
searchQuery = (e.target as HTMLInputElement).value;
|
|
||||||
highlightedIndex = 0;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onclick={handleClickOutside} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-select"
|
|
||||||
class:inline
|
|
||||||
class:open={isOpen}
|
|
||||||
class:error={hasError}
|
|
||||||
class:disabled={isDisabled}
|
|
||||||
style="--select-color: {getButtonStyle(line.style)}"
|
|
||||||
bind:this={selectRef}
|
|
||||||
>
|
|
||||||
{#if line.content}
|
|
||||||
<label class="select-label">
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
|
||||||
{/if}
|
|
||||||
{#each labelSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="select-trigger"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
aria-controls="select-listbox"
|
|
||||||
aria-haspopup="listbox"
|
|
||||||
aria-disabled={isDisabled}
|
|
||||||
tabindex={isDisabled ? -1 : 0}
|
|
||||||
onclick={handleToggle}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
>
|
|
||||||
<span class="select-prompt">❯</span>
|
|
||||||
<span class="select-value" class:placeholder={!selectedOption}>
|
|
||||||
{displayValue || placeholder}
|
|
||||||
</span>
|
|
||||||
<span class="select-arrow">{isOpen ? "▲" : "▼"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if isOpen}
|
|
||||||
<div class="select-dropdown" role="listbox" id="select-listbox">
|
|
||||||
{#if searchable}
|
|
||||||
<div class="select-search">
|
|
||||||
<Icon icon="mdi:magnify" width="14" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search..."
|
|
||||||
value={searchQuery}
|
|
||||||
oninput={handleSearchInput}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<div class="select-options">
|
|
||||||
{#each filteredOptions as option, i}
|
|
||||||
{@const optionSegments = parseColorText(option.label, $themeColors.colorMap)}
|
|
||||||
<div
|
|
||||||
class="select-option"
|
|
||||||
class:selected={value === option.value}
|
|
||||||
class:highlighted={i === highlightedIndex}
|
|
||||||
class:option-disabled={option.disabled}
|
|
||||||
role="option"
|
|
||||||
aria-selected={value === option.value}
|
|
||||||
aria-disabled={option.disabled}
|
|
||||||
tabindex={option.disabled ? -1 : 0}
|
|
||||||
onclick={() =>
|
|
||||||
!option.disabled && handleSelect(option.value)}
|
|
||||||
onkeydown={(e) =>
|
|
||||||
e.key === "Enter" &&
|
|
||||||
!option.disabled &&
|
|
||||||
handleSelect(option.value)}
|
|
||||||
onmouseenter={() => (highlightedIndex = i)}
|
|
||||||
>
|
|
||||||
{#if option.icon}
|
|
||||||
<Icon
|
|
||||||
icon={option.icon}
|
|
||||||
width="14"
|
|
||||||
class="option-icon"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
<span class="option-label">
|
|
||||||
{#each optionSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon
|
|
||||||
icon={segment.icon}
|
|
||||||
width={segment.iconSize ?? 14}
|
|
||||||
class="inline-icon"
|
|
||||||
/>
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}
|
|
||||||
>{segment.text}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{#if value === option.value}
|
|
||||||
<Icon
|
|
||||||
icon="mdi:check"
|
|
||||||
width="14"
|
|
||||||
class="check-icon"
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="select-empty">No options found</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if hasError && errorMessage}
|
|
||||||
<div class="select-error">
|
|
||||||
<Icon icon="mdi:alert-circle" width="12" />
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-select {
|
|
||||||
position: relative;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.open .select-trigger,
|
|
||||||
.select-trigger:focus-visible {
|
|
||||||
border-color: var(--select-color);
|
|
||||||
box-shadow: 0 0 0 1px
|
|
||||||
color-mix(in srgb, var(--select-color) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.error .select-trigger {
|
|
||||||
border-color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-select.disabled .select-trigger {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-prompt {
|
|
||||||
color: var(--select-color);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-value {
|
|
||||||
flex: 1;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-value.placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-arrow {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-dropdown {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
background: var(--terminal-bg);
|
|
||||||
border: 1px solid var(--select-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
||||||
z-index: 100;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-search {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-search input {
|
|
||||||
flex: 1;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-search input::placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-options {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option.highlighted {
|
|
||||||
background: color-mix(in srgb, var(--select-color) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option.selected {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-option.option-disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.option-icon) {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.check-icon) {
|
|
||||||
color: var(--select-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-empty {
|
|
||||||
padding: 0.75rem;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
color: #f38ba8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { TableLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-table.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: TableLine;
|
|
||||||
inline?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { line, inline = false }: Props = $props();
|
|
||||||
|
|
||||||
const headers = $derived(line.tableHeaders || []);
|
|
||||||
const rows = $derived(line.tableRows || []);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-table-wrapper"
|
|
||||||
class:inline
|
|
||||||
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,
|
|
||||||
$themeColors.colorMap,
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { TextareaLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-textarea.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: TextareaLine;
|
|
||||||
inline?: boolean;
|
|
||||||
value?: string;
|
|
||||||
oninput?: (value: string) => void;
|
|
||||||
onchange?: (value: string) => void;
|
|
||||||
onfocus?: () => void;
|
|
||||||
onblur?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
value = $bindable(""),
|
|
||||||
oninput,
|
|
||||||
onchange,
|
|
||||||
onfocus,
|
|
||||||
onblur,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const labelSegments = $derived(
|
|
||||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
const placeholder = $derived(line.inputPlaceholder || "");
|
|
||||||
const isDisabled = $derived(line.inputDisabled || false);
|
|
||||||
const hasError = $derived(line.inputError);
|
|
||||||
const errorMessage = $derived(line.inputErrorMessage || "");
|
|
||||||
const rows = $derived(line.textareaRows || 4);
|
|
||||||
const maxLength = $derived(line.textareaMaxLength);
|
|
||||||
|
|
||||||
let isFocused = $state(false);
|
|
||||||
let charCount = $state(value.length);
|
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
|
||||||
const target = e.target as HTMLTextAreaElement;
|
|
||||||
value = target.value;
|
|
||||||
charCount = value.length;
|
|
||||||
oninput?.(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(e: Event) {
|
|
||||||
const target = e.target as HTMLTextAreaElement;
|
|
||||||
onchange?.(target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFocus() {
|
|
||||||
isFocused = true;
|
|
||||||
onfocus?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
isFocused = false;
|
|
||||||
onblur?.();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-textarea"
|
|
||||||
class:inline
|
|
||||||
class:focused={isFocused}
|
|
||||||
class:error={hasError}
|
|
||||||
class:disabled={isDisabled}
|
|
||||||
style="--input-color: {getButtonStyle(line.style)}"
|
|
||||||
>
|
|
||||||
{#if line.content}
|
|
||||||
<label class="textarea-label">
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="14" class="label-icon" />
|
|
||||||
{/if}
|
|
||||||
{#each labelSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="textarea-wrapper">
|
|
||||||
<div class="line-numbers">
|
|
||||||
{#each Array(Math.max(rows, value.split("\n").length)) as _, i}
|
|
||||||
<span class="line-num">{i + 1}</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
{placeholder}
|
|
||||||
{rows}
|
|
||||||
maxlength={maxLength}
|
|
||||||
disabled={isDisabled}
|
|
||||||
bind:value
|
|
||||||
oninput={handleInput}
|
|
||||||
onchange={handleChange}
|
|
||||||
onfocus={handleFocus}
|
|
||||||
onblur={handleBlur}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="textarea-footer">
|
|
||||||
{#if hasError && errorMessage}
|
|
||||||
<div class="textarea-error">
|
|
||||||
<Icon icon="mdi:alert-circle" width="12" />
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if maxLength}
|
|
||||||
<div class="char-count" class:warning={charCount > maxLength * 0.9}>
|
|
||||||
{charCount}/{maxLength}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-textarea {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin: 0 0.5rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.label-icon) {
|
|
||||||
color: var(--input-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-wrapper {
|
|
||||||
display: flex;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.focused .textarea-wrapper {
|
|
||||||
border-color: var(--input-color);
|
|
||||||
box-shadow: 0 0 0 1px
|
|
||||||
color-mix(in srgb, var(--input-color) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.error .textarea-wrapper {
|
|
||||||
border-color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-textarea.disabled .textarea-wrapper {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-numbers {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 60%, black);
|
|
||||||
border-right: 1px solid var(--terminal-muted);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.line-num {
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
text-align: right;
|
|
||||||
min-width: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: var(--terminal-text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: 1.5;
|
|
||||||
resize: vertical;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea::placeholder {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-error {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: #f38ba8;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count {
|
|
||||||
margin-left: auto;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-count.warning {
|
|
||||||
color: #f9e2af;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Icon from "@iconify/svelte";
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { ToggleLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-toggle.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: ToggleLine;
|
|
||||||
inline?: boolean;
|
|
||||||
checked?: boolean;
|
|
||||||
onchange?: (checked: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
line,
|
|
||||||
inline = false,
|
|
||||||
checked = $bindable(false),
|
|
||||||
onchange,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const labelSegments = $derived(
|
|
||||||
line.content ? parseColorText(line.content, $themeColors.colorMap) : [],
|
|
||||||
);
|
|
||||||
const isDisabled = $derived(line.inputDisabled || false);
|
|
||||||
const onLabel = $derived(line.toggleOnLabel || "ON");
|
|
||||||
const offLabel = $derived(line.toggleOffLabel || "OFF");
|
|
||||||
const showLabels = $derived(line.toggleShowLabels !== false);
|
|
||||||
|
|
||||||
function handleToggle() {
|
|
||||||
if (isDisabled) return;
|
|
||||||
checked = !checked;
|
|
||||||
onchange?.(checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === " " || e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleToggle();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="tui-toggle"
|
|
||||||
class:inline
|
|
||||||
class:checked
|
|
||||||
class:disabled={isDisabled}
|
|
||||||
style="--toggle-color: {getButtonStyle(line.style)}"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
aria-disabled={isDisabled}
|
|
||||||
tabindex={isDisabled ? -1 : 0}
|
|
||||||
onclick={handleToggle}
|
|
||||||
onkeydown={handleKeydown}
|
|
||||||
>
|
|
||||||
{#if line.icon}
|
|
||||||
<Icon icon={line.icon} width="14" class="toggle-icon" />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if line.content}
|
|
||||||
<span class="toggle-label">
|
|
||||||
{#each labelSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width={segment.iconSize ?? 14} class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="toggle-track">
|
|
||||||
{#if showLabels}
|
|
||||||
<span class="toggle-off-label">{offLabel}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="toggle-switch">
|
|
||||||
<span class="toggle-knob">{checked ? "●" : "○"}</span>
|
|
||||||
</span>
|
|
||||||
{#if showLabels}
|
|
||||||
<span class="toggle-on-label">{onLabel}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.tui-toggle {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin: 0.35rem 0;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.inline {
|
|
||||||
display: inline-flex;
|
|
||||||
margin: 0 0.75rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle:hover:not(.disabled) {
|
|
||||||
background: color-mix(in srgb, var(--toggle-color) 10%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle:focus-visible {
|
|
||||||
outline: 1px solid var(--toggle-color);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.toggle-icon) {
|
|
||||||
color: var(--toggle-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-track {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: color-mix(in srgb, var(--terminal-bg) 80%, black);
|
|
||||||
border: 1px solid var(--terminal-muted);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
transition: all 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.checked .toggle-track {
|
|
||||||
border-color: var(--toggle-color);
|
|
||||||
background: color-mix(in srgb, var(--toggle-color) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-off-label,
|
|
||||||
.toggle-on-label {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-weight: bold;
|
|
||||||
text-transform: uppercase;
|
|
||||||
min-width: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
transition: color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle:not(.checked) .toggle-off-label {
|
|
||||||
color: var(--terminal-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.checked .toggle-on-label {
|
|
||||||
color: var(--toggle-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-switch {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.5rem;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-knob {
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
transform: translateX(-0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-toggle.checked .toggle-knob {
|
|
||||||
color: var(--toggle-color);
|
|
||||||
transform: translateX(0.5rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile: inline toggles become full-width */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.tui-toggle.inline {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0.35rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getButtonStyle, parseColorText, getSegmentStyle } from "./utils";
|
|
||||||
import { themeColors } from "$lib/stores/theme";
|
|
||||||
import type { TooltipLine } from "./types";
|
|
||||||
import "$lib/assets/css/tui-tooltip.css";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
line: TooltipLine;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { line }: Props = $props();
|
|
||||||
|
|
||||||
let showTooltip = $state(false);
|
|
||||||
let triggerEl: HTMLSpanElement | undefined = $state();
|
|
||||||
let tooltipStyle = $state("");
|
|
||||||
|
|
||||||
const contentSegments = $derived(parseColorText(line.content, $themeColors.colorMap));
|
|
||||||
const position = $derived(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}
|
|
||||||
onmouseenter={handleMouseEnter}
|
|
||||||
onmouseleave={handleMouseLeave}
|
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
onfocus={handleMouseEnter}
|
|
||||||
onblur={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>
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
/**
|
|
||||||
* Terminal API Factory
|
|
||||||
*
|
|
||||||
* Creates a reactive API for manipulating terminal content programmatically.
|
|
||||||
* Use this with TerminalTUI's bindable `terminal` prop.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { TerminalLine, ParsedLine, DisplayedLine, TerminalAPI } from './types';
|
|
||||||
import { parseColorText, getPlainText } from './utils';
|
|
||||||
import type { ThemeColorMap } from './utils';
|
|
||||||
|
|
||||||
export interface TerminalState {
|
|
||||||
lines: TerminalLine[];
|
|
||||||
displayedLines: DisplayedLine[];
|
|
||||||
isTyping: boolean;
|
|
||||||
isComplete: boolean;
|
|
||||||
currentLineIndex: number;
|
|
||||||
selectedIndex: number;
|
|
||||||
skipRequested: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TerminalAPIOptions {
|
|
||||||
getState: () => TerminalState;
|
|
||||||
setState: (updates: Partial<TerminalState>) => void;
|
|
||||||
getColorMap: () => ThemeColorMap;
|
|
||||||
getBodyElement: () => HTMLDivElement | undefined;
|
|
||||||
typeText: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a single terminal line into segments
|
|
||||||
*/
|
|
||||||
export function parseLine(line: TerminalLine, colorMap: ThemeColorMap): ParsedLine {
|
|
||||||
const segments = parseColorText(line.content, colorMap);
|
|
||||||
return {
|
|
||||||
line,
|
|
||||||
segments,
|
|
||||||
plainText: getPlainText(segments)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a terminal API instance
|
|
||||||
*/
|
|
||||||
export function createTerminalAPI(options: TerminalAPIOptions): TerminalAPI {
|
|
||||||
const { getState, setState, getColorMap, getBodyElement, typeText } = options;
|
|
||||||
|
|
||||||
function refreshDisplayedLines() {
|
|
||||||
const state = getState();
|
|
||||||
const colorMap = getColorMap();
|
|
||||||
// Only update lines that are already displayed to avoid forcing untyped lines to appear
|
|
||||||
const displayedLines = state.displayedLines.map((displayed, i) => {
|
|
||||||
const line = state.lines[i];
|
|
||||||
if (!line) return displayed; // Should not happen if state is consistent
|
|
||||||
|
|
||||||
const parsed = parseLine(line, colorMap);
|
|
||||||
return {
|
|
||||||
parsed,
|
|
||||||
charIndex: displayed.complete ? parsed.plainText.length : displayed.charIndex,
|
|
||||||
complete: displayed.complete,
|
|
||||||
showImage: displayed.showImage ?? (line.type === 'image')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setState({ displayedLines, lines: [...state.lines] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiScrollToBottom() {
|
|
||||||
const bodyElement = getBodyElement();
|
|
||||||
if (!bodyElement) return;
|
|
||||||
bodyElement.scrollTo({
|
|
||||||
top: bodyElement.scrollHeight,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function apiScrollToLine(index: number) {
|
|
||||||
const bodyElement = getBodyElement();
|
|
||||||
if (!bodyElement) return;
|
|
||||||
const lineElements = bodyElement.querySelectorAll('.tui-line');
|
|
||||||
const target = lineElements[index] as HTMLElement | undefined;
|
|
||||||
if (target) {
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
clear: () => {
|
|
||||||
setState({
|
|
||||||
lines: [],
|
|
||||||
displayedLines: [],
|
|
||||||
isComplete: true,
|
|
||||||
isTyping: false
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
write: (line: TerminalLine) => {
|
|
||||||
const state = getState();
|
|
||||||
const colorMap = getColorMap();
|
|
||||||
const parsed = parseLine(line, colorMap);
|
|
||||||
setState({
|
|
||||||
lines: [...state.lines, line],
|
|
||||||
displayedLines: [...state.displayedLines, {
|
|
||||||
parsed,
|
|
||||||
charIndex: parsed.plainText.length,
|
|
||||||
complete: true,
|
|
||||||
showImage: line.type === 'image'
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
setTimeout(apiScrollToBottom, 10);
|
|
||||||
},
|
|
||||||
|
|
||||||
writeLines: (newLines: TerminalLine[]) => {
|
|
||||||
const state = getState();
|
|
||||||
const colorMap = getColorMap();
|
|
||||||
const newDisplayed = newLines.map(line => {
|
|
||||||
const parsed = parseLine(line, colorMap);
|
|
||||||
return {
|
|
||||||
parsed,
|
|
||||||
charIndex: parsed.plainText.length,
|
|
||||||
complete: true,
|
|
||||||
showImage: line.type === 'image'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setState({
|
|
||||||
lines: [...state.lines, ...newLines],
|
|
||||||
displayedLines: [...state.displayedLines, ...newDisplayed]
|
|
||||||
});
|
|
||||||
setTimeout(apiScrollToBottom, 10);
|
|
||||||
},
|
|
||||||
|
|
||||||
update: (index: number, updates: Partial<TerminalLine>) => {
|
|
||||||
const state = getState();
|
|
||||||
if (index < 0 || index >= state.lines.length) return;
|
|
||||||
// Cast to TerminalLine to allow partial updates across union types
|
|
||||||
state.lines[index] = { ...state.lines[index], ...updates } as TerminalLine;
|
|
||||||
refreshDisplayedLines();
|
|
||||||
},
|
|
||||||
|
|
||||||
updateContent: (index: number, content: string) => {
|
|
||||||
const state = getState();
|
|
||||||
if (index < 0 || index >= state.lines.length) return;
|
|
||||||
state.lines[index] = { ...state.lines[index], content };
|
|
||||||
refreshDisplayedLines();
|
|
||||||
},
|
|
||||||
|
|
||||||
insert: (index: number, line: TerminalLine) => {
|
|
||||||
const state = getState();
|
|
||||||
const clampedIndex = Math.max(0, Math.min(index, state.lines.length));
|
|
||||||
setState({
|
|
||||||
lines: [...state.lines.slice(0, clampedIndex), line, ...state.lines.slice(clampedIndex)]
|
|
||||||
});
|
|
||||||
refreshDisplayedLines();
|
|
||||||
},
|
|
||||||
|
|
||||||
remove: (index: number) => {
|
|
||||||
const state = getState();
|
|
||||||
if (index < 0 || index >= state.lines.length) return;
|
|
||||||
setState({
|
|
||||||
lines: [...state.lines.slice(0, index), ...state.lines.slice(index + 1)]
|
|
||||||
});
|
|
||||||
refreshDisplayedLines();
|
|
||||||
},
|
|
||||||
|
|
||||||
removeRange: (startIndex: number, count: number) => {
|
|
||||||
const state = getState();
|
|
||||||
if (startIndex < 0 || startIndex >= state.lines.length) return;
|
|
||||||
setState({
|
|
||||||
lines: [...state.lines.slice(0, startIndex), ...state.lines.slice(startIndex + count)]
|
|
||||||
});
|
|
||||||
refreshDisplayedLines();
|
|
||||||
},
|
|
||||||
|
|
||||||
setLines: (newLines: TerminalLine[]) => {
|
|
||||||
setState({ lines: [...newLines] });
|
|
||||||
refreshDisplayedLines();
|
|
||||||
},
|
|
||||||
|
|
||||||
getLineCount: () => getState().lines.length,
|
|
||||||
|
|
||||||
getLine: (index: number) => getState().lines[index],
|
|
||||||
|
|
||||||
getLines: () => [...getState().lines],
|
|
||||||
|
|
||||||
findById: (id: string) => getState().lines.findIndex(line => line.id === id),
|
|
||||||
|
|
||||||
updateById: (id: string, updates: Partial<TerminalLine>) => {
|
|
||||||
const state = getState();
|
|
||||||
const index = state.lines.findIndex(line => line.id === id);
|
|
||||||
if (index !== -1) {
|
|
||||||
// Cast to TerminalLine to allow partial updates across union types
|
|
||||||
state.lines[index] = { ...state.lines[index], ...updates } as TerminalLine;
|
|
||||||
refreshDisplayedLines();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeById: (id: string) => {
|
|
||||||
const state = getState();
|
|
||||||
const index = state.lines.findIndex(line => line.id === id);
|
|
||||||
if (index !== -1) {
|
|
||||||
setState({
|
|
||||||
lines: [...state.lines.slice(0, index), ...state.lines.slice(index + 1)]
|
|
||||||
});
|
|
||||||
refreshDisplayedLines();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
scrollToBottom: apiScrollToBottom,
|
|
||||||
|
|
||||||
scrollToLine: apiScrollToLine,
|
|
||||||
|
|
||||||
isAnimating: () => getState().isTyping,
|
|
||||||
|
|
||||||
skip: () => {
|
|
||||||
const state = getState();
|
|
||||||
if (!state.isTyping) return;
|
|
||||||
|
|
||||||
const colorMap = getColorMap();
|
|
||||||
const displayedLines = state.lines.map(line => {
|
|
||||||
const parsed = parseLine(line, colorMap);
|
|
||||||
return {
|
|
||||||
parsed,
|
|
||||||
charIndex: parsed.plainText.length,
|
|
||||||
complete: true,
|
|
||||||
showImage: line.type === 'image'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setState({
|
|
||||||
skipRequested: true,
|
|
||||||
displayedLines,
|
|
||||||
isTyping: false,
|
|
||||||
isComplete: true,
|
|
||||||
currentLineIndex: state.lines.length - 1
|
|
||||||
});
|
|
||||||
|
|
||||||
apiScrollToBottom();
|
|
||||||
},
|
|
||||||
|
|
||||||
restart: () => {
|
|
||||||
setState({
|
|
||||||
displayedLines: [],
|
|
||||||
currentLineIndex: 0,
|
|
||||||
isTyping: false,
|
|
||||||
isComplete: false,
|
|
||||||
skipRequested: false,
|
|
||||||
selectedIndex: -1
|
|
||||||
});
|
|
||||||
typeText();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user