~3,470 new keys (5,770 total, EN=ES), ~115 more files wired. - nav + chromeUi: header, More menu, burger menu, footer, map toolbar widgets (sort/trust/weather), spot-type label helper. - placesUi: events, shops, tours, food. socialUi: crews, DMs, notifications, collectives, crew chat/coordinator. settingsUi: settings, auth/signup, onboarding, profile edit, legal. walletUi: wallet/Cashu, stash, mints, app shop, newsletter, community stats, leaderboards, profile. - Fixed 738 usages where an agent dropped the socialUi. prefix (caught by check:i18n).
125 lines
5.2 KiB
TypeScript
125 lines
5.2 KiB
TypeScript
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 (
|
|
<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" /> {t('walletUi.newsletter.archiveSynced')}
|
|
</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">
|
|
{t('walletUi.newsletter.missingCount', { count: missing.length })}
|
|
</p>
|
|
<p className="mt-0.5 text-[11px] leading-snug text-gray-400">
|
|
{t('walletUi.newsletter.backfillDesc', { list: missing.map((i) => `#${String(i.number).padStart(3, '0')}`).join(', ') })}
|
|
</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 ? t('walletUi.newsletter.publishing') : done ? t('common.done') : t('walletUi.newsletter.backfill', { count: missing.length })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|