feat: arrancar UX/UI con AppShell y componentes base

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent 65502e0b0b
commit a9ec614364

@ -0,0 +1,35 @@
<script lang="ts">
export let tone: 'default' | 'warning' | 'danger' | 'success' = 'default';
</script>
<span class={`badge ${tone}`}><slot /></span>
<style>
.badge {
display: inline-flex;
align-items: center;
line-height: 1;
font-size: 12px;
padding: 4px 6px;
border-radius: 999px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
gap: 6px;
}
.badge.warning {
background: rgba(217, 119, 6, 0.12);
border-color: rgba(217, 119, 6, 0.35);
color: var(--color-warning);
}
.badge.danger {
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.35);
color: var(--color-danger);
}
.badge.success {
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.35);
color: var(--color-success);
}
</style>

@ -0,0 +1,53 @@
<script lang="ts">
export let variant: 'primary' | 'secondary' | 'ghost' | 'danger' = 'secondary';
export let size: 'sm' | 'md' = 'md';
export let type: 'button' | 'submit' | 'reset' = 'button';
export let disabled: boolean = false;
</script>
<button class={`btn ${variant} ${size}`} {type} {disabled}>
<slot />
</button>
<style>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
min-height: 36px;
cursor: pointer;
transition: background 120ms ease, box-shadow 120ms ease, transform 80ms ease;
text-decoration: none;
}
.btn.sm { min-height: 30px; padding: 0 10px; font-size: 0.95rem; }
.btn.md { min-height: 36px; }
.btn:hover { box-shadow: var(--shadow-sm); }
.btn:active { transform: translateY(0.5px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn.primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
.btn.primary:hover { filter: brightness(0.98); }
.btn.danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: white;
}
.btn.ghost {
background: transparent;
border-color: transparent;
}
.btn.secondary {
background: var(--color-surface);
}
</style>

@ -0,0 +1,24 @@
<script lang="ts">
export let width: string = '100%';
export let height: string = '12px';
export let radius: string = '6px';
</script>
<div class="skeleton" style={`width:${width};height:${height};border-radius:${radius};`} />
<style>
.skeleton {
background: linear-gradient(90deg, rgba(0,0,0,0.06), rgba(0,0,0,0.12), rgba(0,0,0,0.06));
background-size: 200% 100%;
animation: shimmer 1.2s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (prefers-color-scheme: dark) {
.skeleton {
background: linear-gradient(90deg, rgba(255,255,255,0.08), rgba(255,255,255,0.16), rgba(255,255,255,0.08));
}
}
</style>

@ -0,0 +1,11 @@
<span class="sr-only"><slot /></span>
<style>
.sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
</style>

@ -0,0 +1,52 @@
<script lang="ts">
import Card from '$lib/ui/layout/Card.svelte';
import Badge from '$lib/ui/atoms/Badge.svelte';
export type Counts = { open: number; unassigned: number };
export type TaskPreview = { id: number; description: string; due_date: string | null; display_code: number | null };
export let id: string;
export let name: string | null = null;
export let counts: Counts = { open: 0, unassigned: 0 };
export let previews: TaskPreview[] = [];
</script>
<Card>
<div class="header">
<strong class="name">{name ?? id}</strong>
<div class="badges">
<Badge>abiertas: {counts.open}</Badge>
<Badge tone="warning">sin responsable: {counts.unassigned}</Badge>
</div>
</div>
{#if previews?.length}
<div class="previews">
<em class="title">Sin responsable (hasta 3):</em>
<ul class="list">
{#each previews as t}
<li>
<span>#{t.display_code ?? t.id}{t.description}</span>
{#if t.due_date}<small class="muted"> (vence: {t.due_date})</small>{/if}
</li>
{/each}
</ul>
</div>
{/if}
</Card>
<style>
.header {
display: flex;
align-items: center;
gap: var(--space-2);
justify-content: space-between;
}
.name { font-size: 1rem; }
.badges { display: inline-flex; gap: var(--space-2); flex-wrap: wrap; }
.previews { margin-top: var(--space-3); }
.title { color: var(--color-text); }
.list { margin: 6px 0 0 18px; padding: 0; }
.list li { margin: 4px 0; }
.muted { color: var(--color-text-muted); }
</style>

@ -0,0 +1,60 @@
<script lang="ts">
import Badge from '$lib/ui/atoms/Badge.svelte';
import { dueStatus } from '$lib/utils/date';
export let id: number;
export let description: string;
export let due_date: string | null = null;
export let display_code: number | null = null;
export let assignees: string[] = [];
export let group_id: string | null = null;
const code = display_code ?? id;
$: status = dueStatus(due_date, 3);
</script>
<li class="task">
<div class="left">
<span class="code">#{code}</span>
<span class="desc">{description}</span>
{#if due_date}
{#if status === 'overdue'}
<Badge tone="danger">vence: {due_date}</Badge>
{:else if status === 'soon'}
<Badge tone="warning">vence: {due_date}</Badge>
{:else}
<Badge>vence: {due_date}</Badge>
{/if}
{/if}
</div>
{#if assignees?.length}
<div class="right">
<small class="muted">asignados: {assignees.join(', ')}</small>
</div>
{/if}
</li>
<style>
.task {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.task:last-child { border-bottom: 0; }
.left {
display: inline-flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.code {
font-weight: 600;
color: var(--color-text-muted);
}
.desc { }
.right { margin-left: auto; }
.muted { color: var(--color-text-muted); }
</style>

@ -0,0 +1,45 @@
<script lang="ts">
export type Option = { label: string; value: string };
export let name: string;
export let options: Option[] = [];
export let value: string;
</script>
<div class="segmented" role="radiogroup" aria-label={name}>
{#each options as opt}
<label class={`item ${value === opt.value ? 'active' : ''}`}>
<input type="radio" {name} value={opt.value} bind:group={value} />
<span>{opt.label}</span>
</label>
{/each}
</div>
<style>
.segmented {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface);
}
.item {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-right: 1px solid var(--color-border);
cursor: pointer;
user-select: none;
}
.item:last-child { border-right: 0; }
.item input { position: absolute; opacity: 0; pointer-events: none; }
.item.active {
background: rgba(37,99,235,0.12);
color: var(--color-primary);
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
.item.active { background: rgba(96,165,250,0.14); }
}
</style>

@ -0,0 +1,32 @@
<script lang="ts">
export let type: string = 'text';
export let name: string | undefined;
export let value: string | number | undefined = undefined;
export let placeholder: string = '';
export let disabled: boolean = false;
</script>
<input
class="textfield"
{type}
{name}
bind:value
{placeholder}
{disabled}
/>
<style>
.textfield {
width: 100%;
min-height: 36px;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
}
.textfield:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
</style>

@ -1,13 +1,14 @@
<script lang="ts">
import { page } from '$app/stores';
</script>
<header class="app-header">
<div class="container row">
<a class="brand" href="/app" aria-label="Inicio">Tareas</a>
<nav class="nav">
<a href="/app">Mis tareas</a>
<a href="/app/groups">Grupos</a>
<a href="/app/preferences">Preferencias</a>
<a href="/app" class:active={$page.url.pathname === '/app'}>Mis tareas</a>
<a href="/app/groups" class:active={$page.url.pathname.startsWith('/app/groups')}>Grupos</a>
<a href="/app/preferences" class:active={$page.url.pathname.startsWith('/app/preferences')}>Preferencias</a>
</nav>
<form method="POST" action="/api/logout">
<button type="submit" class="logout">Cerrar sesión</button>
@ -31,39 +32,50 @@
.row {
display: flex;
align-items: center;
gap: var(--space-4);
min-height: 56px;
gap: var(--space-3);
min-height: 52px;
}
.brand {
font-weight: 700;
color: var(--color-primary);
text-decoration: none;
}
.nav {
display: flex;
gap: var(--space-3);
gap: var(--space-2);
margin-left: auto;
}
.nav a {
padding: 8px 10px;
padding: 6px 10px;
border-radius: var(--radius-sm);
text-decoration: none;
color: inherit;
}
.nav a:hover,
.nav a:focus-visible {
background: rgba(0,0,0,0.04);
}
.nav a.active {
background: rgba(37, 99, 235, 0.12);
color: var(--color-primary);
font-weight: 600;
}
@media (prefers-color-scheme: dark) {
.nav a:hover,
.nav a:focus-visible {
background: rgba(255,255,255,0.06);
}
.nav a.active {
background: rgba(96, 165, 250, 0.14);
}
}
.logout {
margin-left: var(--space-3);
margin-left: var(--space-2);
min-height: 36px;
padding: 0 10px;
}
.main {
padding-top: var(--space-5);
padding-bottom: var(--space-5);
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
</style>

@ -0,0 +1,13 @@
<div class="card">
<slot />
</div>
<style>
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
padding: var(--space-4);
}
</style>

@ -0,0 +1,32 @@
<script lang="ts">
export let prevHref: string | null = null;
export let nextHref: string | null = null;
</script>
<nav class="pagination" aria-label="Paginación">
{#if prevHref}
<a class="link" rel="prev" href={prevHref}>Anterior</a>
{/if}
{#if nextHref}
<a class="link" rel="next" href={nextHref}>Siguiente</a>
{/if}
</nav>
<style>
.pagination {
display: flex;
gap: var(--space-2);
margin-top: var(--space-3);
}
.link {
padding: 6px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-surface);
text-decoration: none;
}
.link:hover,
.link:focus-visible {
box-shadow: var(--shadow-sm);
}
</style>

@ -0,0 +1,31 @@
export function todayYmdUTC(): string {
const d = new Date();
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function compareYmd(a: string, b: string): number {
// returns -1 if a<b, 0 if equal, 1 if a>b
if (a === b) return 0;
return a < b ? -1 : 1;
}
export function addDaysYmd(ymd: string, days: number): string {
const d = new Date(`${ymd}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function dueStatus(ymd: string | null, soonDays: number = 3): 'none' | 'overdue' | 'soon' {
if (!ymd) return 'none';
const today = todayYmdUTC();
if (compareYmd(ymd, today) < 0) return 'overdue';
const soonCut = addDaysYmd(today, soonDays);
if (compareYmd(ymd, soonCut) <= 0) return 'soon';
return 'none';
}

@ -1,4 +1,9 @@
<script lang="ts">
import Card from '$lib/ui/layout/Card.svelte';
import TextField from '$lib/ui/inputs/TextField.svelte';
import TaskItem from '$lib/ui/data/TaskItem.svelte';
import Pagination from '$lib/ui/layout/Pagination.svelte';
export let data: {
userId: string;
tasks: Array<{
@ -14,20 +19,15 @@
page?: number | null;
hasMore?: boolean | null;
};
</script>
<h1>Panel</h1>
<p>Sesión iniciada como: <strong>{data.userId}</strong></p>
<div style="margin: 1rem 0;">
<form method="POST" action="/api/logout">
<button type="submit">Cerrar sesión</button>
</form>
</div>
<h1 style="margin-bottom: .5rem;">Panel</h1>
<p style="color: var(--color-text-muted); margin: 0 0 1rem 0;">Sesión: <strong>{data.userId}</strong></p>
<form method="GET" action="/app" style="margin: 1rem 0;">
<input type="text" name="q" placeholder="Buscar tareas..." value={data.q ?? ''} />
<form method="GET" action="/app" style="margin: 0 0 1rem 0; display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
<div style="flex: 1 1 260px; min-width: 200px;">
<TextField name="q" placeholder="Buscar tareas..." value={data.q ?? ''} />
</div>
<select name="soonDays">
<option value="" selected={String(data.soonDays ?? '') === ''}>Todas las fechas</option>
<option value="3" selected={String(data.soonDays ?? '') === '3'}>Próximos 3 días</option>
@ -37,42 +37,28 @@
<button type="submit">Filtrar</button>
</form>
<h2>Mis tareas (abiertas)</h2>
<h2 style="margin: .5rem 0;">Mis tareas (abiertas)</h2>
{#if data.tasks.length === 0}
<p>No tienes tareas abiertas.</p>
{:else}
<ul>
{#each data.tasks as t}
<li>
<span>#{t.display_code ?? t.id}{t.description}</span>
{#if t.due_date}
<small> (vence: {t.due_date})</small>
{/if}
{#if t.assignees?.length}
<small> — asignados: {t.assignees.join(', ')}</small>
{/if}
</li>
{/each}
</ul>
<Card>
<ul style="margin: 0; padding: 0;">
{#each data.tasks as t}
<TaskItem {...t} />
{/each}
</ul>
</Card>
{/if}
{#if (data.page ?? 1) > 1 || data.hasMore}
<nav style="margin-top: 1rem;">
{#if (data.page ?? 1) > 1}
<a href={`/app?${new URLSearchParams({
q: data.q ?? '',
soonDays: data.soonDays != null ? String(data.soonDays) : '',
page: String((data.page ?? 1) - 1)
}).toString()}`}>Anterior</a>
{/if}
{#if data.hasMore}
<a style="margin-left:8px" href={`/app?${new URLSearchParams({
q: data.q ?? '',
soonDays: data.soonDays != null ? String(data.soonDays) : '',
page: String((data.page ?? 1) + 1)
}).toString()}`}>Siguiente</a>
{/if}
</nav>
<Pagination
prevHref={(data.page ?? 1) > 1
? `/app?${new URLSearchParams({ q: data.q ?? '', soonDays: data.soonDays != null ? String(data.soonDays) : '', page: String((data.page ?? 1) - 1) }).toString()}`
: null}
nextHref={data.hasMore
? `/app?${new URLSearchParams({ q: data.q ?? '', soonDays: data.soonDays != null ? String(data.soonDays) : '', page: String((data.page ?? 1) + 1) }).toString()}`
: null}
/>
{/if}
<p style="margin-top:1rem;">La cookie de sesión se renueva con cada visita (idle timeout).</p>
<p style="margin-top:.75rem; color: var(--color-text-muted);">La cookie de sesión se renueva con cada visita (idle timeout).</p>

@ -1,4 +1,6 @@
<script lang="ts">
import GroupCard from '$lib/ui/data/GroupCard.svelte';
type GroupItem = {
id: string;
name: string | null;
@ -24,29 +26,18 @@
{#if groups.length === 0}
<p>No perteneces a ningún grupo permitido.</p>
{:else}
<h1>Grupos</h1>
<ul>
<h1 style="margin-bottom: .75rem;">Grupos</h1>
<div class="grid">
{#each groups as g}
<li>
<strong>{g.name ?? g.id}</strong>
<small style="margin-left:8px;color:#555">(abiertas: {g.counts.open}, sin responsable: {g.counts.unassigned})</small>
{#if previews[g.id]?.length}
<div style="margin-top:6px; padding:6px 8px; background:#f6f6f6; border-radius:6px;">
<em style="color:#333;">Sin responsable (hasta 3):</em>
<ul style="margin:6px 0 0 16px;">
{#each previews[g.id] as t}
<li>
<span>#{t.display_code ?? t.id}{t.description}</span>
{#if t.due_date}
<small> (vence: {t.due_date})</small>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
</li>
<GroupCard id={g.id} name={g.name} counts={g.counts} previews={previews[g.id] || []} />
{/each}
</ul>
</div>
{/if}
<style>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-3);
}
</style>

@ -1,4 +1,8 @@
<script lang="ts">
import SegmentedControl from '$lib/ui/inputs/SegmentedControl.svelte';
import Card from '$lib/ui/layout/Card.svelte';
import Button from '$lib/ui/atoms/Button.svelte';
export let data: {
pref: { freq: 'off' | 'daily' | 'weekly' | 'weekdays'; time: string | null };
tz: string;
@ -8,43 +12,49 @@
let freq: 'off' | 'daily' | 'weekly' | 'weekdays' = data.pref.freq;
let time: string = data.pref.time ?? '08:30';
const options = [
{ label: 'Apagado', value: 'off' },
{ label: 'Diario', value: 'daily' },
{ label: 'LV', value: 'weekdays' },
{ label: 'Semanal', value: 'weekly' }
];
</script>
<section style="max-width: 720px; margin: 2rem auto; padding: 0 1rem;">
<h1 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 1rem;">Preferencias de recordatorios</h1>
<form method="POST" style="display: grid; gap: 1rem;">
<div>
<label for="freq" style="display:block; font-weight:600; margin-bottom: 0.25rem;">Frecuencia</label>
<select id="freq" name="freq" bind:value={freq}>
<option value="off">Apagado</option>
<option value="daily">Diario</option>
<option value="weekdays">Laborables (LV)</option>
<option value="weekly">Semanal (lunes)</option>
</select>
<p style="font-size: 0.9rem; color: #555; margin-top: 0.25rem;">
- Diario: cada día a la hora indicada. - Laborables: solo lunes a viernes. - Semanal: los lunes.
</p>
</div>
<div>
<label for="time" style="display:block; font-weight:600; margin-bottom: 0.25rem;">Hora (HH:MM)</label>
<input id="time" name="time" type="time" step="60" bind:value={time} disabled={freq === 'off'} />
<p style="font-size: 0.9rem; color: #555; margin-top: 0.25rem;">Zona horaria: {data.tz}</p>
</div>
{#if form?.error}
<div style="color: #b00020;">{form.error}</div>
{/if}
{#if form?.success}
<div style="color: #007e33;">Preferencias guardadas.</div>
{/if}
<button type="submit" style="padding: 0.5rem 1rem;">Guardar</button>
</form>
<div style="margin-top: 2rem;">
<h2 style="font-size: 1.2rem; font-weight: 600;">Próximo recordatorio</h2>
<section style="max-width: 720px; margin: 1.5rem auto; padding: 0 1rem;">
<h1 style="font-size: 1.4rem; font-weight: 600; margin-bottom: .75rem;">Preferencias de recordatorios</h1>
<Card>
<form method="POST" style="display: grid; gap: .75rem;">
<div>
<label for="freq" style="display:block; font-weight:600; margin-bottom: 0.25rem;">Frecuencia</label>
<SegmentedControl name="freq" options={options} bind:value={freq} />
<p style="font-size: 0.9rem; color: var(--color-text-muted); margin-top: 0.25rem;">
- Diario: cada día a la hora indicada. - Laborables: solo lunes a viernes. - Semanal: los lunes.
</p>
</div>
<div>
<label for="time" style="display:block; font-weight:600; margin-bottom: 0.25rem;">Hora (HH:MM)</label>
<input id="time" name="time" type="time" step="60" bind:value={time} disabled={freq === 'off'} />
<p style="font-size: 0.9rem; color: var(--color-text-muted); margin-top: 0.25rem;">Zona horaria: {data.tz}</p>
</div>
{#if form?.error}
<div style="color: var(--color-danger);">{form.error}</div>
{/if}
{#if form?.success}
<div style="color: var(--color-success);">Preferencias guardadas.</div>
{/if}
<div>
<Button type="submit" variant="primary">Guardar</Button>
</div>
</form>
</Card>
<div style="margin-top: 1rem;">
<h2 style="font-size: 1.1rem; font-weight: 600;">Próximo recordatorio</h2>
<ul>
<li>Servidor: {data.next ?? '—'}</li>
</ul>

Loading…
Cancel
Save