mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Work on Tauri settings
This commit is contained in:
parent
3351cc08c2
commit
2af9257562
11 changed files with 1988 additions and 557 deletions
|
|
@ -2208,6 +2208,122 @@ pub async fn test_terminal(terminal: String, state: State<'_, AppState>) -> Resu
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Welcome flow specific commands
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct VtInstallationStatus {
|
||||||
|
pub installed: bool,
|
||||||
|
pub path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_vt_installation() -> Result<VtInstallationStatus, String> {
|
||||||
|
let installed = crate::cli_installer::check_cli_installed()
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let path = if installed {
|
||||||
|
Some("/usr/local/bin/vt".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(VtInstallationStatus { installed, path })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn install_vt() -> Result<(), String> {
|
||||||
|
crate::cli_installer::install_cli()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PermissionsStatus {
|
||||||
|
pub automation: bool,
|
||||||
|
pub accessibility: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_permissions(state: State<'_, AppState>) -> Result<PermissionsStatus, String> {
|
||||||
|
let permissions_manager = &state.permissions_manager;
|
||||||
|
|
||||||
|
// Check terminal access permission (closest to automation)
|
||||||
|
let automation_status = permissions_manager
|
||||||
|
.check_permission_silent(crate::permissions::PermissionType::TerminalAccess)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Check accessibility permission
|
||||||
|
let accessibility_status = permissions_manager
|
||||||
|
.check_permission_silent(crate::permissions::PermissionType::Accessibility)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(PermissionsStatus {
|
||||||
|
automation: automation_status == crate::permissions::PermissionStatus::Granted,
|
||||||
|
accessibility: accessibility_status == crate::permissions::PermissionStatus::Granted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn request_automation_permission(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let permissions_manager = &state.permissions_manager;
|
||||||
|
permissions_manager
|
||||||
|
.request_permission(crate::permissions::PermissionType::TerminalAccess)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn request_accessibility_permission(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
let permissions_manager = &state.permissions_manager;
|
||||||
|
permissions_manager
|
||||||
|
.request_permission(crate::permissions::PermissionType::Accessibility)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_dashboard_password(password: String) -> Result<(), String> {
|
||||||
|
// Save password to keychain
|
||||||
|
crate::keychain::KeychainManager::set_dashboard_password(&password)
|
||||||
|
.map_err(|e| e.message)?;
|
||||||
|
|
||||||
|
// Update settings to enable password
|
||||||
|
let mut settings = crate::settings::Settings::load().unwrap_or_default();
|
||||||
|
settings.dashboard.enable_password = true;
|
||||||
|
settings.save()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn open_dashboard(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
// Check if server is running
|
||||||
|
if !state.backend_manager.is_running().await {
|
||||||
|
// Start server if not running
|
||||||
|
start_server(state.clone(), app).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get server port from settings
|
||||||
|
let settings = crate::settings::Settings::load().unwrap_or_default();
|
||||||
|
let url = format!("http://127.0.0.1:{}", settings.dashboard.server_port);
|
||||||
|
|
||||||
|
// Open URL in default browser
|
||||||
|
open::that(url).map_err(|e| format!("Failed to open dashboard: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn finish_welcome(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
// Mark welcome as completed
|
||||||
|
state.welcome_manager.skip_tutorial().await?;
|
||||||
|
|
||||||
|
// Update settings to not show welcome on startup
|
||||||
|
let mut settings = crate::settings::Settings::load().unwrap_or_default();
|
||||||
|
settings.general.show_welcome_on_startup = Some(false);
|
||||||
|
settings.save()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,14 @@ fn main() {
|
||||||
// Welcome flow commands
|
// Welcome flow commands
|
||||||
request_all_permissions,
|
request_all_permissions,
|
||||||
test_terminal,
|
test_terminal,
|
||||||
|
check_vt_installation,
|
||||||
|
install_vt,
|
||||||
|
check_permissions,
|
||||||
|
request_automation_permission,
|
||||||
|
request_accessibility_permission,
|
||||||
|
save_dashboard_password,
|
||||||
|
open_dashboard,
|
||||||
|
finish_welcome,
|
||||||
])
|
])
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
// Set app handle in managers
|
// Set app handle in managers
|
||||||
|
|
|
||||||
|
|
@ -211,9 +211,11 @@ impl WelcomeManager {
|
||||||
tauri::WebviewUrl::App("welcome.html".into()),
|
tauri::WebviewUrl::App("welcome.html".into()),
|
||||||
)
|
)
|
||||||
.title("Welcome to VibeTunnel")
|
.title("Welcome to VibeTunnel")
|
||||||
.inner_size(800.0, 600.0)
|
.inner_size(640.0, 560.0)
|
||||||
.center()
|
.center()
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
|
.decorations(false)
|
||||||
|
.transparent(true)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
tauri/src/components/glowing-app-icon.ts
Normal file
139
tauri/src/components/glowing-app-icon.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import { html, css, LitElement } from 'lit';
|
||||||
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
@customElement('glowing-app-icon')
|
||||||
|
export class GlowingAppIcon extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-container {
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-container:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-container:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
width: var(--size, 128px);
|
||||||
|
height: var(--size, 128px);
|
||||||
|
border-radius: 27.2%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: var(--size, 128px);
|
||||||
|
height: var(--size, 128px);
|
||||||
|
border-radius: 27.2%;
|
||||||
|
opacity: var(--glow-opacity, 0.3);
|
||||||
|
filter: blur(var(--glow-blur, 20px));
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: breathing 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating {
|
||||||
|
animation: float 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes breathing {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.15);
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-50%, -50%) scale(1.25);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme adjustments */
|
||||||
|
:host-context(.light) .glow {
|
||||||
|
opacity: 0.2;
|
||||||
|
filter: blur(15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.light) .app-icon {
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
:host-context(.dark) .app-icon {
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
size = 128;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
enableFloating = true;
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
enableInteraction = true;
|
||||||
|
|
||||||
|
@property({ type: Number })
|
||||||
|
glowIntensity = 0.3;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private iconSrc = '';
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
// Use the app icon from the public directory
|
||||||
|
this.iconSrc = '/icon_512x512.png';
|
||||||
|
this.style.setProperty('--size', `${this.size}px`);
|
||||||
|
this.style.setProperty('--glow-opacity', `${this.glowIntensity}`);
|
||||||
|
this.style.setProperty('--glow-blur', `${this.size * 0.15}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClick(): void {
|
||||||
|
if (this.enableInteraction) {
|
||||||
|
this.dispatchEvent(new CustomEvent('icon-click', {
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="icon-container ${this.enableFloating ? 'floating' : ''}"
|
||||||
|
@click=${this.handleClick}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src=${this.iconSrc}
|
||||||
|
alt="VibeTunnel"
|
||||||
|
class="app-icon"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src=${this.iconSrc}
|
||||||
|
alt=""
|
||||||
|
class="glow"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ import { TauriBase } from './base/tauri-base';
|
||||||
import { formStyles } from './shared/styles';
|
import { formStyles } from './shared/styles';
|
||||||
import './settings-tab';
|
import './settings-tab';
|
||||||
import './settings-checkbox';
|
import './settings-checkbox';
|
||||||
|
import './settings-select';
|
||||||
|
import './glowing-app-icon';
|
||||||
|
|
||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
general?: {
|
general?: {
|
||||||
|
|
@ -13,9 +15,18 @@ interface SettingsData {
|
||||||
theme?: 'system' | 'light' | 'dark';
|
theme?: 'system' | 'light' | 'dark';
|
||||||
default_terminal?: string;
|
default_terminal?: string;
|
||||||
};
|
};
|
||||||
dashboard?: Record<string, unknown>;
|
dashboard?: {
|
||||||
|
password_enabled?: boolean;
|
||||||
|
password?: string;
|
||||||
|
access_mode?: 'localhost' | 'network';
|
||||||
|
port?: string;
|
||||||
|
ngrok_enabled?: boolean;
|
||||||
|
ngrok_token?: string;
|
||||||
|
};
|
||||||
advanced?: {
|
advanced?: {
|
||||||
debug_mode?: boolean;
|
debug_mode?: boolean;
|
||||||
|
cleanup_on_startup?: boolean;
|
||||||
|
preferred_terminal?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +203,172 @@ export class SettingsApp extends TauriBase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form Elements */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:hover {
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input[type="number"] {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.password-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* About Tab Styles */
|
||||||
|
.about-content {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-name {
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 24px 0 8px 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-section {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-section p {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: fit-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-item svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-section {
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-link {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credit-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyright {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme Variables */
|
/* Theme Variables */
|
||||||
:host {
|
:host {
|
||||||
/* Dark theme (default) */
|
/* Dark theme (default) */
|
||||||
|
|
@ -390,24 +567,18 @@ export class SettingsApp extends TauriBase {
|
||||||
|
|
||||||
<div class="setting-card">
|
<div class="setting-card">
|
||||||
<h3>Terminal</h3>
|
<h3>Terminal</h3>
|
||||||
<div class="form-group">
|
<settings-select
|
||||||
<label for="terminal-select">Default Terminal</label>
|
label="Default Terminal"
|
||||||
<select
|
help="Choose your preferred terminal application"
|
||||||
id="terminal-select"
|
settingKey="general.default_terminal"
|
||||||
class="form-select"
|
.value=${this.settings.general?.default_terminal || 'terminal'}
|
||||||
@change=${(e: Event) => {
|
.options=${[
|
||||||
const target = e.target as HTMLSelectElement;
|
{ value: 'terminal', label: 'Terminal.app' },
|
||||||
this.handleSettingChange(new CustomEvent('change', {
|
{ value: 'iterm2', label: 'iTerm2' },
|
||||||
detail: { settingKey: 'general.default_terminal', value: target.value }
|
{ value: 'warp', label: 'Warp' }
|
||||||
}) as SettingChangeEvent);
|
]}
|
||||||
}}
|
@change=${this.handleSettingChange}
|
||||||
>
|
></settings-select>
|
||||||
<option value="terminal" ?selected=${!this.settings.general?.default_terminal || this.settings.general.default_terminal === 'terminal'}>Terminal.app</option>
|
|
||||||
<option value="iterm2" ?selected=${this.settings.general?.default_terminal === 'iterm2'}>iTerm2</option>
|
|
||||||
<option value="warp" ?selected=${this.settings.general?.default_terminal === 'warp'}>Warp</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-text">Choose your preferred terminal application</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-card">
|
<div class="setting-card">
|
||||||
|
|
@ -420,24 +591,116 @@ export class SettingsApp extends TauriBase {
|
||||||
@change=${this.handleSettingChange}
|
@change=${this.handleSettingChange}
|
||||||
></settings-checkbox>
|
></settings-checkbox>
|
||||||
|
|
||||||
<div class="form-group">
|
<settings-select
|
||||||
<label for="theme-select">Theme</label>
|
label="Theme"
|
||||||
<select
|
help="Choose your preferred color scheme"
|
||||||
id="theme-select"
|
settingKey="general.theme"
|
||||||
class="form-select"
|
.value=${this.settings.general?.theme || 'system'}
|
||||||
@change=${(e: Event) => {
|
.options=${[
|
||||||
const target = e.target as HTMLSelectElement;
|
{ value: 'system', label: 'System' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'dark', label: 'Dark' }
|
||||||
|
]}
|
||||||
|
@change=${this.handleSettingChange}
|
||||||
|
></settings-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderDashboardTab(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="tab-content ${this.activeTab === 'dashboard' ? 'active' : ''}" id="dashboard">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-card">
|
||||||
|
<h3>Security</h3>
|
||||||
|
<settings-checkbox
|
||||||
|
.checked=${this.settings.dashboard?.password_enabled || false}
|
||||||
|
label="Password protect dashboard"
|
||||||
|
help="Require a password to access the dashboard from remote connections"
|
||||||
|
settingKey="dashboard.password_enabled"
|
||||||
|
@change=${this.handleSettingChange}
|
||||||
|
></settings-checkbox>
|
||||||
|
|
||||||
|
${this.settings.dashboard?.password_enabled ? html`
|
||||||
|
<div class="password-section">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter password"
|
||||||
|
class="form-input"
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
this.handleSettingChange(new CustomEvent('change', {
|
this.handleSettingChange(new CustomEvent('change', {
|
||||||
detail: { settingKey: 'general.theme', value: target.value }
|
detail: { settingKey: 'dashboard.password', value: input.value }
|
||||||
}) as SettingChangeEvent);
|
}) as SettingChangeEvent);
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<option value="system" ?selected=${!this.settings.general?.theme || this.settings.general.theme === 'system'}>System</option>
|
|
||||||
<option value="light" ?selected=${this.settings.general?.theme === 'light'}>Light</option>
|
|
||||||
<option value="dark" ?selected=${this.settings.general?.theme === 'dark'}>Dark</option>
|
|
||||||
</select>
|
|
||||||
<small class="form-text">Choose your preferred color scheme</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-card">
|
||||||
|
<h3>Server Configuration</h3>
|
||||||
|
<settings-select
|
||||||
|
label="Allow dashboard access from"
|
||||||
|
help="Control where the dashboard can be accessed from"
|
||||||
|
settingKey="dashboard.access_mode"
|
||||||
|
.value=${this.settings.dashboard?.access_mode || 'localhost'}
|
||||||
|
.options=${[
|
||||||
|
{ value: 'localhost', label: 'This Mac only' },
|
||||||
|
{ value: 'network', label: 'Local network' }
|
||||||
|
]}
|
||||||
|
@change=${this.handleSettingChange}
|
||||||
|
></settings-select>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Server port</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-input"
|
||||||
|
.value=${this.settings.dashboard?.port || '4022'}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
this.handleSettingChange(new CustomEvent('change', {
|
||||||
|
detail: { settingKey: 'dashboard.port', value: input.value }
|
||||||
|
}) as SettingChangeEvent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small class="form-text">The server will automatically restart when the port is changed</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-card">
|
||||||
|
<h3>ngrok Integration</h3>
|
||||||
|
<settings-checkbox
|
||||||
|
.checked=${this.settings.dashboard?.ngrok_enabled || false}
|
||||||
|
label="Enable ngrok tunnel"
|
||||||
|
help="Expose VibeTunnel to the internet using ngrok"
|
||||||
|
settingKey="dashboard.ngrok_enabled"
|
||||||
|
@change=${this.handleSettingChange}
|
||||||
|
></settings-checkbox>
|
||||||
|
|
||||||
|
${this.settings.dashboard?.ngrok_enabled ? html`
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Auth token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter ngrok auth token"
|
||||||
|
class="form-input"
|
||||||
|
.value=${this.settings.dashboard?.ngrok_token || ''}
|
||||||
|
@input=${(e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
this.handleSettingChange(new CustomEvent('change', {
|
||||||
|
detail: { settingKey: 'dashboard.ngrok_token', value: input.value }
|
||||||
|
}) as SettingChangeEvent);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<small class="form-text">Get your free auth token at <a href="https://ngrok.com" target="_blank">ngrok.com</a></small>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -455,6 +718,118 @@ export class SettingsApp extends TauriBase {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _renderAdvancedTab(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="tab-content ${this.activeTab === 'advanced' ? 'active' : ''}" id="advanced">
|
||||||
|
<h2>Advanced</h2>
|
||||||
|
|
||||||
|
<div class="settings-grid">
|
||||||
|
<div class="setting-card">
|
||||||
|
<h3>Terminal</h3>
|
||||||
|
<settings-select
|
||||||
|
label="Preferred Terminal"
|
||||||
|
help="Select which application to use when creating new sessions"
|
||||||
|
settingKey="advanced.preferred_terminal"
|
||||||
|
.value=${this.settings.advanced?.preferred_terminal || 'terminal'}
|
||||||
|
.options=${[
|
||||||
|
{ value: 'terminal', label: 'Terminal.app' },
|
||||||
|
{ value: 'iterm2', label: 'iTerm2' },
|
||||||
|
{ value: 'warp', label: 'Warp' }
|
||||||
|
]}
|
||||||
|
@change=${this.handleSettingChange}
|
||||||
|
></settings-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-card">
|
||||||
|
<h3>Advanced Options</h3>
|
||||||
|
<settings-checkbox
|
||||||
|
.checked=${this.settings.advanced?.cleanup_on_startup !== false}
|
||||||
|
label="Clean up old sessions on startup"
|
||||||
|
help="Automatically remove terminated sessions when the app starts"
|
||||||
|
settingKey="advanced.cleanup_on_startup"
|
||||||
|
@change=${this.handleSettingChange}
|
||||||
|
></settings-checkbox>
|
||||||
|
|
||||||
|
<settings-checkbox
|
||||||
|
.checked=${this.settings.advanced?.debug_mode || false}
|
||||||
|
label="Debug mode"
|
||||||
|
help="Enable additional logging and debugging features"
|
||||||
|
settingKey="advanced.debug_mode"
|
||||||
|
@change=${this.handleSettingChange}
|
||||||
|
></settings-checkbox>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderAboutTab(): TemplateResult {
|
||||||
|
return html`
|
||||||
|
<div class="tab-content ${this.activeTab === 'about' ? 'active' : ''}" id="about">
|
||||||
|
<div class="about-content">
|
||||||
|
<div class="app-info-section">
|
||||||
|
<glowing-app-icon
|
||||||
|
.size=${128}
|
||||||
|
.enableFloating=${true}
|
||||||
|
.enableInteraction=${true}
|
||||||
|
.glowIntensity=${0.3}
|
||||||
|
@icon-click=${() => window.open('https://vibetunnel.sh', '_blank')}
|
||||||
|
></glowing-app-icon>
|
||||||
|
|
||||||
|
<h1 class="app-name">VibeTunnel</h1>
|
||||||
|
<p class="app-version">Version ${this.systemInfo.version || '1.0.0'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="description-section">
|
||||||
|
<p>Turn any browser into your terminal & command your agents on the go.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="links-section">
|
||||||
|
<a href="https://vibetunnel.sh" target="_blank" class="link-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span>Website</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://github.com/amantus-ai/vibetunnel" target="_blank" class="link-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span>View on GitHub</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://github.com/amantus-ai/vibetunnel/issues" target="_blank" class="link-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span>Report an Issue</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="https://x.com/VibeTunnel" target="_blank" class="link-item">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
<span>Follow @VibeTunnel</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="credits-section">
|
||||||
|
<p class="credits-label">Brought to you by</p>
|
||||||
|
<div class="credits-links">
|
||||||
|
<a href="https://mariozechner.at/" target="_blank" class="credit-link">@badlogic</a>
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<a href="https://lucumr.pocoo.org/" target="_blank" class="credit-link">@mitsuhiko</a>
|
||||||
|
<span class="separator">•</span>
|
||||||
|
<a href="https://steipete.me" target="_blank" class="credit-link">@steipete</a>
|
||||||
|
</div>
|
||||||
|
<p class="copyright">© 2025 • MIT Licensed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const tabs: TabConfig[] = [
|
const tabs: TabConfig[] = [
|
||||||
{ id: 'general', name: 'General', icon: html`<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97c0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1c0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66Z"/>` },
|
{ id: 'general', name: 'General', icon: html`<path d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97c0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1c0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66Z"/>` },
|
||||||
|
|
@ -487,20 +862,11 @@ export class SettingsApp extends TauriBase {
|
||||||
${this._renderDebugTab()}
|
${this._renderDebugTab()}
|
||||||
|
|
||||||
<!-- Other tabs will be added here -->
|
<!-- Other tabs will be added here -->
|
||||||
<div class="tab-content ${this.activeTab === 'dashboard' ? 'active' : ''}" id="dashboard">
|
${this._renderDashboardTab()}
|
||||||
<h2>Dashboard</h2>
|
|
||||||
<p>Dashboard settings coming soon...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-content ${this.activeTab === 'advanced' ? 'active' : ''}" id="advanced">
|
${this._renderAdvancedTab()}
|
||||||
<h2>Advanced</h2>
|
|
||||||
<p>Advanced settings coming soon...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-content ${this.activeTab === 'about' ? 'active' : ''}" id="about">
|
${this._renderAboutTab()}
|
||||||
<h2>About</h2>
|
|
||||||
<p>VibeTunnel v${this.systemInfo.version || '1.0.0'}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,15 @@ import { customElement, property } from 'lit/decorators.js';
|
||||||
export class SettingsCheckbox extends LitElement {
|
export class SettingsCheckbox extends LitElement {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px 0;
|
width: 100%;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
|
|
@ -20,15 +24,15 @@ export class SettingsCheckbox extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-indicator {
|
.checkbox-indicator {
|
||||||
width: 20px;
|
width: 16px;
|
||||||
height: 20px;
|
height: 16px;
|
||||||
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
||||||
border: 2px solid var(--border-secondary, rgba(255, 255, 255, 0.12));
|
border: 1.5px solid var(--border-secondary, rgba(255, 255, 255, 0.12));
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 12px;
|
margin-right: 10px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,15 +46,19 @@ export class SettingsCheckbox extends LitElement {
|
||||||
border-color: var(--accent, #10b981);
|
border-color: var(--accent, #10b981);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"]:checked + .checkbox-indicator::after {
|
.checkbox-indicator svg {
|
||||||
content: '✓';
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
color: var(--text-primary, #fff);
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: bold;
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked + .checkbox-indicator svg {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-info {
|
.setting-info {
|
||||||
|
|
@ -104,7 +112,11 @@ export class SettingsCheckbox extends LitElement {
|
||||||
.checked=${this.checked}
|
.checked=${this.checked}
|
||||||
@change=${this._handleChange}
|
@change=${this._handleChange}
|
||||||
>
|
>
|
||||||
<span class="checkbox-indicator"></span>
|
<span class="checkbox-indicator">
|
||||||
|
<svg viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2 6L5 9L10 3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="label">${this.label}</span>
|
<span class="label">${this.label}</span>
|
||||||
${this.help ? html`<span class="help">${this.help}</span>` : ''}
|
${this.help ? html`<span class="help">${this.help}</span>` : ''}
|
||||||
|
|
|
||||||
131
tauri/src/components/settings-select.ts
Normal file
131
tauri/src/components/settings-select.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { html, css, LitElement } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
export interface SelectChangeEvent extends CustomEvent {
|
||||||
|
detail: {
|
||||||
|
settingKey: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('settings-select')
|
||||||
|
export class SettingsSelect extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 32px 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:hover {
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.help {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
label = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
value = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
settingKey = '';
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
help = '';
|
||||||
|
|
||||||
|
@property({ type: Array })
|
||||||
|
options: Array<{ value: string; label: string }> = [];
|
||||||
|
|
||||||
|
private handleChange(e: Event): void {
|
||||||
|
const select = e.target as HTMLSelectElement;
|
||||||
|
this.value = select.value;
|
||||||
|
|
||||||
|
this.dispatchEvent(new CustomEvent('change', {
|
||||||
|
detail: {
|
||||||
|
settingKey: this.settingKey,
|
||||||
|
value: select.value
|
||||||
|
},
|
||||||
|
bubbles: true,
|
||||||
|
composed: true
|
||||||
|
}) as SelectChangeEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="form-group">
|
||||||
|
${this.label ? html`<label for="${this.settingKey}">${this.label}</label>` : ''}
|
||||||
|
<div class="select-wrapper">
|
||||||
|
<select
|
||||||
|
id="${this.settingKey}"
|
||||||
|
.value=${this.value}
|
||||||
|
@change=${this.handleChange}
|
||||||
|
>
|
||||||
|
${this.options.map(option => html`
|
||||||
|
<option value="${option.value}" ?selected=${this.value === option.value}>
|
||||||
|
${option.label}
|
||||||
|
</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
<svg class="arrow" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
${this.help ? html`<small class="help">${this.help}</small>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,108 +1,183 @@
|
||||||
import { LitElement, html, css } from 'lit';
|
import { html, css, LitElement } from 'lit';
|
||||||
import { customElement, property } from 'lit/decorators.js';
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
import { classMap } from 'lit/directives/class-map.js';
|
|
||||||
import { buttonStyles } from './styles';
|
|
||||||
|
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
type ButtonSize = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
@customElement('vt-button')
|
@customElement('vt-button')
|
||||||
export class VTButton extends LitElement {
|
export class VtButton extends LitElement {
|
||||||
static override styles = [
|
static override styles = css`
|
||||||
buttonStyles,
|
|
||||||
css`
|
|
||||||
:host {
|
:host {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
button {
|
||||||
width: 100%;
|
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
outline: none;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
button:focus-visible {
|
||||||
width: 14px;
|
box-shadow: 0 0 0 3px var(--accent-glow, rgba(16, 185, 129, 0.5));
|
||||||
height: 14px;
|
}
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sizes */
|
||||||
|
button.small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.medium {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.large {
|
||||||
|
padding: 14px 28px;
|
||||||
|
font-size: 16px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variants */
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent, #10b981);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover, #0ea671);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:active:not(:disabled) {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--bg-secondary, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover, rgba(255, 255, 255, 0.12));
|
||||||
|
border-color: var(--border-secondary, rgba(255, 255, 255, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: var(--error, #ef4444);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover:not(:disabled) {
|
||||||
|
background: var(--error-hover, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading state */
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: inherit;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid transparent;
|
||||||
border-top-color: currentColor;
|
border-top-color: currentColor;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
];
|
|
||||||
|
.content {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([loading]) .content {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(:not([loading])) .loading {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
variant: ButtonVariant = 'primary';
|
variant: ButtonVariant = 'primary';
|
||||||
|
|
||||||
@property({ type: String })
|
@property({ type: String })
|
||||||
size: ButtonSize = 'md';
|
size: ButtonSize = 'medium';
|
||||||
|
|
||||||
@property({ type: Boolean })
|
@property({ type: Boolean })
|
||||||
disabled = false;
|
disabled = false;
|
||||||
|
|
||||||
@property({ type: Boolean })
|
@property({ type: Boolean, reflect: true })
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
@property({ type: Boolean })
|
|
||||||
icon = false;
|
|
||||||
|
|
||||||
@property({ type: String })
|
|
||||||
href?: string;
|
|
||||||
|
|
||||||
private _handleClick(e: Event): void {
|
|
||||||
if (this.loading || this.disabled) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.href) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.open(this.href, '_blank', 'noopener,noreferrer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const classes = {
|
|
||||||
'btn': true,
|
|
||||||
[`btn-${this.variant}`]: true,
|
|
||||||
[`btn-${this.size}`]: this.size !== 'md',
|
|
||||||
'btn-icon': this.icon
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = this.loading
|
|
||||||
? html`<span class="loading-spinner"></span>`
|
|
||||||
: html`<slot></slot>`;
|
|
||||||
|
|
||||||
const isDisabled = this.disabled || this.loading;
|
|
||||||
|
|
||||||
if (this.href) {
|
|
||||||
return html`
|
|
||||||
<a
|
|
||||||
href=${this.href}
|
|
||||||
class=${classMap(classes)}
|
|
||||||
?disabled=${isDisabled}
|
|
||||||
@click=${this._handleClick}
|
|
||||||
tabindex=${isDisabled ? '-1' : '0'}
|
|
||||||
aria-disabled=${isDisabled ? 'true' : 'false'}
|
|
||||||
>
|
|
||||||
${content}
|
|
||||||
</a>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<button
|
<button
|
||||||
class=${classMap(classes)}
|
class="${this.variant} ${this.size}"
|
||||||
?disabled=${isDisabled}
|
?disabled=${this.disabled || this.loading}
|
||||||
@click=${this._handleClick}
|
@click=${this.handleClick}
|
||||||
aria-busy=${this.loading ? 'true' : 'false'}
|
|
||||||
>
|
>
|
||||||
${content}
|
<span class="content">
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
${this.loading ? html`
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleClick(e: Event) {
|
||||||
|
if (this.disabled || this.loading) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'vt-button': VtButton;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF < /dev/null
|
||||||
315
tauri/src/components/shared/window-header.ts
Normal file
315
tauri/src/components/shared/window-header.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { html, css, LitElement } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { TauriBase } from '../base/tauri-base';
|
||||||
|
|
||||||
|
@customElement('window-header')
|
||||||
|
export class WindowHeader extends TauriBase {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-primary, #000);
|
||||||
|
border-bottom: 1px solid var(--border-primary, rgba(255, 255, 255, 0.08));
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Draggable region - covers most of the header */
|
||||||
|
.drag-region {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 80px; /* Leave space for traffic lights */
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Window controls container */
|
||||||
|
.window-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Traffic light buttons */
|
||||||
|
.traffic-light {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light:hover::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light.close {
|
||||||
|
background: #ff5f57;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light.close:hover {
|
||||||
|
background: #ff6058;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light.minimize {
|
||||||
|
background: #ffbd2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light.minimize:hover {
|
||||||
|
background: #ffbe2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light.maximize {
|
||||||
|
background: #28ca42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-light.maximize:hover {
|
||||||
|
background: #29cb43;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When window is not focused, gray out the buttons */
|
||||||
|
:host(.inactive) .traffic-light {
|
||||||
|
background: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title */
|
||||||
|
.title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content slot */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For non-macOS, use different controls */
|
||||||
|
@media not all and (hover: hover) {
|
||||||
|
.traffic-light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Windows/Linux style controls */
|
||||||
|
.windows-controls {
|
||||||
|
display: none;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.windows-control {
|
||||||
|
width: 46px;
|
||||||
|
height: 38px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.windows-control:hover {
|
||||||
|
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.windows-control.close:hover {
|
||||||
|
background: #e81123;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.windows-control svg {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show Windows controls on non-macOS */
|
||||||
|
:host([platform="windows"]) .traffic-light,
|
||||||
|
:host([platform="linux"]) .traffic-light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host([platform="windows"]) .windows-controls,
|
||||||
|
:host([platform="linux"]) .windows-controls {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ type: String })
|
||||||
|
title = '';
|
||||||
|
|
||||||
|
@property({ type: Boolean })
|
||||||
|
showMaximize = true;
|
||||||
|
|
||||||
|
@property({ type: String, reflect: true })
|
||||||
|
platform = 'macos';
|
||||||
|
|
||||||
|
override async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
// Detect platform
|
||||||
|
if (this.tauriAvailable) {
|
||||||
|
const os = await this.safeInvoke<string>('get_os');
|
||||||
|
this.platform = os || 'macos';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for window focus/blur events
|
||||||
|
window.addEventListener('blur', this._handleBlur);
|
||||||
|
window.addEventListener('focus', this._handleFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
window.removeEventListener('blur', this._handleBlur);
|
||||||
|
window.removeEventListener('focus', this._handleFocus);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleBlur = () => {
|
||||||
|
this.classList.add('inactive');
|
||||||
|
};
|
||||||
|
|
||||||
|
private _handleFocus = () => {
|
||||||
|
this.classList.remove('inactive');
|
||||||
|
};
|
||||||
|
|
||||||
|
private async _closeWindow() {
|
||||||
|
if (this.tauriAvailable) {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
await getCurrentWindow().close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _minimizeWindow() {
|
||||||
|
if (this.tauriAvailable) {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
await getCurrentWindow().minimize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _maximizeWindow() {
|
||||||
|
if (this.tauriAvailable) {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window');
|
||||||
|
const window = getCurrentWindow();
|
||||||
|
const isMaximized = await window.isMaximized();
|
||||||
|
if (isMaximized) {
|
||||||
|
await window.unmaximize();
|
||||||
|
} else {
|
||||||
|
await window.maximize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="header">
|
||||||
|
${this.platform === 'macos' ? html`
|
||||||
|
<div class="window-controls">
|
||||||
|
<button
|
||||||
|
class="traffic-light close"
|
||||||
|
@click=${this._closeWindow}
|
||||||
|
aria-label="Close window"
|
||||||
|
></button>
|
||||||
|
<button
|
||||||
|
class="traffic-light minimize"
|
||||||
|
@click=${this._minimizeWindow}
|
||||||
|
aria-label="Minimize window"
|
||||||
|
></button>
|
||||||
|
${this.showMaximize ? html`
|
||||||
|
<button
|
||||||
|
class="traffic-light maximize"
|
||||||
|
@click=${this._maximizeWindow}
|
||||||
|
aria-label="Maximize window"
|
||||||
|
></button>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="drag-region" data-tauri-drag-region></div>
|
||||||
|
|
||||||
|
${this.title ? html`
|
||||||
|
<div class="title">${this.title}</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.platform !== 'macos' ? html`
|
||||||
|
<div class="windows-controls">
|
||||||
|
<button
|
||||||
|
class="windows-control minimize"
|
||||||
|
@click=${this._minimizeWindow}
|
||||||
|
aria-label="Minimize window"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 10 10" fill="currentColor">
|
||||||
|
<path d="M0 5h10v1H0z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
${this.showMaximize ? html`
|
||||||
|
<button
|
||||||
|
class="windows-control maximize"
|
||||||
|
@click=${this._maximizeWindow}
|
||||||
|
aria-label="Maximize window"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor">
|
||||||
|
<rect x="0.5" y="0.5" width="9" height="9" stroke-width="1"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
<button
|
||||||
|
class="windows-control close"
|
||||||
|
@click=${this._closeWindow}
|
||||||
|
aria-label="Close window"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 10 10" fill="currentColor">
|
||||||
|
<path d="M0 0l10 10M10 0L0 10" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'window-header': WindowHeader;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
44
tauri/welcome.html
Normal file
44
tauri/welcome.html
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Welcome to VibeTunnel</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme detection */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
html { color-scheme: dark; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html { color-scheme: light; }
|
||||||
|
html.light { color-scheme: light; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<welcome-app></welcome-app>
|
||||||
|
<script type="module">
|
||||||
|
import './src/components/welcome-app.ts';
|
||||||
|
|
||||||
|
// Apply theme class to html element
|
||||||
|
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
document.documentElement.classList.toggle('light', !isDark);
|
||||||
|
|
||||||
|
// Listen for theme changes
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||||||
|
document.documentElement.classList.toggle('light', !e.matches);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue