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