BillTracker/client/components/CalendarFeedManager.jsx

227 lines
9.3 KiB
JavaScript

import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
function PlatformNote({ title, children }) {
return (
<div className="rounded-md bg-muted/35 p-3">
<p className="font-medium text-foreground">{title}</p>
<p className="mt-1">{children}</p>
</div>
);
}
export function CalendarFeedManager({ compact = false, showManageLink = false }) {
const [feed, setFeed] = useState(null);
const [preview, setPreview] = useState([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(null);
const loadFeed = useCallback(async () => {
setLoading(true);
try {
const data = await api.calendarFeed();
setFeed(data);
if (data?.active) {
const nextPreview = await api.calendarFeedPreview(10);
setPreview(nextPreview.events || []);
} else {
setPreview([]);
}
} catch (err) {
toast.error(err.message || 'Failed to load calendar feed.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadFeed(); }, [loadFeed]);
const active = !!feed?.active && !!feed?.feed_url;
async function createFeed() {
setBusy('create');
try {
const data = await api.createCalendarFeed();
setFeed(data);
const nextPreview = await api.calendarFeedPreview(10);
setPreview(nextPreview.events || []);
toast.success('Calendar feed created.');
} catch (err) {
toast.error(err.message || 'Failed to create calendar feed.');
} finally {
setBusy(null);
}
}
async function copyFeedUrl() {
if (!feed?.feed_url) return;
try {
await navigator.clipboard.writeText(feed.feed_url);
toast.success('Calendar feed URL copied.');
} catch {
toast.error('Copy failed. Select the URL and copy it manually.');
}
}
async function regenerateFeed() {
setBusy('regenerate');
try {
const data = await api.regenerateCalendarFeed();
setFeed(data);
const nextPreview = await api.calendarFeedPreview(10);
setPreview(nextPreview.events || []);
toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.');
} catch (err) {
toast.error(err.message || 'Failed to regenerate calendar feed.');
} finally {
setBusy(null);
}
}
async function revokeFeed() {
setBusy('revoke');
try {
const data = await api.revokeCalendarFeed();
setFeed(data);
setPreview([]);
toast.success('Calendar feed revoked.');
} catch (err) {
toast.error(err.message || 'Failed to revoke calendar feed.');
} finally {
setBusy(null);
}
}
return (
<div className={cn('space-y-4', compact ? '' : 'px-4 py-4 sm:px-6')}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-primary" />
<p className="text-sm font-medium">Subscribe from Apple Calendar, Google Calendar, Android, Outlook, or any ICS calendar.</p>
</div>
<p className="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">
This creates a private calendar feed URL. Nothing is added automatically; copy the URL into your calendar app to subscribe.
</p>
</div>
{!loading && !active && (
<Button size="sm" onClick={createFeed} disabled={!!busy} className="w-full gap-2 sm:w-auto">
<KeyRound className="h-3.5 w-3.5" />
{busy === 'create' ? 'Creating...' : 'Create Feed'}
</Button>
)}
</div>
{loading && (
<div className="h-24 animate-pulse rounded-lg bg-muted/50" />
)}
{!loading && !active && (
<div className="rounded-lg border border-border/70 bg-muted/20 p-4">
<p className="text-sm font-medium">What happens next?</p>
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
<div className="rounded-md bg-background/60 p-3">
<p className="font-medium text-foreground">1. Create</p>
<p className="mt-1">Generate a private feed URL for your bill calendar.</p>
</div>
<div className="rounded-md bg-background/60 p-3">
<p className="font-medium text-foreground">2. Copy</p>
<p className="mt-1">Paste it into Apple, Google, Outlook, or Android calendar setup.</p>
</div>
<div className="rounded-md bg-background/60 p-3">
<p className="font-medium text-foreground">3. Subscribe</p>
<p className="mt-1">Your calendar app refreshes bill due dates when it checks the feed.</p>
</div>
</div>
</div>
)}
{!loading && active && (
<>
<div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.08] px-3 py-2 text-xs leading-5 text-amber-800 dark:text-amber-200">
Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it was shared somewhere it should not be.
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input value={feed.feed_url} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" />
<Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2">
<Copy className="h-3.5 w-3.5" />
Copy URL
</Button>
<Button asChild size="sm" variant="outline" className="gap-2">
<a href={feed.feed_url} target="_blank" rel="noreferrer">
<Eye className="h-3.5 w-3.5" />
Preview ICS
</a>
</Button>
</div>
<div className="grid gap-3 text-xs text-muted-foreground lg:grid-cols-4">
<PlatformNote title="Apple Calendar">
Add a calendar subscription with the copied URL. The feed uses all-day dates to avoid timezone shifts.
</PlatformNote>
<PlatformNote title="Google / Android">
In Google Calendar on the web, use Other calendars, From URL. Android follows Google Calendar sync.
</PlatformNote>
<PlatformNote title="Outlook">
Subscribe from Outlook on the web with this URL. Imported copies will not update; subscriptions will.
</PlatformNote>
<PlatformNote title="Duplicate Safety">
Bill Tracker emits stable event IDs per bill cycle so subscribed calendars can update without double-adding events.
</PlatformNote>
</div>
<div className="rounded-lg border border-border/70">
<div className="flex flex-col gap-1 border-b border-border/60 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events that will appear</p>
<span className="text-xs text-muted-foreground">
Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
</span>
</div>
<div className="divide-y divide-border/50">
{preview.length === 0 && (
<p className="px-3 py-4 text-sm text-muted-foreground">No upcoming bill events in the preview window.</p>
)}
{preview.map(event => (
<div key={event.uid} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{event.name}</p>
<p className="text-xs text-muted-foreground">{event.due_date} · {event.cycle_type}</p>
</div>
<span className="tracker-number shrink-0 text-xs font-semibold">${Number(event.amount || 0).toFixed(2)}</span>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
{showManageLink && (
<Button asChild size="sm" variant="ghost" className="gap-2">
<Link to="/settings#calendar-feed">
<Settings2 className="h-3.5 w-3.5" />
Manage in Settings
</Link>
</Button>
)}
<Button size="sm" variant="outline" onClick={regenerateFeed} disabled={!!busy} className="gap-2">
<RefreshCw className="h-3.5 w-3.5" />
{busy === 'regenerate' ? 'Regenerating...' : 'Regenerate URL'}
</Button>
<Button size="sm" variant="outline" onClick={revokeFeed} disabled={!!busy} className="gap-2 border-destructive/30 text-destructive hover:bg-destructive/10">
<ShieldOff className="h-3.5 w-3.5" />
{busy === 'revoke' ? 'Revoking...' : 'Revoke Feed'}
</Button>
</div>
</>
)}
</div>
);
}