feat: arrancar UX/UI con AppShell y componentes base
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>webui
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>
|
||||
@ -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';
|
||||
}
|
||||
Loading…
Reference in New Issue