import { useState } from 'react'; import { useNostr } from '@nostrify/react'; import { useQueryClient } from '@tanstack/react-query'; import { Archive, Loader2, Check, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useNewsletterIssues } from '@/hooks/useNewsletterIssues'; import { useToast } from '@/hooks/useToast'; import { useTranslation } from '@/contexts/language'; import { isAdminPubkey } from '@/lib/admins'; import { buildIssueArticle, issueSlug, type StaticIssueLike } from '@/lib/newsletterArticle'; /** * Admin-only one-time backfill: publishes the static archive issues * (NEWSLETTER_ISSUES, passed in as a prop to avoid an import cycle with the * page) to Nostr as NIP-23 articles. Skips any issue whose slug already exists * on-relay, so it's safe to run repeatedly. The rich static page rendering is * untouched — this just mirrors the archive onto Nostr for subscribers/readers. */ export function NewsletterBackfillPanel({ issues }: { issues?: StaticIssueLike[] }) { const { t } = useTranslation(); const { user } = useCurrentUser(); const { nostr } = useNostr(); const { getWriteRelays } = useSkateRelays(); const { data: liveIssues } = useNewsletterIssues(); const { toast } = useToast(); const queryClient = useQueryClient(); const [running, setRunning] = useState(false); const [done, setDone] = useState(false); if (!isAdminPubkey(user?.pubkey) || !issues || issues.length === 0) return null; // Which static issues aren't on Nostr yet? (match by slug) const liveSlugs = new Set((liveIssues ?? []).map((i) => i.identifier)); const missing = issues.filter((i) => !liveSlugs.has(issueSlug(i.number))); const handleBackfill = async () => { setRunning(true); let published = 0; const failures: number[] = []; // Publish oldest → newest so numbering reads naturally on relays. const ordered = [...missing].sort((a, b) => a.number - b.number); for (const issue of ordered) { try { if (!user) throw new Error(t('walletUi.newsletter.notLoggedIn')); const template = buildIssueArticle(issue); const event = await user.signer.signEvent({ kind: template.kind, content: template.content, tags: template.tags, created_at: Math.floor(Date.now() / 1000), }); // Same reason as the composer: publish to the COMMUNITY relay (+ the // admin's relays) so readers — logged in or not — actually find it. const results = await Promise.allSettled( getWriteRelays().map((url) => nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }), ), ); if (results.every((r) => r.status === 'rejected')) throw new Error('all relays failed'); published++; } catch { failures.push(issue.number); } } setRunning(false); setDone(true); queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] }); if (failures.length === 0) { toast({ title: t('walletUi.newsletter.backfilled', { count: published }), description: t('walletUi.newsletter.backfilledDesc'), }); } else { toast({ title: t('walletUi.newsletter.partialBackfill', { published, failed: failures.length }), description: t('walletUi.newsletter.retryFinish', { list: failures.map((n) => `#${String(n).padStart(3, '0')}`).join(', ') }), variant: 'destructive', }); setDone(false); } }; if (missing.length === 0) { return (
{t('walletUi.newsletter.missingCount', { count: missing.length })}
{t('walletUi.newsletter.backfillDesc', { list: missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ') })}