feat: añadir UI de integraciones ICS con FeedCard y toasts
Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>webui
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,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>
|
||||||
Loading…
Reference in New Issue