Layout 100%, Notificações, SetupWizard

This commit is contained in:
Leonardo
2026-03-17 21:08:14 -03:00
parent 84d65e49c0
commit 66f67cd40f
77 changed files with 35823 additions and 15023 deletions
+376 -3
View File
@@ -1,6 +1,6 @@
<!-- src/layout/AppRailSidebar.vue Drawer mobile para Layout Rail -->
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
@@ -37,6 +37,154 @@ watch(() => layoutState.mobileMenuActive, (open) => {
}
})
function expandAll () {
openSections.value = sections.value.map(s => s.key)
}
function collapseAll () {
openSections.value = []
}
// ── Busca ────────────────────────────────────────────────────
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const searchEl = ref(null)
const searchWrapEl = ref(null)
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
}
loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) { forcedOpen.value = false; showResults.value = true; return }
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null })
}
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail))
}
return out
}
const allLinks = computed(() => flattenMenu(menuStore.model || []))
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
watch(results, (list) => { activeIndex.value = list.length ? 0 : -1 })
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
function onSearchKeydown (e) {
if (e.key === 'Escape') { showResults.value = false; forcedOpen.value = false; return }
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goToResult(results.value[activeIndex.value])
}
}
function onSearchFocus () {
if (!query.value?.trim()) { forcedOpen.value = true; showResults.value = true }
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.())
}
async function goToResult (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
await router.push(r.to)
hideMobileMenu()
}
function isSectionOpen (key) {
return openSections.value.includes(key)
}
@@ -99,6 +247,115 @@ watch(() => route.path, () => hideMobileMenu())
</button>
</div>
<!-- Busca + ações -->
<div ref="searchWrapEl" class="rs__search-area">
<!-- Campo de busca -->
<div class="rs__search-field">
<div
aria-hidden="true"
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
>
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="rs_menu_search"
name="rs_menu_search"
type="search"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-8"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="rs_menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="rs__search-clear"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Dropdown de resultados recentes -->
<div
v-if="showResults && !query.trim() && recent.length"
class="rs__dropdown"
>
<div class="rs__dropdown-header">
<span>Recentes</span>
<button type="button" class="rs__dropdown-action" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent"
:key="q"
class="rs__dropdown-item"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history rs__dropdown-item-icon" />
<span class="flex-1">{{ q }}</span>
</button>
</div>
<!-- Dropdown de resultados de busca -->
<div
v-else-if="showResults && results.length"
class="rs__dropdown"
>
<button
v-for="(r, i) in results"
:key="String(r.to)"
type="button"
@mousedown.prevent="goToResult(r)"
:class="['rs__dropdown-item', i === activeIndex ? 'rs__dropdown-item--active' : '']"
>
<i v-if="r.icon" :class="r.icon" class="rs__dropdown-item-icon" />
<div class="flex flex-col flex-1">
<span class="rs__dropdown-item-label" v-html="highlight(r.label, query)" />
<small class="rs__dropdown-item-trail">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="rs__pro">PRO</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="rs__no-results">
Nenhum item encontrado.
</div>
<!-- Botões expandir/contrair -->
<div class="rs__actions">
<button class="rs__action-btn" type="button" @click="expandAll">
<i class="pi pi-chevron-down" />
<span>Expandir tudo</span>
</button>
<button class="rs__action-btn" type="button" @click="collapseAll">
<i class="pi pi-chevron-up" />
<span>Contrair tudo</span>
</button>
</div>
</div>
<!-- Nav -->
<nav class="rs__nav">
<template v-for="section in sections" :key="section.key">
@@ -205,7 +462,123 @@ watch(() => route.path, () => hideMobileMenu())
color: var(--text-color);
}
/* ── Nav list ─────────────────────────────────────────────── */
/* ── Search area ──────────────────────────────────────────── */
.rs__search-area {
flex-shrink: 0;
padding: 10px 12px 0;
border-bottom: 1px solid var(--surface-border);
}
.rs__search-field {
position: relative;
}
.rs__search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
opacity: 0.6;
color: var(--text-color-secondary);
font-size: 0.75rem;
display: grid;
place-items: center;
padding: 2px;
}
.rs__search-clear:hover { opacity: 1; }
/* ── Dropdown ─────────────────────────────────────────────── */
.rs__dropdown {
margin-top: 6px;
border: 1px solid var(--surface-border);
border-radius: 10px;
background: var(--surface-card);
box-shadow: 0 2px 8px rgba(0,0,0,.08);
overflow: hidden;
}
.rs__dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
font-size: 0.72rem;
opacity: 0.65;
color: var(--text-color-secondary);
}
.rs__dropdown-action {
background: transparent;
border: none;
cursor: pointer;
opacity: 0.7;
color: inherit;
font-size: 0.72rem;
}
.rs__dropdown-action:hover { opacity: 1; }
.rs__dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-color);
font-size: 0.82rem;
text-align: left;
transition: background 0.12s;
}
.rs__dropdown-item:hover,
.rs__dropdown-item--active { background: var(--surface-hover); }
.rs__dropdown-item-icon {
font-size: 0.82rem;
opacity: 0.7;
flex-shrink: 0;
}
.rs__dropdown-item-label { font-weight: 500; line-height: 1.3; }
.rs__dropdown-item-trail {
font-size: 0.7rem;
opacity: 0.6;
}
.rs__no-results {
padding: 8px 4px;
font-size: 0.8rem;
opacity: 0.6;
color: var(--text-color-secondary);
}
/* ── Botões expandir/contrair ─────────────────────────────── */
.rs__actions {
display: flex;
gap: 6px;
padding: 8px 0 10px;
}
.rs__action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.13s, color 0.13s;
}
.rs__action-btn:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rs__action-btn .pi {
font-size: 0.65rem;
}
.rs__nav {
flex: 1;
overflow-y: auto;
@@ -309,4 +682,4 @@ watch(() => route.path, () => hideMobileMenu())
.rs-slide-leave-to {
transform: translateX(-100%);
}
</style>
</style>