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_remember,
|
||||||
tauri_commands::memory_forget,
|
tauri_commands::memory_forget,
|
||||||
tauri_commands::memory_search,
|
tauri_commands::memory_search,
|
||||||
|
|
||||||
|
// Profile switching
|
||||||
|
tauri_commands::profile_list,
|
||||||
|
tauri_commands::profile_active,
|
||||||
|
tauri_commands::profile_set,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,7 @@ pub use scheduler::*;
|
||||||
// Long-term memory facts
|
// Long-term memory facts
|
||||||
mod memory;
|
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,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { onMount } from "svelte"
|
import { onMount, onDestroy } from "svelte"
|
||||||
import { currentLanguage, setLanguage, translations, translate } from "@/stores"
|
import { currentLanguage, setLanguage, translations, translate } from "@/stores"
|
||||||
|
|
||||||
let appVersion = ""
|
let appVersion = ""
|
||||||
|
|
@ -16,12 +16,52 @@
|
||||||
{ code: "ua", label: "UA", flag: "🇺🇦", name: "Українська" },
|
{ 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 () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
appVersion = await invoke<string>("get_app_version")
|
appVersion = await invoke<string>("get_app_version")
|
||||||
commandsCount = await invoke<number>("get_commands_count")
|
commandsCount = await invoke<number>("get_commands_count")
|
||||||
|
|
||||||
// load saved language
|
|
||||||
const savedLang = await invoke<string>("db_read", { key: "language" })
|
const savedLang = await invoke<string>("db_read", { key: "language" })
|
||||||
if (savedLang) {
|
if (savedLang) {
|
||||||
selectedLang = savedLang
|
selectedLang = savedLang
|
||||||
|
|
@ -29,6 +69,13 @@
|
||||||
} catch {
|
} catch {
|
||||||
commandsCount = 0
|
commandsCount = 0
|
||||||
}
|
}
|
||||||
|
refreshProfile()
|
||||||
|
loadProfileNames()
|
||||||
|
profilePoll = setInterval(refreshProfile, 5000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (profilePoll !== null) clearInterval(profilePoll)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function selectLanguage(code: string) {
|
async function selectLanguage(code: string) {
|
||||||
|
|
@ -40,18 +87,21 @@
|
||||||
langDropdownOpen = !langDropdownOpen
|
langDropdownOpen = !langDropdownOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeLangDropdown(e: MouseEvent) {
|
function closeDropdowns(e: MouseEvent) {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (!target.closest('.lang-selector')) {
|
if (!target.closest('.lang-selector')) {
|
||||||
langDropdownOpen = false
|
langDropdownOpen = false
|
||||||
}
|
}
|
||||||
|
if (!target.closest('.profile-selector')) {
|
||||||
|
profileDropdownOpen = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$: currentLang = languages.find(l => l.code === $currentLanguage) || languages[0]
|
$: currentLang = languages.find(l => l.code === $currentLanguage) || languages[0]
|
||||||
$: t = (key: string) => translate($translations, key)
|
$: t = (key: string) => translate($translations, key)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeLangDropdown} />
|
<svelte:window on:click={closeDropdowns} />
|
||||||
|
|
||||||
<header id="header" class="header">
|
<header id="header" class="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
|
|
@ -67,6 +117,55 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<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')}>
|
<button class="header-btn" on:click={() => $goto('/commands')}>
|
||||||
<span class="btn-text">{t('header-commands')}</span>
|
<span class="btn-text">{t('header-commands')}</span>
|
||||||
<span class="btn-badge purple">{commandsCount}+</span>
|
<span class="btn-badge purple">{commandsCount}+</span>
|
||||||
|
|
@ -112,6 +211,79 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style lang="scss">
|
<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 {
|
.lang-selector {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue