feat: añadir UI de integraciones ICS con FeedCard y toasts

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
borja 2 weeks ago
parent 554a430873
commit 61ccf63ac0

@ -0,0 +1,41 @@
import { writable } from 'svelte/store';
export type ToastType = 'info' | 'success' | 'error';
export type ToastItem = {
id: string;
type: ToastType;
message: string;
timeout?: number;
};
export const toasts = writable<ToastItem[]>([]);
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function show(message: string, type: ToastType = 'info', timeout = 2500): string {
const id = uid();
toasts.update((list) => [...list, { id, type, message, timeout }]);
if (timeout > 0) {
setTimeout(() => dismiss(id), timeout);
}
return id;
}
export function success(message: string, timeout = 2500): string {
return show(message, 'success', timeout);
}
export function error(message: string, timeout = 3500): string {
return show(message, 'error', timeout);
}
export function info(message: string, timeout = 2500): string {
return show(message, 'info', timeout);
}
export function dismiss(id: string): void {
toasts.update((list) => list.filter((t) => t.id !== id));
}

@ -0,0 +1,73 @@
<script lang="ts">
import Card from '$lib/ui/layout/Card.svelte';
import Button from '$lib/ui/atoms/Button.svelte';
import { copyToClipboard } from '$lib/utils/copy';
import { toasts } from '$lib/stores/toasts';
import { createEventDispatcher } from 'svelte';
export let title: string;
export let description: string = '';
export let url: string | null = null;
export let rotating: boolean = false;
const dispatch = createEventDispatcher<{ rotate: void }>();
async function handleCopy() {
if (!url) return;
const ok = await copyToClipboard(url);
if (ok) toasts.success('Enlace copiado al portapapeles');
else toasts.error('No se pudo copiar el enlace');
}
function handleRotate() {
dispatch('rotate');
}
</script>
<Card>
<div class="head">
<div class="titles">
<div class="title">{title}</div>
{#if description}<div class="desc">{description}</div>{/if}
{#if url}<div class="url">{url}</div>{/if}
</div>
<div class="actions">
<Button variant="secondary" size="sm" on:click|preventDefault={handleCopy} disabled={!url}>Copiar</Button>
<Button variant="danger" size="sm" on:click|preventDefault={handleRotate} disabled={rotating}>
{rotating ? 'Rotando…' : 'Rotar'}
</Button>
</div>
</div>
</Card>
<style>
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.titles {
min-width: 0;
}
.title {
font-weight: 600;
}
.desc {
color: var(--color-text-muted);
margin-top: 2px;
font-size: 0.95rem;
}
.url {
margin-top: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
color: var(--color-text-muted);
word-break: break-all;
}
.actions {
display: inline-flex;
gap: 8px;
flex-shrink: 0;
}
</style>

@ -0,0 +1,13 @@
<div class="empty">
<slot />
</div>
<style>
.empty {
padding: var(--space-4);
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
text-align: center;
}
</style>

@ -0,0 +1,16 @@
<div class="error-banner" role="alert">
<slot />
</div>
<style>
.error-banner {
padding: 10px 12px;
border: 1px solid var(--color-danger);
background: rgba(220,38,38,0.08);
color: var(--color-text);
border-radius: var(--radius-md);
}
@media (prefers-color-scheme: dark) {
.error-banner { background: rgba(248,113,113,0.12); }
}
</style>

@ -0,0 +1,59 @@
<script lang="ts">
import { toasts, dismiss } from '$lib/stores/toasts';
</script>
<div class="toast-region" role="region" aria-live="polite" aria-atomic="true">
{#each $toasts as t (t.id)}
<div class="toast {t.type}">
<div class="msg">{t.message}</div>
<button class="close" aria-label="Cerrar" on:click={() => dismiss(t.id)}>×</button>
</div>
{/each}
</div>
<style>
.toast-region {
position: fixed;
bottom: 16px;
right: 16px;
display: flex;
flex-direction: column;
gap: 8px;
z-index: 50;
pointer-events: none;
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
max-width: 360px;
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: 10px 12px;
}
.toast.success { border-color: rgba(22, 163, 74, 0.4); }
.toast.error { border-color: rgba(220, 38, 38, 0.4); }
.msg { flex: 1; }
.close {
background: transparent;
border: none;
color: inherit;
font-size: 18px;
line-height: 1;
padding: 2px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.close:hover,
.close:focus-visible {
background: rgba(0,0,0,0.06);
}
@media (prefers-color-scheme: dark) {
.close:hover,
.close:focus-visible { background: rgba(255,255,255,0.08); }
}
</style>

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import Toast from '$lib/ui/feedback/Toast.svelte';
</script> </script>
<header class="app-header"> <header class="app-header">
@ -20,6 +21,8 @@
<slot /> <slot />
</main> </main>
<Toast />
<style> <style>
.app-header { .app-header {
position: sticky; position: sticky;

@ -0,0 +1,22 @@
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch {
return false;
}
}

@ -0,0 +1,36 @@
<script lang="ts">
import FeedCard from '$lib/ui/data/FeedCard.svelte';
import EmptyState from '$lib/ui/feedback/EmptyState.svelte';
// Placeholder inicial: en siguientes iteraciones se conectará a /api/integrations/feeds
const personalUrl = 'https://app.example.com/ics/personal/xxxxxxxxxxxxxxxx.ics';
const groupFeeds: Array<{ name: string; url: string | null }> = [];
</script>
<svelte:head>
<title>Integraciones (ICS)</title>
<meta name="robots" content="noindex,nofollow" />
</svelte:head>
<section style="max-width: 920px; margin: 1.5rem auto; padding: 0 1rem;">
<h1 style="font-size: 1.4rem; font-weight: 600; margin-bottom: .75rem;">Integraciones</h1>
<h2 style="font-size: 1.1rem; font-weight: 600; margin: .5rem 0;">Feed personal</h2>
<FeedCard
title="Mis tareas (con fecha)"
description="Suscríbete a este feed en tu calendario para ver tus tareas con fecha de vencimiento."
url={personalUrl}
on:rotate={() => {/* se conectará a POST /api/integrations/feeds/rotate */}}
/>
<h2 style="font-size: 1.1rem; font-weight: 600; margin: 1rem 0 .5rem;">Feeds por grupo (sin responsable)</h2>
{#if groupFeeds.length === 0}
<EmptyState>No hay grupos disponibles todavía. Cuando los haya, verás aquí sus feeds ICS.</EmptyState>
{:else}
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: var(--space-3);">
{#each groupFeeds as g}
<FeedCard title={g.name} description="Tareas sin responsable" url={g.url} />
{/each}
</div>
{/if}
</section>

@ -2,6 +2,7 @@
import SegmentedControl from '$lib/ui/inputs/SegmentedControl.svelte'; import SegmentedControl from '$lib/ui/inputs/SegmentedControl.svelte';
import Card from '$lib/ui/layout/Card.svelte'; import Card from '$lib/ui/layout/Card.svelte';
import Button from '$lib/ui/atoms/Button.svelte'; import Button from '$lib/ui/atoms/Button.svelte';
import { toasts } from '$lib/stores/toasts';
export let data: { export let data: {
pref: { freq: 'off' | 'daily' | 'weekly' | 'weekdays'; time: string | null }; pref: { freq: 'off' | 'daily' | 'weekly' | 'weekdays'; time: string | null };
@ -12,6 +13,7 @@
let freq: 'off' | 'daily' | 'weekly' | 'weekdays' = data.pref.freq; let freq: 'off' | 'daily' | 'weekly' | 'weekdays' = data.pref.freq;
let time: string = data.pref.time ?? '08:30'; let time: string = data.pref.time ?? '08:30';
$: if (form?.success) { try { toasts.success('Preferencias guardadas.'); } catch {} }
const options = [ const options = [
{ label: 'Apagado', value: 'off' }, { label: 'Apagado', value: 'off' },

Loading…
Cancel
Save