227 lines
9.3 KiB
React
227 lines
9.3 KiB
React
|
|
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>
|
||
|
|
);
|
||
|
|
}
|