366 lines
13 KiB
Vue
366 lines
13 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/layout/AppThemeBar.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, onMounted } from 'vue';
|
|
import { useLayout } from '@/layout/composables/layout';
|
|
import { useConfiguratorBar } from '@/layout/composables/useConfiguratorBar';
|
|
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options';
|
|
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
|
|
|
|
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant, setRailOpenMode, layoutState, isMobile, effectiveVariant, effectiveMenuMode, railPanelPushesLayout } = useLayout();
|
|
const { close } = useConfiguratorBar();
|
|
|
|
const { init: initSettings, queuePatch } = useUserSettingsPersistence();
|
|
onMounted(() => initSettings());
|
|
|
|
/* ── Offset esquerdo dinâmico ──────────────────────────────
|
|
Rail desktop : 60px (rail) + 260px (painel, se aberto)
|
|
Clássico static ativo: 20rem (sidebar)
|
|
Demais casos : 0px
|
|
───────────────────────────────────────────────────────────── */
|
|
const leftOffset = computed(() => {
|
|
if (isMobile.value) return '0px';
|
|
if (effectiveVariant.value === 'rail') {
|
|
const panelW = railPanelPushesLayout.value ? 260 : 0;
|
|
return `${60 + panelW}px`;
|
|
}
|
|
// Clássico
|
|
if (effectiveMenuMode.value === 'overlay' || layoutState.staticMenuInactive) return '0px';
|
|
return '20rem';
|
|
});
|
|
|
|
const barStyle = computed(() => ({ left: leftOffset.value }));
|
|
|
|
/* ── Cores e presets ────────────────────────────────────── */
|
|
const menuModeOptions = [
|
|
{ label: 'Static', value: 'static' },
|
|
{ label: 'Overlay', value: 'overlay' }
|
|
];
|
|
|
|
const presetModel = computed({
|
|
get: () => layoutConfig.preset,
|
|
set: (v) => {
|
|
layoutConfig.preset = v;
|
|
applyThemeEngine(layoutConfig);
|
|
queuePatch?.({ preset: v });
|
|
saveThemeToStorage();
|
|
}
|
|
});
|
|
|
|
const menuModeModel = computed({
|
|
get: () => layoutConfig.menuMode,
|
|
set: (v) => {
|
|
changeMenuMode(v);
|
|
queuePatch?.({ menu_mode: v });
|
|
}
|
|
});
|
|
|
|
function saveThemeToStorage() {
|
|
try {
|
|
localStorage.setItem('ui_theme_config', JSON.stringify({
|
|
preset: layoutConfig.preset,
|
|
primary: layoutConfig.primary,
|
|
surface: layoutConfig.surface,
|
|
menuMode: layoutConfig.menuMode
|
|
}));
|
|
} catch {}
|
|
}
|
|
|
|
function updateColors(type, item) {
|
|
if (type === 'primary') {
|
|
layoutConfig.primary = item.name;
|
|
applyThemeEngine(layoutConfig);
|
|
queuePatch?.({ primary_color: item.name });
|
|
saveThemeToStorage();
|
|
}
|
|
if (type === 'surface') {
|
|
layoutConfig.surface = item.name;
|
|
applyThemeEngine(layoutConfig);
|
|
queuePatch?.({ surface_color: item.name });
|
|
saveThemeToStorage();
|
|
}
|
|
}
|
|
|
|
function handleSetVariant(v) {
|
|
setVariant(v);
|
|
queuePatch?.({ layout_variant: v });
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="theme-bar" :style="barStyle">
|
|
<div class="theme-bar__inner">
|
|
|
|
<!-- ── Cor principal ─────────────────────────────── -->
|
|
<div class="theme-bar__section">
|
|
<span class="theme-bar__label">Cor principal</span>
|
|
<div class="theme-bar__swatches">
|
|
<button
|
|
v-for="c of primaryColors"
|
|
:key="c.name"
|
|
type="button"
|
|
:title="c.name"
|
|
@click="updateColors('primary', c)"
|
|
:class="['swatch', { 'swatch--active': layoutConfig.primary === c.name }]"
|
|
:style="{ backgroundColor: c.name === 'noir' ? 'var(--text-color)' : c.palette['500'] }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="theme-bar__divider" />
|
|
|
|
<!-- ── Surface ───────────────────────────────────── -->
|
|
<div class="theme-bar__section">
|
|
<span class="theme-bar__label">Surface</span>
|
|
<div class="theme-bar__swatches">
|
|
<button
|
|
v-for="s of surfaces"
|
|
:key="s.name"
|
|
type="button"
|
|
:title="s.name"
|
|
@click="updateColors('surface', s)"
|
|
:class="[
|
|
'swatch',
|
|
{
|
|
'swatch--active': layoutConfig.surface
|
|
? layoutConfig.surface === s.name
|
|
: isDarkTheme
|
|
? s.name === 'zinc'
|
|
: s.name === 'slate'
|
|
}
|
|
]"
|
|
:style="{ backgroundColor: s.palette['500'] }"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="theme-bar__divider" />
|
|
|
|
<!-- ── Preset ─────────────────────────────────────── -->
|
|
<div class="theme-bar__section">
|
|
<span class="theme-bar__label">Preset</span>
|
|
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" size="small" />
|
|
</div>
|
|
|
|
<!-- ── Menu mode (somente layout clássico) ──────── -->
|
|
<template v-if="effectiveVariant === 'classic'">
|
|
<div class="theme-bar__divider" />
|
|
<div class="theme-bar__section">
|
|
<span class="theme-bar__label">Menu</span>
|
|
<SelectButton v-model="menuModeModel" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" size="small" />
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ── Abrir Menu (somente layout rail) ─────────── -->
|
|
<template v-if="effectiveVariant === 'rail'">
|
|
<div class="theme-bar__divider" />
|
|
<div class="theme-bar__section">
|
|
<span class="theme-bar__label">Abrir Menu</span>
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
class="variant-btn"
|
|
:class="{ 'variant-btn--active': layoutConfig.railOpenMode === 'hover' }"
|
|
@click="setRailOpenMode('hover')"
|
|
>
|
|
<i :class="layoutConfig.railOpenMode === 'hover' ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
|
Hover
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="variant-btn"
|
|
:class="{ 'variant-btn--active': layoutConfig.railOpenMode === 'click' }"
|
|
@click="setRailOpenMode('click')"
|
|
>
|
|
<i :class="layoutConfig.railOpenMode === 'click' ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
|
Clique
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="theme-bar__divider" />
|
|
|
|
<!-- ── Layout variant ────────────────────────────── -->
|
|
<div class="theme-bar__section">
|
|
<span class="theme-bar__label">Layout</span>
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
class="variant-btn"
|
|
:class="{ 'variant-btn--active': layoutConfig.variant === 'rail' }"
|
|
@click="handleSetVariant('rail')"
|
|
>
|
|
<i :class="layoutConfig.variant === 'rail' ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
|
Rail
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="variant-btn"
|
|
:class="{ 'variant-btn--active': layoutConfig.variant === 'classic' }"
|
|
@click="handleSetVariant('classic')"
|
|
>
|
|
<i :class="layoutConfig.variant === 'classic' ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
|
Clássico
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Fechar ─────────────────────────────────────── -->
|
|
<button type="button" class="theme-bar__close" title="Fechar painel de tema" @click="close">
|
|
<i class="pi pi-times" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.theme-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
right: 0;
|
|
background: var(--surface-card);
|
|
border-top: 1px solid var(--surface-border);
|
|
box-shadow: 0 -2px 16px rgba(0, 0, 0, 0.08);
|
|
z-index: 200;
|
|
padding: 0.6rem 1rem;
|
|
/* left acompanha o painel rail; transform é o slide-up/down de abrir/fechar */
|
|
transition:
|
|
left 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
|
transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
|
|
.theme-bar__inner {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0;
|
|
flex-wrap: wrap;
|
|
row-gap: 0.5rem;
|
|
min-height: 52px;
|
|
}
|
|
|
|
.theme-bar__section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.3rem;
|
|
padding: 0 0.85rem;
|
|
}
|
|
|
|
.theme-bar__label {
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--text-color-secondary);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.theme-bar__swatches {
|
|
display: flex;
|
|
gap: 0.28rem;
|
|
flex-wrap: wrap;
|
|
max-width: 220px;
|
|
}
|
|
|
|
.theme-bar__divider {
|
|
width: 1px;
|
|
height: 44px;
|
|
background: var(--surface-border);
|
|
flex-shrink: 0;
|
|
align-self: center;
|
|
}
|
|
|
|
/* ── Swatches ─────────────────────────────────────────── */
|
|
.swatch {
|
|
width: 1.05rem;
|
|
height: 1.05rem;
|
|
border-radius: 50%;
|
|
border: none;
|
|
padding: 0;
|
|
cursor: pointer;
|
|
outline: none;
|
|
outline-offset: 2px;
|
|
transition: transform 0.12s;
|
|
flex-shrink: 0;
|
|
}
|
|
.swatch:hover {
|
|
transform: scale(1.2);
|
|
}
|
|
.swatch--active {
|
|
outline: 2px solid var(--primary-color);
|
|
}
|
|
|
|
/* ── Variant buttons ──────────────────────────────────── */
|
|
.variant-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
padding: 0.28rem 0.6rem;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--surface-border);
|
|
background: var(--surface-ground);
|
|
color: var(--text-color-secondary);
|
|
font-size: 0.78rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
.variant-btn:hover {
|
|
color: var(--text-color);
|
|
border-color: color-mix(in srgb, var(--primary-color) 60%, transparent);
|
|
}
|
|
.variant-btn--active {
|
|
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
|
color: var(--primary-color);
|
|
border-color: var(--primary-color);
|
|
}
|
|
|
|
/* ── Fechar ───────────────────────────────────────────── */
|
|
.theme-bar__close {
|
|
margin-left: auto;
|
|
padding-left: 0.85rem;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color-secondary);
|
|
display: grid;
|
|
place-items: center;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
.theme-bar__close:hover {
|
|
background: var(--surface-ground);
|
|
color: var(--text-color);
|
|
}
|
|
|
|
/* ── Mobile: wrap, sem dividers verticais ─────────────── */
|
|
@media (max-width: 768px) {
|
|
.theme-bar__divider {
|
|
display: none;
|
|
}
|
|
.theme-bar__section {
|
|
padding: 0 0.5rem;
|
|
}
|
|
.theme-bar__swatches {
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
</style>
|