Refactor auth #65

Merged
killer-cf merged 8 commits from refactor-auth into master 2025-12-13 22:13:28 +00:00
19 changed files with 988 additions and 1240 deletions
Showing only changes of commit 4f238022cf - Show all commits

View File

@@ -4,8 +4,8 @@
const currentYear = new Date().getFullYear();
</script>
<footer class="bg-base-200 text-base-content border-base-300 border-t">
<div class="container mx-auto px-4 py-10">
<footer class="bg-base-200 text-base-content border-base-300 mt-16 border-t">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 gap-8 text-center md:grid-cols-3 md:text-left">
<div>
<h3 class="text-primary mb-4 text-lg font-bold">SGSE</h3>

View File

@@ -1,20 +1,43 @@
<script lang="ts">
import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png';
import { page } from '$app/state';
import type { Snippet } from 'svelte';
type HeaderProps = {
left?: Snippet;
right?: Snippet;
};
const { left, right }: HeaderProps = $props();
</script>
<header class="sticky top-0 z-50 w-full border-b backdrop-blur-md bg-base-100/90 border-base-200 shadow-sm transition-all duration-300">
<div class="container mx-auto px-4 h-16 flex items-center justify-between">
<a href="/" class="flex items-center gap-3 group transition-transform hover:scale-[1.02]">
<img src={logo} alt="Logo Governo PE" class="h-10 w-auto object-contain drop-shadow-sm" />
<div class="flex flex-col hidden sm:flex">
<span class="text-xs font-bold text-primary tracking-wider uppercase">Governo de</span>
<span class="text-lg font-extrabold -mt-1 tracking-tight text-base-content leading-none">Pernambuco</span>
</div>
</a>
<header
class="bg-base-200 border-base-100 sticky top-0 z-50 w-full border-b shadow-sm backdrop-blur-md transition-all duration-300"
>
<div class=" flex h-16 w-full items-center justify-between px-4">
<div class="flex items-center gap-3">
{#if left}
{@render left()}
{/if}
<nav class="flex items-center gap-2">
<!-- Links can be added here based on auth state or specific requirements -->
</nav>
<a
href={resolve('/')}
class="group flex items-center gap-3 transition-transform hover:scale-[1.02]"
>
<img src={logo} alt="Logo Governo PE" class="h-10 w-auto object-contain drop-shadow-sm" />
<div class="hidden flex-col sm:flex">
<span class="text-primary text-xs font-bold tracking-wider uppercase">Governo de</span>
<span class="text-base-content -mt-1 text-lg leading-none font-extrabold tracking-tight"
>Pernambuco</span
>
</div>
</a>
</div>
<div class="flex items-center gap-2">
{#if right}
{@render right()}
{/if}
</div>
</div>
</header>

View File

@@ -3,39 +3,19 @@
import { useQuery } from 'convex-svelte';
import type { FunctionReference } from 'convex/server';
import {
Home,
User,
UserPlus,
XCircle,
Users,
DollarSign,
ChevronDown,
ClipboardCheck,
FileText,
ShoppingCart,
Scale,
Megaphone,
Trophy,
Briefcase,
UserCog,
Monitor,
ChevronDown,
GitMerge,
Home,
Settings,
Check,
LogIn,
Menu,
Plus,
Tag
Tag,
Users,
Briefcase,
UserPlus
} from 'lucide-svelte';
import type { Snippet } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import logo from '$lib/assets/logo_governo_PE.png';
import { authClient } from '$lib/auth';
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
import PresenceManager from '$lib/components/chat/PresenceManager.svelte';
interface MenuItemPermission {
recurso: string;
@@ -198,12 +178,13 @@
type IconType = typeof Home;
const { children }: { children: Snippet } = $props();
type SidebarProps = {
onNavigate?: () => void;
};
const { onNavigate }: SidebarProps = $props();
let currentPath = $derived(page.url.pathname);
let showAboutModal = $state(false);
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
const permissionsQuery = useQuery(api.menu.getUserPermissions as FunctionReference<'query'>, {});
// Filtrar menu baseado nas permissões do usuário
@@ -267,27 +248,14 @@
const iconMap: Record<string, IconType> = {
Home,
User,
UserPlus,
XCircle,
Users,
DollarSign,
ClipboardCheck,
FileText,
ShoppingCart,
Scale,
Megaphone,
Trophy,
Briefcase,
UserCog,
Monitor,
ChevronDown,
GitMerge,
Settings,
Check,
LogIn,
Menu,
Plus,
Tag
};
@@ -330,419 +298,102 @@
function getSolicitarClasses(active: boolean) {
return getMenuClasses(active);
}
// Função para obter a URL do avatar/foto do usuário
let avatarUrlDoUsuario = $derived.by(() => {
if (!currentUser.data) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (currentUser.data.fotoPerfilUrl) {
return currentUser.data.fotoPerfilUrl;
}
if (currentUser.data.avatar) {
return currentUser.data.avatar;
}
// Fallback: retornar null para usar o ícone User do Lucide
return null;
});
function goToLogin(redirectTo?: string) {
const target = redirectTo || currentPath || '/';
goto(`${resolve('/login')}?redirect=${encodeURIComponent(target)}`);
}
function openAboutModal() {
showAboutModal = true;
}
function closeAboutModal() {
showAboutModal = false;
}
async function handleLogout() {
const result = await authClient.signOut();
if (result.error) {
console.error('Sign out error:', result.error);
}
// Resetar tema para padrão ao fazer logout
const { aplicarTemaPadrao } = await import('$lib/utils/temas');
aplicarTemaPadrao();
goto(resolve('/'));
}
</script>
<!-- Header Fixo acima de tudo -->
<!-- Header Fixo Minimalista & Premium -->
<div
class="navbar border-primary/10 from-primary/10 via-primary/5 to-primary/10 fixed top-0 right-0 left-0 z-50 h-16 border-b bg-linear-to-r px-4 shadow-sm"
<nav
class="menu text-base-content bg-base-200 border-base-100 h-[calc(100vh-64px)] w-full flex-col gap-2 overflow-y-auto p-4"
>
<div class="flex-none lg:hidden">
<label for="my-drawer-3" class="btn btn-square btn-ghost btn-sm" aria-label="Abrir menu">
<Menu class="h-5 w-5" />
</label>
</div>
<div class="flex flex-1 items-center gap-4 lg:gap-6">
<!-- Logo Visível e Ajustado -->
<div class="flex items-center gap-3">
<div class="relative h-10 overflow-hidden rounded-lg shadow-sm lg:h-14">
<img src={logo} alt="Logo do Governo de PE" class="h-full w-full object-contain p-1" />
</div>
<div class="flex flex-col justify-center">
<h1 class="text-base-content text-xl leading-none font-bold tracking-tight lg:text-2xl">
SGSE
</h1>
<span class="text-base-content/60 font-medium tracking-wider uppercase"
>Secretaria de Esportes</span
>
</div>
</div>
</div>
<div class="ml-auto flex flex-none items-center gap-3 lg:gap-5">
{#if currentUser.data}
<!-- Nome e Perfil -->
<div class="hidden flex-col items-end lg:flex">
<span class="text-base-content text-sm leading-tight font-semibold"
>{currentUser.data.nome}</span
>
<span class="text-base-content/60 text-xs leading-tight">{currentUser.data.role?.nome}</span
>
</div>
{#snippet menuItem(item: MenuItem)}
{@const Icon = getIconComponent(item.icon)}
{@const isActive = isRouteActive(item.link, {
exact: item.link === '/',
excludePaths: item.excludePaths
})}
{@const hasSubmenus = item.submenus && item.submenus.length > 0}
<div class="dropdown dropdown-end">
<!-- Botão de Perfil com Avatar -->
<button
type="button"
tabindex="0"
class="btn avatar ring-base-200 hover:ring-primary/50 h-12 w-12 p-0 ring-2 ring-offset-2 transition-all"
aria-label="Menu do usuário"
>
<div class="h-full w-full overflow-hidden rounded-full">
{#if avatarUrlDoUsuario}
<img
src={avatarUrlDoUsuario}
alt={currentUser.data?.nome || 'Usuário'}
class="h-full w-full object-cover"
/>
{:else}
<div
class="bg-primary/10 text-primary flex h-full w-full items-center justify-center"
>
<User class="h-6 w-6" />
</div>
{/if}
</div>
</button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box ring-base-content/5 z-1 mt-3 w-56 p-2 shadow-xl ring-1"
>
<li class="menu-title border-base-200 mb-2 border-b px-4 py-2">
<span class="text-base-content font-bold">{currentUser.data?.nome}</span>
<span class="text-base-content/60 text-xs font-normal">{currentUser.data.email}</span>
</li>
<li>
<a href={resolve('/perfil')} class="active:bg-primary/10 active:text-primary"
><UserCog class="mr-2 h-4 w-4" /> Meu Perfil</a
>
</li>
<li>
<a href={resolve('/alterar-senha')} class="active:bg-primary/10 active:text-primary"
><Settings class="mr-2 h-4 w-4" /> Alterar Senha</a
>
</li>
<div class="divider my-1"></div>
<li>
<button type="button" onclick={handleLogout} class="text-error hover:bg-error/10"
><LogIn class="mr-2 h-4 w-4 rotate-180" /> Sair</button
>
</li>
</ul>
</div>
<!-- Sino de notificações -->
<div class="relative">
<NotificationBell />
</div>
{:else}
<button
type="button"
class="btn btn-primary btn-sm rounded-full px-6"
onclick={() => goToLogin()}
>
Entrar
</button>
{/if}
</div>
</div>
<div class="drawer lg:drawer-open" style="margin-top: 64px;">
<input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
<div
class="drawer-content flex flex-col transition-all duration-300 lg:ml-72"
style="min-height: calc(100vh - 64px);"
>
<!-- Page content -->
<div class="flex-1 overflow-y-auto p-6">
{@render children?.()}
</div>
<!-- Footer Minimalista -->
<footer
class="footer footer-center text-base-content/60 border-base-200 bg-base-100/50 border-t p-4 text-xs backdrop-blur-sm"
>
<div class="flex flex-wrap justify-center gap-6">
<button
type="button"
class="hover:text-primary font-medium transition-colors"
onclick={() => openAboutModal()}>Sobre</button
>
<a href={resolve('/')} class="hover:text-primary font-medium transition-colors">Contato</a>
<a href={resolve('/abrir-chamado')} class="hover:text-primary font-medium transition-colors"
>Suporte</a
>
<span class="text-base-content/30"></span>
<a
href={resolve('/abrir-chamado')}
class="link link-hover hover:text-primary transition-colors">Suporte</a
>
<span class="text-base-content/30"></span>
<a
href={resolve('/privacidade')}
class="link link-hover hover:text-primary transition-colors">Privacidade</a
>
</div>
<div class="mt-2 opacity-70">
<p>© {new Date().getFullYear()} Governo de Pernambuco - Secretaria de Esportes</p>
</div>
</footer>
</div>
<div class="drawer-side fixed z-40" style="margin-top: 64px;">
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label>
<div
class="menu text-base-content border-base-300 from-primary/10 via-primary/5 to-primary/10 h-[calc(100vh-64px)] w-72 flex-col gap-2 overflow-y-auto border-r-2 bg-linear-to-b p-4 backdrop-blur-sm"
>
<!-- Sidebar menu items -->
<!-- Sidebar menu items -->
{#snippet menuItem(item: MenuItem)}
{@const Icon = getIconComponent(item.icon)}
{@const isActive = isRouteActive(item.link, {
exact: item.link === '/',
excludePaths: item.excludePaths
})}
{@const hasSubmenus = item.submenus && item.submenus.length > 0}
<li class="mb-1">
{#if hasSubmenus}
<details open={isActive} class="group/details">
<summary
class="{getMenuClasses(
isActive,
false,
true
)} cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden"
>
<div class="flex items-center gap-3">
<Icon class="h-5 w-5" strokeWidth={2} />
<span>{item.label}</span>
</div>
<ChevronDown
class="h-4 w-4 opacity-50 transition-transform duration-200 group-open/details:rotate-180"
/>
</summary>
<ul class="border-base-200 mt-1 ml-4 space-y-1 pl-2">
{#if item.submenus}
{#each item.submenus as sub (sub.link)}
{@const isSubActive = isRouteActive(sub.link, {
excludePaths: sub.excludePaths,
exact: sub.exact
})}
<li>
<a href={resolve(sub.link as any)} class={getMenuClasses(isSubActive, true)}>
<span>{sub.label}</span>
</a>
</li>
{/each}
{/if}
</ul>
</details>
{:else}
<a
href={resolve(item.link as any)}
aria-current={isActive ? 'page' : undefined}
class={getMenuClasses(isActive)}
>
<li class="mb-1">
{#if hasSubmenus}
<details open={isActive} class="group/details">
<summary
class="{getMenuClasses(
isActive,
false,
true
)} cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden"
>
<div class="flex items-center gap-3">
<Icon class="h-5 w-5" strokeWidth={2} />
<span>{item.label}</span>
</a>
{/if}
</li>
{/snippet}
<ul class="menu w-full flex-1 p-0 px-2">
{#if permissionsQuery.isLoading}
<div class="flex flex-col gap-2 p-4">
{#each Array(5)}
<div class="skeleton h-12 w-full rounded-lg"></div>
{/each}
</div>
{:else}
{#each menuItems as item (item.link)}
{@render menuItem(item)}
{/each}
{/if}
</ul>
<ul class="menu mt-auto w-full p-0 px-2">
<div class="divider before:bg-base-300 after:bg-base-300 my-2 px-2"></div>
<li class="px-2">
<a
href={resolve('/abrir-chamado')}
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
>
<UserPlus class="h-5 w-5" strokeWidth={2} />
<span>Abrir Chamado</span>
</a>
</li>
</ul>
</div>
</div>
</div>
<!-- Modal Sobre -->
{#if showAboutModal}
<dialog class="modal modal-open">
<div
class="modal-box from-base-100 to-base-200 relative max-w-md overflow-hidden bg-linear-to-br shadow-xl"
>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 absolute top-2 right-2 z-10"
onclick={closeAboutModal}
>
</button>
<div class="space-y-5 px-6 py-6 text-center">
<!-- Logo e Título -->
<div class="flex flex-col items-center gap-3">
<div class="avatar">
<div class="ring-primary/20 w-20 rounded-xl bg-white p-3 shadow-lg ring-2">
<img src={logo} alt="Logo SGSE" class="h-full w-full object-contain" />
</div>
</div>
<div class="space-y-1">
<h3 class="text-primary text-2xl font-bold tracking-tight">SGSE</h3>
<p class="text-base-content/70 text-sm font-medium">
Sistema de Gerenciamento de Secretaria
</p>
</div>
</div>
<!-- Divider -->
<div class="divider my-1"></div>
<!-- Informações de Versão -->
<div
class="from-primary/10 to-primary/5 border-primary/10 space-y-2 rounded-xl border bg-linear-to-br p-4 shadow-sm"
<ChevronDown
class="h-4 w-4 opacity-50 transition-transform duration-200 group-open/details:rotate-180"
/>
</summary>
<ul class="border-base-200 mt-1 ml-4 space-y-1 pl-2">
{#if item.submenus}
{#each item.submenus as sub (sub.link)}
{@const isSubActive = isRouteActive(sub.link, {
excludePaths: sub.excludePaths,
exact: sub.exact
})}
<li>
<a
href={resolve(sub.link as any)}
class={getMenuClasses(isSubActive, true)}
onclick={() => onNavigate?.()}
>
<span>{sub.label}</span>
</a>
</li>
{/each}
{/if}
</ul>
</details>
{:else}
<a
href={resolve(item.link as any)}
aria-current={isActive ? 'page' : undefined}
class={getMenuClasses(isActive)}
onclick={() => onNavigate?.()}
>
<div class="flex items-center justify-center gap-2">
<Tag class="text-primary h-4 w-4" strokeWidth={2} />
<p class="text-base-content/60 text-xs font-medium tracking-wide uppercase">Versão</p>
</div>
<p class="text-primary text-2xl font-bold tracking-tight">1.0 11_2025</p>
<div class="badge badge-warning badge-sm gap-1.5 px-3 py-1.5 text-xs">
<Plus class="h-3.5 w-3.5" strokeWidth={2} />
Em Desenvolvimento
</div>
</div>
<Icon class="h-5 w-5" strokeWidth={2} />
<span>{item.label}</span>
</a>
{/if}
</li>
{/snippet}
<!-- Desenvolvido por -->
<div class="space-y-1.5">
<p class="text-base-content/50 text-xs font-medium tracking-wide uppercase">
Desenvolvido por
</p>
<p class="text-primary text-sm font-semibold">Secretaria de Esportes de Pernambuco</p>
</div>
<!-- Divider -->
<div class="divider my-1"></div>
<!-- Informações Adicionais -->
<div class="grid grid-cols-2 gap-3">
<div
class="bg-base-200/60 border-base-300/50 rounded-lg border p-3 shadow-sm transition-all hover:shadow-md"
>
<p class="text-primary mb-1 text-xs font-semibold tracking-wide uppercase">Governo</p>
<p class="text-base-content/60 text-xs font-medium">Estado de Pernambuco</p>
</div>
<div
class="bg-base-200/60 border-base-300/50 rounded-lg border p-3 shadow-sm transition-all hover:shadow-md"
>
<p class="text-primary mb-1 text-xs font-semibold tracking-wide uppercase">Ano</p>
<p class="text-base-content/60 text-xs font-medium">2025</p>
</div>
</div>
<!-- Botão OK -->
<div class="pt-3">
<button
type="button"
class="btn btn-primary btn-sm mx-auto w-full max-w-xs shadow-md transition-all duration-200 hover:shadow-lg"
onclick={closeAboutModal}
>
<Check class="h-4 w-4" strokeWidth={2} />
OK
</button>
</div>
<ul class="menu w-full flex-1 p-0 px-2">
{#if permissionsQuery.isLoading}
<div class="flex flex-col gap-2 p-4">
{#each Array(5)}
<div class="skeleton h-12 w-full rounded-lg"></div>
{/each}
</div>
</div>
<div
class="modal-backdrop"
onclick={closeAboutModal}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Escape' && closeAboutModal()}
></div>
</dialog>
{/if}
{:else}
{#each menuItems as item (item.link)}
{@render menuItem(item)}
{/each}
{/if}
</ul>
<!-- Componentes de Chat (apenas se autenticado) -->
{#if currentUser.data}
<PresenceManager />
<ChatWidget />
{/if}
<ul class="menu mt-auto w-full p-0 px-2">
<div class="divider before:bg-base-300 after:bg-base-300 my-2 px-2"></div>
<li class="px-2">
<a
href={resolve('/abrir-chamado')}
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
onclick={() => onNavigate?.()}
>
<UserPlus class="h-5 w-5" strokeWidth={2} />
<span>Abrir Chamado</span>
</a>
</li>
</ul>
</nav>
<style>
/* Animação de pulso sutil para o anel do botão de perfil */
@keyframes pulse-ring-subtle {
0%,
100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
}
/* Animação de pulso para o badge de status online */
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
/* Remove default details marker */
details > summary {
list-style: none;

View File

@@ -0,0 +1,124 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import type { FunctionReference } from 'convex/server';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { LogIn, Settings, User, UserCog } from 'lucide-svelte';
import { authClient } from '$lib/auth';
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
let currentPath = $derived(page.url.pathname);
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
// Função para obter a URL do avatar/foto do usuário
let avatarUrlDoUsuario = $derived.by(() => {
if (!currentUser.data) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (currentUser.data.fotoPerfilUrl) {
return currentUser.data.fotoPerfilUrl;
}
if (currentUser.data.avatar) {
return currentUser.data.avatar;
}
// Fallback: retornar null para usar o ícone User do Lucide
return null;
});
function goToLogin(redirectTo?: string) {
const target = redirectTo || currentPath || '/';
goto(`${resolve('/login')}?redirect=${encodeURIComponent(target)}`);
}
async function handleLogout() {
const result = await authClient.signOut();
if (result.error) {
console.error('Sign out error:', result.error);
}
// Resetar tema para padrão ao fazer logout
const { aplicarTemaPadrao } = await import('$lib/utils/temas');
aplicarTemaPadrao();
goto(resolve('/home'));
}
</script>
<div class="flex items-center gap-3">
{#if currentUser.data}
<!-- Nome e Perfil -->
<div class="hidden flex-col items-end lg:flex">
<span class="text-base-content text-sm leading-tight font-semibold"
>{currentUser.data.nome}</span
>
<span class="text-base-content/60 text-xs leading-tight">{currentUser.data.role?.nome}</span>
</div>
<div class="dropdown dropdown-end">
<!-- Botão de Perfil com Avatar -->
<button
type="button"
tabindex="0"
class="btn avatar ring-base-200 hover:ring-primary/50 h-10 w-10 p-0 ring-2 ring-offset-2 transition-all"
aria-label="Menu do usuário"
>
<div class="h-full w-full overflow-hidden rounded-full">
{#if avatarUrlDoUsuario}
<img
src={avatarUrlDoUsuario}
alt={currentUser.data?.nome || 'Usuário'}
class="h-full w-full object-cover"
/>
{:else}
<div class="bg-primary/10 text-primary flex h-full w-full items-center justify-center">
<User class="h-5 w-5" />
</div>
{/if}
</div>
</button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box ring-base-content/5 z-1 mt-3 w-56 p-2 shadow-xl ring-1"
>
<li class="menu-title border-base-200 mb-2 border-b px-4 py-2">
<span class="text-base-content font-bold">{currentUser.data?.nome}</span>
<span class="text-base-content/60 text-xs font-normal">{currentUser.data.email}</span>
</li>
<li>
<a href={resolve('/perfil')} class="active:bg-primary/10 active:text-primary"
><UserCog class="mr-2 h-4 w-4" /> Meu Perfil</a
>
</li>
<li>
<a href={resolve('/alterar-senha')} class="active:bg-primary/10 active:text-primary"
><Settings class="mr-2 h-4 w-4" /> Alterar Senha</a
>
</li>
<div class="divider my-1"></div>
<li>
<button type="button" onclick={handleLogout} class="text-error hover:bg-error/10"
><LogIn class="mr-2 h-4 w-4 rotate-180" /> Sair</button
>
</li>
</ul>
</div>
<!-- Sino de notificações -->
<div class="relative">
<NotificationBell />
</div>
{:else}
<button
type="button"
class="btn btn-primary btn-sm rounded-full px-6"
onclick={() => goToLogin()}
>
Entrar
</button>
{/if}
</div>

View File

@@ -1,16 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Toaster } from 'svelte-sonner';
import PushNotificationManager from '$lib/components/PushNotificationManager.svelte';
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import DashboardHeaderActions from '$lib/components/dashboard/DashboardHeaderActions.svelte';
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
import PresenceManager from '$lib/components/chat/PresenceManager.svelte';
import { Menu, X } from 'lucide-svelte';
const { children } = $props();
let sidebarOpen = $state(false);
const toggleSidebar = () => (sidebarOpen = !sidebarOpen);
const closeSidebar = () => (sidebarOpen = false);
// No desktop, abrir por padrão; no mobile, começar fechado
onMount(() => {
sidebarOpen = window.matchMedia('(min-width: 1024px)').matches;
});
</script>
<div class="flex flex-col">
<Sidebar>
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
</Sidebar>
<div
class="bg-base-100 text-base-content selection:bg-primary selection:text-primary-content flex min-h-screen flex-col font-sans"
>
<Header>
{#snippet left()}
<button
type="button"
class="btn btn-ghost btn-sm"
aria-label={sidebarOpen ? 'Fechar menu' : 'Abrir menu'}
onclick={toggleSidebar}
>
{#if sidebarOpen}
<X class="h-5 w-5" />
{:else}
<Menu class="h-5 w-5" />
{/if}
</button>
{/snippet}
{#snippet right()}
<DashboardHeaderActions />
{/snippet}
</Header>
<div class="relative flex min-h-[calc(100vh-4rem)] flex-1">
<!-- Overlay (mobile) -->
{#if sidebarOpen}
<div
class="fixed inset-0 z-40 bg-black/30 backdrop-blur-[1px] lg:hidden"
role="button"
tabindex="0"
aria-label="Fechar menu"
onclick={closeSidebar}
onkeydown={(e) => e.key === 'Escape' && closeSidebar()}
></div>
{/if}
<!-- Sidebar -->
<aside
class="bg-base-100 border-base-200 fixed top-16 bottom-0 left-0 z-50 w-72 border-r shadow-sm transition-transform duration-200"
class:translate-x-0={sidebarOpen}
class:-translate-x-full={!sidebarOpen}
>
<div class="h-full overflow-y-auto">
<Sidebar onNavigate={closeSidebar} />
</div>
</aside>
<!-- Conteúdo -->
<div
class="min-w-0 flex-1 transition-[padding] duration-200 {sidebarOpen
? 'lg:pl-72'
: 'lg:pl-0'}"
>
<div id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</div>
<Footer />
</div>
</div>
<!-- Componentes de Chat (gerenciam auth internamente) -->
<PresenceManager />
<ChatWidget />
</div>
<!-- Toast Notifications (Sonner) -->
@@ -18,3 +94,14 @@
<!-- Push Notification Manager (registra subscription automaticamente) -->
<PushNotificationManager />
<style>
/* Evita “corredor” quando páginas usam `container mx-auto` */
#container-central :global(.container) {
max-width: none !important;
}
#container-central :global(.container.mx-auto) {
margin-left: 0 !important;
margin-right: 0 !important;
}
</style>

View File

@@ -140,7 +140,7 @@
</script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4">
<main class="w-full px-4 py-4">
<!-- Alerta de Acesso Negado / Autenticação -->
{#if showAlert}
{@const alertData = getAlertMessage()}