feat: Implement dedicated login page and public/dashboard layouts, refactoring authentication flow and removing the todos page.

This commit is contained in:
2025-12-12 14:22:28 -03:00
parent b47a317c33
commit b771322b24
18 changed files with 665 additions and 802 deletions

View File

@@ -1,14 +1,21 @@
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { error, redirect } from '@sveltejs/kit';
import type { FunctionReference } from 'convex/server';
export const load = async ({ locals }) => {
if (!locals.token) {
throw redirect(302, '/login');
}
try {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser, {});
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
if (!currentUser) {
throw redirect(302, '/login');
}
return { currentUser };
} catch (error) {
console.error('Erro ao carregar usuário atual no layout do dashboard:', error);
// Evita quebrar toda a área logada em caso de falha transitória na API/auth
return { currentUser: null };
} catch {
return error(500, 'Ops! Ocorreu um erro, tente novamente mais tarde.');
}
};

View File

@@ -1,100 +1,17 @@
<script lang="ts">
import { page } from '$app/state';
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import ActionGuard from '$lib/components/ActionGuard.svelte';
import { Toaster } from 'svelte-sonner';
import PushNotificationManager from '$lib/components/PushNotificationManager.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
const { children } = $props();
// Usuário atual e consentimento LGPD
const currentUser = useQuery(api.auth.getCurrentUser, {});
const consentimentoLGPD = useQuery(api.lgpd.verificarConsentimento, { tipo: 'termo_uso' });
// Redirecionar para o termo de consentimento se obrigatório e não aceito
$effect(() => {
const p = page.url.pathname;
// Rotas públicas/que não exigem termo
if (
p === '/' ||
p === '/abrir-chamado' ||
p === '/termo-consentimento' ||
p.startsWith('/privacidade') ||
p.startsWith('/api/')
) {
return;
}
// Precisa estar autenticado para exigir LGPD
if (!currentUser?.data) {
return;
}
// Query ainda carregando ou sem dados
if (!consentimentoLGPD?.data) {
return;
}
const data = consentimentoLGPD.data;
if (data.termoObrigatorio && !data.aceito) {
const redirect = encodeURIComponent(p);
window.location.href = `/termo-consentimento?redirect=${redirect}`;
}
});
// Resolver recurso/ação a partir da rota
const routeAction = $derived.by(() => {
const p = page.url.pathname;
if (p === '/' || p === '/abrir-chamado') return null;
// Funcionários
if (p.startsWith('/recursos-humanos/funcionarios')) {
if (p.includes('/cadastro')) return { recurso: 'funcionarios', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'funcionarios', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/funcionarioId'))
return { recurso: 'funcionarios', acao: 'editar' };
return { recurso: 'funcionarios', acao: 'listar' };
}
// Símbolos
if (p.startsWith('/recursos-humanos/simbolos')) {
if (p.includes('/cadastro')) return { recurso: 'simbolos', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'simbolos', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/simboloId'))
return { recurso: 'simbolos', acao: 'editar' };
return { recurso: 'simbolos', acao: 'listar' };
}
// Outras áreas (uso genérico: ver)
if (p.startsWith('/financeiro')) return { recurso: 'financeiro', acao: 'ver' };
if (p.startsWith('/controladoria')) return { recurso: 'controladoria', acao: 'ver' };
if (p.startsWith('/licitacoes')) return { recurso: 'licitacoes', acao: 'ver' };
if (p.startsWith('/compras')) return { recurso: 'compras', acao: 'ver' };
if (p.startsWith('/juridico')) return { recurso: 'juridico', acao: 'ver' };
if (p.startsWith('/comunicacao')) return { recurso: 'comunicacao', acao: 'ver' };
if (p.startsWith('/programas-esportivos'))
return { recurso: 'programas_esportivos', acao: 'ver' };
if (p.startsWith('/secretaria-executiva'))
return { recurso: 'secretaria_executiva', acao: 'ver' };
if (p.startsWith('/gestao-pessoas')) return { recurso: 'gestao_pessoas', acao: 'ver' };
return null;
});
</script>
{#if routeAction}
<ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
<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>
</ActionGuard>
{:else}
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
{/if}
</Sidebar>
</div>
<!-- Toast Notifications (Sonner) -->
<Toaster position="top-right" richColors closeButton expand={true} />

View File

@@ -6,7 +6,6 @@
import { resolve } from '$app/paths';
import { UserPlus, Mail, Clock, Award, TrendingUp, Zap, Users, Database } from 'lucide-svelte';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import { loginModalStore } from '$lib/stores/loginModal.svelte';
// Queries para dados do dashboard
const statsQuery = useQuery(api.dashboard.getStats, {});
@@ -36,7 +35,12 @@
// Se for erro de autenticação, abrir modal de login automaticamente
if (error === 'auth_required') {
loginModalStore.open(route || to.url.pathname);
const redirectTo = route || to.url.pathname;
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true,
noScroll: true
});
return;
}
// Limpar URL usando SvelteKit (após router estar inicializado)
@@ -65,7 +69,12 @@
const route = urlParams.get('route') || urlParams.get('redirect') || '';
if (error === 'auth_required') {
loginModalStore.open(route || window.location.pathname);
const redirectTo = route || window.location.pathname;
goto(`${resolve('/login')}?redirect=${encodeURIComponent(redirectTo)}`, {
replaceState: true,
noScroll: true
});
return;
}
}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import Footer from '$lib/components/Footer.svelte';
import Header from '$lib/components/Header.svelte';
import { Toaster } from 'svelte-sonner';
let { children } = $props();
</script>
<div
class="bg-base-100 text-base-content selection:bg-primary selection:text-primary-content flex min-h-screen flex-col font-sans"
>
<Header />
<main class="w-full flex-1">
{@render children()}
</main>
<Footer />
<!-- Toast Notifications (Sonner) -->
<Toaster position="top-right" richColors closeButton expand={true} />
</div>

View File

@@ -0,0 +1,134 @@
<script lang="ts">
import { ArrowRight, CheckCircle2, ShieldCheck, Zap, Users, BarChart3 } from 'lucide-svelte';
import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png';
</script>
<svelte:head>
<title>Home - SGSE</title>
<meta name="description" content="Sistema de Gestão de Secretaria - Governo de Pernambuco" />
</svelte:head>
<div class="flex flex-col">
<!-- Hero Section -->
<section class="relative overflow-hidden bg-base-100 pt-16 pb-32 lg:pt-32 lg:pb-48">
<div class="absolute top-0 left-0 w-full h-full overflow-hidden z-0">
<div class="absolute -top-[30%] -right-[10%] w-[70%] h-[70%] rounded-full bg-primary/5 blur-[100px]"></div>
<div class="absolute bottom-[10%] -left-[10%] w-[50%] h-[50%] rounded-full bg-secondary/5 blur-[100px]"></div>
</div>
<div class="container mx-auto px-4 relative z-10">
<div class="max-w-4xl mx-auto text-center">
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary font-medium text-sm mb-8 animate-fade-in-up">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
</span>
Sistema de Gestão de Secretaria
</div>
<h1 class="text-5xl md:text-7xl font-extrabold tracking-tight mb-8 text-base-content leading-tight">
Simplificando a <span class="bg-clip-text text-transparent bg-gradient-to-r from-primary to-secondary">Gestão Pública</span>
</h1>
<p class="text-xl md:text-2xl text-base-content/70 mb-12 max-w-2xl mx-auto leading-relaxed">
Uma plataforma unificada para otimizar processos, conectar departamentos e garantir eficiência na administração pública.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center items-center">
<a href={resolve('/login')} class="btn btn-primary btn-lg gap-2 shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all hover:-translate-y-1">
Acessar Sistema
<ArrowRight class="w-5 h-5" />
</a>
<a href="#recursos" class="btn btn-ghost btn-lg gap-2">
Conheça os Recursos
</a>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section id="recursos" class="py-24 bg-base-200/50">
<div class="container mx-auto px-4">
<div class="text-center mb-16">
<h2 class="text-3xl md:text-4xl font-bold mb-4">Recursos Principais</h2>
<p class="text-lg text-base-content/70 max-w-2xl mx-auto">
Ferramentas desenvolvidas especificamente para atender às necessidades da gestão secretaria.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{#each [
{
icon: Zap,
title: 'Agilidade nos Processos',
description: 'Automatize tarefas repetitivas e reduza o tempo de tramitação de documentos.'
},
{
icon: ShieldCheck,
title: 'Segurança de Dados',
description: 'Proteção avançada para garantir a integridade e confidencialidade das informações.'
},
{
icon: Users,
title: 'Gestão de Pessoas',
description: 'Ferramentas integradas para acompanhamento e desenvolvimento dos servidores.'
},
{
icon: BarChart3,
title: 'Relatórios Inteligentes',
description: 'Dashboards interativos para tomada de decisão baseada em dados reais.'
},
{
icon: CheckCircle2,
title: 'Controle de Ativos',
description: 'Rastreamento completo de bens e recursos da secretaria.'
},
{
icon: Users,
title: 'Colaboração em Tempo Real',
description: 'Conecte equipes e facilite a comunicação interna entre departamentos.'
}
] as feature}
<div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow duration-300 border border-base-200">
<div class="card-body">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center text-primary mb-4">
<feature.icon class="w-6 h-6" />
</div>
<h3 class="card-title text-xl mb-2">{feature.title}</h3>
<p class="text-base-content/70">{feature.description}</p>
</div>
</div>
{/each}
</div>
</div>
</section>
<!-- Call to Action -->
<section class="py-24 relative overflow-hidden">
<div class="absolute inset-0 bg-primary/5"></div>
<div class="container mx-auto px-4 relative z-10">
<div class="bg-base-100 rounded-3xl p-8 md:p-16 text-center shadow-xl border border-base-200 max-w-5xl mx-auto">
<h2 class="text-3xl md:text-5xl font-bold mb-6">Pronto para começar?</h2>
<p class="text-xl text-base-content/70 mb-10 max-w-2xl mx-auto">
Acesse o portal e tenha todo o controle da secretaria na palma da sua mão.
</p>
<a href={resolve('/login')} class="btn btn-primary btn-lg shadow-lg shadow-primary/20 hover:shadow-xl hover:shadow-primary/30 transition-all hover:-translate-y-1">
Fazer Login Agora
</a>
</div>
</div>
</section>
</div>
<style>
/* Custom animations if needed */
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
animation: fade-in-up 0.8s ease-out forwards;
}
</style>

View File

@@ -0,0 +1,24 @@
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { redirect } from '@sveltejs/kit';
import type { FunctionReference } from 'convex/server';
export const load = async ({ locals, url }) => {
try {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
if (currentUser) {
const redirectTo = url.searchParams.get('redirect');
if (redirectTo && redirectTo.startsWith('/')) {
throw redirect(302, redirectTo);
}
throw redirect(302, '/');
}
} catch (error) {
// Se houver falha transitória na API/auth, ainda assim permitir renderizar a página de login.
console.error('Erro ao validar sessão na página de login:', error);
}
return {};
};

View File

@@ -0,0 +1,312 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient } 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 logo from '$lib/assets/logo_governo_PE.png';
import { authClient } from '$lib/auth';
import { obterIPPublico } from '$lib/utils/deviceInfo';
import { LogIn, XCircle } from 'lucide-svelte';
interface GPSLocation {
latitude?: number;
longitude?: number;
precisao?: number;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}
let matricula = $state('');
let senha = $state('');
let erroLogin = $state('');
let carregandoLogin = $state(false);
const convexClient = useConvexClient();
const redirectAfterLogin = $derived.by(() => {
const redirectTo = page.url.searchParams.get('redirect');
return redirectTo && redirectTo.startsWith('/') ? redirectTo : '/';
});
async function handleLogin(e: Event) {
e.preventDefault();
erroLogin = '';
carregandoLogin = true;
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)
);
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');
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 {};
}
}
const gpsPromise = coletarGPS();
const result = await authClient.signIn.email(
{ email: matricula.trim(), password: senha },
{
onError: async (ctx) => {
try {
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// ignorar
}
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: false,
motivoFalha: ctx.error?.message || 'Erro desconhecido',
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);
}
erroLogin = ctx.error?.message || 'Erro ao fazer login';
}
}
);
if (result.data) {
// Registrar tentativa de login bem-sucedida sem bloquear o redirect
(async () => {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
let localizacaoGPS: GPSLocation = {};
try {
localizacaoGPS = await Promise.race([
gpsPromise,
new Promise<GPSLocation>((resolve) => setTimeout(() => resolve({}), 100))
]);
} catch {
// ignorar
}
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,
ipAddress: ipPublico,
latitudeGPS: localizacaoGPS.latitude,
longitudeGPS: localizacaoGPS.longitude,
precisaoGPS: localizacaoGPS.precisao,
enderecoGPS: localizacaoGPS.endereco,
cidadeGPS: localizacaoGPS.cidade,
estadoGPS: localizacaoGPS.estado,
paisGPS: localizacaoGPS.pais
});
} else {
await convexClient.mutation(api.logsLogin.registrarTentativaLogin, {
matriculaOuEmail: matricula.trim(),
sucesso: true,
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);
}
})();
await goto(resolve(redirectAfterLogin as any), { replaceState: true });
} else {
erroLogin = result.error?.message || 'Erro ao fazer login';
}
carregandoLogin = false;
}
</script>
<main
class="relative flex min-h-screen w-full items-center justify-center overflow-hidden bg-[#0f172a]"
>
<!-- Animated Background Elements -->
<div class="absolute inset-0 h-full w-full">
<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>
<!-- Glass Card -->
<div class="relative z-10 w-full max-w-md p-6">
<div
class="relative overflow-hidden rounded-2xl border border-white/10 bg-white/5 p-8 shadow-2xl ring-1 ring-white/10 backdrop-blur-xl transition-all duration-300"
>
<!-- Decorative Top Line -->
<div
class="via-primary absolute top-0 left-0 h-1 w-full bg-linear-to-r from-transparent to-transparent opacity-50"
></div>
<!-- Header -->
<div class="mb-10 text-center">
<div
class="mb-6 inline-flex items-center justify-center rounded-2xl bg-white/5 p-4 shadow-inner ring-1 ring-white/10"
>
<img src={logo} alt="Logo SGSE" class="h-12 w-auto object-contain" />
</div>
<h1 class="mb-2 font-sans text-3xl font-bold tracking-tight text-white">
Bem-vindo de volta
</h1>
<p class="text-sm font-medium text-gray-400">
Entre com suas credenciais para acessar o sistema
</p>
</div>
<!-- Error Message -->
{#if erroLogin}
<div
class="mb-6 flex items-center gap-3 rounded-lg border border-red-500/20 bg-red-500/10 p-4 text-red-200 backdrop-blur-md"
>
<XCircle class="h-5 w-5 shrink-0" />
<span class="text-sm font-medium">{erroLogin}</span>
</div>
{/if}
<!-- Form -->
<form class="space-y-6" onsubmit={handleLogin}>
<div class="space-y-2">
<label
for="login-matricula"
class="text-xs font-semibold tracking-wider text-gray-400 uppercase"
>
Matrícula ou E-mail
</label>
<div class="group relative">
<input
id="login-matricula"
type="text"
class="focus:border-primary/50 focus:ring-primary/20 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-white placeholder-gray-500 transition-all duration-300 focus:bg-black/40 focus:ring-2 focus:outline-none"
placeholder="Digite sua identificação"
bind:value={matricula}
required
disabled={carregandoLogin}
autocomplete="username"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<label
for="login-password"
class="text-xs font-semibold tracking-wider text-gray-400 uppercase"
>
Senha
</label>
<a
href={resolve('/esqueci-senha')}
class="text-primary hover:text-primary-focus text-xs font-medium transition-colors"
>
Esqueceu a senha?
</a>
</div>
<div class="group relative">
<input
id="login-password"
type="password"
class="focus:border-primary/50 focus:ring-primary/20 w-full rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-white placeholder-gray-500 transition-all duration-300 focus:bg-black/40 focus:ring-2 focus:outline-none"
placeholder="Digite sua senha"
bind:value={senha}
required
disabled={carregandoLogin}
autocomplete="current-password"
/>
</div>
</div>
<button
type="submit"
class="group bg-primary hover:bg-primary-focus hover:shadow-primary/25 relative w-full overflow-hidden rounded-xl px-4 py-3.5 text-sm font-bold text-white shadow-lg transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
disabled={carregandoLogin}
>
<div class="relative z-10 flex items-center justify-center gap-2">
{#if carregandoLogin}
<span
class="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white"
></span>
<span>Autenticando...</span>
{:else}
<span>Entrar no Sistema</span>
<LogIn class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" />
{/if}
</div>
<!-- Shine Effect -->
<div
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/20 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
></div>
</button>
</form>
<!-- Footer Links -->
<div class="mt-8 text-center">
<p class="text-sm text-gray-500">
Precisa de ajuda?
<a
href={resolve('/abrir-chamado')}
class="font-medium text-gray-300 decoration-1 transition-colors hover:text-white hover:underline"
>
Abrir um chamado
</a>
</p>
</div>
</div>
<!-- Footer Info -->
<div class="mt-8 text-center text-xs text-gray-600">
<p>© {new Date().getFullYear()} Governo de Pernambuco. Todos os direitos reservados.</p>
</div>
</div>
</main>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import '../app.css';
import Sidebar from '$lib/components/Sidebar.svelte';
import { createSvelteAuthClient } from '@mmailaender/convex-better-auth-svelte/svelte';
import { authClient } from '$lib/auth';
// Importar polyfill ANTES de qualquer outro código que possa usar Jitsi
@@ -50,8 +49,4 @@
});
</script>
<div>
<div class="flex">
<Sidebar>{@render children()}</Sidebar>
</div>
</div>
{@render children()}

View File

@@ -1,153 +0,0 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
let newTodoText = $state('');
let isAdding = $state(false);
let addError = $state<Error | null>(null);
let togglingId = $state<Id<'todos'> | null>(null);
let toggleError = $state<Error | null>(null);
let deletingId = $state<Id<'todos'> | null>(null);
let deleteError = $state<Error | null>(null);
const client = useConvexClient();
const todosQuery = useQuery(api.todos.getAll, {});
async function handleAddTodo(event: SubmitEvent) {
event.preventDefault();
const text = newTodoText.trim();
if (!text || isAdding) return;
isAdding = true;
addError = null;
try {
await client.mutation(api.todos.create, { text });
newTodoText = '';
} catch (err) {
console.error('Failed to add todo:', err);
addError = err instanceof Error ? err : new Error(String(err));
} finally {
isAdding = false;
}
}
async function handleToggleTodo(id: Id<'todos'>, completed: boolean) {
if (togglingId === id || deletingId === id) return;
togglingId = id;
toggleError = null;
try {
await client.mutation(api.todos.toggle, { id, completed: !completed });
} catch (err) {
console.error('Failed to toggle todo:', err);
toggleError = err instanceof Error ? err : new Error(String(err));
} finally {
if (togglingId === id) {
togglingId = null;
}
}
}
async function handleDeleteTodo(id: Id<'todos'>) {
if (togglingId === id || deletingId === id) return;
deletingId = id;
deleteError = null;
try {
await client.mutation(api.todos.deleteTodo, { id });
} catch (err) {
console.error('Failed to delete todo:', err);
deleteError = err instanceof Error ? err : new Error(String(err));
} finally {
if (deletingId === id) {
deletingId = null;
}
}
}
const canAdd = $derived(!isAdding && newTodoText.trim().length > 0);
const isLoadingTodos = $derived(todosQuery.isLoading);
const todos = $derived(todosQuery.data ?? []);
const hasTodos = $derived(todos.length > 0);
</script>
<div class="p-4">
<h1 class="mb-4 text-xl">Todos (Convex)</h1>
<form onsubmit={handleAddTodo} class="mb-4 flex gap-2">
<input
type="text"
bind:value={newTodoText}
placeholder="New task..."
disabled={isAdding}
class="flex-grow p-1"
/>
<button
type="submit"
disabled={!canAdd}
class="rounded bg-blue-500 px-3 py-1 text-white disabled:opacity-50"
>
{#if isAdding}Adding...{:else}Add{/if}
</button>
</form>
{#if isLoadingTodos}
<p>Loading...</p>
{:else if !hasTodos}
<p>No todos yet.</p>
{:else}
<ul class="space-y-1">
{#each todos as todo (todo._id)}
{@const isTogglingThis = togglingId === todo._id}
{@const isDeletingThis = deletingId === todo._id}
{@const isDisabled = isTogglingThis || isDeletingThis}
<li class="flex items-center justify-between p-2" class:opacity-50={isDisabled}>
<div class="flex items-center gap-2">
<input
type="checkbox"
id={`todo-${todo._id}`}
checked={todo.completed}
onchange={() => handleToggleTodo(todo._id, todo.completed)}
disabled={isDisabled}
/>
<label for={`todo-${todo._id}`} class:line-through={todo.completed}>
{todo.text}
</label>
</div>
<button
type="button"
onclick={() => handleDeleteTodo(todo._id)}
disabled={isDisabled}
aria-label="Delete todo"
class="px-1 text-red-500 disabled:opacity-50"
>
{#if isDeletingThis}Deleting...{:else}X{/if}
</button>
</li>
{/each}
</ul>
{/if}
{#if todosQuery.error}
<p class="mt-4 text-red-500">
Error loading: {todosQuery.error?.message ?? 'Unknown error'}
</p>
{/if}
{#if addError}
<p class="mt-4 text-red-500">
Error adding: {addError.message ?? 'Unknown error'}
</p>
{/if}
{#if toggleError}
<p class="mt-4 text-red-500">
Error updating: {toggleError.message ?? 'Unknown error'}
</p>
{/if}
{#if deleteError}
<p class="mt-4 text-red-500">
Error deleting: {deleteError.message ?? 'Unknown error'}
</p>
{/if}
</div>