Merge pull request #65 from killer-cf/refactor-auth

Refactor auth
This commit is contained in:
Kilder Costa
2025-12-13 19:13:28 -03:00
committed by GitHub
90 changed files with 2256 additions and 2197 deletions

View File

@@ -1,71 +0,0 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { TriangleAlert } from 'lucide-svelte';
interface Props {
recurso: string;
acao: string;
children?: any;
}
let { recurso, acao, children }: Props = $props();
let verificando = $state(true);
let permitido = $state(false);
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
let permissaoQuery = $derived(
currentUser?.data
? useQuery(api.permissoesAcoes.verificarAcao, {
usuarioId: currentUser.data._id as Id<'usuarios'>,
recurso,
acao
})
: null
);
$effect(() => {
if (!currentUser?.data) {
verificando = false;
permitido = false;
const currentPath = window.location.pathname;
loginModalStore.open(currentPath);
return;
}
if (permissaoQuery?.error) {
verificando = false;
permitido = false;
} else if (permissaoQuery && !permissaoQuery.isLoading) {
// Backend retorna null quando permitido
verificando = false;
permitido = true;
}
});
</script>
{#if verificando}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
</div>
</div>
{:else if permitido}
{@render children?.()}
{:else}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div class="bg-error/10 mb-4 inline-block rounded-full p-4">
<TriangleAlert class="text-error h-16 w-16" strokeWidth={2} />
</div>
<h2 class="text-base-content mb-2 text-2xl font-bold">Acesso Negado</h2>
<p class="text-base-content/70">Você não tem permissão para acessar esta ação.</p>
</div>
</div>
{/if}

View File

@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<div class={['absolute inset-0 h-full w-full', className]}>
<div
class="bg-primary/20 absolute top-[-10%] left-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px]"
></div>
<div
class="bg-secondary/20 absolute right-[-10%] bottom-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px] delay-700"
></div>
</div>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<div
class={[
'via-primary absolute top-0 left-0 h-1 w-full bg-linear-to-r from-transparent to-transparent opacity-50',
className
]}
></div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { XCircle } from 'lucide-svelte';
interface Props {
message?: string | null;
class?: string;
}
let { message = null, class: className = '' }: Props = $props();
</script>
{#if message}
<div
class={[
'border-error/20 bg-error/10 text-error-content/90 mb-6 flex items-center gap-3 rounded-lg border p-4 backdrop-blur-md',
className
]}
>
<XCircle class="h-5 w-5 shrink-0" />
<span class="text-sm font-medium">{message}</span>
</div>
{/if}

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
const currentYear = new Date().getFullYear();
</script>
<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>
<p class="mx-auto max-w-xs text-sm opacity-75 md:mx-0">
Sistema de Gestão de Secretaria<br />
Simplificando processos e conectando pessoas.
</p>
</div>
<div>
<h3 class="mb-4 text-lg font-bold">Links Úteis</h3>
<ul class="space-y-2 text-sm opacity-75">
<li>
<a
href="https://www.pe.gov.br/"
target="_blank"
class="hover:text-primary transition-colors">Portal do Governo</a
>
</li>
<li>
<a href={resolve('/privacidade')} class="hover:text-primary transition-colors"
>Política de Privacidade</a
>
</li>
<li>
<a href={resolve('/abrir-chamado')} class="hover:text-primary transition-colors"
>Suporte</a
>
</li>
</ul>
</div>
<div>
<h3 class="mb-4 text-lg font-bold">Contato</h3>
<p class="text-sm opacity-75">
Secretaria de Educação<br />
Recife - PE
</p>
</div>
</div>
<div class="divider mt-8 mb-4"></div>
<div class="flex flex-col items-center justify-between text-sm opacity-60 md:flex-row">
<p>&copy; {currentYear} Governo de Pernambuco. Todos os direitos reservados.</p>
<p class="mt-2 md:mt-0">Desenvolvido com tecnologia de ponta.</p>
</div>
</div>
</footer>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div
class={[
'border-base-content/10 bg-base-content/5 ring-base-content/10 relative overflow-hidden rounded-2xl border p-8 shadow-2xl ring-1 backdrop-blur-xl transition-all duration-300',
className
]}
>
{@render children?.()}
</div>

View File

@@ -1,7 +1,60 @@
<script lang="ts">
import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png';
import type { Snippet } from 'svelte';
type HeaderProps = {
left?: Snippet;
right?: Snippet;
};
const { left, right }: HeaderProps = $props();
</script>
<div class="navbar bg-base-200 w-76 p-4 shadow-sm">
<img src={logo} alt="Logo" class="" />
</div>
<header
class="bg-base-200 border-base-100 sticky top-0 z-50 w-full border-b py-3 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}
<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-2xl font-bold tracking-wider uppercase">SGSE</span>
<span class="text-base-content -mt-1 text-lg leading-none font-extrabold tracking-tight"
>Sistema de Gestão da Secretaria de Esportes</span
>
</div>
</a>
</div>
<div class="flex items-center gap-2">
<select
class="select select-sm bg-base-100 border-base-300 w-40"
aria-label="Selecionar tema"
data-choose-theme
>
<option value="aqua">Aqua</option>
<option value="sgse-blue">Azul</option>
<option value="sgse-green">Verde</option>
<option value="sgse-orange">Laranja</option>
<option value="sgse-red">Vermelho</option>
<option value="sgse-pink">Rosa</option>
<option value="sgse-teal">Verde-água</option>
<option value="sgse-corporate">Corporativo</option>
<option value="light">Claro</option>
<option value="dark">Escuro</option>
</select>
{#if right}
{@render right()}
{/if}
</div>
</div>
</header>

View File

@@ -1,163 +0,0 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { onMount } from 'svelte';
interface MenuProtectionProps {
menuPath: string;
requireGravar?: boolean;
children?: any;
redirectTo?: string;
}
let {
menuPath,
requireGravar = false,
children,
redirectTo = '/'
}: MenuProtectionProps = $props();
let verificando = $state(true);
let temPermissao = $state(false);
let motivoNegacao = $state('');
// Query para verificar permissões (só executa se o usuário estiver autenticado)
const currentUser = useQuery(api.auth.getCurrentUser, {});
let permissaoQuery = $derived(
currentUser?.data
? useQuery(api.menuPermissoes.verificarAcesso, {
usuarioId: currentUser.data._id as Id<'usuarios'>,
menuPath: menuPath
})
: null
);
onMount(() => {
verificarPermissoes();
});
$effect(() => {
// Re-verificar quando o status do usuário atual mudar
verificarPermissoes();
});
$effect(() => {
// Re-verificar quando a query carregar
if (permissaoQuery?.data) {
verificarPermissoes();
}
});
function verificarPermissoes() {
// Dashboard e abertura de chamados são públicos
if (menuPath === '/' || menuPath === '/abrir-chamado') {
verificando = false;
temPermissao = true;
return;
}
// Se não está autenticado
if (!currentUser?.data) {
verificando = false;
temPermissao = false;
motivoNegacao = 'auth_required';
// Abrir modal de login e salvar rota de redirecionamento
const currentPath = window.location.pathname;
loginModalStore.open(currentPath);
// NÃO redirecionar, apenas mostrar o modal
// O usuário verá a mensagem "Verificando permissões..." enquanto o modal está aberto
return;
}
// Se está autenticado, verificar permissões
if (permissaoQuery?.data) {
const permissao = permissaoQuery.data;
// Se não pode acessar
if (!permissao.podeAcessar) {
verificando = false;
temPermissao = false;
motivoNegacao = 'access_denied';
return;
}
// Se requer gravação mas não tem permissão
if (requireGravar && !permissao.podeGravar) {
verificando = false;
temPermissao = false;
motivoNegacao = 'write_denied';
return;
}
// Tem permissão!
verificando = false;
temPermissao = true;
} else if (permissaoQuery?.error) {
verificando = false;
temPermissao = false;
motivoNegacao = 'error';
}
}
</script>
{#if verificando}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
{#if motivoNegacao === 'auth_required'}
<div class="bg-warning/10 mb-4 inline-block rounded-full p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-warning h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 class="text-base-content mb-2 text-2xl font-bold">Acesso Restrito</h2>
<p class="text-base-content/70 mb-4">
Esta área requer autenticação.<br />
Por favor, faça login para continuar.
</p>
{:else}
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
{/if}
</div>
</div>
{:else if temPermissao}
{@render children?.()}
{:else}
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<div class="bg-error/10 mb-4 inline-block rounded-full p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-error h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2 class="text-base-content mb-2 text-2xl font-bold">Acesso Negado</h2>
<p class="text-base-content/70">Você não tem permissão para acessar esta página.</p>
</div>
</div>
{/if}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { prefersReducedMotion, Spring } from 'svelte/motion';
interface Props {
open: boolean;
class?: string;
stroke?: number;
}
let { open, class: className = '', stroke = 2 }: Props = $props();
const progress = Spring.of(() => (open ? 1 : 0), {
stiffness: 0.25,
damping: 0.65,
precision: 0.001
});
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
let t = $derived(prefersReducedMotion.current ? (open ? 1 : 0) : progress.current);
let tFast = $derived(clamp01(t * 1.15));
// Fechado: hambúrguer. Aberto: "outro menu" (linhas deslocadas + comprimentos diferentes).
// Continua sendo ícone de menu (não vira X).
let topY = $derived(lerp(-6, -7, tFast));
let botY = $derived(lerp(6, 7, tFast));
let topX = $derived(lerp(0, 3.25, t));
let midX = $derived(lerp(0, -2.75, t));
let botX = $derived(lerp(0, 1.75, t));
// micro-inclinação só pra dar “vida”, sem cruzar em X
let topR = $derived(lerp(0, 2.5, tFast));
let botR = $derived(lerp(0, -2.5, tFast));
let topScaleX = $derived(lerp(1, 0.62, tFast));
let midScaleX = $derived(lerp(1, 0.92, tFast));
let botScaleX = $derived(lerp(1, 0.72, tFast));
let topOpacity = $derived(1);
let midOpacity = $derived(1);
let botOpacity = $derived(1);
</script>
<span class="menu-toggle-icon {className}" aria-hidden="true" style="--stroke: {stroke}px">
<span
class="line"
style="--x: {topX}px; --y: {topY}px; --r: {topR}deg; --o: {topOpacity}; --sx: {topScaleX}"
></span>
<span
class="line"
style="--x: {midX}px; --y: 0px; --r: 0deg; --o: {midOpacity}; --sx: {midScaleX}"
></span>
<span
class="line"
style="--x: {botX}px; --y: {botY}px; --r: {botR}deg; --o: {botOpacity}; --sx: {botScaleX}"
></span>
</span>
<style>
.menu-toggle-icon {
position: relative;
display: inline-block;
width: 1.25rem;
height: 1.25rem;
color: currentColor;
}
.line {
position: absolute;
left: 0;
right: 0;
top: 50%;
margin-top: calc(var(--stroke) / -2);
height: var(--stroke);
border-radius: 9999px;
background: currentColor;
opacity: var(--o, 1);
transform-origin: center;
transform: translateX(var(--x, 0px)) translateY(var(--y, 0px)) rotate(var(--r, 0deg))
scaleX(var(--sx, 1));
will-change: transform, opacity;
}
</style>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<div
class={[
'via-base-content/20 absolute inset-0 -translate-x-full bg-linear-to-r from-transparent to-transparent transition-transform duration-1000 group-hover:translate-x-full',
className
]}
></div>

View File

@@ -1,54 +1,21 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient, useQuery } from 'convex-svelte';
import { useQuery } from 'convex-svelte';
import type { FunctionReference } from 'convex/server';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
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';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
import { obterIPPublico } from '$lib/utils/deviceInfo';
interface GPSLocation {
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}
interface MenuItemPermission {
recurso: string;
@@ -211,26 +178,22 @@
type IconType = typeof Home;
const { children }: { children: Snippet } = $props();
type SidebarProps = {
onNavigate?: () => void;
};
const { onNavigate }: SidebarProps = $props();
let currentPath = $derived(page.url.pathname);
let matricula = $state('');
let senha = $state('');
let erroLogin = $state('');
let carregandoLogin = $state(false);
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
function filterSubmenusByPermissions(
items: SubMenuItem[],
items: readonly SubMenuItem[],
isMaster: boolean,
permissionsSet: Set<string>
): SubMenuItem[] {
if (isMaster) return items;
if (isMaster) return [...items];
return items.filter((item) => {
if (!item.permission) return true;
@@ -240,11 +203,11 @@
}
function filterMenuByPermissions(
items: MenuItem[],
items: readonly MenuItem[],
isMaster: boolean,
permissionsSet: Set<string>
): MenuItem[] {
if (isMaster) return items;
if (isMaster) return [...items];
const filtered: MenuItem[] = [];
@@ -283,31 +246,16 @@
return filterMenuByPermissions(MENU_STRUCTURE, data.isMaster, permissionsSet);
});
const convexClient = useConvexClient();
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
};
@@ -350,718 +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 openLoginModal() {
loginModalStore.open();
matricula = '';
senha = '';
erroLogin = '';
carregandoLogin = false;
}
function closeLoginModal() {
loginModalStore.close();
matricula = '';
senha = '';
erroLogin = '';
carregandoLogin = false;
}
function openAboutModal() {
showAboutModal = true;
}
function closeAboutModal() {
showAboutModal = false;
}
async function handleLogin(e: Event) {
e.preventDefault();
erroLogin = '';
carregandoLogin = true;
// Obter IP público e userAgent (rápido, não bloqueia)
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : undefined;
// Obter IP público com timeout curto (não bloquear login)
const ipPublicoPromise = obterIPPublico().catch(() => undefined);
const ipPublicoTimeout = new Promise<undefined>(
(resolve) => setTimeout(() => resolve(undefined), 2000) // Timeout de 2 segundos
);
const ipPublico = await Promise.race([ipPublicoPromise, ipPublicoTimeout]);
// Função para coletar GPS em background (não bloqueia login)
async function coletarGPS(): Promise<GPSLocation> {
try {
const { obterLocalizacaoRapida } = await import('$lib/utils/deviceInfo');
// Usar versão rápida com timeout curto (3 segundos máximo)
const gpsPromise = obterLocalizacaoRapida();
const gpsTimeout = new Promise<GPSLocation>((resolve) =>
setTimeout(() => resolve({}), 3000)
);
return await Promise.race([gpsPromise, gpsTimeout]);
} catch (err) {
console.warn('Erro ao obter GPS (não bloqueia login):', err);
return {};
}
}
// Iniciar coleta de GPS em background (não esperar)
const gpsPromise = coletarGPS();
const result = await authClient.signIn.email(
{ email: matricula.trim(), password: senha },
{
onError: async (ctx) => {
// Registrar tentativa de login falha
try {
// Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// Ignorar se GPS não estiver pronto
}
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: false,
motivoFalha: ctx.error?.message || 'Erro desconhecido',
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
} catch (err) {
console.error('Erro ao registrar tentativa de login falha:', err);
}
alert(ctx.error.message);
}
}
);
if (result.data) {
// Registrar tentativa de login bem-sucedida
// Fazer de forma assíncrona para não bloquear o login
// Não tentamos buscar getCurrentUser aqui porque pode causar timeout
// O useQuery no componente já busca o usuário automaticamente quando a sessão estiver pronta
(async () => {
try {
// Aguardar um pouco para o usuário ser sincronizado no Convex
await new Promise((resolve) => setTimeout(resolve, 500));
// Tentar obter GPS se já estiver disponível (não esperar)
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// Ignorar se GPS não estiver pronto
}
// Buscar o usuário no Convex usando getCurrentUser
// (o typesafe FunctionReference pode falhar aqui; tipamos o retorno e mantemos a chamada)
const usuario = (await convexClient.query(
api.auth.getCurrentUser as unknown as FunctionReference<'query'>,
{}
)) as { _id?: Id<'usuarios'> } | null;
if (usuario && usuario._id) {
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
usuarioId: usuario._id,
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
} else {
// Se não encontrou o usuário, registrar sem usuarioId (será atualizado depois)
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: true,
userAgent: userAgent,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
}
} catch (err) {
console.error('Erro ao registrar tentativa de login:', err);
// Não bloquear o login se houver erro ao registrar
}
})();
closeLoginModal();
goto(resolve('/'));
} else {
erroLogin = 'Erro ao fazer login';
}
carregandoLogin = 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={() => openLoginModal()}
>
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)} class={getMenuClasses(isSubActive, true)}>
<span>{sub.label}</span>
</a>
</li>
{/each}
{/if}
</ul>
</details>
{:else}
<a
href={resolve(item.link)}
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 de Login -->
{#if loginModalStore.showModal}
<dialog class="modal modal-open">
<div
class="modal-box from-base-100 via-base-100 to-primary/5 relative max-w-md overflow-hidden bg-linear-to-br shadow-2xl backdrop-blur-sm"
>
<!-- Botão de fechar moderno -->
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-error/20 hover:text-error absolute top-4 right-4 z-10 transition-all duration-200"
onclick={closeLoginModal}
aria-label="Fechar modal"
>
<XCircle class="h-5 w-5" strokeWidth={2.5} />
</button>
<!-- Decoração de fundo -->
<div class="bg-primary/10 absolute -top-20 -right-20 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-primary/5 absolute -bottom-20 -left-20 h-40 w-40 rounded-full blur-3xl"></div>
<div class="relative z-10 p-8">
<!-- Header com logo e título -->
<div class="mb-8 text-center">
<div class="avatar mx-auto mb-5">
<div
class="group ring-primary/20 relative w-24 overflow-hidden rounded-2xl bg-white p-4 shadow-xl ring-2 transition-all duration-300 hover:scale-105 hover:shadow-2xl"
>
<div
class="from-primary/10 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
></div>
<img src={logo} alt="Logo SGSE" class="relative z-10 h-full w-full object-contain" />
</div>
</div>
<h3 class="text-primary mb-2 text-4xl font-bold tracking-tight">Login</h3>
<p class="text-base-content/70 text-sm font-medium">
Acesse o sistema com suas credenciais
</p>
</div>
<!-- Mensagem de erro -->
{#if erroLogin}
<div
class="alert alert-error border-error/30 bg-error/10 mb-6 shadow-lg backdrop-blur-sm"
>
<XCircle class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2.5} />
<span class="font-medium">{erroLogin}</span>
</div>
{/if}
<!-- Formulário -->
<form class="space-y-5" onsubmit={handleLogin}>
<!-- Campo Matrícula/E-mail -->
<div class="form-control">
<label class="label pb-2" for="login-matricula">
<span class="text-primary label-text text-sm font-semibold">Matrícula ou E-mail</span>
</label>
<div class="relative">
<input
id="login-matricula"
type="text"
placeholder="Digite sua matrícula ou e-mail"
class="input input-bordered input-primary focus:border-primary focus:shadow-primary/20 w-full border-2 transition-all duration-200 focus:shadow-lg disabled:opacity-50"
bind:value={matricula}
required
disabled={carregandoLogin}
autocomplete="username"
/>
</div>
</div>
<!-- Campo Senha -->
<div class="form-control">
<label class="label pb-2" for="login-password">
<span class="text-primary label-text text-sm font-semibold">Senha</span>
</label>
<div class="relative">
<input
id="login-password"
type="password"
placeholder="Digite sua senha"
class="input input-bordered input-primary focus:border-primary focus:shadow-primary/20 w-full border-2 transition-all duration-200 focus:shadow-lg disabled:opacity-50"
bind:value={senha}
required
disabled={carregandoLogin}
autocomplete="current-password"
/>
</div>
</div>
<!-- Botão de submit -->
<div class="form-control pt-2">
<button
type="submit"
class="btn btn-primary btn-lg group from-primary via-primary to-primary/90 relative w-full overflow-hidden border-0 bg-linear-to-r shadow-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl disabled:opacity-50"
disabled={carregandoLogin}
>
<!-- Efeito de brilho animado -->
<div
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
></div>
{#if carregandoLogin}
<span class="loading loading-spinner loading-sm"></span>
<span class="font-semibold">Entrando...</span>
{:else}
<LogIn
class="h-5 w-5 transition-transform duration-300 group-hover:scale-110"
strokeWidth={2.5}
/>
<span class="font-semibold">Entrar</span>
{/if}
</button>
</div>
<!-- Links auxiliares -->
<div class="space-y-3 pt-4 text-center">
<a
href={resolve('/abrir-chamado')}
class="link link-primary block text-sm font-medium transition-all duration-200 hover:scale-105"
onclick={closeLoginModal}
>
Abrir Chamado
</a>
<a
href={resolve('/esqueci-senha')}
class="link link-secondary block text-sm font-medium transition-all duration-200 hover:scale-105"
onclick={closeLoginModal}
>
Esqueceu sua senha?
</a>
</div>
</form>
</div>
</div>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<form method="dialog" class="modal-backdrop" onclick={closeLoginModal}>
<button type="button">close</button>
</form>
</dialog>
{/if}
<!-- 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

@@ -0,0 +1,62 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import type { Snippet } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props {
id: string;
label: string;
type?: string;
placeholder?: string;
autocomplete?: HTMLInputAttributes['autocomplete'];
disabled?: boolean;
required?: boolean;
error?: string | null;
right?: Snippet;
value?: string;
}
let {
id,
label,
type = 'text',
placeholder = '',
autocomplete,
disabled = false,
required = false,
error = null,
right,
value = $bindable('')
}: Props = $props();
const invalid = $derived(!!error);
</script>
<Field.Root {invalid} {required} class="space-y-2">
<div class="flex items-center justify-between gap-3">
<Field.Label
for={id}
class="text-base-content/60 text-xs font-semibold tracking-wider uppercase"
>
{label}
</Field.Label>
{@render right?.()}
</div>
<div class="group relative">
<Field.Input
{id}
{type}
{placeholder}
{disabled}
{autocomplete}
{required}
bind:value
class="border-base-content/10 bg-base-200/25 text-base-content placeholder-base-content/40 focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border px-4 py-3 transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
{#if error}
<Field.ErrorText class="text-error text-sm font-medium">{error}</Field.ErrorText>
{/if}
</Field.Root>