feat(gui): profile switcher chip in header
Visible visual indicator of the active profile + click-to-swap dropdown.
Closes the last small UX gap pointed out in earlier sessions.
Tauri (crates/jarvis-gui/src/tauri_commands/profile.rs)
- profile_list() → Vec<String> of available profile names
- profile_active() → {name, description, icon, greeting}
- profile_set(name) → swaps active profile, returns new active
Frontend (frontend/src/components/Header.svelte)
- Polls profile_active every 5 seconds.
- Shows a coloured chip with icon+name when profile != "default"
(orange tint, attention-grabbing). For "default" — muted star chip.
- Click opens a dropdown listing all profile names; click one → swap.
- Dropdown closes on outside-click (shared handler with lang dropdown).
- SCSS styled to match existing aesthetic.
Build: cargo build --release -p jarvis-gui green (2m).
Now the user can see at a glance which profile is active (e.g. "💼 work")
without having to ask voice or open Settings.
This commit is contained in:
parent
ea4341fbc9
commit
4efe306b3a
4 changed files with 225 additions and 6 deletions
|
|
@ -128,6 +128,11 @@ fn main() {
|
|||
tauri_commands::memory_remember,
|
||||
tauri_commands::memory_forget,
|
||||
tauri_commands::memory_search,
|
||||
|
||||
// Profile switching
|
||||
tauri_commands::profile_list,
|
||||
tauri_commands::profile_active,
|
||||
tauri_commands::profile_set,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -53,4 +53,8 @@ pub use scheduler::*;
|
|||
|
||||
// Long-term memory facts
|
||||
mod memory;
|
||||
pub use memory::*;
|
||||
pub use memory::*;
|
||||
|
||||
// Profile switching
|
||||
mod profile;
|
||||
pub use profile::*;
|
||||
38
crates/jarvis-gui/src/tauri_commands/profile.rs
Normal file
38
crates/jarvis-gui/src/tauri_commands/profile.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
//! Tauri commands for profile switching.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ProfileInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub icon: String,
|
||||
pub greeting: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn profile_list() -> Vec<String> {
|
||||
jarvis_core::profiles::list()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn profile_active() -> ProfileInfo {
|
||||
let p = jarvis_core::profiles::active();
|
||||
ProfileInfo {
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
icon: p.icon,
|
||||
greeting: p.greeting,
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn profile_set(name: String) -> Result<ProfileInfo, String> {
|
||||
let p = jarvis_core::profiles::set_active(&name)?;
|
||||
Ok(ProfileInfo {
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
icon: p.icon,
|
||||
greeting: p.greeting,
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { goto } from "@roxi/routify"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { onMount } from "svelte"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import { currentLanguage, setLanguage, translations, translate } from "@/stores"
|
||||
|
||||
|
||||
let appVersion = ""
|
||||
let commandsCount = 0
|
||||
|
||||
|
|
@ -16,12 +16,52 @@
|
|||
{ code: "ua", label: "UA", flag: "🇺🇦", name: "Українська" },
|
||||
]
|
||||
|
||||
// Profile chip — shows the active profile icon + name, click to swap.
|
||||
interface ProfileInfo {
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
greeting: string
|
||||
}
|
||||
let activeProfile: ProfileInfo | null = null
|
||||
let profileNames: string[] = []
|
||||
let profileDropdownOpen = false
|
||||
let profilePoll: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshProfile() {
|
||||
try {
|
||||
activeProfile = await invoke<ProfileInfo>("profile_active")
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function loadProfileNames() {
|
||||
try {
|
||||
profileNames = await invoke<string[]>("profile_list")
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
async function selectProfile(name: string) {
|
||||
try {
|
||||
activeProfile = await invoke<ProfileInfo>("profile_set", { name })
|
||||
} catch (err) {
|
||||
console.error("profile set failed:", err)
|
||||
}
|
||||
profileDropdownOpen = false
|
||||
}
|
||||
|
||||
function toggleProfileDropdown(e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
if (!profileDropdownOpen) {
|
||||
loadProfileNames()
|
||||
}
|
||||
profileDropdownOpen = !profileDropdownOpen
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
appVersion = await invoke<string>("get_app_version")
|
||||
commandsCount = await invoke<number>("get_commands_count")
|
||||
|
||||
// load saved language
|
||||
const savedLang = await invoke<string>("db_read", { key: "language" })
|
||||
if (savedLang) {
|
||||
selectedLang = savedLang
|
||||
|
|
@ -29,6 +69,13 @@
|
|||
} catch {
|
||||
commandsCount = 0
|
||||
}
|
||||
refreshProfile()
|
||||
loadProfileNames()
|
||||
profilePoll = setInterval(refreshProfile, 5000)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (profilePoll !== null) clearInterval(profilePoll)
|
||||
})
|
||||
|
||||
async function selectLanguage(code: string) {
|
||||
|
|
@ -40,18 +87,21 @@
|
|||
langDropdownOpen = !langDropdownOpen
|
||||
}
|
||||
|
||||
function closeLangDropdown(e: MouseEvent) {
|
||||
function closeDropdowns(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.closest('.lang-selector')) {
|
||||
langDropdownOpen = false
|
||||
}
|
||||
if (!target.closest('.profile-selector')) {
|
||||
profileDropdownOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
$: currentLang = languages.find(l => l.code === $currentLanguage) || languages[0]
|
||||
$: t = (key: string) => translate($translations, key)
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeLangDropdown} />
|
||||
<svelte:window on:click={closeDropdowns} />
|
||||
|
||||
<header id="header" class="header">
|
||||
<div class="header-left">
|
||||
|
|
@ -67,6 +117,55 @@
|
|||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
{#if activeProfile && activeProfile.name !== 'default'}
|
||||
<div class="profile-selector">
|
||||
<button
|
||||
class="profile-chip"
|
||||
title="{activeProfile.name} — {activeProfile.description}. Кликни чтобы сменить."
|
||||
on:click|stopPropagation={toggleProfileDropdown}
|
||||
>
|
||||
<span class="profile-icon">{activeProfile.icon || '★'}</span>
|
||||
<span class="profile-name">{activeProfile.name}</span>
|
||||
</button>
|
||||
{#if profileDropdownOpen}
|
||||
<div class="profile-dropdown">
|
||||
{#each profileNames as pname}
|
||||
<button
|
||||
class="profile-option"
|
||||
class:active={pname === activeProfile.name}
|
||||
on:click|stopPropagation={() => selectProfile(pname)}
|
||||
>
|
||||
{pname}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeProfile}
|
||||
<div class="profile-selector">
|
||||
<button
|
||||
class="profile-chip default"
|
||||
title="Текущий профиль — обычный. Кликни чтобы сменить."
|
||||
on:click|stopPropagation={toggleProfileDropdown}
|
||||
>
|
||||
<span class="profile-icon">★</span>
|
||||
</button>
|
||||
{#if profileDropdownOpen}
|
||||
<div class="profile-dropdown">
|
||||
{#each profileNames as pname}
|
||||
<button
|
||||
class="profile-option"
|
||||
class:active={pname === activeProfile.name}
|
||||
on:click|stopPropagation={() => selectProfile(pname)}
|
||||
>
|
||||
{pname}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="header-btn" on:click={() => $goto('/commands')}>
|
||||
<span class="btn-text">{t('header-commands')}</span>
|
||||
<span class="btn-badge purple">{commandsCount}+</span>
|
||||
|
|
@ -112,6 +211,79 @@
|
|||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
.profile-selector {
|
||||
position: relative;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.profile-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 9px;
|
||||
background: rgba(255, 168, 60, 0.12);
|
||||
border: 1px solid rgba(255, 168, 60, 0.35);
|
||||
border-radius: 14px;
|
||||
color: #ffa83c;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.default {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 168, 60, 0.22);
|
||||
border-color: rgba(255, 168, 60, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
.profile-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.profile-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
background: rgba(20, 30, 35, 0.98);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
z-index: 100;
|
||||
min-width: 130px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.profile-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 168, 60, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(255, 168, 60, 0.18);
|
||||
color: #ffa83c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.lang-selector {
|
||||
position: relative;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue