SkateSpots/src/components/NewsletterBackfillPanel.tsx
shakespeare.diy dc410106b3 Newsletter: subscriber count + admin archive backfill to Nostr
Two follow-ups to the Nostr-native newsletter (skipped auto-DM by design —
too spammy/fragile for now; the bell mailbox already covers the inbox feel).

Subscriber count (social proof):
- useNewsletterSubscriberCount tallies public NIP-78 subscription markers
  (deduped per author, counting current "true" state).
- Shown under the subscribe CTA as "Join N+ skaters getting the drops",
  hidden until it's meaningful (>=3) and rounded once it grows.

Archive backfill (consistency without losing the rich page):
- lib/newsletterArticle.ts converts a static NEWSLETTER_ISSUES entry into a
  NIP-23 (kind 30023) article — structured sections rendered to Markdown,
  stable slug `issue-N`, published_at from the issue date.
- NewsletterBackfillPanel (admin-only) diffs the static archive against what's
  already on-relay and publishes only the missing issues; safe to re-run,
  reports partial failures. The rich static page rendering is untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 19:21:09 +07:00

108 lines
4.3 KiB
TypeScript

import { useState } from '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 { useNostrPublish } from '@/hooks/useNostrPublish';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useToast } from '@/hooks/useToast';
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 { user } = useCurrentUser();
const { mutateAsync: publish } = useNostrPublish();
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 {
await publish(buildIssueArticle(issue));
published++;
} catch {
failures.push(issue.number);
}
}
setRunning(false);
setDone(true);
queryClient.invalidateQueries({ queryKey: ['newsletter-issues'] });
if (failures.length === 0) {
toast({
title: `Backfilled ${published} issue${published === 1 ? '' : 's'} 🛹`,
description: 'The archive now lives on Nostr for subscribers and readers.',
});
} else {
toast({
title: `Published ${published}, ${failures.length} failed`,
description: `Retry to finish: #${failures.map((n) => String(n).padStart(3, '0')).join(', #')}.`,
variant: 'destructive',
});
setDone(false);
}
};
if (missing.length === 0) {
return (
<p className="mt-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-green-500/70">
<Check className="h-3 w-3" /> Archive synced to Nostr
</p>
);
}
return (
<div className="mt-3 w-full max-w-sm rounded-xl border border-yellow-500/30 bg-yellow-500/[0.06] p-3 text-left">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-yellow-500" />
<div className="min-w-0">
<p className="text-xs font-bold text-yellow-200">
{missing.length} archive issue{missing.length === 1 ? '' : 's'} not on Nostr yet
</p>
<p className="mt-0.5 text-[11px] leading-snug text-gray-400">
Publish {missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ')} as
NIP-23 articles so subscribers and long-form readers can see the full archive. The page
keeps its rich layout regardless.
</p>
<Button
size="sm"
onClick={handleBackfill}
disabled={running || done}
className="mt-2 h-8 gap-1.5 bg-yellow-500 font-black uppercase text-[11px] text-black hover:bg-yellow-400"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : done ? (
<Check className="h-3.5 w-3.5" />
) : (
<Archive className="h-3.5 w-3.5" />
)}
{running ? 'Publishing…' : done ? 'Done' : `Backfill ${missing.length} to Nostr`}
</Button>
</div>
</div>
</div>
);
}