mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-25 14:57:37 +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(())
|
||||
}
|
||||
|
||||
// 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -394,6 +394,14 @@ fn main() {
|
|||
// Welcome flow commands
|
||||
request_all_permissions,
|
||||
test_terminal,
|
||||
check_vt_installation,
|
||||
install_vt,
|
||||
check_permissions,
|
||||
request_automation_permission,
|
||||
request_accessibility_permission,
|
||||
save_dashboard_password,
|
||||
open_dashboard,
|
||||
finish_welcome,
|
||||
])
|
||||
.setup(|app| {
|
||||
// Set app handle in managers
|
||||
|
|
|
|||
|
|
@ -211,9 +211,11 @@ impl WelcomeManager {
|
|||
tauri::WebviewUrl::App("welcome.html".into()),
|
||||
)
|
||||
.title("Welcome to VibeTunnel")
|
||||
.inner_size(800.0, 600.0)
|
||||
.inner_size(640.0, 560.0)
|
||||
.center()
|
||||
.resizable(false)
|
||||
.decorations(false)
|
||||
.transparent(true)
|
||||
.build()
|
||||
.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 './settings-tab';
|
||||
import './settings-checkbox';
|
||||
import './settings-select';
|
||||
import './glowing-app-icon';
|
||||
|
||||
interface SettingsData {
|
||||
general?: {
|
||||
|
|
@ -13,9 +15,18 @@ interface SettingsData {
|
|||
theme?: 'system' | 'light' | 'dark';
|
||||
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?: {
|
||||
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 */
|
||||
:host {
|
||||
/* Dark theme (default) */
|
||||
|
|
@ -390,24 +567,18 @@ export class SettingsApp extends TauriBase {
|
|||
|
||||
<div class="setting-card">
|
||||
<h3>Terminal</h3>
|
||||
<div class="form-group">
|
||||
<label for="terminal-select">Default Terminal</label>
|
||||
<select
|
||||
id="terminal-select"
|
||||
class="form-select"
|
||||
@change=${(e: Event) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
this.handleSettingChange(new CustomEvent('change', {
|
||||
detail: { settingKey: 'general.default_terminal', value: target.value }
|
||||
}) as SettingChangeEvent);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<settings-select
|
||||
label="Default Terminal"
|
||||
help="Choose your preferred terminal application"
|
||||
settingKey="general.default_terminal"
|
||||
.value=${this.settings.general?.default_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">
|
||||
|
|
@ -420,25 +591,117 @@ export class SettingsApp extends TauriBase {
|
|||
@change=${this.handleSettingChange}
|
||||
></settings-checkbox>
|
||||
|
||||
<settings-select
|
||||
label="Theme"
|
||||
help="Choose your preferred color scheme"
|
||||
settingKey="general.theme"
|
||||
.value=${this.settings.general?.theme || 'system'}
|
||||
.options=${[
|
||||
{ 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', {
|
||||
detail: { settingKey: 'dashboard.password', value: input.value }
|
||||
}) as SettingChangeEvent);
|
||||
}}
|
||||
/>
|
||||
</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 for="theme-select">Theme</label>
|
||||
<select
|
||||
id="theme-select"
|
||||
class="form-select"
|
||||
@change=${(e: Event) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
<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: 'general.theme', value: target.value }
|
||||
detail: { settingKey: 'dashboard.port', value: input.value }
|
||||
}) 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>
|
||||
/>
|
||||
<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>
|
||||
`;
|
||||
|
|
@ -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() {
|
||||
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"/>` },
|
||||
|
|
@ -487,20 +862,11 @@ export class SettingsApp extends TauriBase {
|
|||
${this._renderDebugTab()}
|
||||
|
||||
<!-- Other tabs will be added here -->
|
||||
<div class="tab-content ${this.activeTab === 'dashboard' ? 'active' : ''}" id="dashboard">
|
||||
<h2>Dashboard</h2>
|
||||
<p>Dashboard settings coming soon...</p>
|
||||
</div>
|
||||
${this._renderDashboardTab()}
|
||||
|
||||
<div class="tab-content ${this.activeTab === 'advanced' ? 'active' : ''}" id="advanced">
|
||||
<h2>Advanced</h2>
|
||||
<p>Advanced settings coming soon...</p>
|
||||
</div>
|
||||
${this._renderAdvancedTab()}
|
||||
|
||||
<div class="tab-content ${this.activeTab === 'about' ? 'active' : ''}" id="about">
|
||||
<h2>About</h2>
|
||||
<p>VibeTunnel v${this.systemInfo.version || '1.0.0'}</p>
|
||||
</div>
|
||||
${this._renderAboutTab()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -5,11 +5,15 @@ import { customElement, property } from 'lit/decorators.js';
|
|||
export class SettingsCheckbox extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
|
|
@ -20,15 +24,15 @@ export class SettingsCheckbox extends LitElement {
|
|||
}
|
||||
|
||||
.checkbox-indicator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
|
||||
border: 2px solid var(--border-secondary, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid var(--border-secondary, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
margin-right: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
|
|
@ -42,15 +46,19 @@ export class SettingsCheckbox extends LitElement {
|
|||
border-color: var(--accent, #10b981);
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked + .checkbox-indicator::after {
|
||||
content: '✓';
|
||||
.checkbox-indicator svg {
|
||||
position: absolute;
|
||||
color: var(--text-primary, #fff);
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
top: 50%;
|
||||
left: 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 {
|
||||
|
|
@ -104,7 +112,11 @@ export class SettingsCheckbox extends LitElement {
|
|||
.checked=${this.checked}
|
||||
@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">
|
||||
<span class="label">${this.label}</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 { classMap } from 'lit/directives/class-map.js';
|
||||
import { buttonStyles } from './styles';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
type ButtonSize = 'small' | 'medium' | 'large';
|
||||
|
||||
@customElement('vt-button')
|
||||
export class VTButton extends LitElement {
|
||||
static override styles = [
|
||||
buttonStyles,
|
||||
css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
export class VtButton extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
button {
|
||||
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 {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
button:focus-visible {
|
||||
box-shadow: 0 0 0 3px var(--accent-glow, rgba(16, 185, 129, 0.5));
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
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-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
`
|
||||
];
|
||||
}
|
||||
|
||||
.content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:host([loading]) .content {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:host(:not([loading])) .loading {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ type: String })
|
||||
variant: ButtonVariant = 'primary';
|
||||
|
||||
@property({ type: String })
|
||||
size: ButtonSize = 'md';
|
||||
size: ButtonSize = 'medium';
|
||||
|
||||
@property({ type: Boolean })
|
||||
disabled = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
@property({ type: Boolean, reflect: true })
|
||||
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() {
|
||||
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`
|
||||
<button
|
||||
class=${classMap(classes)}
|
||||
?disabled=${isDisabled}
|
||||
@click=${this._handleClick}
|
||||
aria-busy=${this.loading ? 'true' : 'false'}
|
||||
<button
|
||||
class="${this.variant} ${this.size}"
|
||||
?disabled=${this.disabled || this.loading}
|
||||
@click=${this.handleClick}
|
||||
>
|
||||
${content}
|
||||
<span class="content">
|
||||
<slot></slot>
|
||||
</span>
|
||||
${this.loading ? html`
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
` : ''}
|
||||
</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