Rewrite website as a Hyprland desktop
This commit is contained in:
1 parent
7c52779cf6
commit
3065ad6092
197 files changed
+5609
-17779
No files matched your search
@@ -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 { renderMarkdown } from './markdown';
|
||||
|
||||
export interface BlogPost {
|
||||
export interface BlogPostSummary {
|
||||
slug: string;
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
excerpt: string;
|
||||
html: string;
|
||||
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,
|
||||
query: '?raw',
|
||||
import: 'default'
|
||||
@@ -24,8 +32,8 @@ function slugFromPath(path: string): string {
|
||||
}
|
||||
|
||||
function estimateReadingTime(content: string): number {
|
||||
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
return Math.max(1, Math.round(words / 200));
|
||||
const wordCount = content.trim().split(/\s+/).filter(Boolean).length;
|
||||
return Math.max(1, Math.round(wordCount / 200));
|
||||
}
|
||||
|
||||
function buildPost(path: string, raw: string): BlogPost {
|
||||
@@ -35,7 +43,7 @@ function buildPost(path: string, raw: string): BlogPost {
|
||||
return {
|
||||
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) || '',
|
||||
tags: (data.tags as string[]) || [],
|
||||
excerpt: (data.excerpt as string) || '',
|
||||
@@ -46,27 +54,21 @@ function buildPost(path: string, raw: string): BlogPost {
|
||||
|
||||
const allPosts: BlogPost[] = Object.entries(postFiles)
|
||||
.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[] {
|
||||
return allPosts;
|
||||
}
|
||||
|
||||
export function getPostSummaries(): BlogPostSummary[] {
|
||||
return allPosts.map(({ html, ...summary }) => summary);
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): BlogPost | undefined {
|
||||
return allPosts.find((post) => post.slug === slug);
|
||||
}
|
||||
|
||||
export interface BlogPostNav {
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AdjacentPosts {
|
||||
previous: BlogPostNav | null;
|
||||
next: BlogPostNav | null;
|
||||
}
|
||||
|
||||
export function getAdjacentPosts(slug: string): AdjacentPosts {
|
||||
export function getAdjacentPosts(slug: string): { previous: BlogPostLink | null; next: BlogPostLink | null } {
|
||||
const index = allPosts.findIndex((post) => post.slug === slug);
|
||||
if (index === -1) return { previous: null, next: null };
|
||||
|
||||
@@ -80,9 +82,5 @@ export function getAdjacentPosts(slug: string): AdjacentPosts {
|
||||
}
|
||||
|
||||
export function getAllTags(): string[] {
|
||||
const tags = new Set<string>();
|
||||
for (const post of allPosts) {
|
||||
for (const tag of post.tags) tags.add(tag);
|
||||
}
|
||||
return Array.from(tags).sort();
|
||||
return [...new Set(allPosts.flatMap((post) => post.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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import type { TerminalAPI, TerminalLine } from './types';
|
||||
import { user } from '$lib/config';
|
||||
|
||||
export interface CommandContext {
|
||||
terminal: TerminalAPI;
|
||||
navigate: (path: string, external?: boolean) => void;
|
||||
toggleMode: () => void;
|
||||
setColorTheme: (name: string) => void;
|
||||
getMode: () => string;
|
||||
getColorTheme: () => string;
|
||||
themeNames: string[];
|
||||
user: typeof user;
|
||||
skills: Record<string, string[]>;
|
||||
navigation: Array<{ name: string; path: string; external?: boolean }>;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string;
|
||||
description: string;
|
||||
usage?: string;
|
||||
aliases?: string[];
|
||||
hidden?: boolean;
|
||||
run: (args: string[], ctx: CommandContext) => void;
|
||||
}
|
||||
|
||||
const out = (content: string): TerminalLine => ({ type: 'output', content });
|
||||
const ok = (content: string): TerminalLine => ({ type: 'success', content });
|
||||
const err = (content: string): TerminalLine => ({ type: 'error', content });
|
||||
const info = (content: string): TerminalLine => ({ type: 'info', content });
|
||||
const blank = (): TerminalLine => ({ type: 'blank', content: '' });
|
||||
|
||||
interface PageNode {
|
||||
name: string;
|
||||
path: string;
|
||||
children?: PageNode[];
|
||||
}
|
||||
|
||||
const pageTree: PageNode[] = [
|
||||
{ name: 'home', path: '/' },
|
||||
{ name: 'about', path: '/about' },
|
||||
{ name: 'portfolio', path: '/portfolio' },
|
||||
{
|
||||
name: 'projects',
|
||||
path: '/projects',
|
||||
children: [
|
||||
{ name: 'opensource', path: '/projects/opensource' },
|
||||
{ name: 'packages', path: '/projects/packages' },
|
||||
{ name: 'hackathons', path: '/projects/hackathons' },
|
||||
{ name: 'research', path: '/projects/research' }
|
||||
]
|
||||
},
|
||||
{ name: 'models', path: '/models' },
|
||||
{ name: 'components', path: '/components' },
|
||||
{ name: 'links', path: '/links' },
|
||||
{ name: 'blog', path: '/blog' }
|
||||
];
|
||||
|
||||
const pageRoutes: Record<string, string> = pageTree.reduce((routes, node) => {
|
||||
routes[node.name] = node.path;
|
||||
node.children?.forEach((child) => {
|
||||
routes[child.name] = child.path;
|
||||
});
|
||||
return routes;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
function resolvePagePath(target: string): string | undefined {
|
||||
if (pageRoutes[target] !== undefined) return pageRoutes[target];
|
||||
|
||||
const segments = target.split('/').filter(Boolean);
|
||||
if (segments.length < 2) return undefined;
|
||||
|
||||
let nodes: PageNode[] = pageTree;
|
||||
let path: string | undefined;
|
||||
for (const segment of segments) {
|
||||
const node = nodes.find((candidate) => candidate.name === segment);
|
||||
if (!node) return undefined;
|
||||
path = node.path;
|
||||
nodes = node.children ?? [];
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function cowsay(message: string): string[] {
|
||||
const text = message || 'Moo?';
|
||||
const border = '-'.repeat(text.length + 2);
|
||||
return [
|
||||
` ${border}`,
|
||||
`< ${text} >`,
|
||||
` ${border}`,
|
||||
' \\ ^__^',
|
||||
' \\ (oo)\\_______',
|
||||
' (__)\\ )\\/\\',
|
||||
' ||----w |',
|
||||
' || ||'
|
||||
];
|
||||
}
|
||||
|
||||
const baseCommands: Command[] = [
|
||||
{
|
||||
name: 'help',
|
||||
aliases: ['h', '?'],
|
||||
description: 'List all available commands',
|
||||
run: (_args, ctx) => {
|
||||
const rows = commands
|
||||
.filter((command) => !command.hidden)
|
||||
.map((command) => [
|
||||
`(&primary,bold)${command.name}(&)` +
|
||||
(command.aliases?.length ? ` (&muted)(${command.aliases.join(', ')})(&)` : ''),
|
||||
command.description
|
||||
]);
|
||||
ctx.terminal.write({
|
||||
type: 'table',
|
||||
content: 'Available Commands',
|
||||
tableHeaders: ['Command', 'Description'],
|
||||
tableRows: rows,
|
||||
style: 'accent'
|
||||
} as TerminalLine);
|
||||
ctx.terminal.write(info('Tip: try (&accent)neofetch(&), (&accent)cowsay(&), or (&accent)cd projects(&)'));
|
||||
ctx.terminal.write(blank());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
aliases: ['cls'],
|
||||
description: 'Clear the terminal',
|
||||
run: (_args, ctx) => ctx.terminal.clear()
|
||||
},
|
||||
{
|
||||
name: 'whoami',
|
||||
description: 'Show a short bio',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.writeLines([
|
||||
out(`(&primary,bold)${ctx.user.displayname}(&) (&muted)— ${ctx.user.title}(&)`),
|
||||
out(`(&muted)${ctx.user.bio}(&)`),
|
||||
blank()
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'neofetch',
|
||||
aliases: ['banner'],
|
||||
description: 'Display system info',
|
||||
run: (_args, ctx) => {
|
||||
const launched = new Date('2020-01-01T00:00:00Z');
|
||||
const days = Math.floor((Date.now() - launched.getTime()) / 86400000);
|
||||
const infoLines: TerminalLine[] = [
|
||||
out(`(&accent,bold)${ctx.user.username}(&)(&muted)@(&)(&primary,bold)${ctx.user.hostname}(&)`),
|
||||
out('(&muted)------------------------------(&)'),
|
||||
out(`(&blue,bold)Name(&): ${ctx.user.displayname}`),
|
||||
out(`(&blue,bold)Title(&): ${ctx.user.title}`),
|
||||
out(`(&blue,bold)Location(&): ${ctx.user.location}`),
|
||||
out(`(&blue,bold)Shell(&): tui-terminal`),
|
||||
out(`(&blue,bold)Theme(&): ${ctx.getColorTheme()} (${ctx.getMode()})`),
|
||||
out(`(&blue,bold)Uptime(&): ${days} days since launch`),
|
||||
out(`(&blue,bold)Languages(&): ${ctx.skills.languages.slice(0, 6).join(', ')}`),
|
||||
out('(&red)███(&)(&yellow)███(&)(&green)███(&)(&cyan)███(&)(&blue)███(&)(&magenta)███(&)')
|
||||
];
|
||||
ctx.terminal.writeLines([
|
||||
{
|
||||
type: 'image',
|
||||
content: '',
|
||||
image: ctx.user.avatar,
|
||||
imageAlt: ctx.user.displayname,
|
||||
imageWidth: 325,
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
content: '',
|
||||
inline: true,
|
||||
groupDirection: 'column',
|
||||
groupGap: '0',
|
||||
children: infoLines
|
||||
},
|
||||
blank()
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'skills',
|
||||
description: 'List skills by category',
|
||||
run: (_args, ctx) => {
|
||||
const order = ['languages', 'frameworks', 'tools', 'platforms', 'applications', 'databases', 'interests'];
|
||||
const lines: TerminalLine[] = [];
|
||||
for (const key of order) {
|
||||
const values = ctx.skills[key];
|
||||
if (!values?.length) continue;
|
||||
lines.push(info(`(&blue,bold)${key}(&)`));
|
||||
lines.push(out(' ' + values.map((value) => `(&cyan)${value}(&)`).join(' (&muted)•(&) ')));
|
||||
}
|
||||
lines.push(blank());
|
||||
ctx.terminal.writeLines(lines);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'contact',
|
||||
aliases: ['socials'],
|
||||
description: 'Show social links',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.write(info('(&accent,bold)Find me online:(&)'));
|
||||
ctx.terminal.writeLines(
|
||||
ctx.user.socials.map((social) => ({
|
||||
type: 'button',
|
||||
content: social.name,
|
||||
icon: social.icon,
|
||||
href: social.link,
|
||||
style: 'primary',
|
||||
inline: true
|
||||
})) as TerminalLine[]
|
||||
);
|
||||
ctx.terminal.write(blank());
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
description: 'Show contact email',
|
||||
run: (_args, ctx) => {
|
||||
ctx.terminal.write(out(`(&accent)${ctx.user.email}(&)`));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'cd',
|
||||
aliases: ['goto', 'open'],
|
||||
description: 'Navigate to a page',
|
||||
usage: 'cd <page> (e.g. projects/opensource)',
|
||||
run: (args, ctx) => {
|
||||
const target = (args[0] || 'home').toLowerCase().replace(/^\/+|\/+$/g, '');
|
||||
const navItem = ctx.navigation.find((item) => item.name.toLowerCase() === target);
|
||||
if (navItem) {
|
||||
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||
ctx.navigate(navItem.path, navItem.external);
|
||||
return;
|
||||
}
|
||||
const resolvedPath = resolvePagePath(target);
|
||||
if (resolvedPath !== undefined) {
|
||||
ctx.terminal.write(info(`Navigating to (&accent)${target}(&)...`));
|
||||
ctx.navigate(resolvedPath);
|
||||
return;
|
||||
}
|
||||
ctx.terminal.write(err(`No such page: ${target}. Try (&bold)ls(&).`));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'ls',
|
||||
aliases: ['dir'],
|
||||
description: 'List the pages you can visit',
|
||||
run: (_args, ctx) => {
|
||||
const lines: TerminalLine[] = [];
|
||||
|
||||
pageTree.forEach((node) => {
|
||||
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${node.name}(&)/`));
|
||||
node.children?.forEach((child, i) => {
|
||||
const branch = i === node.children!.length - 1 ? '└──' : '├──';
|
||||
lines.push(out(`(&muted) ${branch}(&) (&primary,bold)${child.name}(&)/`));
|
||||
});
|
||||
});
|
||||
|
||||
ctx.navigation.forEach((item) => {
|
||||
if (item.external && pageRoutes[item.name] === undefined) {
|
||||
lines.push(out(`(&blue)drwxr-xr-x(&) (&primary,bold)${item.name}(&)/`));
|
||||
}
|
||||
});
|
||||
|
||||
ctx.terminal.writeLines(lines);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
description: 'List or set the color theme',
|
||||
usage: 'theme [name]',
|
||||
run: (args, ctx) => {
|
||||
if (!args.length) {
|
||||
ctx.terminal.write(out('Themes: ' + ctx.themeNames.map((name) => `(&blue)${name}(&)`).join(' ')));
|
||||
ctx.terminal.write(info(`Current: (&accent)${ctx.getColorTheme()}(&)`));
|
||||
return;
|
||||
}
|
||||
const name = args[0].toLowerCase();
|
||||
if (ctx.themeNames.includes(name)) {
|
||||
ctx.setColorTheme(name);
|
||||
ctx.terminal.write(ok(`Theme set to (&accent)${name}(&)`));
|
||||
} else {
|
||||
ctx.terminal.write(err(`Unknown theme: ${name}. Available: ${ctx.themeNames.join(', ')}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mode',
|
||||
description: 'Toggle or set dark/light mode',
|
||||
usage: 'mode [dark|light]',
|
||||
run: (args, ctx) => {
|
||||
const current = ctx.getMode();
|
||||
if (!args.length) {
|
||||
ctx.toggleMode();
|
||||
ctx.terminal.write(ok(`Switched to ${current === 'dark' ? 'light' : 'dark'} mode`));
|
||||
return;
|
||||
}
|
||||
const requested = args[0].toLowerCase();
|
||||
if (requested === 'dark' || requested === 'light') {
|
||||
if (requested !== current) ctx.toggleMode();
|
||||
ctx.terminal.write(ok(`${requested} mode active`));
|
||||
} else {
|
||||
ctx.terminal.write(err('Usage: mode [dark|light]'));
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'echo',
|
||||
description: 'Print text back',
|
||||
usage: 'echo <text>',
|
||||
run: (args, ctx) => ctx.terminal.write(out(args.join(' ')))
|
||||
},
|
||||
{
|
||||
name: 'cowsay',
|
||||
description: 'Make a cow say something',
|
||||
usage: 'cowsay <text>',
|
||||
run: (args, ctx) => {
|
||||
const lines = cowsay(args.join(' '));
|
||||
ctx.terminal.writeLines(lines.map((line) => out(`(&accent)${line}(&)`)));
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
description: 'Show the current date and time (Eastern)',
|
||||
run: (_args, ctx) => {
|
||||
const now = new Date();
|
||||
const easternString = now.toLocaleString('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
|
||||
const easternParts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).formatToParts(now);
|
||||
const part = (type: string) => Number(easternParts.find((p) => p.type === type)?.value);
|
||||
const easternDate = new Date(Date.UTC(part('year'), part('month') - 1, part('day')));
|
||||
const startOfYear = new Date(Date.UTC(part('year'), 0, 1));
|
||||
const dayOfYear = Math.floor((easternDate.getTime() - startOfYear.getTime()) / 86400000) + 1;
|
||||
|
||||
ctx.terminal.writeLines([
|
||||
out(easternString),
|
||||
info(`(&muted)Day ${dayOfYear} of ${part('year')}(&)`)
|
||||
]);
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'sudo',
|
||||
hidden: true,
|
||||
description: 'Elevated privileges',
|
||||
run: (_args, ctx) =>
|
||||
ctx.terminal.write(
|
||||
err(`(&error)Nice try.(&) ${ctx.user.username} is not in the sudoers file. This incident will be reported.`)
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const pageCommands: Command[] = Object.entries(pageRoutes).map(([name, path]) => ({
|
||||
name,
|
||||
description: `Go to the ${name} page`,
|
||||
hidden: true,
|
||||
run: (_args, ctx) => ctx.navigate(path)
|
||||
}));
|
||||
|
||||
const socialCommands: Command[] = user.socials.map((social) => ({
|
||||
name: social.name.toLowerCase(),
|
||||
description: `Open ${social.name}`,
|
||||
hidden: true,
|
||||
run: (_args, ctx) => ctx.navigate(social.link, true)
|
||||
}));
|
||||
|
||||
export const commands: Command[] = [...baseCommands, ...pageCommands, ...socialCommands];
|
||||
|
||||
function findCommand(name: string): Command | undefined {
|
||||
const lower = name.toLowerCase();
|
||||
return commands.find(
|
||||
(command) => command.name === lower || command.aliases?.some((alias) => alias.toLowerCase() === lower)
|
||||
);
|
||||
}
|
||||
|
||||
export function runCommand(input: string, ctx: CommandContext): void {
|
||||
const raw = input.trim();
|
||||
ctx.terminal.write({ type: 'command', content: raw });
|
||||
if (!raw) return;
|
||||
|
||||
const parts = raw.split(/\s+/);
|
||||
const name = parts[0];
|
||||
const args = parts.slice(1);
|
||||
const command = findCommand(name);
|
||||
|
||||
if (!command) {
|
||||
ctx.terminal.write(err(`command not found: (&bold)${name}(&) — type (&accent)help(&) for a list.`));
|
||||
return;
|
||||
}
|
||||
|
||||
command.run(args, ctx);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Terminal Keyboard Handler
|
||||
*
|
||||
* Handles keyboard navigation and interaction for the terminal.
|
||||
*/
|
||||
|
||||
import type { DisplayedLine } from './types';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
export interface KeyboardHandlerOptions {
|
||||
getIsTyping: () => boolean;
|
||||
getIsComplete: () => boolean;
|
||||
getInteractive: () => boolean;
|
||||
getButtonIndices: () => number[];
|
||||
getSelectedIndex: () => number;
|
||||
setSelectedIndex: (index: number) => void;
|
||||
getDisplayedLines: () => DisplayedLine[];
|
||||
getBodyElement: () => HTMLDivElement | undefined;
|
||||
skipAnimation: () => void;
|
||||
scrollMargin?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll selected button into view with margin for context
|
||||
*/
|
||||
export function scrollToSelected(
|
||||
bodyElement: HTMLDivElement | undefined,
|
||||
selectedIndex: number,
|
||||
displayedLines: DisplayedLine[],
|
||||
scrollMargin: number = 80
|
||||
): void {
|
||||
if (!bodyElement || selectedIndex < 0) return;
|
||||
|
||||
// Get all buttons in DOM order (including nested ones in groups)
|
||||
const allButtons = bodyElement.querySelectorAll('.tui-button');
|
||||
const selectedButton = allButtons[selectedIndex] as HTMLElement | undefined;
|
||||
|
||||
if (selectedButton && bodyElement) {
|
||||
const containerRect = bodyElement.getBoundingClientRect();
|
||||
const buttonRect = selectedButton.getBoundingClientRect();
|
||||
|
||||
if (buttonRect.top < containerRect.top + scrollMargin) {
|
||||
const scrollAmount = buttonRect.top - containerRect.top - scrollMargin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
} else if (buttonRect.bottom > containerRect.bottom - scrollMargin) {
|
||||
const scrollAmount = buttonRect.bottom - containerRect.bottom + scrollMargin;
|
||||
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle button/link click navigation
|
||||
*/
|
||||
export function handleNavigation(line: { action?: () => void; href?: string; external?: boolean } | undefined): void {
|
||||
if (!line) return;
|
||||
|
||||
if (line.action) {
|
||||
line.action();
|
||||
} else if (line.href) {
|
||||
const isExternal = line.external || line.href.startsWith('http://') || line.href.startsWith('https://');
|
||||
if (isExternal) {
|
||||
window.open(line.href, '_blank', 'noopener,noreferrer');
|
||||
} else {
|
||||
// Use SvelteKit's goto for client-side navigation (no page reload)
|
||||
goto(line.href);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard event handler for the terminal
|
||||
*/
|
||||
export function createKeyboardHandler(options: KeyboardHandlerOptions) {
|
||||
const {
|
||||
getIsTyping,
|
||||
getIsComplete,
|
||||
getInteractive,
|
||||
getButtonIndices,
|
||||
getSelectedIndex,
|
||||
setSelectedIndex,
|
||||
getDisplayedLines,
|
||||
getBodyElement,
|
||||
skipAnimation,
|
||||
scrollMargin = 80
|
||||
} = options;
|
||||
|
||||
return function handleKeydown(event: KeyboardEvent): void {
|
||||
// Ignore key events originating from editable fields (e.g. command input)
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip animation on Y key
|
||||
if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) {
|
||||
event.preventDefault();
|
||||
skipAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getInteractive() || !getIsComplete()) return;
|
||||
|
||||
const bodyElement = getBodyElement();
|
||||
if (!bodyElement) return;
|
||||
|
||||
// Get all buttons from the DOM (including nested ones in groups)
|
||||
const allButtons = bodyElement.querySelectorAll('.tui-button');
|
||||
const buttonCount = allButtons.length;
|
||||
if (buttonCount === 0) return;
|
||||
|
||||
const selectedIndex = getSelectedIndex();
|
||||
const displayedLines = getDisplayedLines();
|
||||
|
||||
if (event.key === 'ArrowDown' || event.key === 'j') {
|
||||
event.preventDefault();
|
||||
const nextIdx = selectedIndex < 0 ? 0 : (selectedIndex + 1) % buttonCount;
|
||||
setSelectedIndex(nextIdx);
|
||||
scrollToSelected(bodyElement, nextIdx, displayedLines, scrollMargin);
|
||||
// Update visual selection on buttons
|
||||
allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === nextIdx));
|
||||
} else if (event.key === 'ArrowUp' || event.key === 'k') {
|
||||
event.preventDefault();
|
||||
const prevIdx = selectedIndex < 0 ? buttonCount - 1 : (selectedIndex - 1 + buttonCount) % buttonCount;
|
||||
setSelectedIndex(prevIdx);
|
||||
scrollToSelected(bodyElement, prevIdx, displayedLines, scrollMargin);
|
||||
// Update visual selection on buttons
|
||||
allButtons.forEach((btn, i) => btn.classList.toggle('selected', i === prevIdx));
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < buttonCount) {
|
||||
const selectedButton = allButtons[selectedIndex] as HTMLElement;
|
||||
if (selectedButton) {
|
||||
// Read navigation data from button data attributes
|
||||
const href = selectedButton.dataset.href;
|
||||
const isExternal = selectedButton.dataset.external === 'true';
|
||||
|
||||
if (href) {
|
||||
handleNavigation({ href, external: isExternal });
|
||||
} else {
|
||||
// Fallback to click for buttons with actions (non-navigational)
|
||||
selectedButton.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to hash target (anchor link like #skills)
|
||||
*/
|
||||
export function scrollToHash(bodyElement: HTMLDivElement | undefined): void {
|
||||
if (!browser || !bodyElement) return;
|
||||
|
||||
const hash = window.location.hash;
|
||||
if (!hash) return;
|
||||
|
||||
const targetId = hash.slice(1); // Remove the #
|
||||
const targetElement = bodyElement.querySelector(`#${CSS.escape(targetId)}`);
|
||||
|
||||
if (targetElement) {
|
||||
// Small delay to ensure layout is complete
|
||||
setTimeout(() => {
|
||||
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Terminal Typing Animation Engine
|
||||
*
|
||||
* Handles the character-by-character typing animation for terminal lines.
|
||||
*/
|
||||
|
||||
import type { ParsedLine, DisplayedLine } from './types';
|
||||
import type { TerminalSettings } from '$lib/config';
|
||||
import { calculateTypeSpeed } from '$lib';
|
||||
|
||||
export interface TypingState {
|
||||
displayedLines: DisplayedLine[];
|
||||
currentLineIndex: number;
|
||||
isTyping: boolean;
|
||||
isComplete: boolean;
|
||||
skipRequested: boolean;
|
||||
selectedIndex: number;
|
||||
}
|
||||
|
||||
export interface TypingOptions {
|
||||
parsedLines: ParsedLine[];
|
||||
speedMultiplier: number;
|
||||
terminalSettings: TerminalSettings;
|
||||
buttonIndices: number[];
|
||||
interactive: boolean;
|
||||
autoscroll: boolean;
|
||||
getBodyElement: () => HTMLDivElement | undefined;
|
||||
getState: () => TypingState;
|
||||
setState: (updates: Partial<TypingState>) => void;
|
||||
onComplete?: () => void;
|
||||
scrollToHash: () => void;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll terminal body to bottom
|
||||
*/
|
||||
function scrollToBottom(bodyElement: HTMLDivElement | undefined, autoscroll: boolean) {
|
||||
if (!autoscroll || !bodyElement) return;
|
||||
bodyElement.scrollTo({
|
||||
top: bodyElement.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the typing animation for all lines
|
||||
*/
|
||||
export async function runTypingAnimation(options: TypingOptions): Promise<void> {
|
||||
const {
|
||||
parsedLines,
|
||||
speedMultiplier,
|
||||
terminalSettings,
|
||||
buttonIndices,
|
||||
interactive,
|
||||
autoscroll,
|
||||
getBodyElement,
|
||||
getState,
|
||||
setState,
|
||||
onComplete,
|
||||
scrollToHash
|
||||
} = options;
|
||||
|
||||
if (parsedLines.length === 0) return;
|
||||
|
||||
setState({ isTyping: true });
|
||||
|
||||
// Apply speed multiplier to start delay
|
||||
const startDelayMs = speedMultiplier === 0 ? 0 : terminalSettings.startDelay * speedMultiplier;
|
||||
await sleep(startDelayMs);
|
||||
if (getState().skipRequested) return;
|
||||
|
||||
for (let i = 0; i < parsedLines.length; i++) {
|
||||
const state = getState();
|
||||
if (state.skipRequested) return;
|
||||
|
||||
const parsed = parsedLines[i];
|
||||
const line = parsed.line;
|
||||
const plainLength = parsed.plainText.length;
|
||||
|
||||
// Add new line to displayed lines
|
||||
const currentDisplayed = [...state.displayedLines, { parsed, charIndex: 0, complete: false, showImage: false }];
|
||||
setState({ displayedLines: currentDisplayed, currentLineIndex: i });
|
||||
|
||||
if (line.delay && speedMultiplier > 0) {
|
||||
await sleep(line.delay * speedMultiplier);
|
||||
if (getState().skipRequested) return;
|
||||
}
|
||||
|
||||
const bodyElement = getBodyElement();
|
||||
|
||||
// Handle different line types
|
||||
if (line.type === 'image') {
|
||||
await sleep(speedMultiplier === 0 ? 0 : 100 * speedMultiplier);
|
||||
if (getState().skipRequested) return;
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: true }, getState, setState);
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
} else if (line.type === 'blank' || line.type === 'divider') {
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
|
||||
} else if (line.type === 'button') {
|
||||
// Buttons appear instantly
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
} else if (speedMultiplier === 0) {
|
||||
// Instant mode - no typing animation
|
||||
updateDisplayedLine(i, { parsed, charIndex: plainLength, complete: true, showImage: false }, getState, setState);
|
||||
if (i % 5 === 0) scrollToBottom(bodyElement, autoscroll);
|
||||
} else if (line.type === 'header') {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier * 0.25);
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (getState().skipRequested) return;
|
||||
updateDisplayedLine(i, {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
}, getState, setState);
|
||||
if (j < plainLength) await sleep(typeSpeed);
|
||||
}
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
} else {
|
||||
const typeSpeed = calculateTypeSpeed(plainLength, speedMultiplier);
|
||||
|
||||
for (let j = 0; j <= plainLength; j++) {
|
||||
if (getState().skipRequested) return;
|
||||
updateDisplayedLine(i, {
|
||||
parsed,
|
||||
charIndex: j,
|
||||
complete: j === plainLength,
|
||||
showImage: false
|
||||
}, getState, setState);
|
||||
|
||||
if (j % 10 === 0) scrollToBottom(bodyElement, autoscroll);
|
||||
|
||||
if (j < plainLength) {
|
||||
await sleep(typeSpeed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (getState().skipRequested) return;
|
||||
|
||||
// Mark line as complete
|
||||
const finalState = getState();
|
||||
const updatedLines = [...finalState.displayedLines];
|
||||
if (updatedLines[i]) {
|
||||
updatedLines[i].complete = true;
|
||||
setState({ displayedLines: updatedLines });
|
||||
}
|
||||
|
||||
scrollToBottom(bodyElement, autoscroll);
|
||||
|
||||
if (i < parsedLines.length - 1 && speedMultiplier > 0) {
|
||||
await sleep(terminalSettings.lineDelay * speedMultiplier);
|
||||
}
|
||||
}
|
||||
|
||||
setState({ isTyping: false, isComplete: true });
|
||||
|
||||
if (interactive && buttonIndices.length > 0) {
|
||||
setState({ selectedIndex: buttonIndices[0] });
|
||||
}
|
||||
|
||||
// Scroll to hash anchor if present in URL
|
||||
scrollToHash();
|
||||
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single displayed line at index
|
||||
*/
|
||||
function updateDisplayedLine(
|
||||
index: number,
|
||||
update: DisplayedLine,
|
||||
getState: () => TypingState,
|
||||
setState: (updates: Partial<TypingState>) => void
|
||||
) {
|
||||
const state = getState();
|
||||
const updatedLines = [...state.displayedLines];
|
||||
updatedLines[index] = update;
|
||||
setState({ displayedLines: updatedLines });
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip animation and show all content instantly
|
||||
*/
|
||||
export function skipTypingAnimation(
|
||||
parsedLines: ParsedLine[],
|
||||
buttonIndices: number[],
|
||||
interactive: boolean,
|
||||
getState: () => TypingState,
|
||||
setState: (updates: Partial<TypingState>) => void,
|
||||
getBodyElement: () => HTMLDivElement | undefined,
|
||||
autoscroll: boolean,
|
||||
onComplete?: () => void
|
||||
): void {
|
||||
const state = getState();
|
||||
if (!state.isTyping) return;
|
||||
|
||||
const displayedLines = parsedLines.map(parsed => ({
|
||||
parsed,
|
||||
charIndex: parsed.plainText.length,
|
||||
complete: true,
|
||||
showImage: parsed.line.type === 'image'
|
||||
}));
|
||||
|
||||
setState({
|
||||
skipRequested: true,
|
||||
displayedLines,
|
||||
isTyping: false,
|
||||
isComplete: true,
|
||||
currentLineIndex: parsedLines.length - 1,
|
||||
selectedIndex: interactive && buttonIndices.length > 0 ? buttonIndices[0] : -1
|
||||
});
|
||||
|
||||
scrollToBottom(getBodyElement(), autoscroll);
|
||||
onComplete?.();
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
import type { TextSegment } from './utils';
|
||||
import type { Card } from '$lib/config';
|
||||
|
||||
// Re-export TextSegment for convenience
|
||||
export type { TextSegment };
|
||||
|
||||
export type LineType =
|
||||
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
|
||||
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
|
||||
| 'card' | 'progress' | 'accordion' | 'table' | 'tooltip' | 'cardgrid'
|
||||
| 'input' | 'textarea' | 'checkbox' | 'radio' | 'select' | 'toggle'
|
||||
| 'group';
|
||||
|
||||
// Option type for radio and select components
|
||||
export interface FormOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Base interface for all terminal lines
|
||||
export interface BaseTerminalLine {
|
||||
type: LineType;
|
||||
content: string;
|
||||
id?: string;
|
||||
inline?: boolean;
|
||||
display?: 'flex' | 'block' | 'grid' | 'inline';
|
||||
delay?: number;
|
||||
style?: 'primary' | 'secondary' | 'accent' | 'warning' | 'error';
|
||||
mobile?: boolean;
|
||||
}
|
||||
|
||||
// Specific line types
|
||||
export interface CommandLine extends BaseTerminalLine {
|
||||
type: 'command';
|
||||
}
|
||||
|
||||
export interface OutputLine extends BaseTerminalLine {
|
||||
type: 'output';
|
||||
}
|
||||
|
||||
export interface PromptLine extends BaseTerminalLine {
|
||||
type: 'prompt';
|
||||
}
|
||||
|
||||
export interface ErrorLine extends BaseTerminalLine {
|
||||
type: 'error';
|
||||
}
|
||||
|
||||
export interface SuccessLine extends BaseTerminalLine {
|
||||
type: 'success';
|
||||
}
|
||||
|
||||
export interface InfoLine extends BaseTerminalLine {
|
||||
type: 'info';
|
||||
}
|
||||
|
||||
export interface WarningLine extends BaseTerminalLine {
|
||||
type: 'warning';
|
||||
}
|
||||
|
||||
export interface BlankLine extends BaseTerminalLine {
|
||||
type: 'blank';
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export interface DividerLine extends BaseTerminalLine {
|
||||
type: 'divider';
|
||||
}
|
||||
|
||||
export interface HeaderLine extends BaseTerminalLine {
|
||||
type: 'header';
|
||||
}
|
||||
|
||||
export interface ButtonLine extends BaseTerminalLine {
|
||||
type: 'button';
|
||||
action?: () => void;
|
||||
href?: string;
|
||||
icon?: string;
|
||||
external?: boolean;
|
||||
textStyle?: 'start' | 'center' | 'end';
|
||||
border?: boolean;
|
||||
flex?: boolean;
|
||||
}
|
||||
|
||||
export interface LinkLine extends BaseTerminalLine {
|
||||
type: 'link';
|
||||
href: string;
|
||||
external?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ImageLine extends BaseTerminalLine {
|
||||
type: 'image';
|
||||
image: string;
|
||||
imageAlt?: string;
|
||||
imageWidth?: number;
|
||||
}
|
||||
|
||||
export interface CardLine extends BaseTerminalLine {
|
||||
type: 'card';
|
||||
cardTitle?: string;
|
||||
cardFooter?: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
imageAlt?: string;
|
||||
children?: TerminalLine[];
|
||||
cardWidth?: string | number;
|
||||
cardHeight?: string | number;
|
||||
cardFloat?: 'start' | 'center' | 'end';
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface ProgressLine extends BaseTerminalLine {
|
||||
type: 'progress';
|
||||
progress: number; // 0-100
|
||||
progressLabel?: string;
|
||||
progressStyle?: 'block' | 'continuous' | 'text';
|
||||
}
|
||||
|
||||
export interface AccordionLine extends BaseTerminalLine {
|
||||
type: 'accordion';
|
||||
accordionItems?: { title: string; content: string }[];
|
||||
children?: TerminalLine[];
|
||||
accordionOpen?: boolean;
|
||||
}
|
||||
|
||||
export interface TableLine extends BaseTerminalLine {
|
||||
type: 'table';
|
||||
tableHeaders: string[];
|
||||
tableRows: string[][];
|
||||
}
|
||||
|
||||
export interface TooltipLine extends BaseTerminalLine {
|
||||
type: 'tooltip';
|
||||
tooltipText: string;
|
||||
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export interface CardGridLine extends BaseTerminalLine {
|
||||
type: 'cardgrid';
|
||||
cards: Card[];
|
||||
}
|
||||
|
||||
export interface InputLine extends BaseTerminalLine {
|
||||
type: 'input';
|
||||
inputPlaceholder?: string;
|
||||
inputType?: 'text' | 'email' | 'password' | 'number' | 'url' | 'tel' | 'search';
|
||||
inputDisabled?: boolean;
|
||||
inputError?: boolean;
|
||||
inputErrorMessage?: string;
|
||||
inputPrefix?: string;
|
||||
inputSuffix?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface TextareaLine extends BaseTerminalLine {
|
||||
type: 'textarea';
|
||||
inputPlaceholder?: string;
|
||||
textareaRows?: number;
|
||||
textareaMaxLength?: number;
|
||||
inputDisabled?: boolean;
|
||||
inputError?: boolean;
|
||||
inputErrorMessage?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface CheckboxLine extends BaseTerminalLine {
|
||||
type: 'checkbox';
|
||||
checkboxIndeterminate?: boolean;
|
||||
inputDisabled?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface RadioLine extends BaseTerminalLine {
|
||||
type: 'radio';
|
||||
radioOptions: FormOption[];
|
||||
radioHorizontal?: boolean;
|
||||
inputDisabled?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface SelectLine extends BaseTerminalLine {
|
||||
type: 'select';
|
||||
selectOptions: FormOption[];
|
||||
selectSearchable?: boolean;
|
||||
inputPlaceholder?: string;
|
||||
inputDisabled?: boolean;
|
||||
inputError?: boolean;
|
||||
inputErrorMessage?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface ToggleLine extends BaseTerminalLine {
|
||||
type: 'toggle';
|
||||
toggleOnLabel?: string;
|
||||
toggleOffLabel?: string;
|
||||
toggleShowLabels?: boolean;
|
||||
inputDisabled?: boolean;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface GroupLine extends BaseTerminalLine {
|
||||
type: 'group';
|
||||
children: TerminalLine[];
|
||||
groupDirection?: 'row' | 'column';
|
||||
groupAlign?: 'start' | 'center' | 'end' | 'stretch';
|
||||
groupGap?: string;
|
||||
groupExpand?: boolean;
|
||||
}
|
||||
|
||||
// Discriminated union of all line types
|
||||
export type TerminalLine =
|
||||
| CommandLine | OutputLine | PromptLine | ErrorLine | SuccessLine | InfoLine | WarningLine
|
||||
| BlankLine | DividerLine | HeaderLine | ButtonLine | LinkLine | ImageLine
|
||||
| CardLine | ProgressLine | AccordionLine | TableLine | TooltipLine | CardGridLine
|
||||
| InputLine | TextareaLine | CheckboxLine | RadioLine | SelectLine | ToggleLine
|
||||
| GroupLine;
|
||||
|
||||
// Terminal API for reactive manipulation
|
||||
export interface TerminalAPI {
|
||||
/** Clear all lines from the terminal */
|
||||
clear: () => void;
|
||||
/** Write a new line to the terminal (appends) */
|
||||
write: (line: TerminalLine) => void;
|
||||
/** Write multiple lines to the terminal (appends) */
|
||||
writeLines: (lines: TerminalLine[]) => void;
|
||||
/** Update a specific line by index */
|
||||
update: (index: number, line: Partial<TerminalLine>) => void;
|
||||
/** Update a line's content by index */
|
||||
updateContent: (index: number, content: string) => void;
|
||||
/** Insert a line at a specific index */
|
||||
insert: (index: number, line: TerminalLine) => void;
|
||||
/** Remove a line by index */
|
||||
remove: (index: number) => void;
|
||||
/** Remove lines by range */
|
||||
removeRange: (startIndex: number, count: number) => void;
|
||||
/** Replace all lines */
|
||||
setLines: (lines: TerminalLine[]) => void;
|
||||
/** Get current line count */
|
||||
getLineCount: () => number;
|
||||
/** Get a line by index */
|
||||
getLine: (index: number) => TerminalLine | undefined;
|
||||
/** Get all lines */
|
||||
getLines: () => TerminalLine[];
|
||||
/** Find line index by id */
|
||||
findById: (id: string) => number;
|
||||
/** Update a line by its id */
|
||||
updateById: (id: string, line: Partial<TerminalLine>) => void;
|
||||
/** Remove a line by its id */
|
||||
removeById: (id: string) => void;
|
||||
/** Scroll to bottom */
|
||||
scrollToBottom: () => void;
|
||||
/** Scroll to a specific line index */
|
||||
scrollToLine: (index: number) => void;
|
||||
/** Check if terminal is currently animating */
|
||||
isAnimating: () => boolean;
|
||||
/** Skip current animation */
|
||||
skip: () => void;
|
||||
/** Restart the typing animation */
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
// Pre-parsed line with segments ready for rendering
|
||||
export interface ParsedLine {
|
||||
line: TerminalLine;
|
||||
segments: TextSegment[];
|
||||
plainText: string; // Plain text without color codes for length calculation
|
||||
}
|
||||
|
||||
// Display state for a line during typing animation
|
||||
export interface DisplayedLine {
|
||||
parsed: ParsedLine;
|
||||
charIndex: number; // How many plain-text characters to show
|
||||
complete: boolean;
|
||||
showImage: boolean;
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
// Shared utilities used by TUI components
|
||||
|
||||
export interface TextSegment {
|
||||
text: string;
|
||||
color?: string;
|
||||
background?: string;
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
strikethrough?: boolean;
|
||||
overline?: boolean;
|
||||
// For inline icons
|
||||
icon?: string;
|
||||
iconSize?: number;
|
||||
// For inline clickable text
|
||||
href?: string;
|
||||
action?: () => void;
|
||||
}
|
||||
|
||||
// Color maps for each theme
|
||||
export type ThemeColorMap = Record<string, string>;
|
||||
|
||||
// Default color map (fallback) - hex values as defaults, overwritten by theme colorMap at runtime
|
||||
export const defaultColorMap: ThemeColorMap = {
|
||||
// Basic colors
|
||||
'red': '#f38ba8',
|
||||
'green': '#a6e3a1',
|
||||
'yellow': '#f9e2af',
|
||||
'blue': '#89b4fa',
|
||||
'magenta': '#cba6f7',
|
||||
'cyan': '#94e2d5',
|
||||
'white': '#cdd6f4',
|
||||
'gray': '#6c7086',
|
||||
'orange': '#fab387',
|
||||
'pink': '#f5c2e7',
|
||||
'black': '#1e1e2e',
|
||||
'surface': '#313244',
|
||||
// Extended colors
|
||||
'teal': '#94e2d5',
|
||||
'sky': '#89dceb',
|
||||
'sapphire': '#74c7ec',
|
||||
'lavender': '#b4befe',
|
||||
'peach': '#fab387',
|
||||
'maroon': '#eba0ac',
|
||||
'mauve': '#cba6f7',
|
||||
'flamingo': '#f2cdcd',
|
||||
'rosewater': '#f5e0dc',
|
||||
// Semantic colors
|
||||
'primary': '#cba6f7',
|
||||
'accent': '#a6e3a1',
|
||||
'muted': '#6c7086',
|
||||
'error': '#f38ba8',
|
||||
'success': '#a6e3a1',
|
||||
'warning': '#f9e2af',
|
||||
'info': '#89b4fa',
|
||||
// Theme colors
|
||||
'text': '#cdd6f4',
|
||||
'textMuted': '#a6adc8',
|
||||
'background': '#1e1e2e',
|
||||
'backgroundLight': '#313244',
|
||||
'border': '#45475a',
|
||||
'terminal': '#1e1e2e',
|
||||
'terminalPrompt': '#cba6f7',
|
||||
'terminalUser': '#a6e3a1',
|
||||
'terminalPath': '#89b4fa',
|
||||
};
|
||||
|
||||
// Legacy alias for backwards compatibility
|
||||
export const colorMap = defaultColorMap;
|
||||
|
||||
// Text style keywords
|
||||
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
|
||||
|
||||
export function parseColorText(text: string, colors: ThemeColorMap = colorMap): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
// Match (&icon, iconName) patterns FIRST, then (&specs)content(&)
|
||||
// This prevents (&icon, ...) from being consumed as the start of a color block
|
||||
const regex = /\(&icon,\s*([^)]+)\)|\(&([^)]+)\)(.*?)\(&\)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ text: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
|
||||
// Check if this is an icon match (Group 1 is the icon name, optionally followed by a size)
|
||||
if (match[1]) {
|
||||
const [iconName, sizeArg] = match[1].split(',').map(part => part.trim());
|
||||
const iconSize = sizeArg ? Number(sizeArg) : undefined;
|
||||
segments.push({ text: '', icon: iconName, iconSize: iconSize && !isNaN(iconSize) ? iconSize : undefined });
|
||||
lastIndex = match.index + match[0].length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard text formatting (Group 2 is specs, Group 3 is content)
|
||||
const specs = match[2].split(',').map(s => s.trim().toLowerCase());
|
||||
const content = match[3];
|
||||
const segment: TextSegment = { text: content };
|
||||
|
||||
for (const spec of specs) {
|
||||
// Text styles
|
||||
if (spec === 'bold') segment.bold = true;
|
||||
else if (spec === 'dim') segment.dim = true;
|
||||
else if (spec === 'italic') segment.italic = true;
|
||||
else if (spec === 'underline') segment.underline = true;
|
||||
else if (spec === 'strikethrough' || spec === 'strike') segment.strikethrough = true;
|
||||
else if (spec === 'overline') segment.overline = true;
|
||||
// Background color (bg-colorname or bg-#hex)
|
||||
else if (spec.startsWith('bg-')) {
|
||||
const bgColor = spec.slice(3);
|
||||
if (colors[bgColor]) {
|
||||
segment.background = colors[bgColor];
|
||||
} else if (bgColor.startsWith('#')) {
|
||||
segment.background = bgColor;
|
||||
}
|
||||
}
|
||||
// Foreground color
|
||||
else if (colors[spec] && !textStyles.includes(spec)) {
|
||||
segment.color = colors[spec];
|
||||
} else if (spec.startsWith('#')) {
|
||||
segment.color = spec;
|
||||
}
|
||||
}
|
||||
|
||||
segments.push(segment);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ text: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
if (segments.length === 0) {
|
||||
segments.push({ text });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
// Get plain text from segments (for length calculation)
|
||||
export function getPlainText(segments: TextSegment[]): string {
|
||||
return segments.map(s => s.text).join('');
|
||||
}
|
||||
|
||||
// Get segments up to a certain character count (for typing animation)
|
||||
export function getSegmentsUpToChar(segments: TextSegment[], charCount: number): TextSegment[] {
|
||||
const result: TextSegment[] = [];
|
||||
let remaining = charCount;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (remaining <= 0) break;
|
||||
|
||||
if (segment.text.length <= remaining) {
|
||||
result.push(segment);
|
||||
remaining -= segment.text.length;
|
||||
} else {
|
||||
// Partial segment
|
||||
result.push({ ...segment, text: segment.text.slice(0, remaining) });
|
||||
remaining = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getSegmentStyle(segment: TextSegment): string {
|
||||
const styles: string[] = [];
|
||||
if (segment.color) styles.push(`color: ${segment.color}`);
|
||||
if (segment.background) styles.push(`background-color: ${segment.background}; padding: 0.1em 0.25em; border-radius: 3px`);
|
||||
if (segment.bold) styles.push('font-weight: bold');
|
||||
if (segment.dim) styles.push('opacity: 0.6');
|
||||
if (segment.italic) styles.push('font-style: italic');
|
||||
|
||||
// Combine text decorations
|
||||
const decorations: string[] = [];
|
||||
if (segment.underline) decorations.push('underline');
|
||||
if (segment.strikethrough) decorations.push('line-through');
|
||||
if (segment.overline) decorations.push('overline');
|
||||
if (decorations.length > 0) {
|
||||
styles.push(`text-decoration: ${decorations.join(' ')}`);
|
||||
}
|
||||
|
||||
return styles.join('; ');
|
||||
}
|
||||
|
||||
export function getLinePrefix(type: string): string {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return '✗ ';
|
||||
case 'success':
|
||||
return '✓ ';
|
||||
case 'warning':
|
||||
return '⚠ ';
|
||||
case 'info':
|
||||
return '› ';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getButtonStyle(style?: string): string {
|
||||
switch (style) {
|
||||
case 'primary':
|
||||
return 'var(--terminal-primary)';
|
||||
case 'accent':
|
||||
return 'var(--terminal-accent)';
|
||||
case 'warning':
|
||||
return '#f9e2af';
|
||||
case 'error':
|
||||
return '#f38ba8';
|
||||
default:
|
||||
return 'var(--terminal-text)';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseDimension(value: string | number | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value === 'number') {
|
||||
// If it's a decimal between 0 and 1, treat as percentage
|
||||
if (value > 0 && value <= 1) {
|
||||
return `${value * 100}%`;
|
||||
}
|
||||
return `${value}px`;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
// Handle fractions like "1/2", "1/3"
|
||||
if (value.includes('/')) {
|
||||
const [num, den] = value.split('/').map(Number);
|
||||
if (!isNaN(num) && !isNaN(den) && den !== 0) {
|
||||
return `${(num / den) * 100}%`;
|
||||
}
|
||||
}
|
||||
// Return as is if it already has a unit or is just a number string
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { isExternalUrl } from '#lib/desktop/actions';
|
||||
|
||||
let {
|
||||
href,
|
||||
icon,
|
||||
variant = 'outline',
|
||||
children
|
||||
}: { href: string; icon?: string; variant?: 'outline' | 'filled'; children: Snippet } = $props();
|
||||
|
||||
const external = $derived(isExternalUrl(href));
|
||||
</script>
|
||||
|
||||
<a
|
||||
class="link-button {variant}"
|
||||
{href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{#if icon}
|
||||
<Icon {icon} width="17" />
|
||||
{/if}
|
||||
{@render children()}
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.link-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.link-button:hover,
|
||||
.filled {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.filled:hover {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { profile } from '#lib/content';
|
||||
|
||||
let { command, title, subtitle, children }: { command: string; title?: string; subtitle?: string; children?: Snippet } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if title}
|
||||
<title>{title} | {profile.displayName}</title>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<meta name="description" content={subtitle} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<header class="page-header">
|
||||
<div class="command">
|
||||
<span class="prompt">{profile.username}@{profile.hostname}</span>
|
||||
<span class="path">~</span>
|
||||
<span class="symbol">$</span>
|
||||
<span>{command}</span>
|
||||
</div>
|
||||
{#if title}
|
||||
<h1>{title}</h1>
|
||||
{/if}
|
||||
{#if subtitle}
|
||||
<p class="subtitle">{subtitle}</p>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.command {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5ch;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.prompt {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.path {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.symbol {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { title, icon, href, children }: { title?: string; icon?: string; href?: string; children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? 'a' : 'article'}
|
||||
class="panel"
|
||||
class:linked={!!href}
|
||||
{href}
|
||||
target={href?.startsWith('http') ? '_blank' : undefined}
|
||||
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
||||
>
|
||||
{#if title}
|
||||
<header class="panel-title">
|
||||
{#if icon}
|
||||
<Icon {icon} width="16" />
|
||||
{/if}
|
||||
<span>{title}</span>
|
||||
</header>
|
||||
{/if}
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 22%, transparent);
|
||||
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
||||
}
|
||||
|
||||
.linked:hover {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { profile } from '#lib/content';
|
||||
|
||||
let { heading, subheading, text }: { heading: string; subheading: string; text: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="profile-intro">
|
||||
<img src={profile.avatar} alt={profile.displayName} />
|
||||
<div class="details">
|
||||
<h1>{heading}</h1>
|
||||
<div class="subheading">{subheading}</div>
|
||||
<p class="muted">{text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-intro {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 160px;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.subheading {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { title, id, children }: { title: string; id?: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<section class="section" {id}>
|
||||
<h2 class="section-title"><span>{title}</span></h2>
|
||||
{@render children()}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.section {
|
||||
margin: 1.75rem 0;
|
||||
scroll-margin-top: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.section-title::before,
|
||||
.section-title::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
background: color-mix(in srgb, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
let { sections }: { sections: { id: string; label: string; color: string }[] } = $props();
|
||||
|
||||
function jumpTo(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="section-nav">
|
||||
<span class="label">Links ></span>
|
||||
{#each sections as section (section.id)}
|
||||
<button style="--chip-color: var(--{section.color})" onclick={() => jumpTo(section.id)}>{section.label}</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.section-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.05rem 0.5rem;
|
||||
background: var(--chip-color);
|
||||
border: 1px solid var(--chip-color);
|
||||
color: var(--background);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: transparent;
|
||||
color: var(--chip-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let { label, colorIndex = 0 }: { label: string; colorIndex?: number } = $props();
|
||||
|
||||
const tagColors = ['blue', 'magenta', 'cyan', 'orange', 'green', 'yellow'];
|
||||
const color = $derived(tagColors[colorIndex % tagColors.length]);
|
||||
</script>
|
||||
|
||||
<span class="tag" style="--tag-color: var(--{color})">{label}</span>
|
||||
|
||||
<style>
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border: 1px solid var(--tag-color);
|
||||
color: var(--tag-color);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
// ============================================================================
|
||||
// CONFIGURATION - BARREL EXPORT
|
||||
// ============================================================================
|
||||
// This file re-exports all configuration from the modular config/ directory.
|
||||
// For backward compatibility, you can continue importing from '$lib/config'.
|
||||
//
|
||||
// The configuration is now split into smaller, focused files:
|
||||
// - config/user.ts → User profile and skills
|
||||
// - config/layout.ts → Layout, breakpoints, fonts, navbar, scrollbar
|
||||
// - config/theme.ts → Colors, animations, effects, loading screen
|
||||
// - config/content.ts → Projects, models, hackathon cards
|
||||
// - config/terminal.ts → Terminal settings, TUI styling, shortcuts
|
||||
// - config/navigation.ts → Navigation links, site metadata, page meta
|
||||
//
|
||||
// You can import directly from individual files for smaller bundles:
|
||||
// import { user } from '$lib/config/user';
|
||||
// import { colorPalette } from '$lib/config/theme';
|
||||
// ============================================================================
|
||||
|
||||
export * from './config/index';
|
||||
@@ -1,2 +0,0 @@
|
||||
export const blogName = 'Finding Out';
|
||||
export const blogTagline = 'My Engineering Blog';
|
||||
@@ -1,360 +0,0 @@
|
||||
// ============================================================================
|
||||
// PROJECTS
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenSourceProject {
|
||||
name: string;
|
||||
description: string;
|
||||
tech: string[];
|
||||
github?: string;
|
||||
live?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
|
||||
export const openSourceProjects: OpenSourceProject[] = [
|
||||
{
|
||||
name: 'TypstDrive',
|
||||
description: 'TypstDrive is a self-hosted collaborative web editor for Typst.',
|
||||
tech: ['Typst', 'Self-Hosted', 'Collaborative'],
|
||||
github: 'https://github.com/SirBlobby/TypstDrive',
|
||||
image: '/projects/typstdrive.png',
|
||||
},
|
||||
{
|
||||
name: 'Typst Desktop',
|
||||
description: 'Typst Desktop is a native desktop app for writing and compiling Typst documents offline.',
|
||||
tech: ['Typst', 'Desktop', 'Offline'],
|
||||
github: 'https://github.com/SirBlobby/typst-desktop',
|
||||
image: '/projects/typstdesktop.png',
|
||||
},
|
||||
{
|
||||
name: 'Pkit',
|
||||
description: 'CLI toolkit and utilities (Rust project) — small developer-focused CLI.',
|
||||
tech: ['Rust', 'CLI'],
|
||||
live: 'https://pkit.sirblob.co/',
|
||||
github: 'https://github.com/dead-projects-inc/pkit-cli',
|
||||
image: 'https://icons.veryicon.com/png/o/business/vscode-program-item-icon/rust-1.png',
|
||||
},
|
||||
{
|
||||
name: 'OBookLLM',
|
||||
description: "OBookLLM is a powerful, self-hosted, offline-capable alternative to NotebookLLM.",
|
||||
tech: ['Python', 'LLM', 'AI', 'NotebookLLM'],
|
||||
github: 'https://github.com/SirBlobby/OBookLLM',
|
||||
image: 'https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg',
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// PACKAGES
|
||||
// ============================================================================
|
||||
|
||||
|
||||
export interface PackageProject {
|
||||
name: string;
|
||||
description: string;
|
||||
tech: string[];
|
||||
github?: string;
|
||||
live?: string;
|
||||
image?: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export const packageProjects: PackageProject[] = [
|
||||
{
|
||||
name: 'PokemonTCGAPI',
|
||||
description: 'UnOfficial NPM package wrapper for the PokemonTCG API.',
|
||||
tech: ['TypeScript', 'Node.js'],
|
||||
live: 'https://www.npmjs.com/package/@bosstop/pokemontcgapi',
|
||||
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
name: 'MCSS TS API',
|
||||
description: 'TypeScript API for MC Server Soft services, published to NPM.',
|
||||
tech: ['TypeScript', 'Node.js'],
|
||||
live: 'https://www.npmjs.com/package/@mcserversoft/mcss-api',
|
||||
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
name: 'MCP Selenium',
|
||||
description: 'Selenium automation utilities for MCP workflows, packaged for reuse.',
|
||||
tech: ['TypeScript', 'Selenium'],
|
||||
live: 'https://www.npmjs.com/package/@sirblob/mcp-selenium',
|
||||
image: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/1200px-Npm-logo.svg.png',
|
||||
featured: true
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// 3D MODELS
|
||||
// ============================================================================
|
||||
|
||||
export interface Model3D {
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
modelUrl?: string; // URL to .glb/.gltf file for preview
|
||||
downloadUrl?: string;
|
||||
software: string[];
|
||||
polyCount?: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
export const models: Model3D[] = [
|
||||
{
|
||||
name: 'Bake Potato',
|
||||
description: 'A fun baked-potato 3D model for demos and testing.',
|
||||
thumbnail: '/models/BakePotato-thumb.png',
|
||||
modelUrl: '/models/BakePotato.glb',
|
||||
software: ['Blender'],
|
||||
polyCount: '1k tris',
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
name: 'Soda Can',
|
||||
description: 'A realistic soda can GLB used for materials/tests.',
|
||||
thumbnail: '/models/Soda-thumb.png',
|
||||
modelUrl: '/models/Soda.glb',
|
||||
software: ['Blender'],
|
||||
polyCount: '800 tris',
|
||||
featured: false
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// EXPERIENCE
|
||||
// ============================================================================
|
||||
|
||||
export interface Experience {
|
||||
role: string;
|
||||
organization: string;
|
||||
employmentType?: string;
|
||||
location?: string;
|
||||
period: string;
|
||||
description?: string;
|
||||
tech?: string[];
|
||||
awards?: string[];
|
||||
}
|
||||
|
||||
|
||||
export const experience: Experience[] = [
|
||||
{
|
||||
role: 'Research Assistant',
|
||||
organization: 'College of Engineering and Computing',
|
||||
employmentType: 'Part-time',
|
||||
period: 'May 2026 – Present · 3 mos'
|
||||
},
|
||||
{
|
||||
role: 'Software Engineering Lead',
|
||||
organization: 'Raytheon Autonomous Vehicle Competition',
|
||||
period: 'Dec 2025 – May 2026 · 6 mos',
|
||||
description:
|
||||
'Developing a collaborative autonomous system utilizing Python and ROS2 to coordinate communication and control as a UAV scouts and lands on a moving UGV. Engineering Gazebo simulation environments to validate computer vision algorithms for ArUco marker target identification and obstacle avoidance.',
|
||||
tech: ['Python', 'ROS2'],
|
||||
awards: ['1st Place Overall Winner']
|
||||
},
|
||||
{
|
||||
role: 'QA Automation Intern',
|
||||
organization: 'Deltek',
|
||||
employmentType: 'Internship',
|
||||
location: 'Hybrid',
|
||||
period: 'Jun 2025 – Aug 2025 · 3 mos',
|
||||
description:
|
||||
'Designed and implemented a workflow integrating AI into the QA automation pipeline, enhancing test efficiency for the GovWin IQ team. Researched Model Context Protocol (MCP) servers to improve automation, developed and published a public Selenium MCP package to NPM, enabling browser automation interactions with Agentic AI.',
|
||||
tech: ['Artificial Intelligence (AI)', 'TypeScript', 'Selenium']
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// RESEARCH
|
||||
// ============================================================================
|
||||
|
||||
export interface ResearchItem {
|
||||
title: string;
|
||||
organization?: string;
|
||||
period?: string;
|
||||
description?: string;
|
||||
tech?: string[];
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export const researchItems: ResearchItem[] = [
|
||||
{
|
||||
title: 'Research Assistant',
|
||||
organization: 'College of Engineering and Computing',
|
||||
period: 'May 2026 – Present · 3 mos'
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// HACKATHONS
|
||||
// ============================================================================
|
||||
|
||||
export type Card = {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string | null;
|
||||
link?: string;
|
||||
repo?: string;
|
||||
devpost?: string;
|
||||
hackathonName?: string;
|
||||
university?: string;
|
||||
location?: string;
|
||||
year?: string;
|
||||
tags?: string[];
|
||||
featured?: boolean;
|
||||
awards?: { track: string; place: string }[];
|
||||
liveWarning?: boolean;
|
||||
};
|
||||
|
||||
export const cards: Card[] = [
|
||||
{
|
||||
image: "/hacks/hushmap.png",
|
||||
title: "HushMap",
|
||||
description:
|
||||
"Architected a real-time campus noise and occupancy monitoring system utilizing SvelteKit, FastAPI, and MongoDB to help students locate quiet study spaces.",
|
||||
link: "https://hushmap.study/",
|
||||
devpost: "https://devpost.com/software/hushmap-193nia",
|
||||
hackathonName: "Bitcamp 2026",
|
||||
university: "University of Maryland",
|
||||
location: "College Park, MD",
|
||||
year: "2026",
|
||||
tags: ["Svelte", "YOLOv8", "FastAPI", "MongoDB"],
|
||||
featured: true,
|
||||
awards: [
|
||||
{ track: "Best UI/UX Hack", place: "Winner" }
|
||||
]
|
||||
},
|
||||
{
|
||||
image: "/hacks/learningbuddy.png",
|
||||
title: "LearningBuddy",
|
||||
description:
|
||||
"Engineered an ESP32-S3 IoT study companion in C++ with secure Wi-Fi provisioning, utilizing WebSockets and Socket.IO to stream low-latency raw audio for live lecture capture.",
|
||||
devpost: "https://devpost.com/software/studybuddy-bc0i3j",
|
||||
hackathonName: "HaxFax x PatriotHacks 2026",
|
||||
university: "George Mason University",
|
||||
location: "Fairfax, VA",
|
||||
year: "2026",
|
||||
tags: ["C++", "WebSocket", "ESP32-S3", "Socket.IO"],
|
||||
featured: true,
|
||||
awards: [
|
||||
{ track: "Best use of MongoDB", place: "Winner" }
|
||||
]
|
||||
},
|
||||
{
|
||||
image: "/hacks/fooddecisive.png",
|
||||
title: "Food Decisive",
|
||||
description:
|
||||
"Ever felt the indecisiveness of choosing what to eat? Don't fret! Decide your next bite with Food Decisive",
|
||||
link: "https://fooddecisive.sirblob.co/",
|
||||
repo: "https://github.com/SirBlobby/VTHacks-12",
|
||||
devpost: "https://devpost.com/software/food-decisive",
|
||||
hackathonName: "VTHacks 12",
|
||||
university: "Virginia Tech",
|
||||
location: "Blacksburg, VA",
|
||||
year: "2024",
|
||||
tags: ["AI", "Llama3.1", "Svelte", "Node.js"],
|
||||
featured: false,
|
||||
liveWarning: true
|
||||
},
|
||||
{
|
||||
image: "/hacks/carbin.png",
|
||||
title: "Carbin",
|
||||
description:
|
||||
"Encourage student participation in responsible waste management with smart bins that guide proper disposal.",
|
||||
repo: "https://github.com/SirBlobby/patriotHacks2024",
|
||||
devpost: "https://devpost.com/software/carboniferous-akc4mj",
|
||||
hackathonName: "PatriotHacks 2024",
|
||||
university: "George Mason University",
|
||||
location: "Fairfax, VA",
|
||||
year: "2024",
|
||||
tags: ["AI", "Azure", "CloudConvert", "Python", "React", "TypeScript", "API"],
|
||||
featured: true,
|
||||
awards: [
|
||||
{ track: "Save the World", place: "2nd Place" },
|
||||
{ track: "Microsoft X Cloudforce", place: "2nd Place" }
|
||||
]
|
||||
},
|
||||
{
|
||||
image: "/hacks/patsafe.png",
|
||||
title: "PatSafe",
|
||||
description:
|
||||
"Bridging the gap between doctors and patients for seamless post-discharge care",
|
||||
link: "https://hoya-hax2025.vercel.app/",
|
||||
repo: "https://github.com/SirBlobby/HoyaHax2025",
|
||||
devpost: "https://devpost.com/software/patsafe",
|
||||
hackathonName: "HoyaHax 2025",
|
||||
university: "Georgetown University",
|
||||
location: "Washington, D.C.",
|
||||
year: "2025",
|
||||
tags: ["Javascript", "Next.js", "React", "TypeScript", "LangChain", "OpenAI"],
|
||||
},
|
||||
{
|
||||
image: "https://placehold.co/800x400/334155/94a3b8?text=Fauxcall",
|
||||
title: "Fauxcall",
|
||||
description:
|
||||
"This product is perfect for situations where you are walking at night and you feel unsafe from someone. Fauxcall lets users create a convincing fake phone call to deter potential attackers and provide a quick escape mechanism.",
|
||||
link: "",
|
||||
repo: "https://github.com/SirBlobby/HooHacks-12",
|
||||
devpost: "https://devpost.com/software/fauxcall",
|
||||
hackathonName: "HooHacks 2025",
|
||||
university: "University of Virginia",
|
||||
location: "Charlottesville, VA",
|
||||
year: "2025",
|
||||
tags: ["mongodb", "next.js", "python", "react", "sesame.com", "skeleton", "twilio"],
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
image: "/hacks/drinkhappy.png",
|
||||
title: "Drink Happy",
|
||||
description:
|
||||
"drinkhappy.tech is a gamified hydration wellness app that helps users track drinks, earn points for healthy choices, and compete with friends. It's powered by Gemini AI for smart drink recognition.",
|
||||
link: "https://drinkhappy.tech",
|
||||
repo: "https://github.com/SirBlobby/Bitcamp-2025",
|
||||
devpost: "https://devpost.com/software/drink-happy",
|
||||
hackathonName: "Bitcamp",
|
||||
university: "University of Maryland",
|
||||
location: "College Park, MD",
|
||||
year: "2025",
|
||||
tags: ["api", "auth0", "gemini", "github", "javascript", "mongodb", "nextjs", "react", "tailwind", "vercel"],
|
||||
featured: false
|
||||
},
|
||||
{
|
||||
image: "/hacks/roadcast.png",
|
||||
title: "Roadcast",
|
||||
description:
|
||||
"Roadcast provides drivers with crash data and weather insights to choose safer routes home. The project combines historical crash data, weather feeds, and spatial analysis to surface hazardous areas and route-level safety recommendations.",
|
||||
link: "https://roadcast.sirblob.co/",
|
||||
repo: "https://github.com/SirBlobby/roadcast",
|
||||
devpost: "https://devpost.com/software/roadcast",
|
||||
hackathonName: "VTHacks 13",
|
||||
university: "Virginia Tech",
|
||||
location: "Blacksburg, VA",
|
||||
year: "2025",
|
||||
tags: ["react", "node.js", "python", "mongodb", "geospatial", "mapbox"],
|
||||
featured: false,
|
||||
liveWarning: true
|
||||
},
|
||||
{
|
||||
image: "/img/hoya26.png",
|
||||
title: "Ethix",
|
||||
description: "A sustainability platform that helps users detect and report greenwashing - misleading environmental claims made by companies and products.",
|
||||
repo: "https://github.com/SirBlobby/Hoya26",
|
||||
devpost: "https://devpost.com/software/ethix",
|
||||
link: "https://ethix.sirblob.co/",
|
||||
hackathonName: "HoyaHax 2026",
|
||||
university: "Georgetown University",
|
||||
location: "Washington, D.C.",
|
||||
year: "2026",
|
||||
tags: ["Svelte", "TypeScript", "Python", "Flask", "Tailwind", "Sustainability"],
|
||||
featured: true
|
||||
}
|
||||
];
|
||||
|
||||
// Sort cards to show featured first
|
||||
export const sortedCards = [...cards].sort((a, b) => {
|
||||
if (a.featured && !b.featured) return -1;
|
||||
if (!a.featured && b.featured) return 1;
|
||||
return 0;
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
// ============================================================================
|
||||
// CONFIGURATION INDEX
|
||||
// ============================================================================
|
||||
// This file re-exports all configuration modules for backward compatibility.
|
||||
// You can import from here or directly from the individual config files.
|
||||
//
|
||||
// Import examples:
|
||||
// import { user, skills } from '$lib/config'; // via barrel
|
||||
// import { user } from '$lib/config/user'; // direct import
|
||||
// import { colorPalette } from '$lib/config/theme'; // direct import
|
||||
// ============================================================================
|
||||
|
||||
// User profile and skills
|
||||
export type { LinkItem } from './user';
|
||||
export { user, skills, links } from './user';
|
||||
|
||||
// Layout, breakpoints, fonts, navbar, scrollbar
|
||||
export { layout, breakpoints, fonts, navbar, scrollbar } from './layout';
|
||||
|
||||
// Theme colors, buttons, animations, effects, loading screen
|
||||
export {
|
||||
colorPalette,
|
||||
terminalButtons,
|
||||
loadingScreen,
|
||||
effects,
|
||||
animations
|
||||
} from './theme';
|
||||
|
||||
// Content: projects, models, experience, research, hackathon cards
|
||||
export type { OpenSourceProject, PackageProject, Model3D, Experience, ResearchItem, Card } from './content';
|
||||
export { openSourceProjects, packageProjects, models, experience, researchItems, cards, sortedCards } from './content';
|
||||
|
||||
// Terminal settings, TUI styling, speed presets, model viewer, particles, shortcuts
|
||||
export type { SpeedPreset, TerminalSettings } from './terminal';
|
||||
export {
|
||||
terminalSettings,
|
||||
tuiStyle,
|
||||
tuiText,
|
||||
pageSpeedSettings,
|
||||
pageAutoscrollSettings,
|
||||
speedPresets,
|
||||
modelViewer,
|
||||
particles,
|
||||
keyboardShortcuts
|
||||
} from './terminal';
|
||||
|
||||
// Navigation, site metadata, page meta
|
||||
export type { PageMeta } from './navigation';
|
||||
export { navigation, site, pageMeta } from './navigation';
|
||||
|
||||
// ============================================================================
|
||||
// COLOR FORMATTING REFERENCE
|
||||
// ============================================================================
|
||||
// Use (&color)text(&) syntax in terminal content for colored text
|
||||
// Use (&icon, iconName) syntax for inline icons
|
||||
// Use (&icon, iconName, size) to set a custom icon size in pixels
|
||||
//
|
||||
// Available colors:
|
||||
// red, green, yellow, blue, magenta, cyan, white, gray, orange, pink
|
||||
// primary, accent, muted, error, success, warning, info
|
||||
//
|
||||
// Available styles:
|
||||
// bold, dim, italic, underline
|
||||
//
|
||||
// Examples:
|
||||
// "Hello (&red)world(&)!" -> red "world"
|
||||
// "(&bold,green)Success(&): Task done" -> bold green "Success"
|
||||
// "(&italic,cyan)Note(&): See docs" -> italic cyan "Note"
|
||||
// "Status: (&primary)Active(&)" -> theme primary color
|
||||
// "(&dim)This is muted text(&)" -> dimmed text
|
||||
// "(&bold,underline,yellow)Warning(&)" -> bold underlined yellow
|
||||
// "(&error)Failed(&) - (&success)Passed(&)" -> multiple colors
|
||||
// "(&icon, mdi:github) GitHub" -> inline GitHub icon
|
||||
// "Check (&icon, mdi:check) Done" -> inline check icon
|
||||
// "(&icon, mdi:trophy, 32) Winner" -> bigger trophy icon (32px)
|
||||
|
||||
// ============================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================================================
|
||||
// NOTE: Helper functions are in `src/lib/index.ts` to provide a central
|
||||
// barrel and avoid duplication. Import helpers from `$lib` instead of `$lib/config`.
|
||||
@@ -1,94 +0,0 @@
|
||||
// ============================================================================
|
||||
// LAYOUT & DIMENSIONS
|
||||
// ============================================================================
|
||||
|
||||
export const layout = {
|
||||
// Navbar
|
||||
navbarHeight: 60, // px
|
||||
navbarMaxWidth: 1400, // px
|
||||
navbarZIndex: 1000,
|
||||
|
||||
// Container
|
||||
containerMaxWidth: 1200, // px
|
||||
containerPadding: '1.5rem',
|
||||
|
||||
// Grid
|
||||
gridGap: '1.5rem',
|
||||
gridMinColumnWidth: 300, // px
|
||||
|
||||
// Page margins
|
||||
pageBottomMargin: 80, // px (desktop)
|
||||
pageMobileBottomMargin: 60, // px (mobile)
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSIVE BREAKPOINTS
|
||||
// ============================================================================
|
||||
|
||||
export const breakpoints = {
|
||||
mobile: 768, // px
|
||||
tablet: 1024, // px
|
||||
desktop: 1400, // px
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// FONTS
|
||||
// ============================================================================
|
||||
|
||||
export const fonts = {
|
||||
mono: "'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', monospace",
|
||||
sans: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
monoWeight: 600,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// NAVBAR
|
||||
// ============================================================================
|
||||
|
||||
export const navbar = {
|
||||
height: 60, // px - also update layout.navbarHeight if changing
|
||||
maxWidth: 1400, // px
|
||||
padding: '0.75rem 1.5rem',
|
||||
gap: '2rem',
|
||||
zIndex: 1000,
|
||||
|
||||
// Brand
|
||||
brandFontSize: '0.9rem',
|
||||
cursorWidth: 8, // px
|
||||
|
||||
// Hamburger menu
|
||||
hamburgerWidth: 24, // px
|
||||
hamburgerHeight: 2, // px
|
||||
hamburgerSpacing: 7, // px
|
||||
|
||||
// Links
|
||||
linksGap: '1.5rem',
|
||||
linkFontSize: '0.85rem',
|
||||
linkPadding: '0.5rem 0.75rem',
|
||||
linkBorderRadius: 4, // px
|
||||
linkUnderlineHeight: 2, // px
|
||||
|
||||
// Mode toggle
|
||||
modeToggleSize: 40, // px
|
||||
modeToggleBorderRadius: 8, // px
|
||||
themeButtonBorderRadius: 6, // px
|
||||
|
||||
// Dropdown
|
||||
dropdownMinWidth: 180, // px
|
||||
dropdownBorderRadius: 8, // px
|
||||
dropdownZIndex: 1001,
|
||||
backdropZIndex: 999,
|
||||
mobileExpandedHeight: 200, // px
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SCROLLBAR
|
||||
// ============================================================================
|
||||
|
||||
export const scrollbar = {
|
||||
width: 8, // px
|
||||
borderRadius: 4, // px
|
||||
trackColor: 'rgba(0, 0, 0, 0.1)',
|
||||
thumbColor: 'rgba(128, 128, 128, 0.5)',
|
||||
thumbHoverColor: 'rgba(128, 128, 128, 0.7)',
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
import { user, skills } from './user';
|
||||
|
||||
// ============================================================================
|
||||
// NAVIGATION
|
||||
// ============================================================================
|
||||
|
||||
export const navigation = [
|
||||
{ name: 'home', path: '/', icon: '~' },
|
||||
{ name: 'about', path: '/about', icon: 'mdi:account' },
|
||||
// { name: 'portfolio', path: '/portfolio', icon: '📁' },
|
||||
// { name: 'models', path: '/models', icon: '🎨' },
|
||||
{ name: 'projects', path: '/projects', icon: '🏆' },
|
||||
// { name: 'components', path: '/components', icon: '🧩' },
|
||||
{ name: 'blog', path: '/blog', icon: '📝' }
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// SITE METADATA
|
||||
// ============================================================================
|
||||
|
||||
export const site = {
|
||||
title: `${user.displayname} | Portfolio`,
|
||||
description: `${user.title} - ${user.bio}`,
|
||||
keywords: ['developer', 'portfolio', 'programming', ...skills.languages, ...skills.frameworks],
|
||||
ogImage: '/og-image.png',
|
||||
twitterHandle: '@yourusername'
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PAGE META (per-route)
|
||||
// ============================================================================
|
||||
|
||||
export interface PageMeta {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string; // Iconify id or path to an image (e.g. 'mdi:home' or '/icons/home.svg')
|
||||
keywords?: string[];
|
||||
ogImage?: string;
|
||||
}
|
||||
|
||||
// Map route => meta. Use route paths as keys (e.g. '/', '/portfolio')
|
||||
export const pageMeta: Record<string, PageMeta> = {
|
||||
'/': {
|
||||
title: `${user.displayname} — Home`,
|
||||
description: `Home — ${user.title} portfolio and projects.`,
|
||||
icon: 'mdi:home',
|
||||
keywords: ['home', 'portfolio', 'about']
|
||||
},
|
||||
'/about': {
|
||||
title: `${user.displayname} — About`,
|
||||
description: `About — ${user.title} portfolio and projects.`,
|
||||
icon: 'mdi:account',
|
||||
keywords: ['about', 'portfolio', 'about']
|
||||
},
|
||||
'/portfolio': {
|
||||
title: `${user.displayname} — Portfolio`,
|
||||
description: 'Selected projects, highlights and case studies.',
|
||||
icon: 'mdi:folder-multiple',
|
||||
keywords: ['projects', 'portfolio', 'showcase']
|
||||
},
|
||||
'/models': {
|
||||
title: `${user.displayname} — 3D Models`,
|
||||
description: 'A curated collection of 3D models and previews.',
|
||||
icon: 'mdi:cube-outline',
|
||||
keywords: ['3d', 'models', 'glb', 'gltf']
|
||||
},
|
||||
'/projects': {
|
||||
title: `${user.displayname} — Projects`,
|
||||
description: 'Hackathon projects, demos and awards.',
|
||||
icon: 'mdi:trophy',
|
||||
keywords: ['hackathon', 'projects', 'events']
|
||||
},
|
||||
'/projects/opensource': {
|
||||
title: `${user.displayname} — Open Source`,
|
||||
description: 'Libraries and tools built and maintained in the open.',
|
||||
icon: 'mdi:source-branch',
|
||||
keywords: ['open source', 'projects', 'github']
|
||||
},
|
||||
'/projects/packages': {
|
||||
title: `${user.displayname} — Packages`,
|
||||
description: 'Published packages and CLI tools.',
|
||||
icon: 'mdi:package-variant',
|
||||
keywords: ['packages', 'npm', 'cli']
|
||||
},
|
||||
'/projects/hackathons': {
|
||||
title: `${user.displayname} — Hackathons`,
|
||||
description: 'Hackathon projects, demos and awards.',
|
||||
icon: 'mdi:trophy',
|
||||
keywords: ['hackathon', 'projects', 'events']
|
||||
},
|
||||
'/projects/research': {
|
||||
title: `${user.displayname} — Research`,
|
||||
description: 'Research roles, projects, and publications.',
|
||||
icon: 'mdi:microscope',
|
||||
keywords: ['research', 'academic', 'publications']
|
||||
},
|
||||
'/components': {
|
||||
title: `${user.displayname} — Components`,
|
||||
description: 'Terminal UI components showcase and documentation.',
|
||||
icon: 'mdi:puzzle',
|
||||
keywords: ['components', 'ui', 'terminal', 'tui']
|
||||
},
|
||||
'/links': {
|
||||
title: `${user.displayname} — Links`,
|
||||
description: 'All my links with scannable QR codes.',
|
||||
icon: 'mdi:link-variant',
|
||||
keywords: ['links', 'qr', 'social', 'contact']
|
||||
},
|
||||
'/blog': {
|
||||
title: `${user.displayname} — Blog`,
|
||||
description: 'Notes and write-ups from my projects.',
|
||||
icon: 'mdi:post-outline',
|
||||
keywords: ['blog', 'writing', 'notes']
|
||||
}
|
||||
};
|
||||
@@ -1,261 +0,0 @@
|
||||
// ============================================================================
|
||||
// TERMINAL DISPLAY SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export type SpeedPreset = 'instant' | 'fast' | 'normal' | 'slow' | 'typewriter';
|
||||
|
||||
export interface TerminalSettings {
|
||||
baseTypeSpeed: number;
|
||||
minTypeSpeed: number;
|
||||
maxTypeSpeed: number;
|
||||
startDelay: number;
|
||||
lineDelay: number;
|
||||
showCursor: boolean;
|
||||
promptStyle: 'full' | 'short' | 'minimal';
|
||||
icon: string;
|
||||
scrollMargin: number;
|
||||
}
|
||||
|
||||
export const terminalSettings: TerminalSettings = {
|
||||
// Base typing speed in ms per character (will be adjusted by content length)
|
||||
baseTypeSpeed: 20,
|
||||
// Minimum typing speed (fastest)
|
||||
minTypeSpeed: 5,
|
||||
// Maximum typing speed (slowest)
|
||||
maxTypeSpeed: 50,
|
||||
// Delay before starting to type (ms)
|
||||
startDelay: 300,
|
||||
// Delay between lines (ms)
|
||||
lineDelay: 100,
|
||||
// Show cursor
|
||||
showCursor: true,
|
||||
// Prompt style
|
||||
promptStyle: 'full',
|
||||
// Terminal icon (emoji or text)
|
||||
icon: '🐧',
|
||||
// Scroll margin when navigating buttons (px)
|
||||
scrollMargin: 80,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TUI (Terminal UI) STYLING
|
||||
// ============================================================================
|
||||
|
||||
export const tuiStyle = {
|
||||
// Border & container
|
||||
borderRadius: 8, // px
|
||||
borderWidth: 2, // px
|
||||
width: '95%',
|
||||
borderGlowOpacity: 0.5,
|
||||
borderGlowBlur: 4, // px
|
||||
|
||||
// Header
|
||||
headerPadding: '0.5rem 0.75rem',
|
||||
headerFontSize: '0.8rem',
|
||||
|
||||
// Body
|
||||
bodyPadding: '1rem 1.25rem 2rem 1.25rem',
|
||||
bodyFontSize: '0.9rem',
|
||||
lineHeight: 1.7,
|
||||
lineMinHeight: '1.7em',
|
||||
blankLineHeight: '0.5em',
|
||||
|
||||
// Divider
|
||||
dividerMargin: '0.75rem 0',
|
||||
|
||||
// Images
|
||||
imageBorderRadius: 6, // px
|
||||
defaultImageWidth: 300, // px
|
||||
|
||||
// Cursor
|
||||
cursorWidth: 8, // px
|
||||
|
||||
// Scrollbar
|
||||
scrollbarWidth: 6, // px
|
||||
|
||||
// Footer/Statusbar
|
||||
statusbarPadding: '0.4rem 0.75rem',
|
||||
statusbarFontSize: '0.75rem',
|
||||
hintFontSize: '0.7rem',
|
||||
hintBorderRadius: 3, // px
|
||||
|
||||
// Buttons
|
||||
buttonPadding: '0.5rem 0.75rem',
|
||||
buttonMargin: '0.2rem 0',
|
||||
buttonBorderRadius: 4, // px
|
||||
buttonFontSize: '0.9rem',
|
||||
buttonIndicatorFontSize: '0.8rem',
|
||||
buttonIndicatorWidth: '1rem',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TUI TEXT & INDICATORS
|
||||
// ============================================================================
|
||||
|
||||
export const tuiText = {
|
||||
// Status text
|
||||
loading: 'Loading...',
|
||||
ready: 'Ready',
|
||||
skipButton: 'Skip (Y)',
|
||||
tuiLabel: 'TUI',
|
||||
|
||||
// Keyboard hints
|
||||
hints: {
|
||||
navigate: '↑↓ navigate',
|
||||
select: '⏎ select',
|
||||
toggle: 'T theme',
|
||||
},
|
||||
|
||||
// Line prefixes
|
||||
prefixes: {
|
||||
error: '✗ ',
|
||||
success: '✓ ',
|
||||
info: '› ',
|
||||
warning: '⚠ ',
|
||||
command: '$ ',
|
||||
},
|
||||
|
||||
// Button indicators
|
||||
buttonIndicator: {
|
||||
selected: '▶',
|
||||
unselected: ' ',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Per-page speed settings (override global settings)
|
||||
// Use preset names or custom multiplier (0.1 = 10x faster, 2 = 2x slower)
|
||||
// ============================================================================
|
||||
|
||||
export const pageSpeedSettings: Record<string, SpeedPreset | number> = {
|
||||
// Examples:
|
||||
// 'home': 'fast', // Fast typing on home page
|
||||
// 'portfolio': 'normal', // Normal speed
|
||||
// 'models': 'instant', // No typing animation
|
||||
// 'hackathons': 0.5, // Custom: 2x faster than normal
|
||||
'home': 'fast',
|
||||
'about': 'normal',
|
||||
'portfolio': 'instant',
|
||||
'models': 'fast',
|
||||
'projects': 'fast',
|
||||
'opensource': 'fast',
|
||||
'packages': 'fast',
|
||||
'hackathons': 'fast',
|
||||
'research': 'fast',
|
||||
'components': 'fast'
|
||||
};
|
||||
|
||||
// Per-page autoscroll settings (whether to auto-scroll as content types)
|
||||
// Set to false to disable autoscroll on specific pages
|
||||
export const pageAutoscrollSettings: Record<string, boolean> = {
|
||||
// Examples:
|
||||
// 'home': true, // Enable autoscroll
|
||||
// 'portfolio': false, // Disable autoscroll
|
||||
'home': true,
|
||||
'about': false,
|
||||
'portfolio': false,
|
||||
'models': false,
|
||||
'projects': false,
|
||||
'opensource': false,
|
||||
'packages': false,
|
||||
'hackathons': false,
|
||||
'research': false,
|
||||
'components': false
|
||||
};
|
||||
|
||||
// Speed preset multipliers (lower = faster)
|
||||
export const speedPresets: Record<SpeedPreset, number> = {
|
||||
'instant': 0, // No delay, instant display
|
||||
'fast': 0.1, // 3x faster
|
||||
'normal': 0.5, // Default speed
|
||||
'slow': 1.5, // 2x slower
|
||||
'typewriter': 2 // 3x slower, classic typewriter feel
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MODEL VIEWER (3D)
|
||||
// ============================================================================
|
||||
|
||||
export const modelViewer = {
|
||||
// Container
|
||||
borderRadius: 8, // px
|
||||
minHeight: 300, // px
|
||||
headerPadding: '0.5rem 0.75rem',
|
||||
nameFontSize: '0.85rem',
|
||||
|
||||
// Controls
|
||||
controlButtonSize: 28, // px
|
||||
controlButtonBorderRadius: 4, // px
|
||||
fullscreenZIndex: 100,
|
||||
fullscreenTopOffset: 60, // px (navbar height)
|
||||
|
||||
// Camera
|
||||
cameraFov: 45,
|
||||
cameraNear: 0.1,
|
||||
cameraFar: 1000,
|
||||
cameraZ: 2.5, // initial camera distance
|
||||
|
||||
// Controls behavior
|
||||
controlsMinDistance: 1,
|
||||
controlsMaxDistance: 10,
|
||||
dampingFactor: 0.05,
|
||||
autoRotateSpeed: 2,
|
||||
|
||||
// Model
|
||||
modelScale: 1.5,
|
||||
|
||||
// Ground plane
|
||||
groundColor: 0x222222,
|
||||
groundOpacity: 0.3,
|
||||
|
||||
// Lighting
|
||||
ambientIntensity: 0.6,
|
||||
directionalIntensity: 0.8,
|
||||
lightColor: 0xffffff,
|
||||
rimLightColor: 0x88ccff,
|
||||
|
||||
// Text
|
||||
loadingText: 'Loading model...',
|
||||
errorText: 'Failed to load model',
|
||||
hints: {
|
||||
rotate: 'Drag to rotate',
|
||||
zoom: 'Scroll to zoom',
|
||||
},
|
||||
|
||||
// Timing
|
||||
resizeTimeout: 100, // ms
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PARTICLES (3D Background)
|
||||
// ============================================================================
|
||||
|
||||
export const particles = {
|
||||
count: 400,
|
||||
spreadArea: 20,
|
||||
size: 0.1,
|
||||
velocityRange: 0.01,
|
||||
|
||||
// Opacity
|
||||
darkOpacity: 0.6,
|
||||
lightOpacity: 0.4,
|
||||
|
||||
// Motion
|
||||
waveAmplitude: 0.001,
|
||||
waveFrequency: 0.1,
|
||||
boundary: 10, // wrap boundary
|
||||
rotationSpeedY: 0.05,
|
||||
rotationSpeedX: 0.02,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// KEYBOARD SHORTCUTS
|
||||
// ============================================================================
|
||||
|
||||
export const keyboardShortcuts = {
|
||||
skip: ['y', 'Y'], // Skip typing animation
|
||||
toggleTheme: ['t', 'T'], // Toggle dark/light mode
|
||||
navigateUp: ['ArrowUp', 'k'],
|
||||
navigateDown: ['ArrowDown', 'j'],
|
||||
select: ['Enter'],
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
// ============================================================================
|
||||
// COLOR PALETTE (DEPRECATED)
|
||||
// ============================================================================
|
||||
// NOTE: This is deprecated. Use themeColors.colorMap from $lib/stores/theme instead.
|
||||
// The colorMap in theme JSON files is the source of truth and supports dynamic themes.
|
||||
// This is kept for backward compatibility only.
|
||||
|
||||
export const colorPalette = {
|
||||
// Basic colors
|
||||
red: '#f38ba8',
|
||||
green: '#a6e3a1',
|
||||
yellow: '#f9e2af',
|
||||
blue: '#89b4fa',
|
||||
magenta: '#cba6f7',
|
||||
cyan: '#94e2d5',
|
||||
white: '#cdd6f4',
|
||||
gray: '#6c7086',
|
||||
orange: '#fab387',
|
||||
pink: '#f5c2e7',
|
||||
black: '#1e1e2e',
|
||||
surface: '#313244',
|
||||
|
||||
// Semantic colors (map to basic colors by default)
|
||||
error: '#f38ba8', // red
|
||||
success: '#a6e3a1', // green
|
||||
warning: '#f9e2af', // yellow
|
||||
info: '#89b4fa', // blue
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TERMINAL WINDOW BUTTONS (traffic lights)
|
||||
// ============================================================================
|
||||
|
||||
export const terminalButtons = {
|
||||
close: '#ff5f56',
|
||||
minimize: '#ffbd2e',
|
||||
maximize: '#27ca40',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// LOADING SCREEN
|
||||
// ============================================================================
|
||||
|
||||
export const loadingScreen = {
|
||||
background: '#0d1117',
|
||||
textColor: '#c9d1d9',
|
||||
cursorColor: '#1793d1',
|
||||
text: "Blob's Portfolio",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// EFFECTS
|
||||
// ============================================================================
|
||||
|
||||
export const effects = {
|
||||
backdropBlur: 10, // px
|
||||
selectionBackground: 'rgba(23, 147, 209, 0.3)',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ANIMATIONS
|
||||
// ============================================================================
|
||||
|
||||
export const animations = {
|
||||
// Terminal/TUI animations
|
||||
terminalFadeIn: { duration: '0.4s', easing: 'ease-out' },
|
||||
tuiFadeIn: { duration: '0.4s', easing: 'ease-out' },
|
||||
lineSlideIn: { duration: '0.15s', easing: 'ease-out' },
|
||||
cursorBlink: { duration: '1s' },
|
||||
borderGlow: { duration: '8s', transition: '0.3s' },
|
||||
|
||||
// Button/interaction animations
|
||||
buttonHover: { duration: '0.2s' },
|
||||
buttonTransition: { duration: '0.15s' },
|
||||
|
||||
// Navigation animations
|
||||
hamburgerTransition: { duration: '0.3s' },
|
||||
navLinkTransition: { duration: '0.2s' },
|
||||
linkUnderline: { duration: '0.3s' },
|
||||
dropdown: { flyDuration: 200, fadeDuration: 150 }, // ms
|
||||
|
||||
// Model viewer
|
||||
modelViewerTransition: { duration: '0.3s' },
|
||||
spin: { duration: '1s' },
|
||||
|
||||
// General
|
||||
pulse: { duration: '2s' },
|
||||
float: { duration: '3s' },
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
// ============================================================================
|
||||
// USER PROFILE
|
||||
// ============================================================================
|
||||
|
||||
export const user = {
|
||||
name: 'Gagan "Adith" M',
|
||||
displayname: 'Sir Blob',
|
||||
username: 'sirblob',
|
||||
hostname: 'engineering',
|
||||
title: 'Engineering Student',
|
||||
email: 'sirblob0@gmail.com',
|
||||
location: 'USA (EAST)',
|
||||
bio: `A engineer who loves making things. ` +
|
||||
`I build fun coding projects, participate in game jams and hackathons, and enjoy games like Minecraft and Pokémon TCG Live. ` +
|
||||
`I'm interested in Open Source, Game Development, Embedded Systems, and AI/ML.`,
|
||||
|
||||
// Prefer an absolute avatar URL if you want to pull directly from GitHub
|
||||
avatar: '/blob_nerd.png',
|
||||
|
||||
// Social links - array of { name, icon (Iconify), link }
|
||||
socials: [
|
||||
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
|
||||
{ name: 'LinkedIn', icon: 'mdi:linkedin', link: 'https://www.linkedin.com/in/gmanjunatha/' },
|
||||
{ name: 'Devpost', icon: 'simple-icons:devpost', link: 'https://devpost.com/Sir_Blob_' },
|
||||
{ name: 'Discord', icon: 'ic:baseline-discord', link: 'https://discord.com/users/sir_blob_' }
|
||||
]
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// LINKS PAGE
|
||||
// ============================================================================
|
||||
|
||||
export interface LinkItem {
|
||||
name: string;
|
||||
icon: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export const links: LinkItem[] = [
|
||||
{ name: 'GitHub', icon: 'mdi:github', link: 'https://github.com/SirBlobby' },
|
||||
{ name: 'LinkedIn', icon: 'mdi:linkedin', link: 'https://www.linkedin.com/in/gmanjunatha/' },
|
||||
{ name: 'Devpost', icon: 'simple-icons:devpost', link: 'https://devpost.com/Sir_Blob_' },
|
||||
{ name: 'Discord', icon: 'ic:baseline-discord', link: 'https://discord.com/users/sir_blob_' },
|
||||
{ name: 'Email', icon: 'mdi:email', link: `mailto:${user.email}` },
|
||||
// { name: 'Blog', icon: 'mdi:rss', link: 'https://blog.sirblob.co' }
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// SKILLS
|
||||
// ============================================================================
|
||||
|
||||
export const skills = {
|
||||
languages: ['Python', 'JavaScript', 'TypeScript', 'C', 'C++', 'Java', 'Node.js', 'Rust', 'Go'],
|
||||
frameworks: ['React', 'Electron', 'Svelte', 'Bootstrap', 'TailwindCSS', 'Discord.js', ],
|
||||
applications: ['IntelliJ', 'VS Code', 'Git', 'Blender', 'Godot'],
|
||||
platforms: ['Windows', 'Linux', 'macOS', 'Arduino', 'Raspberry Pi'],
|
||||
tools: ['Git', 'Docker', 'Neovim', 'VS Code'],
|
||||
databases: ['MongoDB', 'SQLite'],
|
||||
interests: ['Open Source', 'Game Development', 'Embedded Systems', 'AI/ML']
|
||||
};
|
||||
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