diff --git a/src/components/AgoraLogo.tsx b/src/components/AgoraLogo.tsx deleted file mode 100644 index 873a8e7f..00000000 --- a/src/components/AgoraLogo.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { cn } from '@/lib/utils'; - -interface AgoraLogoProps { - className?: string; - size?: number; -} - -function LightningBolt({ size }: { size: number }) { - return ( - - - - ); -} - -/** Agora badge icon used across app chrome. */ -export function AgoraLogo({ className, size = 40 }: AgoraLogoProps) { - const boltSize = Math.max(12, Math.round(size * 0.56)); - - return ( -
-
-
- -
-
- ); -} diff --git a/src/components/AudioNavigationGuard.tsx b/src/components/AudioNavigationGuard.tsx deleted file mode 100644 index c8bc0d17..00000000 --- a/src/components/AudioNavigationGuard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useLocation } from 'react-router-dom'; -import { useAudioPlayer } from '@/contexts/audioPlayerContextDef'; - -/** - * Auto-minimizes the audio player when the user navigates to a different page. - * No dialog — audio just keeps playing in the floating mini-bar. - */ -export function AudioNavigationGuard() { - const { currentTrack, minimized, minimize } = useAudioPlayer(); - const location = useLocation(); - const prevPath = useRef(location.pathname); - - useEffect(() => { - if (location.pathname !== prevPath.current) { - // Route changed — minimize if playing and expanded - if (currentTrack && !minimized) { - minimize(); - } - prevPath.current = location.pathname; - } - }, [location.pathname, currentTrack, minimized, minimize]); - - return null; -} diff --git a/src/components/BackgroundPicker.tsx b/src/components/BackgroundPicker.tsx deleted file mode 100644 index 893946da..00000000 --- a/src/components/BackgroundPicker.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { useRef } from 'react'; -import { ImagePlus, X, Loader2 } from 'lucide-react'; - -import { useTheme } from '@/hooks/useTheme'; -import { useUploadFile } from '@/hooks/useUploadFile'; -import { useToast } from '@/hooks/useToast'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; -import { resizeImage } from '@/lib/resizeImage'; -import type { ThemeBackground } from '@/themes'; - -/** - * Background image picker for theme customization. - * - * Supports two modes: - * - **Uncontrolled** (default): reads/writes via `useTheme().applyCustomTheme()` - * - **Controlled**: pass `value` and `onChange` props to manage state externally - */ -export function BackgroundPicker({ value, onChange }: { - /** Controlled value — overrides useTheme() when provided. */ - value?: ThemeBackground | undefined; - /** Controlled onChange — called instead of applyCustomTheme() when provided. */ - onChange?: (bg: ThemeBackground | undefined) => void; -} = {}) { - const { theme, customTheme, applyCustomTheme } = useTheme(); - const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); - const { toast } = useToast(); - const inputRef = useRef(null); - const controlled = onChange !== undefined; - - const currentBg: ThemeBackground | undefined = controlled - ? value - : (theme === 'custom' ? customTheme?.background : undefined); - - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - e.target.value = ''; - - if (!file.type.startsWith('image/')) { - toast({ title: 'Invalid file', description: 'Please select an image file.', variant: 'destructive' }); - return; - } - - try { - // Resize & convert to JPEG before uploading for better performance. - const { file: optimized, dimensions } = await resizeImage(file); - - const tags = await uploadFile(optimized); - const url = tags[0][1]; - - const bg: ThemeBackground = { - url, - mode: 'cover', - mimeType: optimized.type, - dimensions, - }; - - if (controlled) { - onChange(bg); - } else { - const currentColors = customTheme?.colors ?? { - background: '228 20% 10%', - text: '210 40% 98%', - primary: '258 70% 60%', - }; - applyCustomTheme({ - ...customTheme, - colors: currentColors, - background: bg, - }); - } - } catch (error) { - console.error('Failed to upload background:', error); - toast({ title: 'Upload failed', description: 'Could not upload the image.', variant: 'destructive' }); - } - }; - - const handleRemove = () => { - if (controlled) { - onChange(undefined); - return; - } - if (!customTheme) return; - applyCustomTheme({ - ...customTheme, - background: undefined, - }); - }; - - const handleModeChange = (mode: 'cover' | 'tile') => { - if (controlled) { - if (!value) return; - onChange({ ...value, mode }); - return; - } - if (!customTheme?.background) return; - applyCustomTheme({ - ...customTheme, - background: { ...customTheme.background, mode }, - }); - }; - - return ( -
- - Background - - - {currentBg ? ( -
-
- Theme background - -
- - {/* Mode toggle */} -
- {(['cover', 'tile'] as const).map((mode) => ( - - ))} -
-
- ) : ( - - )} - - -
- ); -} - - diff --git a/src/components/BadgeRecoveryDialog.tsx b/src/components/BadgeRecoveryDialog.tsx deleted file mode 100644 index 2ff65468..00000000 --- a/src/components/BadgeRecoveryDialog.tsx +++ /dev/null @@ -1,388 +0,0 @@ -import { useState, useMemo } from 'react'; -import { useNostr } from '@nostrify/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; - -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; -import { BadgeThumbnail } from '@/components/BadgeThumbnail'; -import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition'; -import { parseProfileBadges } from '@/lib/parseProfileBadges'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useToast } from '@/hooks/useToast'; -import { BADGE_PROFILE_KIND, BADGE_PROFILE_KIND_LEGACY, BADGE_DEFINITION_KIND } from '@/lib/badgeUtils'; -import { cn } from '@/lib/utils'; -import { Award, Check, Loader2, RotateCcw } from 'lucide-react'; - -/** - * Query all events matching a filter using `req()` instead of `query()`. - * This bypasses NSet deduplication in NPool.query(), which discards older - * versions of replaceable events. We need all historical versions for recovery. - */ -async function queryAllEvents( - nostr: { req(filters: NostrFilter[], opts?: { signal?: AbortSignal }): AsyncIterable<['EVENT', string, NostrEvent] | ['EOSE', string] | ['CLOSED', string, string]> }, - filters: NostrFilter[], - signal: AbortSignal, -): Promise { - const events: NostrEvent[] = []; - const seen = new Set(); - - for await (const msg of nostr.req(filters, { signal })) { - if (msg[0] === 'EOSE' || msg[0] === 'CLOSED') break; - if (msg[0] === 'EVENT') { - const event = msg[2]; - if (!seen.has(event.id)) { - seen.add(event.id); - events.push(event); - } - } - } - - return events; -} - -interface BadgeRecoveryDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; -} - -/** Format a unix timestamp into a human-readable date string. */ -function formatDate(timestamp: number): string { - return new Date(timestamp * 1000).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -/** Summary of badges parsed from a snapshot. */ -interface BadgeSummary { - count: number; - /** Parsed badge refs with their a-tag and identifier. */ - refs: { aTag: string; pubkey: string; identifier: string }[]; -} - -/** Parse all badge refs from a profile badges event. */ -function parseBadgeSnapshot(event: NostrEvent): BadgeSummary { - const refs = parseProfileBadges(event); - return { - count: refs.length, - refs: refs.map((r) => ({ aTag: r.aTag, pubkey: r.pubkey, identifier: r.identifier })), - }; -} - -// ─── Badge Snapshot Card ────────────────────────────────────────────── - -function BadgeSnapshotCard({ - summary, - event, - isCurrent, - onRestore, - isRestoring, - badgeMap, -}: { - summary: BadgeSummary; - event: NostrEvent; - isCurrent: boolean; - onRestore: () => void; - isRestoring: boolean; - badgeMap: Map; -}) { - /** Show up to 5 badge thumbnails in the preview. */ - const previewRefs = summary.refs.slice(0, 5); - const remaining = Math.max(0, summary.count - previewRefs.length); - - return ( -
- {isCurrent && ( -
- - Current -
- )} - -
-
- -
- -
-
- {summary.count.toLocaleString()} {summary.count === 1 ? 'badge' : 'badges'} -
- - {/* Badge thumbnail previews */} - {previewRefs.length > 0 && ( -
- {previewRefs.map((ref) => { - const badge = badgeMap.get(ref.aTag); - return badge ? ( - - ) : ( -
- -
- ); - })} - {remaining > 0 && ( - - +{remaining} - - )} -
- )} - -
- {formatDate(event.created_at)} -
-
-
- - {!isCurrent && ( -
- -
- )} -
- ); -} - -// ─── Empty State ────────────────────────────────────────────────────── - -function EmptyState() { - return ( -
-

- No badge list history found. Your relay may not store historical events. -

-
- ); -} - -// ─── Loading Skeleton ───────────────────────────────────────────────── - -function SnapshotSkeleton() { - return ( -
- {[1, 2, 3].map((i) => ( -
-
- -
- - - -
-
-
- ))} -
- ); -} - -// ─── Badge History Content ──────────────────────────────────────────── - -function BadgeHistoryContent({ onClose }: { onClose: () => void }) { - const { nostr } = useNostr(); - const { user } = useCurrentUser(); - const { mutateAsync: publishEvent } = useNostrPublish(); - const { toast } = useToast(); - const queryClient = useQueryClient(); - const [restoringId, setRestoringId] = useState(null); - - const pubkey = user?.pubkey; - - // Fetch all historical kind 10008 and legacy 30008 events - const badgeHistory = useQuery({ - queryKey: ['badge-recovery', 'history', pubkey], - queryFn: async () => { - if (!pubkey) return []; - const events = await queryAllEvents( - nostr, - [ - { kinds: [BADGE_PROFILE_KIND], authors: [pubkey] }, - { kinds: [BADGE_PROFILE_KIND_LEGACY], authors: [pubkey], '#d': ['profile_badges'] }, - ], - AbortSignal.timeout(10000), - ); - return events.sort((a, b) => b.created_at - a.created_at); - }, - enabled: !!pubkey, - staleTime: 30_000, - }); - - // Parse all snapshots - const parsedSnapshots = useMemo(() => { - if (!badgeHistory.data) return new Map(); - const results = new Map(); - for (const event of badgeHistory.data) { - results.set(event.id, parseBadgeSnapshot(event)); - } - return results; - }, [badgeHistory.data]); - - // Collect all unique badge definition refs across all snapshots for thumbnail fetching - const allBadgeRefs = useMemo(() => { - const seen = new Set(); - const refs: { pubkey: string; identifier: string; aTag: string }[] = []; - for (const summary of parsedSnapshots.values()) { - for (const ref of summary.refs) { - if (!seen.has(ref.aTag)) { - seen.add(ref.aTag); - refs.push(ref); - } - } - } - return refs; - }, [parsedSnapshots]); - - // Fetch badge definitions for thumbnails - const badgeDefsQuery = useQuery({ - queryKey: ['badge-recovery', 'definitions', allBadgeRefs.map((r) => r.aTag).join(',')], - queryFn: async ({ signal }) => { - if (allBadgeRefs.length === 0) return []; - const filters = allBadgeRefs.map((ref) => ({ - kinds: [BADGE_DEFINITION_KIND as number], - authors: [ref.pubkey], - '#d': [ref.identifier], - limit: 1, - })); - return nostr.query(filters, { signal }); - }, - enabled: allBadgeRefs.length > 0, - staleTime: 5 * 60_000, - }); - - // Build badge data map for thumbnails - const badgeMap = useMemo(() => { - const map = new Map(); - if (!badgeDefsQuery.data) return map; - for (const event of badgeDefsQuery.data) { - const parsed = parseBadgeDefinition(event); - if (!parsed) continue; - const aTag = `${BADGE_DEFINITION_KIND}:${event.pubkey}:${parsed.identifier}`; - map.set(aTag, parsed); - } - return map; - }, [badgeDefsQuery.data]); - - const badgeEvents = badgeHistory.data ?? []; - const currentBadgeId = badgeEvents[0]?.id; - - const handleRestore = async (event: NostrEvent) => { - setRestoringId(event.id); - try { - // Re-publish as kind 10008 (always write to the new kind), - // stripping any legacy `d` tag from kind 30008 events. - const tags = event.tags.filter(([n, v]) => !(n === 'd' && v === 'profile_badges')); - - await publishEvent({ - kind: BADGE_PROFILE_KIND, - content: event.content, - tags, - created_at: Math.floor(Date.now() / 1000), - }); - - toast({ - title: 'Badge list restored', - description: `Successfully restored from ${formatDate(event.created_at)}.`, - }); - - queryClient.invalidateQueries({ queryKey: ['badge-recovery', 'history', pubkey] }); - queryClient.invalidateQueries({ queryKey: ['profile-badges', pubkey] }); - - onClose(); - } catch (error) { - console.error('Failed to restore badge list:', error); - toast({ - title: 'Restore failed', - description: 'Could not republish the badge list. Please try again.', - variant: 'destructive', - }); - } finally { - setRestoringId(null); - } - }; - - if (badgeHistory.isLoading) { - return ; - } - - if (badgeEvents.length === 0) { - return ; - } - - return ( - <> - {badgeEvents.map((event) => { - const summary = parsedSnapshots.get(event.id); - if (!summary) return null; - - return ( - handleRestore(event)} - isRestoring={restoringId === event.id} - badgeMap={badgeMap} - /> - ); - })} - - ); -} - -// ─── Main Dialog ────────────────────────────────────────────────────── - -export function BadgeRecoveryDialog({ open, onOpenChange }: BadgeRecoveryDialogProps) { - const close = () => onOpenChange(false); - - return ( - - - - Badge List Recovery -

- Browse and restore previous versions of your accepted badges. -

-
- - -
- -
-
-
-
- ); -} diff --git a/src/components/BirdDetectionContent.tsx b/src/components/BirdDetectionContent.tsx deleted file mode 100644 index 6946471e..00000000 --- a/src/components/BirdDetectionContent.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { useMemo } from 'react'; -import { Bird } from 'lucide-react'; -import { Link } from 'react-router-dom'; -import type { NostrEvent } from '@nostrify/nostrify'; - -import { BirdSongPlayer } from '@/components/BirdSongPlayer'; -import { Skeleton } from '@/components/ui/skeleton'; -import { useWikidataEntity } from '@/hooks/useWikidataEntity'; -import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; -import { sanitizeUrl } from '@/lib/sanitizeUrl'; -import { cn } from '@/lib/utils'; - -/** - * Birdstar kind 2473 — Bird Detection. - * - * The species is identified by a NIP-73 `i`/`k` pair pointing at a Wikidata - * entity URI (e.g. `https://www.wikidata.org/entity/Q26825`). We resolve it - * through Wikidata → English Wikipedia to get a display name, a short - * summary, and a thumbnail image. - * - * Structured data lives in tags; `content` is an optional freeform note. - */ - -const WIKIDATA_URL_RE = /^https:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/; - -interface BirdDetectionContentProps { - event: NostrEvent; - className?: string; -} - -function extractWikidata(tags: string[][]): { id: string; url: string } | null { - // A valid detection pairs an `i` tag with `k: web`. There may be multiple - // i/k pairs in principle; we take the first `i` whose URL matches. - for (const tag of tags) { - if (tag[0] !== 'i') continue; - const value = tag[1]; - if (typeof value !== 'string') continue; - const m = value.match(WIKIDATA_URL_RE); - if (m) return { id: m[1], url: value }; - } - return null; -} - -/** Pull a species label from the `alt` tag: "Bird detection: American Robin (Turdus migratorius)". */ -function extractAltSpecies(tags: string[][]): { common?: string; scientific?: string } | null { - const alt = tags.find(([n]) => n === 'alt')?.[1]; - if (!alt) return null; - // Match "Bird detection: ()" — keep the parser loose - // so subtly different NIP-31 prefixes still yield a usable label. - const m = alt.match(/^[^:]*:\s*([^()]+?)\s*(?:\(([^)]+)\))?\s*$/); - if (!m) return { common: alt }; - return { common: m[1]?.trim(), scientific: m[2]?.trim() }; -} - -/** Extract the scientific name from the `n` tag (Birdstar NIP §"Kind 2473" — the - * authoritative scientific-name field, added so clients can label a detection - * without round-tripping Wikidata). Falls through to parsing the `alt` tag for - * older events authored before `n` was part of the NIP. */ -function extractScientificName( - tags: string[][], - altScientific: string | undefined, -): string | undefined { - const n = tags.find(([name]) => name === 'n')?.[1]; - if (typeof n === 'string' && n.trim()) return n.trim(); - return altScientific; -} - -export function BirdDetectionContent({ event, className }: BirdDetectionContentProps) { - const wikidata = useMemo(() => extractWikidata(event.tags), [event.tags]); - const altSpecies = useMemo(() => extractAltSpecies(event.tags), [event.tags]); - const scientificName = useMemo( - () => extractScientificName(event.tags, altSpecies?.scientific), - [event.tags, altSpecies?.scientific], - ); - const note = event.content.trim(); - - // Resolve Wikidata → English Wikipedia title, then fetch the Wikipedia - // summary (extract + thumbnail) for the title. - const { data: entity, isLoading: entityLoading } = useWikidataEntity(wikidata?.id ?? null); - const wikipediaTitle = entity?.wikipediaTitle ?? null; - const { data: summary, isLoading: summaryLoading } = useWikipediaSummary(wikipediaTitle); - - const isLoading = entityLoading || summaryLoading; - - // Prefer the Wikipedia page title for the display name when available, - // but fall back to the species parsed from the `alt` tag so the card is - // still meaningful while the Wikipedia fetch is in flight (or has failed). - const commonName = summary?.title ?? altSpecies?.common ?? 'Unknown species'; - const extract = summary?.extract; - const thumbnail = sanitizeUrl(summary?.thumbnail?.source); - - // The whole card routes to Ditto's external-content page for this - // species' Wikidata URL. Other users' kind 2473 detections and - // NIP-22 comments both attach to the same `i`-tag identifier, so - // the discussion thread aggregates naturally across clients. - const discussPath = wikidata ? `/i/${encodeURIComponent(wikidata.url)}` : undefined; - - // When the user's own freeform note exists we show it above the - // Wikipedia-derived summary. `content` can be empty per the NIP. - const timeStr = new Date(event.created_at * 1000).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }); - - if (!wikidata) { - // Shouldn't happen for a valid kind 2473 (the NIP requires the i tag), - // but render something useful rather than silently dropping the event. - return ( -
- Bird detection with no species reference. -
- ); - } - - return ( -
- e.stopPropagation()} - className="block overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" - > -
- {/* Thumbnail panel */} -
- {isLoading ? ( - - ) : thumbnail ? ( - {commonName} - ) : ( -
- -
- )} -
-
- {timeStr} -
-
- - {/* Text panel */} -
-
-
-

- {commonName} -

- {scientificName && ( -

- {scientificName} -

- )} -
- - {isLoading ? ( -
- - - -
- ) : extract ? ( -

- {extract} -

- ) : ( -

- Heard at {new Date(event.created_at * 1000).toLocaleString()}. -

- )} -
- - {/* Reference recording from Wikipedia/Commons, when available. - * `BirdSongPlayer` returns null when the article has no - * usable audio, so the right-hand column collapses - * cleanly and the text reflows across the full card. - * The click handler stops propagation so toggling - * playback doesn't also navigate to `/i/...`. */} - {wikipediaTitle && ( -
{ - e.stopPropagation(); - e.preventDefault(); - }} - className="shrink-0" - > - -
- )} -
-
- - - {note && ( -

- {note} -

- )} -
- ); -} diff --git a/src/components/BirdSongPlayer.tsx b/src/components/BirdSongPlayer.tsx deleted file mode 100644 index 19e131e9..00000000 --- a/src/components/BirdSongPlayer.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Play } from 'lucide-react'; - -import { Skeleton } from '@/components/ui/skeleton'; -import { useBirdSong, type BirdSong } from '@/hooks/useBirdSong'; -import { cn } from '@/lib/utils'; - -/** - * Inline bird-song play button for Wikipedia species pages. - * - * Looks up a reference recording from the article (via - * `useBirdSong` → Wikipedia/Commons) and renders a circular - * toggle. Clicking plays the song on loop; the play triangle is - * replaced by an animated equaliser so the single control both - * triggers and indicates playback. The `