chore: delete components/hooks/lib orphaned by page deletions

Iteratively removed files no longer imported anywhere after the
unreachable-page sweep:

- Layout chrome: MainLayout, LeftSidebar, RightSidebar, WidgetSidebar,
  MobileDrawer, MobileBottomNav, MobileTopBar, FundraiserLayout
  component, FloatingComposeButton, AudioNavigationGuard,
  MinimizedAudioBar, DeepLinkHandler
- Music subsystem: useMusic*, MusicHeroCard, MusicTrackCard,
  MusicPlaylistsTab, MusicTracksTab, MusicArtistsTab, MusicDiscoverTab,
  MusicSortFilterBar, MusicPlaylistCard
- World/discovery: useWorldFeed, WorldMap, WorldDiscoveryPanel/Drawer,
  CountryBrowser, CountryActivityPopover, CountryPulseStrip, TagChips,
  HorizontalScroll, ContentCTACard
- Letter system: useLetterPreferences, useStationery,
  useThemeStationery, LetterCard, LetterEditor, DrawingCanvas,
  StationeryPicker, FramePicker, FramePreviews, SendAnimation,
  ComposeLetterSheet, LetterPreferencesSection, useEnvelopeDimensions,
  svgDrawing
- Bird/Constellation: useBirdSong, BirdSongPlayer, BirdexContent,
  BirdexTile, BirdexChorusButton, BirdDetectionContent,
  ConstellationContent, ConstellationStarMap, parseBirdex, starCatalog
- Books: useBookFeed, useBookSearch, useBookDetails, usePopularBooks,
  useBookSummary, BookFeedItem
- Bluesky: useBluesky*, BlueskyWidget
- Wikipedia: useWikipediaSearch, useWikipediaFeatured, WikipediaWidget
- AI chat: useAIChatSession, useAIChatTools, aiChatTools, tools/*,
  AIChatWidget
- Article editor: ArticleEditor, MilkdownEditor, MilkdownToolbar,
  LinkDialog, usePublishedArticles, articleHelpers
- Badges/community/highlights: useBadgeFeed, useCommunity*,
  useCreateBadge, BadgeRecoveryDialog, CreateBadgeDialog,
  CreateGoalDialog, CommunityCard, HighlightContent
- Profile tabs: useProfileTabs, useResolveTabFilter,
  usePublishProfileTabs, ProfileTabEditModal, ProfileTabsManagerModal,
  profileTabsEvent
- Webxdc/Photo/Stream/Vine: WebxdcUploadDialog, PhotoComposeModal,
  PhotoBottomBar, useStreamKind, vineGlobalMute
- Miscellaneous: AudioPlayerContext, EditProfileForm (unused), feed
  widgets, FabButton, sidebar widgets, ContentSettings, FontPicker,
  EmbeddedPeopleListCard, PeopleListDetailContent, BitcoinContent*,
  exchange rate service, deduplicateEvents, feedDiversity,
  createZapInvoice, MuteListRecoveryDialog, EventRecoveryDialog,
  ZapContent, useCuratedDittoFeed, useScrollDirection, useDrafts,
  useUserZap, useHasUnreadNotifications, useMutedAuthorFilter,
  useFormatMoney, useKeyboardVisible, AgoraLogo, MailboxIcon,
  PlanetIcon, WhiteNoiseIcon, BarsStaggeredIcon, ScopedTheme,
  CommentsSheet, MobileSearchSheet, FollowAllSplitButton,
  FeedEditModal, KindInfoButton, WidgetCard, WidgetPickerDialog,
  SidebarNavItem, SidebarMoreMenu, TrendSparkline, DorkThinking,
  NostrEventSidebarItem, NsiteSidebarItem, ExternalContentSidebarItem,
  GathererCardHeader, MediaCollage, BackgroundPicker,
  BitcoinPrivateDisclaimer, UnknownKindContent, proxyUrl, localDrafts,
  flagPalette, coordinates, checkWasmSupport
- Unused shadcn primitives: calendar, context-menu, input-otp,
  breadcrumb, menubar, toggle, table, resizable, navigation-menu,
  carousel, aspect-ratio, pagination, color-picker, drawer
This commit is contained in:
Alex Gleason
2026-05-23 12:38:19 -05:00
parent b975e55794
commit 256560cf3b
204 changed files with 0 additions and 33728 deletions
-53
View File
@@ -1,53 +0,0 @@
import { cn } from '@/lib/utils';
interface AgoraLogoProps {
className?: string;
size?: number;
}
function LightningBolt({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<path
d="M13 2L3 14h8l-1 8 10-12h-8l1-8z"
fill="white"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** 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 (
<div
role="img"
aria-label="Agora"
style={{
width: size,
height: size,
}}
className={cn(
'relative rounded-full bg-gradient-to-br from-primary to-primary/80 shadow-lg flex items-center justify-center',
className,
)}
>
<div className="absolute inset-0 rounded-full bg-primary/25 blur-md" aria-hidden />
<div className="relative">
<LightningBolt size={boltSize} />
</div>
</div>
);
}
-25
View File
@@ -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;
}
-174
View File
@@ -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<HTMLInputElement>(null);
const controlled = onChange !== undefined;
const currentBg: ThemeBackground | undefined = controlled
? value
: (theme === 'custom' ? customTheme?.background : undefined);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground">
Background
</span>
{currentBg ? (
<div className="space-y-2">
<div className="relative rounded-lg overflow-hidden border border-border">
<img
src={currentBg.url}
alt="Theme background"
className="w-full h-24 object-cover"
/>
<Button
variant="secondary"
size="icon"
className="absolute top-1.5 right-1.5 size-6 rounded-full bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={handleRemove}
>
<X className="size-3.5" />
</Button>
</div>
{/* Mode toggle */}
<div className="flex gap-1 rounded-lg border border-border p-0.5 w-fit">
{(['cover', 'tile'] as const).map((mode) => (
<button
key={mode}
onClick={() => handleModeChange(mode)}
className={cn(
'px-3 py-1 rounded-md text-xs font-medium transition-colors',
(currentBg.mode ?? 'cover') === mode
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{mode === 'cover' ? 'Cover' : 'Tile'}
</button>
))}
</div>
</div>
) : (
<button
onClick={() => inputRef.current?.click()}
disabled={isUploading}
className="w-full h-20 rounded-lg border-2 border-dashed border-border hover:border-primary/40 transition-colors flex flex-col items-center justify-center gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-50"
>
{isUploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<ImagePlus className="size-4" />
<span className="text-xs">Upload image</span>
</>
)}
</button>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
</div>
);
}
-388
View File
@@ -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<NostrEvent[]> {
const events: NostrEvent[] = [];
const seen = new Set<string>();
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<string, BadgeData>;
}) {
/** 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 (
<div
className={cn(
'group relative rounded-xl border p-4 transition-all',
isCurrent
? 'border-primary/40 bg-primary/5'
: 'border-border hover:border-primary/20 hover:bg-secondary/30',
)}
>
{isCurrent && (
<div className="absolute top-3 right-3 flex items-center gap-1 text-xs font-medium text-primary">
<Check className="size-3.5" />
Current
</div>
)}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-11 shrink-0 rounded-full bg-primary/10">
<Award className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="font-semibold text-sm">
{summary.count.toLocaleString()} {summary.count === 1 ? 'badge' : 'badges'}
</div>
{/* Badge thumbnail previews */}
{previewRefs.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{previewRefs.map((ref) => {
const badge = badgeMap.get(ref.aTag);
return badge ? (
<BadgeThumbnail key={ref.aTag} badge={badge} size={24} className="shrink-0" />
) : (
<div
key={ref.aTag}
className="size-6 rounded border border-border bg-secondary/30 flex items-center justify-center shrink-0"
>
<Award className="size-3 text-muted-foreground" />
</div>
);
})}
{remaining > 0 && (
<span className="text-[11px] text-muted-foreground">
+{remaining}
</span>
)}
</div>
)}
<div className="text-[11px] text-muted-foreground/70 pt-0.5">
{formatDate(event.created_at)}
</div>
</div>
</div>
{!isCurrent && (
<div className="mt-3 flex justify-end">
<Button
variant="outline"
size="sm"
className="h-8 text-xs rounded-lg gap-1.5"
onClick={onRestore}
disabled={isRestoring}
>
{isRestoring ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCcw className="size-3.5" />
)}
Restore
</Button>
</div>
)}
</div>
);
}
// ─── Empty State ──────────────────────────────────────────────────────
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
No badge list history found. Your relay may not store historical events.
</p>
</div>
);
}
// ─── Loading Skeleton ─────────────────────────────────────────────────
function SnapshotSkeleton() {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-xl border p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-20" />
</div>
</div>
</div>
))}
</div>
);
}
// ─── 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<string | null>(null);
const pubkey = user?.pubkey;
// Fetch all historical kind 10008 and legacy 30008 events
const badgeHistory = useQuery<NostrEvent[]>({
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<string, BadgeSummary>();
const results = new Map<string, BadgeSummary>();
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<string>();
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<string, BadgeData>();
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 <SnapshotSkeleton />;
}
if (badgeEvents.length === 0) {
return <EmptyState />;
}
return (
<>
{badgeEvents.map((event) => {
const summary = parsedSnapshots.get(event.id);
if (!summary) return null;
return (
<BadgeSnapshotCard
key={event.id}
event={event}
summary={summary}
isCurrent={event.id === currentBadgeId}
onRestore={() => handleRestore(event)}
isRestoring={restoringId === event.id}
badgeMap={badgeMap}
/>
);
})}
</>
);
}
// ─── Main Dialog ──────────────────────────────────────────────────────
export function BadgeRecoveryDialog({ open, onOpenChange }: BadgeRecoveryDialogProps) {
const close = () => onOpenChange(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 rounded-2xl overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="text-lg font-bold">Badge List Recovery</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
Browse and restore previous versions of your accepted badges.
</p>
</DialogHeader>
<ScrollArea className="h-[420px]">
<div className="p-4 space-y-3">
<BadgeHistoryContent onClose={close} />
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
-208
View File
@@ -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: <Common> (<Scientific>)" — 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 (
<div className={cn('mt-2 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground', className)}>
Bird detection with no species reference.
</div>
);
}
return (
<div className={cn('mt-2', className)}>
<Link
to={discussPath ?? '#'}
onClick={(e) => 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"
>
<div className="flex">
{/* Thumbnail panel */}
<div className="relative w-32 shrink-0 bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 sm:w-40 dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40">
{isLoading ? (
<Skeleton className="h-full w-full" />
) : thumbnail ? (
<img
src={thumbnail}
alt={commonName}
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<Bird
aria-hidden
strokeWidth={1.5}
className="size-10 text-emerald-700/60 dark:text-amber-300/60"
/>
</div>
)}
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/40 to-transparent" />
<div className="absolute bottom-1.5 left-2 font-mono text-[10px] uppercase tracking-wider text-white/85">
{timeStr}
</div>
</div>
{/* Text panel */}
<div className="flex min-w-0 flex-1 items-start gap-3 p-3.5">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="min-w-0">
<h3 className="truncate text-[15px] font-semibold leading-tight">
{commonName}
</h3>
{scientificName && (
<p className="mt-0.5 truncate text-xs italic text-muted-foreground">
{scientificName}
</p>
)}
</div>
{isLoading ? (
<div className="space-y-1.5 pt-0.5">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
<Skeleton className="h-3 w-2/3" />
</div>
) : extract ? (
<p className="line-clamp-3 text-[13px] leading-relaxed text-muted-foreground">
{extract}
</p>
) : (
<p className="text-xs italic text-muted-foreground/70">
Heard at {new Date(event.created_at * 1000).toLocaleString()}.
</p>
)}
</div>
{/* 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 && (
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="shrink-0"
>
<BirdSongPlayer title={wikipediaTitle} ariaLabel={`${commonName} recording`} />
</div>
)}
</div>
</div>
</Link>
{note && (
<p className="mt-2 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
{note}
</p>
)}
</div>
);
}
-181
View File
@@ -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 `<audio>` element is rendered
* hidden inside the component — callers don't need to thread it
* through the tree.
*
* Returns `null` when no usable recording exists, so the caller can
* spread it into a header/title row without worrying about a
* disabled/broken state.
*
* Adapted from Birdstar's BirdInfoDialog `useSongPlayer` (see
* `~/Projects/birdstar/src/components/BirdInfoDialog.tsx`). The
* iNaturalist fallback from the original is deliberately omitted —
* per the user's request Ditto only uses Wikipedia/Commons.
*/
interface BirdSongPlayerProps {
/**
* Wikipedia article title. We resolve it to an audio file on
* Wikimedia Commons.
*/
title: string | null;
className?: string;
/** Rendered in a surrounding flex row; supply a label for a11y when
* the surrounding header doesn't already describe the subject. */
ariaLabel?: string;
}
export function BirdSongPlayer({ title, className, ariaLabel }: BirdSongPlayerProps) {
const { data: song, isLoading } = useBirdSong(title);
if (isLoading) {
return (
<Skeleton
className={cn('size-10 shrink-0 rounded-full', className)}
aria-hidden
/>
);
}
if (!song) return null;
return (
<BirdSongButton song={song} className={className} ariaLabel={ariaLabel} />
);
}
function BirdSongButton({
song,
className,
ariaLabel,
}: {
song: BirdSong;
className?: string;
ariaLabel?: string;
}) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
// When the song source changes (user navigates to a different
// species while one is playing), reset to the paused state — the
// previous <audio> element unmounts, and we don't want the button
// inheriting a stale `isPlaying=true`.
useEffect(() => {
setIsPlaying(false);
}, [song.audioUrl]);
const toggle = () => {
const el = audioRef.current;
if (!el) return;
if (isPlaying) {
el.pause();
setIsPlaying(false);
} else {
// `play()` returns a Promise that rejects when autoplay is
// blocked or the source fails to load. Swallow the rejection —
// the button stays in the paused state and the user can retry.
el.play().then(
() => setIsPlaying(true),
() => setIsPlaying(false),
);
}
};
return (
<>
<button
type="button"
onClick={toggle}
aria-label={
isPlaying
? `Pause ${ariaLabel ?? 'reference recording'}`
: `Play ${ariaLabel ?? 'reference recording'}`
}
aria-pressed={isPlaying}
title={song.attribution}
className={cn(
'group inline-flex size-10 shrink-0 items-center justify-center rounded-full',
'bg-emerald-500 text-white shadow-md ring-1 ring-emerald-400/40',
'transition-[transform,background-color,box-shadow] duration-200',
'hover:bg-emerald-600 hover:shadow-lg active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2',
'focus-visible:ring-offset-background',
'dark:bg-emerald-400 dark:text-emerald-950 dark:hover:bg-emerald-300',
className,
)}
>
{isPlaying ? (
<EqualiserBars />
) : (
// Nudge the play triangle right by 1px — its centroid sits
// left of its bounding box and would otherwise look
// off-center inside the circle.
<Play className="size-4 translate-x-px fill-current" aria-hidden />
)}
</button>
<audio
ref={audioRef}
src={song.audioUrl}
preload="none"
// Loop the reference recording. Commons bird songs are
// typically a few seconds of a single phrase, and users want
// to hear it repeatedly to compare with what they heard in
// the field. The button (same hit region as the equaliser)
// is the explicit stop.
loop
onPause={() => setIsPlaying(false)}
onPlay={() => setIsPlaying(true)}
className="hidden"
>
Your browser does not support embedded audio.
</audio>
</>
);
}
/**
* Four vertical bars bouncing with staggered CSS animations,
* rendered inside the button while playback is active. Color is
* `currentColor` so it inherits the button's text colour — white on
* the emerald background in light mode, emerald-950 (matching
* foreground) in dark mode. Respects `prefers-reduced-motion` via
* Tailwind's `motion-reduce:` variant so the bars freeze rather
* than bouncing for users who've asked for less motion.
*/
function EqualiserBars() {
const delays = ['0ms', '120ms', '60ms', '180ms'];
return (
<span className="flex h-4 items-end gap-[2px]" aria-hidden>
{delays.map((delay, i) => (
<span
key={i}
className={cn(
'block w-[2px] rounded-full bg-current',
// `origin-bottom` keeps the scaleY transform anchored to
// the baseline so the bar "grows up" rather than
// expanding from its center.
'h-full origin-bottom motion-safe:animate-equaliser-bar',
// Static midpoint height when motion is reduced, so the
// UI still conveys "audio is playing" without movement.
'motion-reduce:scale-y-75',
)}
style={{ animationDelay: delay }}
/>
))}
</span>
);
}
-279
View File
@@ -1,279 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Play } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { useBirdSong } from '@/hooks/useBirdSong';
import { useWikidataEntity } from '@/hooks/useWikidataEntity';
import { cn } from '@/lib/utils';
/**
* Chorus play/pause button for a Birdex (kind 12473) life list.
*
* A single control that fires every species' reference recording from
* Wikipedia/Commons *at the same time*, producing an overlapping
* dawn-chorus effect. Each recording loops independently so the
* chorus sustains until the user hits pause.
*
* Architecture: the parent owns a single `isPlaying` flag. It renders
* one `BirdexChorusVoice` per species, each of which:
* 1. Resolves its Wikidata ID → Wikipedia title → Commons audio URL
* via the same hooks the tile thumbnails already call (so the
* title-resolution round-trips are cache hits).
* 2. Owns a hidden `<audio loop>` element.
* 3. Reacts to `isPlaying` by calling `play()` or `pause()` on its
* element, and reports ready-state / error-state back up so the
* button knows when to show a spinner and whether there's
* anything audible to play at all.
*
* Voices that fail to resolve audio (species whose Wikipedia article
* has no usable field recording) are silently skipped — the chorus
* plays whatever subset has audio. If nothing at all has audio the
* button hides itself rather than rendering a dead control.
*
* Note: every species' audio URL is fetched eagerly on mount. The
* cost is bounded by the number of species the user already sees as
* tiles, and every request is cached for 24h via TanStack Query, so
* a second visit is free.
*/
export interface BirdexChorusSpecies {
/** Wikidata entity ID, e.g. "Q26825". */
entityId: string;
}
interface BirdexChorusButtonProps {
species: BirdexChorusSpecies[];
className?: string;
}
type VoiceState = 'loading' | 'ready' | 'missing';
export function BirdexChorusButton({ species, className }: BirdexChorusButtonProps) {
const [isPlaying, setIsPlaying] = useState(false);
// Track each voice's readiness so we can disable the button while
// anything is still resolving, and hide it entirely when no voice
// has usable audio. A Map keyed by entityId so voices can update
// their slot without racing by array index.
const [voiceStates, setVoiceStates] = useState<Map<string, VoiceState>>(
() => new Map(species.map((s) => [s.entityId, 'loading' as VoiceState])),
);
// Keep the map in sync when the species list changes (e.g. the
// Birdex event is replaced with a newer version). Entries that
// disappear are dropped; new ones start as `loading`.
useEffect(() => {
setVoiceStates((prev) => {
const next = new Map<string, VoiceState>();
for (const s of species) {
next.set(s.entityId, prev.get(s.entityId) ?? 'loading');
}
return next;
});
}, [species]);
const reportState = useCallback((entityId: string, state: VoiceState) => {
setVoiceStates((prev) => {
if (prev.get(entityId) === state) return prev;
const next = new Map(prev);
next.set(entityId, state);
return next;
});
}, []);
const anyLoading = useMemo(
() => Array.from(voiceStates.values()).some((s) => s === 'loading'),
[voiceStates],
);
const readyCount = useMemo(
() => Array.from(voiceStates.values()).filter((s) => s === 'ready').length,
[voiceStates],
);
// Hide the button entirely once resolution settles and not a single
// species produced playable audio. While loading we still render
// (the skeleton indicates the chorus is being assembled).
//
// Crucially, the `BirdexChorusVoice` children must always render
// regardless of UI state — they're the hooks that drive the
// resolution we're waiting on. Returning early before rendering
// them would freeze the button in its initial "loading" state
// forever.
const hideButton = !anyLoading && readyCount === 0;
const showSkeleton = !hideButton && anyLoading && readyCount === 0;
const toggle = () => setIsPlaying((p) => !p);
return (
<>
{hideButton ? null : showSkeleton ? (
<Skeleton
className={cn('size-10 shrink-0 rounded-full', className)}
aria-hidden
/>
) : (
<button
type="button"
onClick={toggle}
aria-pressed={isPlaying}
aria-label={
isPlaying
? `Pause dawn chorus of ${readyCount} species`
: `Play dawn chorus of ${readyCount} species`
}
className={cn(
'group inline-flex size-10 shrink-0 items-center justify-center rounded-full',
'bg-emerald-500 text-white shadow-md ring-1 ring-emerald-400/40',
'transition-[transform,background-color,box-shadow] duration-200',
'hover:bg-emerald-600 hover:shadow-lg active:scale-95',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2',
'focus-visible:ring-offset-background',
'dark:bg-emerald-400 dark:text-emerald-950 dark:hover:bg-emerald-300',
className,
)}
>
{isPlaying ? (
<EqualiserBars />
) : (
// Nudge the play triangle right by 1px so its visual
// centroid aligns with the circle's centre — the glyph's
// bounding box is wider on the right than the left.
<Play className="size-4 translate-x-px fill-current" aria-hidden />
)}
</button>
)}
{species.map((s) => (
<BirdexChorusVoice
key={s.entityId}
entityId={s.entityId}
isPlaying={isPlaying}
onStateChange={reportState}
/>
))}
</>
);
}
/**
* Four vertical bars bouncing with staggered CSS animations, shown
* inside the button while the chorus is playing. Matches the
* equaliser used by `BirdSongPlayer` so the chorus button and the
* per-species buttons are visually indistinguishable.
*/
function EqualiserBars() {
const delays = ['0ms', '120ms', '60ms', '180ms'];
return (
<span className="flex h-4 items-end gap-[2px]" aria-hidden>
{delays.map((delay, i) => (
<span
key={i}
className={cn(
'block w-[2px] rounded-full bg-current',
'h-full origin-bottom motion-safe:animate-equaliser-bar',
'motion-reduce:scale-y-75',
)}
style={{ animationDelay: delay }}
/>
))}
</span>
);
}
interface BirdexChorusVoiceProps {
entityId: string;
isPlaying: boolean;
onStateChange: (entityId: string, state: VoiceState) => void;
}
/**
* A single voice in the chorus. Resolves Wikidata → Wikipedia title →
* Commons audio URL and renders a hidden `<audio loop>` element that
* tracks the shared play/pause state. Renders nothing visible.
*/
function BirdexChorusVoice({ entityId, isPlaying, onStateChange }: BirdexChorusVoiceProps) {
const { data: entity, isLoading: entityLoading, isError: entityError } =
useWikidataEntity(entityId);
const wikipediaTitle = entity?.wikipediaTitle ?? null;
// `useBirdSong` only fires once we have a Wikipedia title. While
// it's disabled its `isLoading` is false but `data` is undefined,
// so we gate readiness on the parent query's state too.
const { data: song, isLoading: songLoading, isError: songError } =
useBirdSong(wikipediaTitle);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioUrl = song?.audioUrl ?? null;
// Report our state upward whenever it changes. "missing" fires both
// when Wikidata has no enwiki sitelink and when Wikipedia has no
// usable recording — the UI treats both the same.
useEffect(() => {
if (entityError || (!entityLoading && !wikipediaTitle)) {
onStateChange(entityId, 'missing');
return;
}
if (entityLoading || songLoading) {
onStateChange(entityId, 'loading');
return;
}
if (songError || !audioUrl) {
onStateChange(entityId, 'missing');
return;
}
onStateChange(entityId, 'ready');
}, [
entityId,
entityError,
entityLoading,
wikipediaTitle,
songLoading,
songError,
audioUrl,
onStateChange,
]);
// Drive the hidden `<audio>` element from the shared flag. We
// don't forward `onPlay`/`onPause` events upward because the
// parent is the source of truth; bubbling them back would create
// feedback loops when (e.g.) the browser auto-pauses on tab hide.
useEffect(() => {
const el = audioRef.current;
if (!el || !audioUrl) return;
if (isPlaying) {
// `play()` rejects when autoplay is blocked or the source
// fails to load. Swallow it — the voice just drops out of the
// chorus rather than taking the whole button down.
el.play().catch(() => {});
} else {
el.pause();
// Reset to the start so the next Play gives a fresh chorus
// rather than picking up mid-phrase with every voice out of
// sync with wherever it happened to be paused.
try {
el.currentTime = 0;
} catch {
/* Some browsers throw on seek before metadata loads. */
}
}
}, [isPlaying, audioUrl]);
if (!audioUrl) return null;
return (
<audio
ref={audioRef}
src={audioUrl}
preload="auto"
loop
className="hidden"
aria-hidden
/>
);
}
-169
View File
@@ -1,169 +0,0 @@
import { useMemo } from 'react';
import { Bird } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { BirdexChorusButton } from '@/components/BirdexChorusButton';
import { BirdexTile } from '@/components/BirdexTile';
import { parseBirdexEvent } from '@/lib/parseBirdex';
import { cn } from '@/lib/utils';
/**
* Birdstar kind 12473 — Birdex (life list).
*
* A replaceable per-author index of every distinct bird species the
* author has ever logged via kind 2473. Each species is a positional
* `i`/`n` pair (Wikidata entity URI + scientific name), emitted in
* chronological order of first detection.
*
* Feed variant: a small tiled preview of the most recently-added
* species plus a "+N" capstone, mirroring how kind 3 follow lists
* render as a compact avatar stack with a "+N more" suffix. Full
* variant: the whole life list laid out as a responsive grid so
* visitors can browse every species the author has ever seen.
*/
/** Tiles rendered in the compact feed preview before collapsing into "+N". */
const FEED_PREVIEW_LIMIT = 8;
interface BirdexContentProps {
event: NostrEvent;
/**
* When true, render every species on the life list instead of the
* truncated feed preview. Used on the post-detail page.
*/
expanded?: boolean;
className?: string;
}
export function BirdexContent({ event, expanded, className }: BirdexContentProps) {
const entries = useMemo(() => parseBirdexEvent(event), [event]);
// Empty Birdex — either a malformed event or a newly-published
// placeholder. Render a minimal dashed card so the feed row still
// has a meaningful anchor.
if (entries.length === 0) {
return (
<div
className={cn(
'mt-2 flex items-center gap-2 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground',
className,
)}
>
<Bird className="size-4" aria-hidden />
Empty Birdex no confirmed species yet.
</div>
);
}
if (expanded) {
return (
<div className={cn('mt-2', className)}>
<div className="mb-3 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Bird className="size-4 shrink-0 text-emerald-600 dark:text-amber-300" aria-hidden />
<h3 className="truncate text-[15px] font-semibold leading-tight">
Birdex
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
{entries.length} species
</span>
</h3>
</div>
{/* Single control that plays every species' Wikipedia
* recording simultaneously — a dawn chorus for the whole
* life list. Hides itself when no species has usable audio. */}
<BirdexChorusButton
species={entries.map(({ entityId }) => ({ entityId }))}
className="shrink-0"
/>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{entries.map((entry) => (
<BirdexTile
key={entry.entityUri}
entityUri={entry.entityUri}
entityId={entry.entityId}
scientificName={entry.scientificName || undefined}
/>
))}
</div>
</div>
);
}
// Feed variant — show the *most recent* species (tail of the list)
// so the preview reflects the author's latest additions, with an
// overflow capstone on the final tile when the Birdex is larger
// than the preview. The capstone displaces one species slot, so
// when overflowing we render (LIMIT - 1) real tiles + the capstone;
// the capstone's count is "species not shown", which includes the
// one species the capstone itself displaced.
const overflowing = entries.length > FEED_PREVIEW_LIMIT;
const visibleSpeciesCount = overflowing ? FEED_PREVIEW_LIMIT - 1 : entries.length;
const previewEntries = entries.slice(-visibleSpeciesCount);
const overflowCount = entries.length - visibleSpeciesCount;
return (
<div className={cn('mt-2', className)}>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Bird className="size-4 shrink-0 text-emerald-600 dark:text-amber-300" aria-hidden />
<span className="text-[15px] font-semibold leading-tight">
Birdex
</span>
<span className="text-sm text-muted-foreground">
· {entries.length} species
</span>
</div>
{/* Same chorus button as the expanded view — plays the
* entire life list, not just the preview slice, because the
* button represents the whole event. Wrapped in a click
* swallower: the feed variant sits inside a clickable
* NoteCard, and toggling playback must not navigate away. */}
<div
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
className="shrink-0"
>
<BirdexChorusButton
species={entries.map(({ entityId }) => ({ entityId }))}
/>
</div>
</div>
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8">
{previewEntries.map((entry) => (
<BirdexTile
key={entry.entityUri}
entityUri={entry.entityUri}
entityId={entry.entityId}
scientificName={entry.scientificName || undefined}
/>
))}
{overflowing && <OverflowTile count={overflowCount} />}
</div>
</div>
);
}
/**
* Final capstone tile that reads "+N" when the life list overflows
* the feed preview. Mirrors the "+N more" suffix on kind 3 follow-list
* avatar stacks.
*/
function OverflowTile({ count }: { count: number }) {
return (
<div
className="flex aspect-square items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 text-muted-foreground"
aria-label={`${count} more species`}
>
<span className="text-xs font-semibold sm:text-sm">
+{count}
</span>
</div>
);
}
-118
View File
@@ -1,118 +0,0 @@
import { Bird } from 'lucide-react';
import { Link } from 'react-router-dom';
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';
/**
* A single tile in a Birdex grid — one species.
*
* Resolves Wikidata → English Wikipedia to pull a thumbnail and common
* name. The scientific name (optional, from the paired `n` tag on the
* Birdex event) is used as a fallback label while the remote fetch is
* in flight or fails.
*
* Clicking the tile routes to Ditto's external-content page for the
* species' Wikidata URL, so the species page aggregates detections,
* comments, and other Birdex authors who have this species on their
* life lists — the same landing spot used by kind 2473 bird-detection
* cards.
*/
interface BirdexTileProps {
entityUri: string;
entityId: string;
/** Optional scientific name from the paired `n` tag. */
scientificName?: string;
/** Extra classes applied to the tile container. */
className?: string;
/** Drop the navigation link (used by disabled-hover embeds). */
nonInteractive?: boolean;
}
export function BirdexTile({
entityUri,
entityId,
scientificName,
className,
nonInteractive,
}: BirdexTileProps) {
const { data: entity, isLoading: entityLoading } = useWikidataEntity(entityId);
const wikipediaTitle = entity?.wikipediaTitle ?? null;
const { data: summary, isLoading: summaryLoading } = useWikipediaSummary(wikipediaTitle);
const isLoading = entityLoading || summaryLoading;
// Prefer the Wikipedia page title for the display label; fall back to
// the scientific name from the Birdex's `n` tag while fetches are in
// flight or when no English article exists.
const commonName = summary?.title ?? (scientificName || 'Unknown species');
const thumbnail = sanitizeUrl(summary?.thumbnail?.source);
const inner = (
<div
className={cn(
'group relative aspect-square overflow-hidden rounded-xl bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 shadow-sm',
'dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40',
!nonInteractive && 'transition-shadow hover:shadow-md focus-visible:shadow-md',
className,
)}
>
{isLoading ? (
<Skeleton className="absolute inset-0 h-full w-full" />
) : thumbnail ? (
<img
src={thumbnail}
alt={commonName}
className={cn(
'absolute inset-0 h-full w-full object-cover',
!nonInteractive && 'motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]',
)}
loading="lazy"
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<Bird
aria-hidden
strokeWidth={1.5}
className="size-8 text-emerald-700/60 dark:text-amber-300/60"
/>
</div>
)}
{/* Name overlay — always rendered, even during skeleton, so the
tile's shape is stable. Common name on top (from Wikipedia
when available, scientific fallback otherwise); scientific
name from the Birdex's paired `n` tag as a persistent
italic sub-label underneath, mirroring how kind 2473
detection cards stack the two labels. */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/40 to-transparent pt-6">
<div className="px-2 pb-1.5">
<p className="truncate text-[11px] font-semibold leading-tight text-white drop-shadow sm:text-xs">
{isLoading && !scientificName ? '\u00A0' : commonName}
</p>
{scientificName && scientificName !== commonName && (
<p className="truncate text-[10px] italic leading-tight text-white/80">
{scientificName}
</p>
)}
</div>
</div>
</div>
);
if (nonInteractive) return inner;
return (
<Link
to={`/i/${encodeURIComponent(entityUri)}`}
onClick={(e) => e.stopPropagation()}
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
aria-label={commonName}
>
{inner}
</Link>
);
}
-631
View File
@@ -1,631 +0,0 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, hover cards, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction — fetches real data. */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const amount = tx ? tx.totalOutput : 0;
const fee = tx?.fee ?? 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
{tx && (
<span className={tx.confirmed
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
)}
</div>
<p className="text-sm font-medium truncate mt-0.5">
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
{tx && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
) : null}
</p>
{tx && (
<p className="text-xs text-muted-foreground truncate">
Fee {formatSats(fee)} sats
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address — fetches real data. */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const balance = addressDetail?.totalBalance ?? 0;
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
{addressDetail && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
) : null}
</p>
{addressDetail && (
<p className="text-xs text-muted-foreground truncate">
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
{' · '}
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
@@ -1,51 +0,0 @@
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
/**
* Informational notice for BIP-352 silent-payment receive endpoints
* (sp1…). Surfaces the "private but experimental" trade-off the user
* accepts when they choose silent payments instead of a regular
* on-chain address.
*
* Visual treatment mirrors `BitcoinPublicDisclaimer` with `tone="soft"`:
* `role="note"`, amber tint, no icon, no checkbox. The lead sentence
* carries the headline, and "Learn more" opens a popover with the full
* explanation.
*/
export function BitcoinPrivateDisclaimer() {
return (
<Alert
role="note"
className="border-amber-500/30 bg-amber-500/10 text-foreground"
>
{/* No icon — the shadcn Alert reserves left padding for an icon via
`[&>svg~*]:pl-7`, so omitting it reclaims the indent. */}
<AlertDescription className="text-xs">
<p>
Experimental. Donations are private, but bugs may occur.{' '}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
>
Learn more
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
Your private wallet hides the real address of your wallet
and your donors on the Bitcoin network. Funds are always
fully recoverable, but bugs in the wallet may cause it to
show an incorrect balance, and it may require long wait
times to synchronize.
</PopoverContent>
</Popover>
</p>
</AlertDescription>
</Alert>
);
}
-410
View File
@@ -1,410 +0,0 @@
import { useMemo, useState, useRef, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { BookOpen, MessageSquare, Star, AlertTriangle } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { PostActionBar } from '@/components/PostActionBar';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { EmojifiedText } from '@/components/CustomEmoji';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useOpenPost } from '@/hooks/useOpenPost';
import { useBookSummary } from '@/hooks/useBookSummary';
import { getDisplayName } from '@/lib/getDisplayName';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr';
import type { NostrEvent } from '@nostrify/nostrify';
interface BookFeedItemProps {
event: NostrEvent;
className?: string;
}
/** Max height in px before truncation kicks in. */
const MAX_HEIGHT = 300;
/** Encodes the NIP-19 identifier for navigating to an event. */
function encodeEventId(event: NostrEvent): string {
if (event.kind >= 30000 && event.kind < 40000) {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (dTag) {
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
}
}
return nip19.neventEncode({ id: event.id, author: event.pubkey });
}
export function BookFeedItem({ event, className }: BookFeedItemProps) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [replyOpen, setReplyOpen] = useState(false);
const isbn = useMemo(() => extractISBNFromEvent(event), [event]);
const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW;
const isComment = event.kind === 1111;
const review = useMemo(() => isReview ? parseBookReview(event) : null, [event, isReview]);
// For kind 1111 comments on books, navigate to the book page rather than the event detail.
// For all other events, navigate to the event detail page.
const postPath = useMemo(() => {
if (isComment && isbn) {
return `/i/isbn:${isbn}`;
}
return `/${encodeEventId(event)}`;
}, [event, isComment, isbn]);
const { onClick: openPost, onAuxClick: auxOpenPost } = useOpenPost(postPath);
const handleCardClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.closest('a') ||
target.closest('button') ||
target.closest('[role="dialog"]') ||
target.closest('[data-radix-dialog-overlay]') ||
target.closest('[data-radix-dialog-content]') ||
target.closest('[data-vaul-drawer]') ||
target.closest('[data-vaul-drawer-overlay]') ||
target.closest('[data-testid="zap-modal"]')
) {
return;
}
openPost();
};
const handleAuxClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.closest('a') ||
target.closest('button') ||
target.closest('[role="dialog"]')
) {
return;
}
auxOpenPost(e);
};
// Stars display for reviews
const starCount = review?.rating !== undefined ? ratingToStars(review.rating) : 0;
return (
<article
className={cn(
'px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors cursor-pointer overflow-hidden',
className,
)}
onClick={handleCardClick}
onAuxClick={handleAuxClick}
>
<div className="flex gap-3">
{/* Avatar */}
{author.isLoading ? (
<Skeleton className="size-11 rounded-full shrink-0" />
) : (
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar className="size-11">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
)}
{/* Main content */}
<div className="min-w-0 flex-1">
{/* Author info */}
{author.isLoading ? (
<div className="min-w-0 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-36" />
</div>
) : (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link
to={profileUrl}
className="font-bold text-[15px] hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</Link>
</ProfileHoverCard>
{isReview && (
<Badge variant="secondary" className="gap-1 text-[10px] px-1.5 py-0 shrink-0 bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
<Star className="size-3" />
reviewed
</Badge>
)}
{isComment && (
<Badge variant="secondary" className="gap-1 text-[10px] px-1.5 py-0 shrink-0 bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
<MessageSquare className="size-3" />
commented
</Badge>
)}
{!isReview && !isComment && isbn && (
<Badge variant="secondary" className="gap-1 text-[10px] px-1.5 py-0 shrink-0 bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
<BookOpen className="size-3" />
posted
</Badge>
)}
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="shrink-0 hover:underline whitespace-nowrap">
{timeAgo(event.created_at)}
</span>
</div>
</div>
)}
{/* Star rating for reviews */}
{isReview && review?.rating !== undefined && (
<div className="flex items-center gap-1 mt-2">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={cn(
'size-4',
i < starCount
? 'fill-amber-400 text-amber-400'
: 'text-muted-foreground/30',
)}
/>
))}
<span className="text-sm text-muted-foreground ml-1">
{(review.rating * 5).toFixed(1)}
</span>
</div>
)}
{/* Content with spoiler guard and truncation */}
{isReview && review?.contentWarning ? (
<SpoilerGuard warning={review.contentWarning}>
<TruncatedContent event={event} content={review.content} isReview />
</SpoilerGuard>
) : isReview && review ? (
<TruncatedContent event={event} content={review.content} isReview />
) : (
<TruncatedContent event={event} />
)}
{/* Book card */}
{isbn && <InlineBookCard isbn={isbn} />}
{/* Action buttons */}
<PostActionBar
event={event}
onReply={() => setReplyOpen(true)}
onMore={() => setMoreMenuOpen(true)}
className="mt-3"
/>
</div>
</div>
{/* Modals */}
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
</article>
);
}
/** Truncated content block with "Read more" fade and button. */
function TruncatedContent({ event, content, isReview }: { event: NostrEvent; content?: string; isReview?: boolean }) {
const contentRef = useRef<HTMLDivElement>(null);
const [overflows, setOverflows] = useState(false);
const [expanded, setExpanded] = useState(false);
const measure = useCallback(() => {
const el = contentRef.current;
if (el) setOverflows(el.scrollHeight > MAX_HEIGHT);
}, []);
useEffect(() => {
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [measure]);
// Re-measure after images load
useEffect(() => {
const el = contentRef.current;
if (!el) return;
const imgs = el.querySelectorAll('img');
if (imgs.length === 0) return;
imgs.forEach((img) => img.addEventListener('load', measure, { once: true }));
return () => imgs.forEach((img) => img.removeEventListener('load', measure));
}, [measure]);
// For reviews with no written text, show a placeholder
if (content !== undefined && !content) {
return (
<p className="mt-2 text-sm text-muted-foreground italic">Rating only, no written review</p>
);
}
return (
<div className={cn('mt-2 break-words overflow-hidden', isReview && 'pl-3 border-l-2 border-amber-300 dark:border-amber-700')}>
<div
ref={contentRef}
style={!expanded && overflows ? { maxHeight: MAX_HEIGHT, overflow: 'hidden' } : undefined}
className="relative"
>
<NoteContent event={event} className="text-[15px] leading-relaxed" />
{!expanded && overflows && (
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
{overflows && (
<button
className="mt-1 text-sm text-primary hover:underline"
onClick={(e) => { e.stopPropagation(); setExpanded((v) => !v); }}
>
{expanded ? 'Show less' : 'Read more'}
</button>
)}
</div>
);
}
/** Renders a spoiler guard that hides content behind a warning. */
function SpoilerGuard({ warning, children }: { warning: string; children: React.ReactNode }) {
const [revealed, setRevealed] = useState(false);
if (revealed) {
return (
<div>
<Badge variant="outline" className="text-orange-600 border-orange-200 dark:border-orange-800 mb-2 mt-2">
<AlertTriangle className="size-3 mr-1" />
Contains Spoilers
</Badge>
{children}
</div>
);
}
return (
<div className="mt-2 py-4 text-center space-y-2" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-2 text-orange-600">
<AlertTriangle className="size-4" />
<span className="text-sm font-medium">Spoiler Warning</span>
</div>
<p className="text-xs text-muted-foreground">{warning}</p>
<Button
variant="outline"
size="sm"
onClick={() => setRevealed(true)}
>
Show Review
</Button>
</div>
);
}
/** Compact inline book card that shows cover, title, author, and year. */
function InlineBookCard({ isbn }: { isbn: string }) {
const { data: book, isLoading } = useBookSummary(isbn);
if (isLoading) {
return (
<div className="mt-3 flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border">
<Skeleton className="w-10 h-14 rounded shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
);
}
if (!book) {
return (
<Link
to={`/i/isbn:${isbn}`}
className="mt-3 flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border hover:bg-muted/80 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<div className="w-10 h-14 rounded bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-5 text-muted-foreground/40" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground">ISBN {isbn}</p>
</div>
</Link>
);
}
return (
<Link
to={`/i/isbn:${isbn}`}
className="mt-3 flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border hover:bg-muted/80 transition-colors group/book"
onClick={(e) => e.stopPropagation()}
>
{book.coverUrl ? (
<img
src={book.coverUrl}
alt={`Cover of ${book.title}`}
className="w-10 h-14 rounded object-cover shrink-0 shadow-sm group-hover/book:shadow-md transition-shadow"
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
) : (
<div className="w-10 h-14 rounded bg-muted flex items-center justify-center shrink-0">
<BookOpen className="size-5 text-muted-foreground/40" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold line-clamp-1 group-hover/book:underline">{book.title}</p>
<p className="text-xs text-muted-foreground line-clamp-1">{book.author}</p>
{book.pubDate && (
<p className="text-xs text-muted-foreground/70 mt-0.5">{book.pubDate}</p>
)}
</div>
</Link>
);
}
export function BookFeedItemSkeleton() {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="min-w-0 flex-1 space-y-3">
<div className="space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-16" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border border-border">
<Skeleton className="w-10 h-14 rounded shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
</div>
</div>
</div>
);
}
-196
View File
@@ -1,196 +0,0 @@
/**
* CommentsModal — a centered rounded modal for displaying and composing
* comments/replies on any Nostr event.
*/
import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { X } from 'lucide-react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { getDisplayName } from '@/lib/getDisplayName';
import { timeAgo } from '@/lib/timeAgo';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { ComposeBox } from '@/components/ComposeBox';
function getTag(tags: string[][], name: string): string | undefined {
return tags.find(([n]) => n === name)?.[1];
}
// ── data hook ─────────────────────────────────────────────────────────────────
function useEventComments(event: NostrEvent | undefined) {
const { nostr } = useNostr();
const aTag = event
? `${event.kind}:${event.pubkey}:${getTag(event.tags, 'd') ?? ''}`
: undefined;
return useQuery<NostrEvent[]>({
queryKey: ['event-comments', aTag ?? event?.id ?? ''],
queryFn: async ({ signal }) => {
if (!event) return [];
const abort = AbortSignal.any([signal, AbortSignal.timeout(5000)]);
const filters =
event.kind >= 30000 && event.kind < 40000 && aTag
? [{ kinds: [1111, 1244], '#A': [aTag], limit: 80 }]
: event.kind === 1
? [
{ kinds: [1, 1111], '#e': [event.id], limit: 80 },
{ kinds: [1111], '#E': [event.id], limit: 80 },
]
: [{ kinds: [1111], '#e': [event.id], limit: 80 }];
const events = await nostr.query(filters, { signal: abort });
const seen = new Set<string>();
return events
.filter((e) => { if (seen.has(e.id)) return false; seen.add(e.id); return true; })
.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!event,
staleTime: 15_000,
refetchInterval: 20_000,
});
}
// ── comment row ───────────────────────────────────────────────────────────────
function CommentRow({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
return (
<div className="flex gap-2.5 px-4 py-2.5 hover:bg-muted/30 transition-colors">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0">
{author.isLoading ? (
<Skeleton className="size-7 rounded-full" />
) : (
<Avatar className="size-7">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="text-[10px] bg-primary/20 text-primary">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
)}
</Link>
</ProfileHoverCard>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-1.5 mb-0.5">
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="text-xs font-semibold hover:underline truncate max-w-[140px]">
{displayName}
</Link>
</ProfileHoverCard>
<span className="text-[10px] text-muted-foreground shrink-0">{timeAgo(event.created_at)}</span>
</div>
<p className="text-xs text-foreground/90 leading-relaxed break-words line-clamp-4">
{event.content}
</p>
</div>
</div>
);
}
function CommentSkeleton() {
return (
<div className="flex gap-2.5 px-4 py-2.5">
<Skeleton className="size-7 rounded-full shrink-0" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
);
}
// ── modal ─────────────────────────────────────────────────────────────────────
interface CommentsModalProps {
event: NostrEvent | undefined;
open: boolean;
onClose: () => void;
}
export function CommentsSheet({ event, open, onClose }: CommentsModalProps) {
const { data: rawComments = [], isLoading, dataUpdatedAt } = useEventComments(event);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
// Only show comments once the query has actually fetched for this event.
// `dataUpdatedAt === 0` means the query has never resolved — show nothing.
const comments = useMemo(() => {
if (dataUpdatedAt === 0) return [];
const seen = new Set<string>();
return rawComments.filter((e) => seen.has(e.id) ? false : (seen.add(e.id), true));
}, [rawComments, dataUpdatedAt]);
const modalRef = useCallback((node: HTMLDivElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm cursor-pointer animate-in fade-in-0 duration-200"
onClick={(e) => { e.stopPropagation(); onClose(); }}
/>
{/* Modal — centered, rounded */}
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 pointer-events-none">
<div
ref={modalRef}
className="pointer-events-auto w-full max-w-lg max-h-[80vh] flex flex-col bg-background/90 backdrop-blur-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 fade-in-0 duration-200"
onClick={(e) => e.stopPropagation()}
>
<PortalContainerProvider value={portalContainer}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 shrink-0">
<h3 className="font-semibold text-sm">{event?.kind === 1 ? 'Replies' : 'Comments'}</h3>
<button
className="p-1.5 rounded-full hover:bg-secondary transition-colors text-muted-foreground"
onClick={onClose}
>
<X className="size-4" strokeWidth={4} />
</button>
</div>
{/* Compose — top */}
{event && (
<div className="shrink-0 -mb-px overflow-hidden">
<ComposeBox replyTo={event} compact placeholder={event?.kind === 1 ? 'Add a reply…' : 'Add a comment…'} />
</div>
)}
{/* Comment list */}
<div className="flex-1 min-h-0 overflow-y-auto divide-y divide-border/50">
{isLoading ? (
<div className="py-2">
{Array.from({ length: 5 }).map((_, i) => <CommentSkeleton key={i} />)}
</div>
) : comments.length === 0 ? (
<div className="flex items-center justify-center h-32">
<p className="text-sm text-muted-foreground">
{event?.kind === 1 ? 'No replies yet. Be the first!' : 'No comments yet. Be the first!'}
</p>
</div>
) : (
comments.map((reply) => <CommentRow key={reply.id} event={reply} />)
)}
</div>
</PortalContainerProvider>
</div>
</div>
</>
);
}
-96
View File
@@ -1,96 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Bookmark, Crown, Shield, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { parseCommunityEvent, COMMUNITY_DEFINITION_KIND } from '@/lib/communityUtils';
import { cn } from '@/lib/utils';
interface CommunityCardProps {
/** The kind 34550 community definition event. */
event: NostrEvent;
/** Whether the current user founded this community. */
isFounded?: boolean;
/** Whether the current user is a validated member. */
isMember?: boolean;
/** Whether the current user follows this community via NIP-51 kind 10004. */
isBookmarked?: boolean;
className?: string;
}
/**
* Compact card for displaying a community in a list.
* Shows image, name, description snippet, founder info, and community status.
*/
export function CommunityCard({
event,
isFounded,
isMember,
isBookmarked,
className,
}: CommunityCardProps) {
const community = useMemo(() => parseCommunityEvent(event), [event]);
if (!community) return null;
const naddr = nip19.naddrEncode({
kind: COMMUNITY_DEFINITION_KIND,
pubkey: event.pubkey,
identifier: community.dTag,
});
return (
<Link
to={`/${naddr}`}
className={cn(
'group relative block min-h-[240px] overflow-hidden rounded-2xl bg-muted shadow-sm transition-all hover:shadow-lg sm:min-h-[260px]',
className,
)}
>
<div className="absolute right-3 top-3 z-10 flex gap-1.5 [text-shadow:none]">
{isFounded && (
<span className="flex size-7 items-center justify-center rounded-full bg-black/35 text-white shadow-sm backdrop-blur-sm" title="Founder" aria-label="Founder">
<Crown className="size-3.5" />
</span>
)}
{isMember && (
<span className="flex size-7 items-center justify-center rounded-full bg-black/35 text-white shadow-sm backdrop-blur-sm" title="Member" aria-label="Member">
<Shield className="size-3.5" />
</span>
)}
{isBookmarked && (
<span className="flex size-7 items-center justify-center rounded-full bg-black/35 text-white shadow-sm backdrop-blur-sm" title="Following" aria-label="Following">
<Bookmark className="size-3.5 fill-current" />
</span>
)}
</div>
{/* Image backdrop */}
{community.image ? (
<img
src={community.image}
alt={community.name}
className="absolute inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : (
<div className="absolute inset-0 bg-gradient-to-br from-primary/50 via-primary/25 to-primary/5">
<Users className="absolute left-1/2 top-1/3 size-16 -translate-x-1/2 -translate-y-1/2 text-white/20" />
</div>
)}
<div className="absolute inset-0 bg-[linear-gradient(to_bottom,rgba(0,0,0,0.04)_0%,rgba(0,0,0,0.16)_38%,rgba(0,0,0,0.78)_74%,rgba(0,0,0,0.92)_100%)]" />
<div className="absolute inset-x-0 bottom-0 p-4 pt-16 [text-shadow:0_1px_4px_rgba(0,0,0,0.75)]">
<h3 className="mb-2 truncate text-lg font-bold leading-tight text-white transition-colors group-hover:text-white">
{community.name}
</h3>
{community.description && (
<p className="line-clamp-1 text-xs leading-relaxed text-white/80">
{community.description}
</p>
)}
</div>
</Link>
);
}
-121
View File
@@ -1,121 +0,0 @@
import { lazy, Suspense, useMemo } from 'react';
import { ExternalLink, Sparkles } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { Skeleton } from '@/components/ui/skeleton';
import { openUrl } from '@/lib/downloadFile';
import { cn } from '@/lib/utils';
/**
* Birdstar kind 30621 — Custom Constellation.
*
* An addressable event carrying a user-drawn star-figure: a title, a
* freeform description in `content`, and one or more `edge` tags referencing
* pairs of Hipparcos catalog numbers (e.g. `["edge", "32349", "37279"]`).
*
* Rendering the figure requires the full Hipparcos star catalog (~1.3 MB),
* so the preview component is code-split via `lazy()` — the catalog data
* only loads when a user actually scrolls a constellation event into view.
*/
const ConstellationStarMap = lazy(() =>
import('./ConstellationStarMap').then((m) => ({ default: m.ConstellationStarMap })),
);
interface ConstellationContentProps {
event: NostrEvent;
className?: string;
}
interface ParsedConstellation {
title: string;
description: string;
edges: Array<readonly [number, number]>;
}
function parseConstellation(event: NostrEvent): ParsedConstellation {
const title = event.tags.find(([n]) => n === 'title')?.[1]
?? event.tags.find(([n]) => n === 'd')?.[1]
?? 'Untitled constellation';
const edges: Array<readonly [number, number]> = [];
for (const tag of event.tags) {
if (tag[0] !== 'edge' || tag.length < 3) continue;
const from = Number(tag[1]);
const to = Number(tag[2]);
// Reject non-positive-integer HIP numbers per the NIP's validation rules.
if (!Number.isInteger(from) || from <= 0) continue;
if (!Number.isInteger(to) || to <= 0) continue;
edges.push([from, to] as const);
}
return {
title,
description: event.content.trim(),
edges,
};
}
export function ConstellationContent({ event, className }: ConstellationContentProps) {
const { title, description, edges } = useMemo(() => parseConstellation(event), [event]);
// Birdstar routes constellations at `/:nip19` using the event's naddr1
// coordinate (kind 30621 is addressable). Build the link once so we can
// drop it into a "View on Birdstar" action below the map.
const birdstarUrl = useMemo(() => {
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
if (!dTag) return undefined;
try {
const naddr = nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
});
return `https://birdstar.app/${naddr}`;
} catch {
return undefined;
}
}, [event]);
return (
<div className={cn('mt-2', className)}>
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
{/* Star map */}
<div className="aspect-[4/3] w-full">
<Suspense fallback={<Skeleton className="size-full" />}>
<ConstellationStarMap edges={edges} title={title} />
</Suspense>
</div>
{/* Title + description */}
<div className="space-y-1.5 p-3.5">
<div className="flex items-center gap-2">
<Sparkles aria-hidden className="size-3.5 shrink-0 text-amber-500" />
<h3 className="truncate text-[15px] font-semibold leading-tight">
{title}
</h3>
{birdstarUrl && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openUrl(birdstarUrl);
}}
className="ml-auto inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
>
View on Birdstar
<ExternalLink aria-hidden className="size-3" />
</button>
)}
</div>
{description && (
<p className="whitespace-pre-wrap break-words text-[13px] leading-relaxed text-muted-foreground">
{description}
</p>
)}
</div>
</div>
</div>
);
}
-322
View File
@@ -1,322 +0,0 @@
import { useId, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { starByHip } from '@/lib/starCatalog';
/**
* Renders a custom constellation as an SVG star-map.
*
* This component is code-split via `lazy()` from `ConstellationContent`:
* the Hipparcos star catalog it imports is ~1.3 MB of JSON and must never
* ship in the main bundle.
*
* The figure is gnomonically projected onto a tangent plane centered on the
* centroid of its stars (on the unit sphere) and then normalized to fit the
* SVG viewBox with equal aspect, so shapes are never distorted. Stars are
* sized by apparent magnitude, with the brightest few getting a soft glow
* to evoke a real sky.
*
* Adapted from the `ConstellationPreview` component in the Birdstar
* reference client.
*/
export interface ConstellationStarMapProps {
edges: ReadonlyArray<readonly [number, number]>;
title?: string;
className?: string;
}
const DEG = Math.PI / 180;
const HOUR = (15 * Math.PI) / 180; // 1h = 15°
interface ResolvedStar {
hip: number;
ra: number; // hours
dec: number; // degrees
mag: number;
}
interface ProjectedPoint {
hip: number;
x: number;
y: number;
r: number;
}
interface ProjectedEdge {
x1: number;
y1: number;
x2: number;
y2: number;
}
interface BackgroundStar {
x: number;
y: number;
r: number;
o: number;
}
interface ProjectionResult {
points: Map<number, ProjectedPoint>;
edges: ProjectedEdge[];
backgroundStars: BackgroundStar[];
}
export function ConstellationStarMap({ edges, title, className }: ConstellationStarMapProps) {
// A stable unique id keeps multiple previews on the page from colliding on
// the shared <filter> id.
const rawId = useId();
const uid = rawId.replace(/:/g, '');
const glowId = `cm-glow-${uid}`;
const projected = useMemo(() => project(edges), [edges]);
if (!projected || projected.points.size === 0) {
return (
<div
className={cn(
'flex size-full items-center justify-center rounded-xl ring-1 ring-border bg-[radial-gradient(ellipse_at_50%_40%,#1e1b4b_0%,#0b1026_55%,#020617_100%)] text-xs text-white/60',
className,
)}
role="img"
aria-label={title ?? 'Constellation preview'}
>
No recognizable stars.
</div>
);
}
const { points, edges: projEdges, backgroundStars } = projected;
return (
<div
className={cn(
'relative h-full w-full overflow-hidden rounded-xl ring-1 ring-border',
'bg-[radial-gradient(ellipse_at_50%_40%,#1e1b4b_0%,#0b1026_55%,#020617_100%)]',
className,
)}
role="img"
aria-label={title ?? 'Constellation preview'}
>
{/* Background field stars — cover the whole container regardless of
aspect ratio, so corners never look bare. */}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid slice"
className="absolute inset-0 size-full"
aria-hidden
>
<g fill="rgba(255, 255, 255, 0.5)">
{backgroundStars.map((s, i) => (
<circle key={i} cx={s.x} cy={s.y} r={s.r} opacity={s.o} />
))}
</g>
</svg>
{/* Figure — preserves aspect so stick-figures never distort. */}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid meet"
className="absolute inset-0 size-full"
>
<defs>
<filter
id={glowId}
x="-100%"
y="-100%"
width="300%"
height="300%"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation="1.1" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Edges */}
<g
stroke="rgba(253, 230, 138, 0.8)"
strokeWidth={0.9}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
pointerEvents="none"
>
{projEdges.map((e, i) => (
<line key={i} x1={e.x1} y1={e.y1} x2={e.x2} y2={e.y2} />
))}
</g>
{/* Figure stars with soft glow */}
<g fill="rgb(254, 243, 199)" filter={`url(#${glowId})`}>
{Array.from(points.values()).map((p) => (
<circle key={p.hip} cx={p.x} cy={p.y} r={p.r} pointerEvents="none" />
))}
</g>
</svg>
</div>
);
}
function project(edges: ReadonlyArray<readonly [number, number]>): ProjectionResult | null {
// Collect unique stars referenced by the figure. Unknown HIP numbers are
// silently dropped per the NIP's validation rules.
const stars = new Map<number, ResolvedStar>();
for (const [a, b] of edges) {
if (!stars.has(a)) {
const s = starByHip(a);
if (s) stars.set(a, { hip: s.hip, ra: s.ra, dec: s.dec, mag: s.mag });
}
if (!stars.has(b)) {
const s = starByHip(b);
if (s) stars.set(b, { hip: s.hip, ra: s.ra, dec: s.dec, mag: s.mag });
}
}
if (stars.size === 0) return null;
// Mean unit-vector as the projection tangent point — handles wrap-around
// at RA=0h/24h and the poles without special-casing.
let mx = 0;
let my = 0;
let mz = 0;
for (const s of stars.values()) {
const raRad = s.ra * HOUR;
const decRad = s.dec * DEG;
const cosDec = Math.cos(decRad);
mx += cosDec * Math.cos(raRad);
my += cosDec * Math.sin(raRad);
mz += Math.sin(decRad);
}
const norm = Math.hypot(mx, my, mz) || 1;
mx /= norm;
my /= norm;
mz /= norm;
const centerDec = Math.asin(Math.max(-1, Math.min(1, mz)));
const centerRa = Math.atan2(my, mx);
const sinC = Math.sin(centerDec);
const cosC = Math.cos(centerDec);
// Gnomonic projection onto a tangent plane at (centerRa, centerDec).
const raw = new Map<number, { x: number; y: number; mag: number }>();
for (const s of stars.values()) {
const ra = s.ra * HOUR;
const dec = s.dec * DEG;
const cosDec = Math.cos(dec);
const sinDec = Math.sin(dec);
const dRa = ra - centerRa;
const cosDRa = Math.cos(dRa);
const sinDRa = Math.sin(dRa);
const cosDistance = sinC * sinDec + cosC * cosDec * cosDRa;
if (cosDistance <= 1e-6) continue;
const x = (cosDec * sinDRa) / cosDistance;
const y = (cosC * sinDec - sinC * cosDec * cosDRa) / cosDistance;
// Flip x so RA increases to the left (conventional sky orientation).
raw.set(s.hip, { x: -x, y, mag: s.mag });
}
if (raw.size === 0) return null;
// Bounding box.
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const p of raw.values()) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
const PADDING = 14;
const AVAILABLE = 100 - PADDING * 2;
const spanX = maxX - minX;
const spanY = maxY - minY;
const span = Math.max(spanX, spanY);
const scale = span > 1e-9 ? AVAILABLE / span : 0;
const offsetX = (AVAILABLE - spanX * scale) / 2 + PADDING;
const offsetY = (AVAILABLE - spanY * scale) / 2 + PADDING;
const points = new Map<number, ProjectedPoint>();
for (const [hip, p] of raw) {
const x = (p.x - minX) * scale + offsetX;
// Invert SVG y so north-ish stars sit on top.
const y = 100 - ((p.y - minY) * scale + offsetY);
points.set(hip, { hip, x, y, r: magToRadius(p.mag) });
}
const projEdges: ProjectedEdge[] = [];
for (const [a, b] of edges) {
const pa = points.get(a);
const pb = points.get(b);
if (!pa || !pb) continue;
projEdges.push({ x1: pa.x, y1: pa.y, x2: pb.x, y2: pb.y });
}
// Deterministic scatter of faint background stars seeded from the edge
// list, so the same figure always renders identically.
const backgroundStars = makeBackgroundStars(edges, points);
return { points, edges: projEdges, backgroundStars };
}
function makeBackgroundStars(
edges: ReadonlyArray<readonly [number, number]>,
figure: Map<number, ProjectedPoint>,
): BackgroundStar[] {
let seed = 2166136261;
for (const [a, b] of edges) {
seed ^= a * 16777619;
seed = Math.imul(seed, 16777619);
seed ^= b * 2246822519;
seed = Math.imul(seed, 16777619);
}
const rand = mulberry32(seed >>> 0);
const MIN_DIST = 5; // clearance from figure stars (viewBox units)
const out: BackgroundStar[] = [];
const figurePts = Array.from(figure.values());
let attempts = 0;
while (out.length < 22 && attempts < 120) {
attempts++;
const x = rand() * 100;
const y = rand() * 100;
let tooClose = false;
for (const p of figurePts) {
if (Math.hypot(p.x - x, p.y - y) < MIN_DIST) {
tooClose = true;
break;
}
}
if (tooClose) continue;
out.push({ x, y, r: 0.2 + rand() * 0.5, o: 0.3 + rand() * 0.55 });
}
return out;
}
function mulberry32(a: number): () => number {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Map apparent magnitude to a preview dot radius in viewBox units.
* Brighter stars (lower magnitude) get larger dots, clamped to keep mag~6
* stars visible and mag~0 stars from dominating the thumbnail.
*/
function magToRadius(mag: number): number {
const r = 2.3 - 0.25 * mag;
if (r < 0.8) return 0.8;
if (r > 2.4) return 2.4;
return r;
}
File diff suppressed because it is too large Load Diff
-355
View File
@@ -1,355 +0,0 @@
import { useMemo, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Check, ChevronRight, Clock, Loader2, Megaphone, Plus, Upload } from 'lucide-react';
import { TimezoneSwitcher } from '@/components/TimezoneSwitcher';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Textarea } from '@/components/ui/textarea';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useIsMobile } from '@/hooks/useIsMobile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { useUploadFile } from '@/hooks/useUploadFile';
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
import { parseContentTagInput } from '@/lib/contentTags';
import { countryCodeToFlag, getAllCountries, getGeoDisplayName } from '@/lib/countries';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
import { unixSecondsInTimezone } from '@/lib/timezone';
import { usdToSats } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
interface CreateActionDialogProps {
countryCode?: string;
communityATag?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}
interface CreateActionFormState {
title: string;
description: string;
tagInput: string;
pledgeUsd: string;
deadline: string;
time: string;
coverImage: string;
selectedCountry: string;
timezone: string;
}
function CreateActionForm({
formData,
setFormData,
isSubmitting,
handleSubmit,
onCancel,
pageCountryCode,
}: {
formData: CreateActionFormState;
setFormData: (data: CreateActionFormState) => void;
isSubmitting: boolean;
handleSubmit: () => void;
onCancel: () => void;
pageCountryCode?: string;
}) {
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { data: btcPrice } = useBtcPrice();
const allCountries = useMemo(() => getAllCountries(), []);
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
const countryOptions = useMemo(() => {
const options: Array<{ value: string; label: string; flag: string }> = [
{ value: 'none', label: 'No country', flag: '🌍' },
];
if (pageCountryCode) {
options.push({
value: pageCountryCode,
label: getGeoDisplayName(pageCountryCode),
flag: countryCodeToFlag(pageCountryCode),
});
}
allCountries.forEach((country) => {
if (country.code !== pageCountryCode) {
options.push({ value: country.code, label: country.name, flag: countryCodeToFlag(country.code) });
}
});
return options;
}, [pageCountryCode, allCountries]);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const [[, url]] = await uploadFile(file);
setFormData({ ...formData, coverImage: url });
} catch (error) {
console.error('Failed to upload cover image:', error);
}
};
return (
<>
<div className="space-y-4 py-2 px-4 max-w-full overflow-hidden">
<div className="space-y-2">
<Label htmlFor="country">Country (optional)</Label>
<Popover open={countryPickerOpen} onOpenChange={setCountryPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={countryPickerOpen} className="w-full justify-between">
{formData.selectedCountry ? (
<span className="flex items-center gap-2">
<span>{countryCodeToFlag(formData.selectedCountry)}</span>
<span>{getGeoDisplayName(formData.selectedCountry)}</span>
</span>
) : (
<span>No country</span>
)}
<ChevronRight className="ml-2 h-4 w-4 shrink-0 opacity-50 rotate-90" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0" align="start" sideOffset={4}>
<Command>
<CommandInput placeholder="Search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{countryOptions.map((option) => (
<CommandItem
key={option.value}
value={`${option.label} ${option.value}`}
onSelect={() => {
setFormData({ ...formData, selectedCountry: option.value === 'none' ? '' : option.value });
setCountryPickerOpen(false);
}}
className="gap-2"
>
<span>{option.flag}</span>
<span className="flex-1">{option.label}</span>
<Check className={cn('h-4 w-4', (formData.selectedCountry || 'none') === option.value ? 'opacity-100' : 'opacity-0')} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label>Cover image</Label>
<div className="relative w-full h-32 rounded-lg overflow-hidden border border-border">
<img src={formData.coverImage || DEFAULT_COVER_IMAGE} alt="Cover preview" className="w-full h-full object-cover" />
</div>
<div className="flex items-center gap-2">
<Label htmlFor="cover-upload" className="flex-1 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 border border-border rounded-lg hover:bg-primary/10 transition-colors">
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
<span className="text-sm">Upload custom</span>
</Label>
<input id="cover-upload" type="file" accept="image/*" className="hidden" onChange={handleFileUpload} disabled={isUploading} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input id="title" placeholder="What needs to happen?" value={formData.title} onChange={(e) => setFormData({ ...formData, title: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Explain the action, evidence, or outcome you want to inspire and what submissions should include..."
className="min-h-[80px]"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="pledge-tags">Tags</Label>
<Input id="pledge-tags" placeholder="beach-cleanup, legal-defense" value={formData.tagInput} onChange={(e) => setFormData({ ...formData, tagInput: e.target.value })} />
</div>
<div className="space-y-2">
<Label htmlFor="pledgeUsd">Pledge</Label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground">
$
</span>
<Input
id="pledgeUsd"
type="text"
inputMode="decimal"
placeholder="100"
value={formData.pledgeUsd}
onChange={(e) => setFormData({ ...formData, pledgeUsd: e.target.value })}
className="pl-7 pr-14"
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs font-medium text-muted-foreground">
USD
</span>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="deadline">Deadline (optional)</Label>
<Input id="deadline" type="date" className="w-full min-w-0" value={formData.deadline} onChange={(e) => setFormData({ ...formData, deadline: e.target.value })} />
{formData.deadline && <Input id="time" type="time" className="w-full min-w-0" value={formData.time} onChange={(e) => setFormData({ ...formData, time: e.target.value })} />}
</div>
{formData.deadline && (
<div className="space-y-2 bg-muted/30 p-3 rounded-lg border border-border/50 animate-in slide-in-from-top-2 duration-200">
<Label className="text-sm font-medium flex items-center gap-2"><Clock className="h-4 w-4" /> Timezone</Label>
<TimezoneSwitcher value={formData.timezone} onChange={(timezone) => setFormData({ ...formData, timezone })} />
<p className="text-xs text-muted-foreground">Deadline time will be interpreted in this timezone.</p>
</div>
)}
</div>
<div className="flex flex-col gap-2 p-4 pt-2">
<Button onClick={handleSubmit} disabled={!formData.title || !formData.description || !formData.pledgeUsd || usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice) <= 0 || isSubmitting} className="gap-2 w-full">
{isSubmitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
Create pledge
</Button>
<Button variant="outline" onClick={onCancel} className="w-full">Cancel</Button>
</div>
</>
);
}
export function CreateActionDialog({ countryCode, communityATag, open, onOpenChange }: CreateActionDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const { user } = useCurrentUser();
const { mutateAsync: createEvent } = useNostrPublish();
const { data: btcPrice } = useBtcPrice();
const queryClient = useQueryClient();
const isMobile = useIsMobile();
const { toast } = useToast();
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const [formData, setFormData] = useState<CreateActionFormState>({
title: '',
description: '',
tagInput: '',
pledgeUsd: '',
deadline: '',
time: '',
coverImage: DEFAULT_COVER_IMAGE,
selectedCountry: countryCode || '',
timezone: browserTimezone,
});
const handleSubmit = async () => {
if (!user) return;
setIsSubmitting(true);
try {
const now = Date.now();
const slug = formData.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const dTag = `${slug || 'pledge'}-${now}`;
const pledgeSats = usdToSats(Number(formData.pledgeUsd.replace(/[, $]/g, '')), btcPrice);
if (pledgeSats <= 0) throw new Error('Waiting for BTC/USD price to calculate the pledge amount.');
const pledgeTags = parseContentTagInput(formData.tagInput);
const tags: string[][] = [
['d', dTag],
['title', formData.title],
['bounty', String(pledgeSats)],
['t', 'agora-action'],
['alt', `Agora pledge: ${formData.title}`],
];
for (const tag of pledgeTags) tags.push(['t', tag]);
if (formData.selectedCountry) tags.push(['i', createCountryIdentifier(formData.selectedCountry.toUpperCase())]);
if (communityATag) {
tags.push(...createOrganizationAssociationTags(communityATag));
}
if (formData.coverImage) tags.push(['image', formData.coverImage]);
if (formData.deadline) {
const [year, month, day] = formData.deadline.split('-').map(Number);
const [hours, minutes] = formData.time ? formData.time.split(':').map(Number) : [23, 59];
tags.push(['deadline', String(unixSecondsInTimezone(year, month, day, hours, minutes, formData.timezone))]);
}
await createEvent({ kind: 36639, content: formData.description, tags });
await queryClient.invalidateQueries({ queryKey: ['agora-actions'] });
await queryClient.refetchQueries({ queryKey: ['agora-actions'] });
if (communityATag) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-actions', communityATag] }),
queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
}
// Pledges (kind 36639) surface in the home Agora activity feed.
await queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
await queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
setFormData({
title: '', description: '', tagInput: '', pledgeUsd: '',
deadline: '', time: '',
coverImage: DEFAULT_COVER_IMAGE,
selectedCountry: countryCode || '',
timezone: browserTimezone,
});
onOpenChange(false);
toast({ title: 'Pledge created' });
} catch (error) {
console.error('Failed to create pledge:', error);
toast({ title: 'Failed to create pledge', variant: 'destructive' });
} finally {
setIsSubmitting(false);
}
};
if (!user) return null;
const description = communityATag
? 'New group pledge. You can optionally choose a country below.'
: countryCode
? `New pledge for ${getGeoDisplayName(countryCode)}.`
: 'New pledge. You can optionally choose a country below.';
if (isMobile) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[85dvh] max-h-[85dvh]">
<DrawerHeader className="text-left">
<DrawerTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DrawerTitle>
<DrawerDescription>{description}</DrawerDescription>
</DrawerHeader>
<div className="overflow-y-auto flex-1 pb-safe">
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
</div>
</DrawerContent>
</Drawer>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md sm:max-w-lg md:max-w-2xl max-h-[85vh] w-[calc(100vw-2rem)] sm:w-full overflow-hidden flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
<DialogTitle className="flex items-center gap-2"><Megaphone className="h-5 w-5 text-primary" /> Create pledge</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="overflow-y-auto overflow-x-hidden flex-1 min-h-0">
<CreateActionForm formData={formData} setFormData={setFormData} isSubmitting={isSubmitting} handleSubmit={handleSubmit} onCancel={() => onOpenChange(false)} pageCountryCode={countryCode} />
</div>
</DialogContent>
</Dialog>
);
}
-368
View File
@@ -1,368 +0,0 @@
import { useState, useCallback, useMemo, useRef } from 'react';
import { Award, Upload, Loader2, Check, Copy, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AwardBadgeDialog } from '@/components/AwardBadgeDialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useCreateBadge } from '@/hooks/useCreateBadge';
import { useAwardBadge } from '@/hooks/useAwardBadge';
import { useAcceptBadge } from '@/hooks/useAcceptBadge';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
/** Convert a badge name into a URL-safe slug for the d-tag identifier. */
function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
interface CreateBadgeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps) {
const { user } = useCurrentUser();
const { toast } = useToast();
const shareOrigin = useShareOrigin();
// Form state
const [name, setName] = useState('');
const [identifier, setIdentifier] = useState('');
const [identifierTouched, setIdentifierTouched] = useState(false);
const [description, setDescription] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [imagePreview, setImagePreview] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Post-creation state
const [createdBadge, setCreatedBadge] = useState<NostrEvent | null>(null);
const [awardDialogOpen, setAwardDialogOpen] = useState(false);
const [selfAwarded, setSelfAwarded] = useState(false);
const [copied, setCopied] = useState(false);
// Mutations
const { mutateAsync: createBadge, isPending: isCreating } = useCreateBadge();
const { mutateAsync: awardBadge, isPending: isAwardingSelf } = useAwardBadge();
const { mutateAsync: acceptBadge } = useAcceptBadge();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
// Derived values
const effectiveIdentifier = identifierTouched ? identifier : slugify(name);
const badgeATag = useMemo(() => {
if (!createdBadge) return '';
const dTag = createdBadge.tags.find(([n]) => n === 'd')?.[1] ?? '';
return `${BADGE_DEFINITION_KIND}:${createdBadge.pubkey}:${dTag}`;
}, [createdBadge]);
const badgeName = useMemo(() => {
if (!createdBadge) return '';
return createdBadge.tags.find(([n]) => n === 'name')?.[1] ?? '';
}, [createdBadge]);
const resetForm = useCallback(() => {
setName('');
setIdentifier('');
setIdentifierTouched(false);
setDescription('');
setImageUrl('');
setImagePreview('');
setCreatedBadge(null);
setSelfAwarded(false);
setCopied(false);
}, []);
const handleOpenChange = useCallback((nextOpen: boolean) => {
if (!nextOpen) resetForm();
onOpenChange(nextOpen);
}, [onOpenChange, resetForm]);
// Handlers
const handleNameChange = useCallback((value: string) => {
setName(value);
if (!identifierTouched) {
setIdentifier(slugify(value));
}
}, [identifierTouched]);
const handleIdentifierChange = useCallback((value: string) => {
setIdentifierTouched(true);
setIdentifier(value);
}, []);
const handleFileSelect = useCallback(async (file: File) => {
if (!file.type.startsWith('image/')) {
toast({ title: 'Invalid file', description: 'Please select an image file.', variant: 'destructive' });
return;
}
const reader = new FileReader();
reader.onload = (e) => setImagePreview(e.target?.result as string);
reader.readAsDataURL(file);
try {
const [[, url]] = await uploadFile(file);
setImageUrl(url);
toast({ title: 'Image uploaded' });
} catch {
setImagePreview('');
toast({ title: 'Upload failed', description: 'Please try again.', variant: 'destructive' });
}
}, [uploadFile, toast]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) handleFileSelect(file);
}, [handleFileSelect]);
const handleCreate = useCallback(async () => {
if (!name.trim() || !effectiveIdentifier.trim()) return;
try {
const event = await createBadge({
name: name.trim(),
identifier: effectiveIdentifier.trim(),
description: description.trim() || undefined,
imageUrl: imageUrl || undefined,
});
setCreatedBadge(event);
toast({ title: 'Badge created!' });
} catch {
toast({ title: 'Failed to create badge', description: 'Please try again.', variant: 'destructive' });
}
}, [name, effectiveIdentifier, description, imageUrl, createBadge, toast]);
const handleSelfAward = useCallback(async () => {
if (!user || !createdBadge || !badgeATag) return;
try {
const awardEvent = await awardBadge({
aTag: badgeATag,
recipientPubkeys: [user.pubkey],
});
await acceptBadge({
aTag: badgeATag,
awardEventId: awardEvent.id,
});
setSelfAwarded(true);
toast({ title: 'Badge awarded to yourself!' });
} catch {
toast({ title: 'Failed to self-award', description: 'Please try again.', variant: 'destructive' });
}
}, [user, createdBadge, badgeATag, awardBadge, acceptBadge, toast]);
const handleCopyLink = useCallback(() => {
if (!createdBadge) return;
const dTag = createdBadge.tags.find(([n]) => n === 'd')?.[1] ?? '';
const naddr = nip19.naddrEncode({
kind: BADGE_DEFINITION_KIND,
pubkey: createdBadge.pubkey,
identifier: dTag,
});
navigator.clipboard.writeText(`${shareOrigin}/${naddr}`);
setCopied(true);
toast({ title: 'Link copied to clipboard!' });
setTimeout(() => setCopied(false), 2000);
}, [createdBadge, toast, shareOrigin]);
if (!user) return null;
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md gap-0 p-0 overflow-hidden">
{createdBadge ? (
/* ── Success state ── */
<div className="py-8 px-6 text-center">
<div className="space-y-5">
<div className="inline-flex items-center justify-center size-14 rounded-full bg-green-500/10 mx-auto">
<Check className="size-7 text-green-500" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-bold">Badge Created!</h2>
<p className="text-sm text-muted-foreground">
"{badgeName}" is ready to be awarded.
</p>
</div>
{imageUrl && (
<div className="mx-auto w-20 h-20 rounded-xl overflow-hidden">
<img src={imageUrl} alt={badgeName} className="w-full h-full object-cover" />
</div>
)}
<div className="grid gap-2 max-w-xs mx-auto">
<Button
onClick={handleSelfAward}
disabled={selfAwarded || isAwardingSelf}
variant={selfAwarded ? 'outline' : 'default'}
className="gap-2"
size="sm"
>
{isAwardingSelf ? (
<><Loader2 className="size-4 animate-spin" /> Awarding...</>
) : selfAwarded ? (
<><Check className="size-4" /> Awarded to Yourself</>
) : (
<><Award className="size-4" /> Award to Yourself</>
)}
</Button>
<Button variant="outline" size="sm" onClick={() => setAwardDialogOpen(true)} className="gap-2">
<Users className="size-4" />
Award to Others
</Button>
<Button variant="outline" size="sm" onClick={handleCopyLink} className="gap-2">
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
{copied ? 'Copied!' : 'Share Link'}
</Button>
<Button variant="ghost" size="sm" onClick={resetForm} className="gap-2 text-muted-foreground">
Create Another
</Button>
</div>
</div>
</div>
) : (
/* ── Creation form ── */
<>
<DialogHeader className="px-5 pt-5 pb-3">
<DialogTitle className="flex items-center gap-2">
<Award className="size-5 text-primary" />
Create a Badge
</DialogTitle>
<DialogDescription>
Design a NIP-58 badge to award to users.
</DialogDescription>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="px-5 pb-5 space-y-4">
{/* Image upload */}
<div className="space-y-1.5">
<Label>Badge Image</Label>
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); }}
className="relative flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-border rounded-xl bg-secondary/5 hover:bg-secondary/10 transition-colors cursor-pointer overflow-hidden"
>
{imagePreview ? (
<img src={imagePreview} alt="Badge preview" className="w-full h-full object-contain" />
) : isUploading ? (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Loader2 className="size-6 animate-spin" />
<span className="text-xs">Uploading...</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-muted-foreground">
<Upload className="size-6 opacity-40" />
<span className="text-xs">Drop an image or click to upload</span>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
}}
/>
</div>
<p className="text-xs text-muted-foreground">
Recommended aspect ratio is 1:1 (max 1024x1024 px).
</p>
</div>
{/* Badge name */}
<div className="space-y-1.5">
<Label htmlFor="badge-name">Badge Name *</Label>
<Input
id="badge-name"
placeholder="e.g. Early Adopter"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
/>
</div>
{/* Identifier / d-tag */}
<div className="space-y-1.5">
<Label htmlFor="badge-identifier">Identifier (d-tag)</Label>
<Input
id="badge-identifier"
placeholder="auto-generated-slug"
value={identifierTouched ? identifier : effectiveIdentifier}
onChange={(e) => handleIdentifierChange(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
URL-safe identifier. Auto-generated from the name.
</p>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label htmlFor="badge-description">Description</Label>
<Textarea
id="badge-description"
placeholder="What is this badge awarded for?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
{/* Create button */}
<Button
onClick={handleCreate}
disabled={!name.trim() || !effectiveIdentifier.trim() || isCreating || isUploading}
className="w-full gap-2"
>
{isCreating ? (
<><Loader2 className="size-4 animate-spin" /> Creating...</>
) : (
<><Award className="size-4" /> Create Badge</>
)}
</Button>
</div>
</ScrollArea>
</>
)}
</DialogContent>
</Dialog>
{/* Nested award dialog for post-creation flow */}
<AwardBadgeDialog
open={awardDialogOpen}
onOpenChange={setAwardDialogOpen}
badgeATag={badgeATag}
badgeName={badgeName}
/>
</>
);
}
-228
View File
@@ -1,228 +0,0 @@
import { useState } from 'react';
import { Target } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { ImageUploadField } from '@/components/ImageUploadField';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { getEffectiveRelays } from '@/lib/appRelays';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { useQueryClient } from '@tanstack/react-query';
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
import { withAgoraTag } from '@/lib/agoraNoteTags';
interface CreateGoalDialogProps {
/** The community `a` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
communityATag: string;
children?: React.ReactNode;
/** Controlled open state. When provided, the component is controlled externally. */
open?: boolean;
/** Callback when the open state changes (for controlled mode). */
onOpenChange?: (open: boolean) => void;
}
export function CreateGoalDialog({ communityATag, children, open: controlledOpen, onOpenChange }: CreateGoalDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const { user } = useCurrentUser();
const { toast } = useToast();
const { config } = useAppContext();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [amountSats, setAmountSats] = useState('');
const [summary, setSummary] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [deadlineDate, setDeadlineDate] = useState('');
const [isImageUploading, setIsImageUploading] = useState(false);
const resetForm = () => {
setTitle('');
setAmountSats('');
setSummary('');
setImageUrl('');
setDeadlineDate('');
setIsImageUploading(false);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
if (isImageUploading) {
toast({ title: 'Image is still uploading', description: 'Please wait for the upload to finish.' });
return;
}
const sats = parseInt(amountSats, 10);
if (isNaN(sats) || sats <= 0) {
toast({ title: 'Enter a valid amount in sats', variant: 'destructive' });
return;
}
if (!title.trim()) {
toast({ title: 'Enter a title for the goal', variant: 'destructive' });
return;
}
const msats = sats * 1000;
// NIP-75 relay hints are where zap receipts should be published and tallied.
const relayUrls = getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays).relays
.filter((r) => r.write)
.map((r) => r.url);
if (relayUrls.length === 0) {
toast({ title: 'No write relays configured', variant: 'destructive' });
return;
}
const tags: string[][] = [
['amount', String(msats)],
['relays', ...relayUrls],
['a', communityATag],
['alt', `Zap goal: ${title.trim()}`],
];
if (summary.trim()) {
tags.push(['summary', summary.trim()]);
}
if (imageUrl.trim()) {
const sanitizedImage = sanitizeUrl(imageUrl.trim());
if (!sanitizedImage) {
toast({ title: 'Image URL must be a valid https URL', variant: 'destructive' });
return;
}
tags.push(['image', sanitizedImage]);
}
if (deadlineDate) {
const deadline = Math.floor(new Date(deadlineDate).getTime() / 1000);
if (!isNaN(deadline) && deadline > 0) {
tags.push(['closed_at', String(deadline)]);
}
}
try {
await publishEvent({
kind: ZAP_GOAL_KIND,
content: title.trim(),
tags: withAgoraTag(tags),
});
// Refresh the goals tab and the community activity feed
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['community-goals', communityATag] }),
queryClient.invalidateQueries({
predicate: (q) => {
const [root, aTagsKey] = q.queryKey;
return root === 'community-activity-feed'
&& typeof aTagsKey === 'string'
&& aTagsKey.split(',').includes(communityATag);
},
}),
]);
toast({ title: 'Goal created!' });
resetForm();
setOpen(false);
} catch {
toast({ title: 'Failed to create goal', variant: 'destructive' });
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
{controlledOpen === undefined && (
<DialogTrigger asChild>
{children ?? (
<Button variant="outline" size="sm" className="gap-1.5">
<Target className="size-4" />
New Goal
</Button>
)}
</DialogTrigger>
)}
<DialogContent className="sm:max-w-md">
<DialogTitle className="flex items-center gap-2">
<Target className="size-5" />
Create Goal
</DialogTitle>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div className="space-y-2">
<Label htmlFor="goal-title">Title</Label>
<Input
id="goal-title"
placeholder="e.g. Group meetup expenses"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="goal-amount">Amount (sats)</Label>
<Input
id="goal-amount"
type="number"
min="1"
placeholder="e.g. 100000"
value={amountSats}
onChange={(e) => setAmountSats(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="goal-deadline">Deadline (optional)</Label>
<Input
id="goal-deadline"
type="datetime-local"
value={deadlineDate}
onChange={(e) => setDeadlineDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="goal-summary">Description (optional)</Label>
<Textarea
id="goal-summary"
placeholder="Tell people what this goal is for..."
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={3}
/>
</div>
<ImageUploadField
id="goal-image"
label="Image (recommended)"
value={imageUrl}
onChange={setImageUrl}
onUploadingChange={setIsImageUploading}
previewAlt="Goal image preview"
/>
<Button type="submit" className="w-full" disabled={isPending || isImageUploading}>
{isPending ? 'Creating...' : isImageUploading ? 'Uploading...' : 'Create Goal'}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
-45
View File
@@ -1,45 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Capacitor } from '@capacitor/core';
/**
* Handles deep links from ditto.pub on native platforms.
* Listens for appUrlOpen events and navigates to the corresponding route.
* Must be rendered inside a <BrowserRouter>.
*/
export function DeepLinkHandler() {
const navigate = useNavigate();
useEffect(() => {
if (!Capacitor.isNativePlatform()) return;
let cleanup: (() => void) | undefined;
async function setup() {
const { App } = await import('@capacitor/app');
// Handle URLs opened while the app is already running
const listener = await App.addListener('appUrlOpen', (event) => {
try {
const url = new URL(event.url);
const path = url.pathname + url.search + url.hash;
if (path) {
navigate(path);
}
} catch {
// Invalid URL, ignore
}
});
cleanup = () => listener.remove();
}
setup();
return () => {
cleanup?.();
};
}, [navigate]);
return null;
}
-12
View File
@@ -1,12 +0,0 @@
import { cn } from '@/lib/utils';
/** Animated thinking indicator shown while the AI agent is processing. */
export function DorkThinking({ className }: { className?: string }) {
return (
<div className={cn('flex items-center gap-1.5', className)}>
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:0ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:150ms]" />
<span className="size-1.5 rounded-full bg-muted-foreground/60 animate-bounce [animation-delay:300ms]" />
</div>
);
}
-619
View File
@@ -1,619 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Loader2, Upload, ChevronDown, ChevronUp, Plus, Trash2 } from 'lucide-react';
import { NSchema as n } from '@nostrify/nostrify';
import type { NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { z } from 'zod';
import { ImageCropDialog } from '@/components/ImageCropDialog';
// Extended form schema that includes custom fields
const formSchema = n.metadata().extend({
fields: z.array(z.object({
label: z.string(),
value: z.string(),
})).optional(),
});
type ExtendedMetadata = z.infer<typeof formSchema>;
interface EditProfileFormProps {
/** Called whenever form values change — used by the live preview */
onValuesChange?: (values: Partial<NostrMetadata>) => void;
}
export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange }) => {
const queryClient = useQueryClient();
const [showAdvanced, setShowAdvanced] = useState(false);
const { user, metadata, event } = useCurrentUser();
const { mutateAsync: publishEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
// Crop dialog state
const [cropState, setCropState] = useState<{
open: boolean;
imageSrc: string;
aspect: number;
field: 'picture' | 'banner';
title: string;
} | null>(null);
// Parse existing fields from raw event content
const parseFields = (): Array<{ label: string; value: string }> => {
if (!event) return [];
try {
const parsed = JSON.parse(event.content);
if (Array.isArray(parsed.fields)) {
return parsed.fields
.filter((f: unknown) => Array.isArray(f) && f.length >= 2)
.map((f: string[]) => ({ label: f[0], value: f[1] }));
}
} catch {
// Invalid JSON or no fields
}
return [];
};
// Initialize the form with default values
const form = useForm<ExtendedMetadata>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
about: '',
picture: '',
banner: '',
website: '',
nip05: '',
lud16: '',
bot: false,
fields: [],
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { fields, append, remove } = useFieldArray({ control: form.control as any, name: 'fields' });
// Update form values when user data is loaded
useEffect(() => {
if (metadata) {
const existingFields = parseFields();
form.reset({
name: metadata.name || '',
about: metadata.about || '',
picture: metadata.picture || '',
banner: metadata.banner || '',
website: metadata.website || '',
nip05: metadata.nip05 || '',
lud16: metadata.lud16 || '',
bot: metadata.bot || false,
fields: existingFields,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metadata, event]);
// Propagate live preview changes
const notifyChange = useCallback(() => {
if (!onValuesChange) return;
const v = form.getValues();
onValuesChange({
name: v.name,
display_name: v.display_name,
about: v.about,
picture: v.picture,
banner: v.banner,
website: v.website,
nip05: v.nip05,
lud16: v.lud16,
bot: v.bot,
} as Partial<NostrMetadata>);
}, [form, onValuesChange]);
// Watch all fields and propagate
useEffect(() => {
const sub = form.watch(() => notifyChange());
return () => sub.unsubscribe();
}, [form, notifyChange]);
// Open crop dialog when user picks a file
const openCropDialog = (file: File, field: 'picture' | 'banner') => {
const objectUrl = URL.createObjectURL(file);
setCropState({
open: true,
imageSrc: objectUrl,
aspect: field === 'picture' ? 1 : 3,
field,
title: field === 'picture' ? 'Crop Profile Picture' : 'Crop Banner Image',
});
};
// Handle cropped blob — upload it
const handleCropConfirm = async (blob: Blob) => {
if (!cropState) return;
const { field, imageSrc } = cropState;
setCropState(null);
URL.revokeObjectURL(imageSrc);
const file = new File([blob], `${field}.jpg`, { type: 'image/jpeg' });
try {
const [[, url]] = await uploadFile(file);
form.setValue(field, url);
notifyChange();
toast({
title: 'Success',
description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`,
});
} catch (error) {
console.error(`Failed to upload ${field}:`, error);
toast({
title: 'Error',
description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`,
variant: 'destructive',
});
}
};
const handleCropCancel = () => {
if (cropState) {
URL.revokeObjectURL(cropState.imageSrc);
setCropState(null);
}
};
const onSubmit = async (values: ExtendedMetadata) => {
if (!user) {
toast({
title: 'Error',
description: 'You must be logged in to update your profile',
variant: 'destructive',
});
return;
}
try {
const { fields: customFields, ...standardMetadata } = values;
// Combine existing metadata with new values
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
// Strip any legacy avatar-shape field carried over from older clients.
delete data.shape;
// Clean up empty values in standard metadata
for (const key in data) {
if (data[key] === '') {
delete data[key];
}
}
// Add custom fields if they exist (convert to array format)
if (customFields && customFields.length > 0) {
const nonEmptyFields = customFields.filter(f => f.label.trim() && f.value.trim());
if (nonEmptyFields.length > 0) {
data.fields = nonEmptyFields.map(f => [f.label, f.value]);
}
}
// Publish the metadata event (kind 0)
await publishEvent({
kind: 0,
content: JSON.stringify(data),
});
// Invalidate queries to refresh the data
queryClient.invalidateQueries({ queryKey: ['logins'] });
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
toast({
title: 'Success',
description: 'Your profile has been updated',
});
} catch (error) {
console.error('Failed to update profile:', error);
toast({
title: 'Error',
description: 'Failed to update your profile. Please try again.',
variant: 'destructive',
});
}
};
return (
<div>
{/* Intro */}
<div className="px-3 pt-2 pb-4">
<h2 className="text-sm font-semibold">Your Identity</h2>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
</p>
</div>
{/* Crop dialog */}
{cropState && (
<ImageCropDialog
open={cropState.open}
imageSrc={cropState.imageSrc}
aspect={cropState.aspect}
title={cropState.title}
onCancel={handleCropCancel}
onCrop={handleCropConfirm}
/>
)}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-5 px-3">
<div className="border-b border-border pb-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs font-medium">Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} className="h-9" />
</FormControl>
<FormDescription className="text-xs">
This is your display name that will be displayed to others.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="border-b border-border pb-5">
<FormField
control={form.control}
name="about"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs font-medium">Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell others about yourself"
className="resize-none min-h-20"
{...field}
/>
</FormControl>
<FormDescription className="text-xs">
A short description about yourself.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="border-b border-border pb-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField
control={form.control}
name="picture"
render={({ field }) => (
<ImageUploadField
field={field}
label="Profile Picture"
placeholder="https://example.com/profile.jpg"
description="Upload an image or provide a URL"
previewType="square"
onPickFile={(file) => openCropDialog(file, 'picture')}
/>
)}
/>
<FormField
control={form.control}
name="banner"
render={({ field }) => (
<ImageUploadField
field={field}
label="Banner Image"
placeholder="https://example.com/banner.jpg"
description="Wide banner image for your profile"
previewType="wide"
onPickFile={(file) => openCropDialog(file, 'banner')}
/>
)}
/>
</div>
</div>
<div className="border-b border-border pb-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<FormField
control={form.control}
name="website"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs font-medium">Website</FormLabel>
<FormControl>
<Input placeholder="https://yourwebsite.com" {...field} className="h-9" />
</FormControl>
<FormDescription className="text-xs">
Your personal website or social link
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nip05"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs font-medium">NIP-05 Identifier</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} className="h-9" />
</FormControl>
<FormDescription className="text-xs">
Your verified Nostr identifier
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lud16"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs font-medium">Lightning Address</FormLabel>
<FormControl>
<Input placeholder="you@walletofsatoshi.com" {...field} className="h-9" />
</FormControl>
<FormDescription className="text-xs">
Your lightning address for receiving zaps
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Custom Profile Fields */}
<div className="border-b border-border pb-5">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<FormLabel className="text-xs font-medium">Profile Fields</FormLabel>
<FormDescription className="text-xs mt-1">
Add custom fields like social links, Bitcoin address, or other info
</FormDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ label: '', value: '' })}
className="h-8 text-xs"
>
<Plus className="h-3 w-3 mr-1" />
Add Field
</Button>
</div>
{fields.length > 0 && (
<div className="space-y-3 pt-2">
{fields.map((field, index) => (
<div key={field.id} className="grid grid-cols-[1fr,2fr,auto] gap-2 items-start">
<FormField
control={form.control}
name={`fields.${index}.label`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Label"
{...field}
className="h-9"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`fields.${index}.value`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
placeholder="Value or URL"
{...field}
className="h-9"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
className="h-9 w-9 text-destructive hover:text-destructive"
title="Remove field"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Advanced Settings */}
<div className="border-b border-border pb-5">
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="w-full justify-between p-0 h-auto hover:bg-transparent"
>
<span className="text-xs font-medium text-muted-foreground">Advanced Settings</span>
{showAdvanced ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<FormField
control={form.control}
name="bot"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel className="text-sm">Bot Account</FormLabel>
<FormDescription className="text-xs">
Mark this account as automated or a bot
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
className="scale-90"
/>
</FormControl>
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</div>
<div className="pt-2 pb-4">
<Button
type="submit"
className="w-full md:w-auto"
disabled={isPending || isUploading}
>
{(isPending || isUploading) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Profile
</Button>
</div>
</form>
</Form>
</div>
);
};
// Reusable component for image upload fields
interface ImageUploadFieldProps {
field: {
value: string | undefined;
onChange: (value: string) => void;
name: string;
onBlur: () => void;
};
label: string;
placeholder: string;
description: string;
previewType: 'square' | 'wide';
onPickFile: (file: File) => void;
}
const ImageUploadField: React.FC<ImageUploadFieldProps> = ({
field,
label,
placeholder,
description,
previewType,
onPickFile,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<FormItem>
<FormLabel className="text-xs font-medium">{label}</FormLabel>
<div className="flex flex-col gap-2">
<FormControl>
<Input
placeholder={placeholder}
name={field.name}
value={field.value ?? ''}
onChange={e => field.onChange(e.target.value)}
onBlur={field.onBlur}
className="h-9"
/>
</FormControl>
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onPickFile(file);
// Reset input so re-selecting same file triggers onChange
e.target.value = '';
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="h-8 text-xs"
>
<Upload className="h-3 w-3 mr-1.5" />
Upload &amp; Crop
</Button>
{field.value && (
<div className={`h-8 ${previewType === 'square' ? 'w-8' : 'w-20'} rounded overflow-hidden border`}>
<img
src={field.value}
alt={`${label} preview`}
className="h-full w-full object-cover"
/>
</div>
)}
</div>
</div>
<FormDescription className="text-xs">
{description}
</FormDescription>
<FormMessage />
</FormItem>
);
};
-128
View File
@@ -1,128 +0,0 @@
import { useMemo } from 'react';
import { Users, PartyPopper, UserCheck } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { encodeEventAddress } from '@/lib/encodeEvent';
import { genUserName } from '@/lib/genUserName';
import { parsePeopleList, getDisplayPubkeys } from '@/lib/packUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
/** Max avatars shown in the embedded preview stack. */
const EMBED_AVATAR_LIMIT = 6;
interface EmbeddedPeopleListCardProps {
event: NostrEvent;
className?: string;
disableHoverCards?: boolean;
}
/**
* Compact embedded card for people-list events — kind 3 (follow list),
* 30000 (follow set), and 39089 (follow pack).
*
* The generic `EmbeddedNoteCard` / `EmbeddedNaddrCard` fallbacks render an
* empty shell for these kinds because the meaningful data (the list of
* pubkeys) lives in `p` tags, not in `content` or title tags. This card
* shows the title, an avatar stack, and a member count — matching the
* visual language of the full feed card `PeopleListContent`.
*/
export function EmbeddedPeopleListCard({ event, className, disableHoverCards }: EmbeddedPeopleListCardProps) {
// For kind 3 follow lists we synthesize a title from the author's display name.
const needsAuthorMeta = event.kind === 3;
const author = useAuthor(needsAuthorMeta ? event.pubkey : '');
const authorMetadata = needsAuthorMeta ? author.data?.metadata : undefined;
const { title, description, image, pubkeys, variant } = useMemo(
() => parsePeopleList(event, {
authorMetadata,
authorDisplayName: authorMetadata?.name || authorMetadata?.display_name,
}),
[event, authorMetadata],
);
const nip19Id = useMemo(() => encodeEventAddress(event), [event]);
const previewPubkeys = useMemo(
() => getDisplayPubkeys(event, pubkeys).slice(0, EMBED_AVATAR_LIMIT),
[event, pubkeys],
);
const { data: membersMap } = useAuthors(previewPubkeys);
const safeImage = useMemo(() => sanitizeUrl(image), [image]);
const TitleIcon = variant === 'follow-list' ? UserCheck : variant === 'follow-set' ? Users : PartyPopper;
const memberLabel = pubkeys.length === 1 ? '1 member' : `${pubkeys.length} members`;
return (
<EmbeddedCardShell
pubkey={event.pubkey}
createdAt={event.created_at}
navigateTo={nip19Id}
className={className}
disableHoverCards={disableHoverCards}
>
{/* Title with variant icon */}
<div className="flex items-center gap-1.5 min-w-0">
<TitleIcon className="size-3.5 text-primary shrink-0" />
<p className="text-sm font-semibold leading-snug line-clamp-1">
{title}
</p>
</div>
{/* Description */}
{description && (
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{description}
</p>
)}
{/* Cover image — only for packs/sets that declare one */}
{safeImage && (
<div className="rounded-lg overflow-hidden">
<img
src={safeImage}
alt={title}
className="w-full max-h-[140px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
{/* Avatar stack + member count */}
{pubkeys.length > 0 && (
<div className="flex items-center gap-2">
<div className="flex -space-x-1.5">
{previewPubkeys.map((pk) => {
const member = membersMap?.get(pk);
const name = member?.metadata?.name || member?.metadata?.display_name || genUserName(pk);
const shape = getAvatarShape(member?.metadata);
return (
<Avatar
key={pk}
shape={shape}
className="size-5 ring-1 ring-background"
>
<AvatarImage src={member?.metadata?.picture} alt={name} />
<AvatarFallback className="bg-primary/20 text-primary text-[9px]">
{name[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
);
})}
</div>
<span className="text-[11px] text-muted-foreground">
{memberLabel}
</span>
</div>
)}
</EmbeddedCardShell>
);
}
-278
View File
@@ -1,278 +0,0 @@
import { useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
import { Check, Loader2, RotateCcw } from 'lucide-react';
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 { EmbeddedPost } from '@/components/EmbeddedPost';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { isAddressableKind } from '@/lib/eventKinds';
import { cn } from '@/lib/utils';
/**
* 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 every historical version 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<NostrEvent[]> {
const events: NostrEvent[] = [];
const seen = new Set<string>();
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;
}
/** 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',
});
}
interface EventRecoveryDialogProps {
/** The current event whose history should be browsed. */
event: NostrEvent;
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Generic recovery dialog for replaceable and addressable events.
*
* Lists every historical version of the event matching `(kind, author[, d])`,
* sorted newest first, and lets the user republish a chosen version with a
* fresh `created_at`. `published_at` is preserved via the `prev` property on
* `useNostrPublish`.
*
* The dialog only queries by `(kind, authors[, #d])` — the same filter shape
* used by all other recovery dialogs. Without `authors` (and without `#d` for
* addressable kinds), relays would either reject the request or return an
* unbounded firehose.
*/
export function EventRecoveryDialog({ event, open, onOpenChange }: EventRecoveryDialogProps) {
const close = () => onOpenChange(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 rounded-2xl overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="text-lg font-bold">Restore previous version</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
Browse and restore older versions of this event.
</p>
</DialogHeader>
<ScrollArea className="h-[420px]">
<div className="p-4 space-y-3">
{/* Mounting key forces a fresh refetch each time the dialog reopens
so a user who rapidly edits, then reopens, sees the latest history. */}
{open && <RecoveryContent key={event.id} event={event} onClose={close} />}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
interface RecoveryContentProps {
event: NostrEvent;
onClose: () => void;
}
function RecoveryContent({ event, onClose }: RecoveryContentProps) {
const { nostr } = useNostr();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const [restoringId, setRestoringId] = useState<string | null>(null);
const dTag = isAddressableKind(event.kind)
? event.tags.find(([name]) => name === 'd')?.[1] ?? ''
: undefined;
const queryKey = ['event-recovery', event.kind, event.pubkey, dTag ?? null] as const;
const history = useQuery<NostrEvent[]>({
queryKey,
queryFn: async () => {
const filter: NostrFilter = {
kinds: [event.kind],
authors: [event.pubkey],
};
if (dTag !== undefined) {
filter['#d'] = [dTag];
}
const events = await queryAllEvents(
nostr,
[filter],
AbortSignal.timeout(10_000),
);
return events.sort((a, b) => b.created_at - a.created_at);
},
staleTime: 30_000,
});
if (history.isLoading) {
return <SnapshotSkeleton />;
}
const events = history.data ?? [];
if (events.length === 0) {
return <EmptyState />;
}
// The newest event by created_at is treated as "current". This may differ
// from the `event` we were called with (e.g. user edited from another device
// since this menu was opened) — in that case we still mark the actual newest
// as current to avoid letting the user "restore" what is already current.
const currentId = events[0].id;
const handleRestore = async (snapshot: NostrEvent) => {
setRestoringId(snapshot.id);
try {
await publishEvent({
kind: snapshot.kind,
content: snapshot.content,
tags: snapshot.tags,
created_at: Math.floor(Date.now() / 1000),
// Pass the snapshot as `prev` so useNostrPublish preserves the
// original `published_at` tag (NIP-24) instead of resetting it.
prev: snapshot,
});
toast({
title: 'Event restored',
description: `Successfully restored from ${formatDate(snapshot.created_at)}.`,
});
queryClient.invalidateQueries({ queryKey });
onClose();
} catch (error) {
console.error('Failed to restore event:', error);
toast({
title: 'Restore failed',
description: 'Could not republish the event. Please try again.',
variant: 'destructive',
});
} finally {
setRestoringId(null);
}
};
return (
<>
{events.map((snapshot) => {
const isCurrent = snapshot.id === currentId;
const isRestoring = restoringId === snapshot.id;
return (
<div key={snapshot.id} className="relative">
<EmbeddedPost
event={snapshot}
disableHoverCards
className={cn(isCurrent && 'ring-1 ring-primary/40')}
/>
{/* Overlay the Restore button / Current badge in the top-right
corner of the embedded card. The card itself is a clickable
link, so the button stops propagation to keep navigation and
restore distinct interactions. */}
<div
className="absolute top-2 right-2 z-10"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{isCurrent ? (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<Check className="size-3" />
Current
</span>
) : (
<Button
size="sm"
variant="secondary"
onClick={() => handleRestore(snapshot)}
disabled={restoringId !== null}
className="h-7 gap-1.5 px-2.5 text-xs shadow-sm"
>
{isRestoring ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RotateCcw className="size-3" />
)}
Restore
</Button>
)}
</div>
</div>
);
})}
</>
);
}
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
No previous versions found. Your relays may not store historical events.
</p>
</div>
);
}
function SnapshotSkeleton() {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-xl border p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-40" />
</div>
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
))}
</div>
);
}
@@ -1,129 +0,0 @@
import { Link } from 'react-router-dom';
import { GripVertical, X, Globe, BookOpen } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useMemo } from 'react';
import { cn } from '@/lib/utils';
import { parseExternalUri, headerLabel } from '@/lib/externalContent';
import { getCountryInfo } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useBookInfo } from '@/hooks/useBookInfo';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface ExternalContentSidebarItemProps {
/** The external identifier (URL, iso3166:XX, isbn:...). */
id: string;
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
}
// ── Icon helpers ──────────────────────────────────────────────────────────────
function ExternalSidebarIcon({ id }: { id: string }) {
const content = useMemo(() => parseExternalUri(id), [id]);
if (content.type === 'iso3166') {
const info = getCountryInfo(content.code);
if (info?.flag) {
return (
<CountryFlag
code={content.code}
emoji={info.flag}
label={info.subdivisionName ?? info.name}
className="text-lg shrink-0"
/>
);
}
}
if (content.type === 'url') {
return (
<ExternalFavicon
url={content.value}
size={20}
fallback={<Globe className="size-5 text-muted-foreground" />}
className="size-6 shrink-0 flex items-center justify-center"
/>
);
}
if (content.type === 'isbn') {
return <BookOpen className="size-5 shrink-0" />;
}
return <Globe className="size-6 shrink-0" />;
}
function ExternalSidebarLabel({ id }: { id: string }) {
const content = useMemo(() => parseExternalUri(id), [id]);
const isbn = content.type === 'isbn' ? content.value.replace('isbn:', '') : null;
const { data: book } = useBookInfo(isbn);
const label = content.type === 'isbn' && book?.title ? book.title : headerLabel(content);
return <span className="truncate">{label}</span>;
}
// ── Main component ────────────────────────────────────────────────────────────
export function ExternalContentSidebarItem({
id, active, editing, onRemove, onClick, linkClassName,
}: ExternalContentSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const path = `/i/${encodeURIComponent(id)}`;
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<Link
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0">
<ExternalSidebarIcon id={id} />
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
<ExternalSidebarLabel id={id} />
</span>
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
)}
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
interface FabButtonProps {
onClick: () => void;
icon: React.ReactNode;
disabled?: boolean;
className?: string;
title?: string;
}
/**
* Reusable circular FAB.
*/
export function FabButton({ onClick, icon, disabled, className = '', title }: FabButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
title={title}
className={`relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none ${className}`}
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div className="absolute inset-0 bg-primary rounded-full" />
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{icon}
</span>
</button>
);
}
-257
View File
@@ -1,257 +0,0 @@
/**
* FeedEditModal
*
* Modal for creating or editing a saved home feed tab.
* Mirrors the structure of ProfileTabEditModal: direct state management,
* MultiKindPicker for multi-select kinds, and a 3-way author scope toggle
* (Anyone / Follows / People) with list/pack picker.
*/
import { useState, useMemo } from 'react';
import { Loader2, Check, Globe, Users, UserSearch } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
import {
MultiKindPicker,
AuthorChip,
AuthorFilterDropdown,
ScopeToggle,
ListPackPicker,
} from '@/components/SavedFeedFiltersEditor';
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import type { TabVarDef } from '@/lib/profileTabsEvent';
import type { TabFilter } from '@/contexts/AppContext';
// ─── Types ────────────────────────────────────────────────────────────────────
type AuthorScope = 'anyone' | 'follows' | 'people';
interface FeedEditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** When provided, the modal is in "edit" mode. */
initialLabel?: string;
/** Initial filter values (for edit mode). */
initialFilter?: TabFilter;
/** Called when the user confirms. */
onSave: (label: string, filter: TabFilter, vars: TabVarDef[]) => Promise<void>;
isPending?: boolean;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function filterToScope(filter: TabFilter): AuthorScope {
const authors = Array.isArray(filter.authors) ? (filter.authors as string[]) : [];
if (authors.includes('$follows')) return 'follows';
if (authors.length > 0) return 'people';
return 'anyone';
}
const FEED_SCOPE_OPTIONS: ScopeOption<AuthorScope>[] = [
{ value: 'anyone', label: 'Anyone', icon: Globe },
{ value: 'follows', label: 'Follows', icon: Users },
{ value: 'people', label: 'People', icon: UserSearch },
];
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function FeedEditModal({
open,
onOpenChange,
initialLabel,
initialFilter,
onSave,
isPending = false,
}: FeedEditModalProps) {
const isEditing = !!initialLabel;
const kindOptions = useMemo(() => buildKindOptions(), []);
const { lists } = useUserLists();
const { data: followPacks = [] } = useFollowPacks();
const { user } = useCurrentUser();
const initFrom = (filter: TabFilter | undefined) => ({
label: initialLabel ?? '',
scope: filterToScope(filter ?? {}),
authors: Array.isArray(filter?.authors)
? (filter.authors as string[]).filter((a) => a !== '$follows')
: [],
kinds: parseSelectedKinds(filter ?? {}),
search: typeof filter?.search === 'string' ? filter.search : '',
});
const [label, setLabel] = useState(() => initFrom(initialFilter).label);
const [authorScope, setAuthorScope] = useState<AuthorScope>(() => initFrom(initialFilter).scope);
const [authorPubkeys, setAuthorPubkeys] = useState<string[]>(() => initFrom(initialFilter).authors);
const [selectedKinds, setSelectedKinds] = useState<string[]>(() => initFrom(initialFilter).kinds);
const [search, setSearch] = useState(() => initFrom(initialFilter).search);
// Reset all state when the modal opens
const handleOpenChange = (o: boolean) => {
if (o) {
const init = initFrom(initialFilter);
setLabel(init.label);
setAuthorScope(init.scope);
setAuthorPubkeys(init.authors);
setSelectedKinds(init.kinds);
setSearch(init.search);
}
onOpenChange(o);
};
const addAuthor = (pubkey: string) => {
setAuthorPubkeys((prev) => prev.includes(pubkey) ? prev : [...prev, pubkey]);
};
const removeAuthor = (pubkey: string) => {
setAuthorPubkeys((prev) => prev.filter((p) => p !== pubkey));
};
const listPickerValue = useMatchedListId(authorPubkeys);
const handleSave = async () => {
if (!label.trim() || isPending) return;
const filter: TabFilter = {};
const vars: TabVarDef[] = [];
if (search.trim()) filter.search = search.trim();
if (authorScope === 'follows') {
filter.authors = ['$follows'];
// Emit a var definition so useResolveTabFilter can expand $follows
// via the current user's contact list (kind 3), matching profile tab behaviour.
if (user) {
vars.push({
name: '$follows',
tagName: 'p',
pointer: `a:3:${user.pubkey}:`,
});
}
} else if (authorScope === 'people' && authorPubkeys.length > 0) {
filter.authors = authorPubkeys;
}
const kinds = selectedKinds.map(Number).filter((n) => !isNaN(n) && n > 0);
if (kinds.length > 0) filter.kinds = kinds;
await onSave(label.trim(), filter, vars);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-sm max-h-[90dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit feed' : 'Add home feed'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-1">
{/* Feed name */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Feed name
</span>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
placeholder="e.g. Bitcoin, Photography..."
autoFocus
/>
</div>
<Separator />
{/* Search query */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Search query</span>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="e.g. bitcoin"
className="bg-secondary/50 border-border focus-visible:ring-1 h-8 text-base md:text-sm"
/>
</div>
<Separator />
{/* Author scope */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">From</span>
<ScopeToggle
value={authorScope}
options={FEED_SCOPE_OPTIONS}
onChange={(s) => {
setAuthorScope(s);
if (s !== 'people') setAuthorPubkeys([]);
}}
/>
{authorScope === 'people' && (
<div className="space-y-1.5 pt-0.5">
{authorPubkeys.length > 0 && (
<div className="flex flex-wrap gap-1">
{authorPubkeys.map((pk) => (
<AuthorChip key={pk} pubkey={pk} onRemove={() => removeAuthor(pk)} />
))}
</div>
)}
<AuthorFilterDropdown onCommit={(pk) => addAuthor(pk)} />
<ListPackPicker
lists={lists}
followPacks={followPacks}
value={listPickerValue}
onSelectPubkeys={setAuthorPubkeys}
/>
</div>
)}
</div>
<Separator />
{/* Kind multi-select */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Kinds</span>
<MultiKindPicker
selectedKinds={selectedKinds}
options={kindOptions}
onChange={setSelectedKinds}
/>
</div>
</div>
<DialogFooter className="flex-col gap-2 pt-2 sm:flex-col">
<Button
className="w-full gap-2"
onClick={handleSave}
disabled={!label.trim() || isPending}
>
{isPending
? <Loader2 className="size-4 animate-spin" />
: <Check className="size-4" />}
{isEditing ? 'Save changes' : 'Add feed'}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
-179
View File
@@ -1,179 +0,0 @@
import { lazy, Suspense, useState } from 'react';
import { Construction } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { LogoIcon } from '@/components/icons/LogoIcon';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { FabButton } from '@/components/FabButton';
import type { FabMenuItem } from '@/contexts/LayoutContext';
// Lazy-load the compose modal (pulls in emoji-mart ~620K)
const ReplyComposeModal = lazy(() => import('@/components/ReplyComposeModal').then(m => ({ default: m.ReplyComposeModal })));
interface FloatingComposeButtonProps {
/** The Nostr event kind this FAB creates. kind=1 opens compose; others show "Coming soon". */
kind?: number;
/** If set, the FAB navigates to this URL instead of opening a dialog. */
href?: string;
/** If set, overrides the default FAB click behavior. */
onFabClick?: () => void;
/** If set, overrides the default Plus icon. */
icon?: React.ReactNode;
/** If set, the FAB opens an anchored popover with these items. */
menu?: FabMenuItem[];
}
export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }: FloatingComposeButtonProps) {
const { user } = useCurrentUser();
const navigate = useNavigate();
const [composeOpen, setComposeOpen] = useState(false);
const [comingSoonOpen, setComingSoonOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
if (!user) {
return null;
}
const hasCustomIcon = icon !== undefined;
const renderedIcon = icon;
const logoButtonClassName = "relative size-20 text-primary transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm";
const logoButtonStyle = { filter: 'drop-shadow(0 3px 10px hsl(var(--primary) / 0.28))' };
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
if (menu && menu.length > 0) {
return (
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
<PopoverTrigger asChild>
{hasCustomIcon ? (
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
>
<div className="absolute inset-0 bg-primary rounded-full" />
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
{renderedIcon}
</span>
</button>
) : (
<button
type="button"
aria-label="Add"
aria-expanded={menuOpen}
aria-haspopup="menu"
className={logoButtonClassName}
style={logoButtonStyle}
>
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
<LogoIcon className="relative size-full" />
</button>
)}
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
sideOffset={12}
className="w-auto min-w-[180px] p-1.5 rounded-2xl"
>
<div role="menu" aria-label="Add" className="flex flex-col gap-0.5">
{menu.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
item.onSelect();
}}
className="group flex items-center gap-3 rounded-xl px-3 py-2 text-sm font-medium text-foreground hover:bg-primary hover:text-primary-foreground focus-visible:bg-primary focus-visible:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors text-left"
>
{item.icon && (
<span className="text-primary shrink-0 group-hover:text-primary-foreground group-focus-visible:text-primary-foreground transition-colors">
{item.icon}
</span>
)}
<span>{item.label}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
);
}
const handleClick = () => {
if (onFabClick) {
onFabClick();
} else if (href) {
navigate(href);
} else if (kind === 1) {
setComposeOpen(true);
} else {
setComingSoonOpen(true);
}
};
return (
<>
{hasCustomIcon ? (
<FabButton
onClick={handleClick}
icon={renderedIcon}
/>
) : (
<button
type="button"
onClick={handleClick}
aria-label="Add"
className={logoButtonClassName}
style={logoButtonStyle}
>
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
<LogoIcon className="relative size-full" />
</button>
)}
{/* Kind 1: Compose modal (lazy-loaded) */}
{kind === 1 && composeOpen && (
<Suspense fallback={null}>
<ReplyComposeModal open={composeOpen} onOpenChange={setComposeOpen} />
</Suspense>
)}
{/* Other kinds: Coming soon dialog */}
{kind !== 1 && (
<Dialog open={comingSoonOpen} onOpenChange={setComingSoonOpen}>
<DialogContent className="max-w-[360px] rounded-2xl text-center">
<div className="flex flex-col items-center gap-4 py-4">
<div className="size-16 rounded-full bg-muted flex items-center justify-center">
<Construction className="size-8 text-muted-foreground" />
</div>
<DialogTitle className="text-lg font-semibold">Coming soon</DialogTitle>
<p className="text-sm text-muted-foreground leading-relaxed max-w-[260px]">
Creating this type of content isn't available yet. Stay tuned!
</p>
<Button
variant="outline"
className="rounded-full mt-2"
onClick={() => setComingSoonOpen(false)}
>
Got it
</Button>
</div>
</DialogContent>
</Dialog>
)}
</>
);
}
-272
View File
@@ -1,272 +0,0 @@
import { useCallback, useState } from 'react';
import { Check, ChevronDown, Loader2, UserPlus, VolumeX } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowActions } from '@/hooks/useFollowActions';
import { useMuteList } from '@/hooks/useMuteList';
import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils';
export interface FollowAllSplitButtonProps {
/** Pubkeys (hex) to follow / mute in bulk. */
pubkeys: string[];
/**
* Pubkeys the current user is already following, used to compute the
* "Already following all" state and the "N new for you" counts. Optional —
* if omitted, the button always shows "Follow All (N)" until pressed.
*/
followedPubkeys?: Set<string>;
/**
* Human-readable noun for the list (e.g. "this list", "this pack",
* "the team", "this badge"). Used in the Mute All confirmation copy.
* Defaults to "this list".
*/
listNoun?: string;
/**
* If true, also add the pack/list author to the follow list. Used by kind 3
* follow-list views where the viewed event IS the author's own follow list.
* Pass the author pubkey to include; ignored if omitted.
*/
includeAuthorPubkey?: string;
/**
* Optional className applied to the outer wrapper (controls layout, e.g.
* "flex-1"). The split button itself is always an inline-flex group.
*/
className?: string;
/**
* Optional size to apply to the buttons. Defaults to "default". Use "sm"
* for compact cards.
*/
size?: 'default' | 'sm' | 'lg';
/**
* Optional variant for the main button when the user is already following
* everyone in the list. Defaults to keeping the same "default" variant with
* a check icon. Set to "outline" for a more subdued completed state.
*/
followedVariant?: 'default' | 'outline';
/** Optional toast title to show on successful Follow All. */
followSuccessTitle?: string;
}
/**
* Split button that combines "Follow All" with a dropdown caret offering
* "Mute all" — letting a viewer treat any list (NIP-02 follow list, NIP-51
* follow set, follow pack, badge awardees, etc.) as either a follow source
* or a mute source. Mute All shows a confirmation AlertDialog before merging
* the pubkeys into the user's NIP-51 kind 10000 mute list.
*
* Follow and mute are independent — a viewer can follow AND mute the same
* pubkeys. Mute filtering in feed queries is what makes the second case
* meaningful (mute wins).
*/
export function FollowAllSplitButton({
pubkeys,
followedPubkeys,
listNoun = 'this list',
includeAuthorPubkey,
className,
size = 'default',
followedVariant = 'default',
followSuccessTitle,
}: FollowAllSplitButtonProps) {
const { user } = useCurrentUser();
const { followMany, isPending: isFollowing } = useFollowActions();
const { muteManyPubkeys } = useMuteList();
const { toast } = useToast();
const [muteDialogOpen, setMuteDialogOpen] = useState(false);
// "Already following all" is only meaningful if the caller provided a
// followedPubkeys set; otherwise we always show the active CTA.
const allFollowed = !!followedPubkeys
&& pubkeys.length > 0
&& pubkeys.every((pk) => followedPubkeys.has(pk));
const muteCount = pubkeys.filter((pk) => pk !== user?.pubkey).length;
const isMuting = muteManyPubkeys.isPending;
const isBusy = isFollowing || isMuting;
const handleFollowAll = useCallback(async () => {
if (!user) {
toast({
title: 'Not logged in',
description: 'Please log in to follow users.',
variant: 'destructive',
});
return;
}
try {
const candidates = includeAuthorPubkey
? [...pubkeys, includeAuthorPubkey]
: pubkeys;
const added = await followMany(candidates);
toast({
title: followSuccessTitle ?? 'Following all!',
description: added > 0
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
: `You were already following everyone in ${listNoun}.`,
});
} catch (error) {
console.error('Failed to follow all:', error);
toast({
title: 'Failed to follow',
description: 'There was an error updating your follow list.',
variant: 'destructive',
});
}
}, [user, pubkeys, includeAuthorPubkey, followMany, toast, listNoun, followSuccessTitle]);
const handleMuteAll = useCallback(async () => {
if (!user) {
toast({
title: 'Not logged in',
description: 'Please log in to mute users.',
variant: 'destructive',
});
return;
}
try {
// Don't mute yourself even if the list happens to include you.
const candidates = pubkeys.filter((pk) => pk !== user.pubkey);
const added = await muteManyPubkeys.mutateAsync(candidates);
toast({
title: 'Muted',
description: added > 0
? `Added ${added} account${added !== 1 ? 's' : ''} to your mute list.`
: `Everyone in ${listNoun} was already muted.`,
});
} catch (error) {
console.error('Failed to mute all:', error);
toast({
title: 'Failed to mute',
description: 'There was an error updating your mute list.',
variant: 'destructive',
});
} finally {
setMuteDialogOpen(false);
}
}, [user, pubkeys, muteManyPubkeys, toast, listNoun]);
return (
<div className={cn('inline-flex', className)}>
{/* Main "Follow All" button — flush against the caret, with no rounded right corners */}
<Button
className="flex-1 rounded-r-none gap-2"
size={size}
variant={allFollowed ? followedVariant : 'default'}
onClick={handleFollowAll}
disabled={isBusy || !user || pubkeys.length === 0}
>
{isFollowing ? (
<>
<Loader2 className="size-4 animate-spin" />
Following
</>
) : allFollowed ? (
<>
<Check className="size-4" />
Already following all
</>
) : (
<>
<UserPlus className="size-4" />
Follow All ({pubkeys.length})
</>
)}
</Button>
{/* Caret dropdown — visible divider line on the left, no rounded left corners */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className={cn(
'rounded-l-none border-l border-l-primary-foreground/25 px-2',
// When the main button is in "outline" (allFollowed + followedVariant=outline),
// the divider should match the outline border instead.
allFollowed && followedVariant === 'outline'
&& 'border-l-border',
)}
size={size}
variant={allFollowed ? followedVariant : 'default'}
disabled={isBusy || !user || pubkeys.length === 0}
aria-label="More follow options"
>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{/*
* Use `onClick` (not `onSelect` + `e.preventDefault()`) so the menu
* fully closes before the AlertDialog opens. Otherwise Radix's
* DismissableLayer for the menu overlaps with the dialog's and can
* leave `pointer-events: none` on the body, freezing the page until
* a refresh. Defer the dialog open by a microtask so the menu's
* unmount/cleanup runs first.
*/}
<DropdownMenuItem
onClick={() => {
queueMicrotask(() => setMuteDialogOpen(true));
}}
className="gap-2"
>
<VolumeX className="size-4" />
Mute all
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Confirmation dialog before bulk-muting */}
<AlertDialog open={muteDialogOpen} onOpenChange={setMuteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Mute {muteCount} account{muteCount !== 1 ? 's' : ''}?
</AlertDialogTitle>
<AlertDialogDescription>
This will add everyone in {listNoun} to your mute list. Their
posts won't appear in your feeds, even if you also follow them.
You can unmute individual accounts later from Settings.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isMuting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void handleMuteAll();
}}
disabled={isMuting}
>
{isMuting ? (
<>
<Loader2 className="size-4 animate-spin" />
Muting
</>
) : (
<>Mute all</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
-332
View File
@@ -1,332 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { Check, ChevronsUpDown, Type, Upload, Loader2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { bundledFonts, findBundledFont, loadBundledFont, resolveCssFamily, type FontCategory } from '@/lib/fonts';
import { loadFont } from '@/lib/fontLoader';
import type { ThemeFont } from '@/themes';
/** Accepted font file extensions. */
const FONT_ACCEPT = '.woff2,.woff,.ttf,.otf';
/** Category labels for UI display */
const CATEGORY_LABELS: Record<FontCategory, string> = {
sans: 'Sans Serif',
serif: 'Serif',
mono: 'Monospace',
display: 'Display',
handwriting: 'Handwriting',
};
/** Category display order */
const CATEGORY_ORDER: FontCategory[] = ['sans', 'serif', 'mono', 'display', 'handwriting'];
/** Fonts grouped by category for the picker. */
const fontsByCategory = CATEGORY_ORDER
.map((cat) => ({
category: cat,
label: CATEGORY_LABELS[cat],
fonts: bundledFonts.filter((f) => f.category === cat),
}))
.filter((g) => g.fonts.length > 0);
/** Preload all bundled fonts so they display in their own face in the picker. */
function usePreloadFonts(open: boolean) {
useEffect(() => {
if (!open) return;
// Stagger loading to avoid a burst
const timer = setTimeout(() => {
for (const font of bundledFonts) {
loadBundledFont(font.family);
}
}, 100);
return () => clearTimeout(timer);
}, [open]);
}
/**
* Extract a human-readable font family name from a filename.
* e.g. "MyCustomFont-Regular.woff2" → "MyCustomFont"
* "awesome_font.ttf" → "awesome font"
*/
function familyFromFilename(filename: string): string {
// Remove extension
const base = filename.replace(/\.(woff2?|ttf|otf)$/i, '');
// Remove common weight/style suffixes
const cleaned = base.replace(/[-_\s]?(Regular|Bold|Italic|Light|Medium|SemiBold|ExtraBold|Thin|Black|Variable|VF)$/i, '');
// Replace hyphens/underscores with spaces
return cleaned.replace(/[-_]/g, ' ').trim() || base;
}
/**
* Bare font picker combobox — no section header, no preview text.
*
* Supports two modes:
* - **Uncontrolled** (default): reads/writes the body font via `useTheme().applyCustomTheme()`
* - **Controlled**: pass `value` and `onChange` props to manage state externally
*
* Also supports uploading a custom font file via Blossom.
*/
export function FontPicker({ value, onChange, placeholder = 'Default (Inter)', placeholderFont }: {
/** Controlled value — overrides useTheme() when provided. */
value?: ThemeFont | undefined;
/** Controlled onChange — called instead of applyCustomTheme() when provided. */
onChange?: (font: ThemeFont | undefined) => void;
/** Text shown when no font is selected. Defaults to "Default (Inter)". */
placeholder?: string;
/** Font to render the placeholder text in (when no value is selected). */
placeholderFont?: ThemeFont | undefined;
} = {}) {
const { theme, customTheme, applyCustomTheme } = useTheme();
const { user } = useCurrentUser();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const controlled = onChange !== undefined;
usePreloadFonts(open);
const currentFont: ThemeFont | undefined = controlled
? value
: (theme === 'custom' ? customTheme?.font : undefined);
/** Whether the current font is a custom upload (has a URL and is not bundled). */
const isCustomUpload = currentFont?.url && !findBundledFont(currentFont.family);
const applyFont = (font: ThemeFont | undefined) => {
if (controlled) {
onChange(font);
} else {
const currentColors = customTheme?.colors ?? {
background: '228 20% 10%',
text: '210 40% 98%',
primary: '258 70% 60%',
};
applyCustomTheme({
...customTheme,
colors: currentColors,
font,
});
}
};
const handleSelect = (family: string) => {
if (currentFont?.family === family) {
// Deselect
handleReset();
} else {
applyFont({ family });
}
setOpen(false);
setSearch('');
};
const handleReset = () => {
applyFont(undefined);
};
/** Handle custom font file upload. */
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = '';
// Validate file type
const validExtensions = ['.woff2', '.woff', '.ttf', '.otf'];
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
if (!validExtensions.includes(ext)) {
toast({ title: 'Invalid file', description: 'Please select a .woff2, .woff, .ttf, or .otf font file.', variant: 'destructive' });
return;
}
try {
const tags = await uploadFile(file);
const url = tags[0][1];
const family = familyFromFilename(file.name);
// Load and inject the font so it's immediately visible
await loadFont(family, url);
applyFont({ family, url });
setOpen(false);
setSearch('');
toast({ title: 'Font uploaded', description: `"${family}" is now active.` });
} catch (error) {
console.error('Failed to upload font:', error);
toast({ title: 'Upload failed', description: 'Could not upload the font file.', variant: 'destructive' });
}
};
/** Trigger the hidden file input from within the command list. */
const handleUploadClick = () => {
fileInputRef.current?.click();
};
return (
<>
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal h-9 text-sm"
style={currentFont
? { fontFamily: `"${resolveCssFamily(currentFont.family)}", sans-serif` }
: placeholderFont
? { fontFamily: `"${resolveCssFamily(placeholderFont.family)}", sans-serif` }
: undefined
}
>
<span className="truncate">
{currentFont?.family ?? placeholder}
{isCustomUpload && (
<span className="ml-1.5 text-muted-foreground text-xs">(uploaded)</span>
)}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0 overflow-hidden" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
<Command shouldFilter={true}>
<CommandInput
placeholder="Search fonts..."
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
<span className="text-muted-foreground">No matching fonts</span>
</CommandEmpty>
{/* Custom uploaded font (shown at top when active) */}
{isCustomUpload && currentFont && (
<CommandGroup heading="Custom">
<CommandItem
value={currentFont.family}
onSelect={() => handleSelect(currentFont.family)}
style={{ fontFamily: `"${currentFont.family}", sans-serif` }}
>
<Check className="mr-2 size-4 opacity-100" />
<span className="flex-1 truncate">{currentFont.family}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleReset();
setOpen(false);
}}
className="ml-2 p-0.5 rounded-sm text-muted-foreground hover:text-foreground transition-colors"
aria-label="Remove custom font"
>
<X className="size-3.5" />
</button>
</CommandItem>
</CommandGroup>
)}
{fontsByCategory.map((group) => (
<CommandGroup key={group.category} heading={group.label}>
{group.fonts.map((font) => (
<CommandItem
key={font.family}
value={font.family}
onSelect={() => handleSelect(font.family)}
style={{ fontFamily: `"${font.cssFamily}", sans-serif` }}
>
<Check
className={cn(
'mr-2 size-4',
currentFont?.family === font.family ? 'opacity-100' : 'opacity-0',
)}
/>
{font.family}
</CommandItem>
))}
</CommandGroup>
))}
{/* Upload custom font option */}
{user && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
value="__upload_custom_font__"
onSelect={handleUploadClick}
disabled={isUploading}
className="text-muted-foreground"
>
{isUploading ? (
<Loader2 className="mr-2 size-4 animate-spin" />
) : (
<Upload className="mr-2 size-4" />
)}
{isUploading ? 'Uploading...' : 'Upload custom font...'}
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Hidden file input for font upload */}
<input
ref={fileInputRef}
type="file"
accept={FONT_ACCEPT}
className="hidden"
onChange={handleFileUpload}
/>
</>
);
}
/**
* Unified font section with body + title pickers and a live preview.
*
* Shows a single "Fonts" header, two labeled rows (Body / Title),
* and a combined preview showing both fonts in context.
*/
export function FontSection({ bodyFont, onBodyFontChange, titleFont, onTitleFontChange }: {
bodyFont?: ThemeFont | undefined;
onBodyFontChange?: (font: ThemeFont | undefined) => void;
titleFont?: ThemeFont | undefined;
onTitleFontChange?: (font: ThemeFont | undefined) => void;
}) {
return (
<div className="space-y-3">
<span className="text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Type className="size-3.5" />
Fonts
</span>
{/* Two-row picker layout */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-10 shrink-0">Title</span>
<div className="flex-1">
<FontPicker value={titleFont} onChange={onTitleFontChange} placeholder={bodyFont?.family ?? 'Default (Inter)'} placeholderFont={bodyFont} />
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-10 shrink-0">Body</span>
<div className="flex-1">
<FontPicker value={bodyFont} onChange={onBodyFontChange} />
</div>
</div>
</div>
</div>
);
}
-128
View File
@@ -1,128 +0,0 @@
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { TopNav } from '@/components/TopNav';
import { Skeleton } from '@/components/ui/skeleton';
import {
CenterColumnContext,
DrawerContext,
LayoutStore,
LayoutStoreContext,
NavHiddenContext,
useLayoutSnapshot,
} from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
/**
* Persistent app shell for the fundraising-platform overhaul.
*
* Replaces the previous Twitter-style three-column `MainLayout` with a
* GoFundMe-style top-nav-only chrome. Routes render in a single full-width
* content area below the {@link TopNav}.
*
* Compatibility surface:
* - We still provide `LayoutStoreContext`, so pages that call
* `useLayoutOptions(...)` keep working. Most options (FAB, sidebars,
* mobile arc) are intentionally ignored here because the new shell has
* no FAB and no sidebars. The store drives two width-related escape
* hatches: `wrapperClassName` (extra classes on the center column) and
* `noMaxWidth` (drops the default `max-w-3xl` cap). The `fullBleed`
* preset expands to both, so edge-to-edge pages keep working.
* - `CenterColumnContext` exposes the content `<div>` so legacy components
* (e.g. nsite preview overlay) can still portal into it.
* - `DrawerContext` and `NavHiddenContext` are kept as no-op providers so
* pages that read them don't crash.
*/
function PageSkeleton() {
return (
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 py-8 space-y-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-72 w-full rounded-xl" />
</div>
);
}
function FundraiserLayoutInner() {
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
const { noMaxWidth, wrapperClassName, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu } = useLayoutSnapshot();
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
// become no-ops. Keeping the context shape avoids touching every page that
// pulls the hook.
const openDrawer = useCallback(() => {}, []);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={false}>
<div className="min-h-dvh flex flex-col bg-background">
<TopNav />
<Suspense fallback={<PageSkeleton />}>
<div
ref={(el) => {
centerColumnRef.current = el;
setCenterColumnEl(el);
}}
className={cn(
'flex-1 min-w-0 w-full mx-auto',
// App-wide cap on the center column so pages like /help
// don't stretch across widescreen monitors. Pages that
// need a wider canvas opt out via `noMaxWidth: true` (or
// the `fullBleed` preset), which expands to `!max-w-none`
// through `wrapperClassName`.
!noMaxWidth && 'max-w-3xl',
wrapperClassName,
)}
>
<Outlet />
</div>
</Suspense>
{showFAB && (
<div className="fixed bottom-fab right-6 z-30 pointer-events-none sidebar:right-[max(1.5rem,calc((100vw-48rem)/2-7rem))]">
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
</div>
</div>
)}
<SiteFooter />
</div>
</NavHiddenContext.Provider>
</DrawerContext.Provider>
</CenterColumnContext.Provider>
);
}
function SiteFooter() {
return (
<footer className="bg-background mt-auto pt-12">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<span>&copy; {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
<nav className="flex items-center gap-5">
<Link to="/help" className="hover:text-foreground motion-safe:transition-colors">Help</Link>
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</Link>
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</Link>
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">Changelog</Link>
</nav>
</div>
</footer>
);
}
export function FundraiserLayout() {
const store = useMemo(() => new LayoutStore(), []);
return (
<LayoutStoreContext.Provider value={store}>
<FundraiserLayoutInner />
</LayoutStoreContext.Provider>
);
}
export default FundraiserLayout;
-291
View File
@@ -1,291 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { ExternalLink } from 'lucide-react';
import { CardsIcon } from '@/components/icons/CardsIcon';
import { LinkEmbed } from '@/components/LinkEmbed';
import { Skeleton } from '@/components/ui/skeleton';
import { Lightbox } from '@/components/ImageGallery';
import { useScryfallCard } from '@/hooks/useScryfallCard';
import { useCardTilt } from '@/hooks/useCardTilt';
import { cardPrimaryImage, type ScryfallCard, type ScryfallCardFace } from '@/lib/scryfall';
import type { GathererCard } from '@/lib/linkEmbed';
import { cn } from '@/lib/utils';
/** Max rendered width of the card image. */
const CARD_MAX_WIDTH = 280;
/** Magic cards have a printed corner radius of roughly 4.75% of their width. */
const CARD_CORNER_RADIUS = 'rounded-[4.75%]';
export function GathererCardHeader({
card: lookup,
url,
}: {
card: GathererCard;
url: string;
}) {
const scryfallLookup = useMemo(() => (
lookup.kind === 'multiverse'
? { kind: 'multiverse' as const, multiverseId: lookup.multiverseId }
: { kind: 'set' as const, set: lookup.set, number: lookup.number, lang: lookup.lang }
), [lookup]);
const { data: card, isLoading, isError } = useScryfallCard(scryfallLookup);
if (isLoading) {
return (
<div className="flex flex-col items-center py-4">
<Skeleton
className={cn('w-full aspect-[5/7]', CARD_CORNER_RADIUS)}
style={{ maxWidth: CARD_MAX_WIDTH }}
/>
</div>
);
}
// Fallback to the generic link preview when Scryfall has no record of the
// card (e.g. name-only searches, promos not yet indexed, API errors).
if (isError || !card) {
return <LinkEmbed url={url} showActions={false} />;
}
return <CardDisplay card={card} url={url} />;
}
function CardDisplay({ card, url }: { card: ScryfallCard; url: string }) {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [faceIndex, setFaceIndex] = useState(0);
// Collect all display images (one per face for multi-face layouts).
const images = useMemo(() => {
if (card.card_faces && card.card_faces[0]?.image_uris) {
return card.card_faces.map((f) => f.image_uris?.large).filter((s): s is string => !!s);
}
const primary = cardPrimaryImage(card, 'large');
return primary ? [primary] : [];
}, [card]);
const faces: Array<ScryfallCardFace | ScryfallCard> = useMemo(() => {
if (card.card_faces && card.card_faces.length > 0) return card.card_faces;
return [card];
}, [card]);
const activeFace = faces[faceIndex] ?? faces[0];
const heroImage = images[faceIndex] ?? images[0];
const hasMultipleFaces = faces.length > 1;
return (
<div className="flex flex-col items-center py-4">
{/* 3D-tilt card image */}
<div className="w-full" style={{ maxWidth: CARD_MAX_WIDTH }}>
{heroImage ? (
<CardImageTilt
src={heroImage}
name={activeFace.name}
onClick={() => setLightboxOpen(true)}
/>
) : (
<div
className={cn(
'w-full aspect-[5/7] bg-secondary flex items-center justify-center',
CARD_CORNER_RADIUS,
)}
>
<CardsIcon className="size-12 text-muted-foreground/40" />
</div>
)}
</div>
{/* Face toggle for DFC/MDFC/split cards — essential when only the image is shown */}
{hasMultipleFaces && (
<div className="flex gap-1.5 mt-4">
{faces.map((f, i) => (
<button
key={i}
type="button"
onClick={() => setFaceIndex(i)}
className={cn(
'text-xs px-2.5 py-1 rounded-full transition-colors',
i === faceIndex
? 'bg-primary text-primary-foreground'
: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
)}
>
{f.name}
</button>
))}
</div>
)}
{/* Source links */}
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 mt-4">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<CardsIcon className="size-3.5" />
<span>View on Gatherer</span>
<ExternalLink className="size-3" />
</a>
<a
href={card.scryfall_uri}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<span>View on Scryfall</span>
<ExternalLink className="size-3" />
</a>
</div>
{/* Full-size image lightbox */}
{lightboxOpen && images.length > 0 && (
<Lightbox
images={images}
currentIndex={faceIndex}
onClose={() => setLightboxOpen(false)}
onNext={() => setFaceIndex((i) => (i + 1) % images.length)}
onPrev={() => setFaceIndex((i) => (i - 1 + images.length) % images.length)}
showDownload={false}
maxDotIndicators={10}
/>
)}
</div>
);
}
/**
* Card image with a 3D tilt effect matching the badge showcase. Supports
* mouse, pen, and touch input: on touch, press-and-drag drives the tilt,
* while a quick tap still opens the lightbox via the inner button.
*/
function CardImageTilt({
src,
name,
onClick,
}: {
src: string;
name: string;
onClick: () => void;
}) {
const tilt = useCardTilt(18, 1.04);
const glareRef = useRef<HTMLDivElement>(null);
const glareFadeTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateGlare = useCallback((clientX: number, clientY: number) => {
const el = tilt.ref.current;
const glare = glareRef.current;
if (!el || !glare) return;
const rect = el.getBoundingClientRect();
const x = ((clientX - rect.left) / rect.width) * 100;
const y = ((clientY - rect.top) / rect.height) * 100;
glare.style.background = `radial-gradient(circle at ${x}% ${y}%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 35%, transparent 65%)`;
glare.style.opacity = '1';
}, [tilt.ref]);
const fadeGlare = useCallback(() => {
const glare = glareRef.current;
if (glare) glare.style.opacity = '0';
}, []);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerDown(e);
if (e.pointerType === 'touch') {
clearTimeout(glareFadeTimerRef.current);
updateGlare(e.clientX, e.clientY);
}
},
[tilt, updateGlare],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerMove(e);
// Mirror useCardTilt: for touch, only update while finger is down.
if (e.pointerType === 'touch' && !tilt.isTouchActive) return;
updateGlare(e.clientX, e.clientY);
},
[tilt, updateGlare],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerUp(e);
if (e.pointerType === 'touch') {
clearTimeout(glareFadeTimerRef.current);
glareFadeTimerRef.current = setTimeout(fadeGlare, 600);
}
},
[tilt, fadeGlare],
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
tilt.onPointerLeave(e);
if (e.pointerType === 'touch') {
clearTimeout(glareFadeTimerRef.current);
glareFadeTimerRef.current = setTimeout(fadeGlare, 600);
} else {
fadeGlare();
}
},
[tilt, fadeGlare],
);
// Allow vertical page scrolling to still work on touch — tilt is driven
// by horizontal drags and brief holds.
const style: React.CSSProperties = {
...tilt.style,
touchAction: 'pan-y',
transformStyle: 'preserve-3d',
};
return (
<div
ref={tilt.ref}
style={style}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerLeave}
className="relative select-none"
>
<button
type="button"
onClick={onClick}
aria-label={`View ${name} full size`}
className={cn(
'block w-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
CARD_CORNER_RADIUS,
)}
>
<img
src={src}
alt={name}
loading="eager"
draggable={false}
className={cn(
'w-full aspect-[5/7] object-cover shadow-[0_14px_40px_-12px_rgba(0,0,0,0.45)]',
CARD_CORNER_RADIUS,
)}
/>
</button>
{/* Specular glare overlay, clipped to the card's rounded corners */}
<div
ref={glareRef}
aria-hidden="true"
className={cn(
'absolute inset-0 pointer-events-none',
CARD_CORNER_RADIUS,
)}
style={{
opacity: 0,
transition: 'opacity 0.4s ease-out',
mixBlendMode: 'overlay',
}}
/>
</div>
);
}
-304
View File
@@ -1,304 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Highlighter, ExternalLink, Quote } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { EmbeddedNote } from '@/components/EmbeddedNote';
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
interface HighlightContentProps {
event: NostrEvent;
/** When true, render a larger variant for the detail page. */
expanded?: boolean;
className?: string;
/** When true, skip the embedded source event preview (used inside embeds to avoid nesting). */
disableSourceEmbed?: boolean;
}
/** Parse an `a` tag value in the `kind:pubkey:identifier` form. */
function parseAddr(value: string): { kind: number; pubkey: string; identifier: string } | undefined {
const [kindStr, pubkey, ...rest] = value.split(':');
const kind = Number(kindStr);
if (!Number.isFinite(kind) || !pubkey || pubkey.length !== 64) return undefined;
return { kind, pubkey, identifier: rest.join(':') };
}
/** Extract the hostname (without leading `www.`) from a URL, or `undefined` on failure. */
function hostnameOf(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return undefined;
}
}
/**
* Renders a NIP-84 Highlight event (kind 9802).
*
* - `content` is the highlighted excerpt — displayed as a blockquote-style pull
* quote with an accent border and the Highlighter icon.
* - A `context` tag (if present and longer than `content`) wraps the highlight
* in its surrounding paragraph with the highlighted portion emphasized.
* - The source is shown as either an embedded Nostr event card (`a` tag for
* addressable events like wiki/articles, `e` tag for regular events) or, for
* non-Nostr sources, a clickable URL chip (`r` tag).
*/
export function HighlightContent({ event, expanded = false, className, disableSourceEmbed = false }: HighlightContentProps) {
const { highlight, context, source } = useMemo(() => {
const rawHighlight = event.content.trim();
// NIP-84 `context` tag: surrounding prose that contains the highlight.
const contextTag = event.tags.find(([n]) => n === 'context')?.[1]?.trim();
const contextText = contextTag && contextTag.length > rawHighlight.length ? contextTag : undefined;
// Source precedence: `a` (addressable event) > `e` (regular event) > `r` (URL).
// Skip tags marked `mention` (NIP-84 quote-highlight attribution).
//
// For `r` tags the spec uses a `source`/`mention` marker to distinguish the
// cited source from URLs that appear in a companion `comment`. If no marker
// is present, fall back to the first `r`.
const aTag = event.tags.find(([n, , , marker]) => n === 'a' && marker !== 'mention')?.[1];
const eTag = event.tags.find(([n, , , marker]) => n === 'e' && marker !== 'mention');
const rSourceTag = event.tags.find(([n, , , marker]) => n === 'r' && marker === 'source')?.[1]
?? event.tags.find(([n, , , marker]) => n === 'r' && marker !== 'mention')?.[1];
let src:
| { kind: 'addr'; addr: { kind: number; pubkey: string; identifier: string }; relays?: string[] }
| { kind: 'event'; id: string; relays?: string[]; authorHint?: string }
| { kind: 'url'; url: string }
| undefined;
if (aTag) {
const addr = parseAddr(aTag);
if (addr) {
const relayHint = event.tags.find(([n, v]) => n === 'a' && v === aTag)?.[2];
src = { kind: 'addr', addr, relays: relayHint ? [relayHint] : undefined };
}
}
if (!src && eTag?.[1]) {
const [, id, relayHint, , authorHint] = eTag;
src = {
kind: 'event',
id,
relays: relayHint ? [relayHint] : undefined,
authorHint: authorHint && authorHint.length === 64 ? authorHint : undefined,
};
}
if (!src && rSourceTag) {
const sanitized = sanitizeUrl(rSourceTag);
if (sanitized) src = { kind: 'url', url: sanitized };
}
return { highlight: rawHighlight, context: contextText, source: src };
}, [event.tags, event.content]);
// The blockquote: highlight text with a prominent left accent border.
// When `context` is present, render the context with the highlighted portion
// wrapped in a `<mark>` so the reader sees the selection in situ.
const quoteBlock = context
? <ContextualHighlight context={context} highlight={highlight} expanded={expanded} />
: <Blockquote text={highlight} expanded={expanded} />;
return (
<div className={cn(expanded ? 'mt-3 space-y-3' : 'mt-2 space-y-2.5', className)}>
{quoteBlock}
{/* Source attribution */}
{source && !disableSourceEmbed && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
<Quote className="size-3" />
Highlighted from
</div>
{source.kind === 'addr' ? (
<EmbeddedNaddr addr={source.addr} className="my-0" />
) : source.kind === 'event' ? (
<EmbeddedNote
eventId={source.id}
relays={source.relays}
authorHint={source.authorHint}
className="my-0"
/>
) : (
<SourceUrlChip url={source.url} />
)}
</div>
)}
{/* Compact source link when embeds are disabled (e.g. inside another embed) */}
{source && disableSourceEmbed && (
<SourceChipCompact source={source} />
)}
</div>
);
}
/** Pull-quote style highlighted text. */
function Blockquote({ text, expanded }: { text: string; expanded: boolean }) {
if (!text) {
// Per NIP-84, content may be empty for highlights of non-text media.
return (
<div
className={cn(
'rounded-xl border border-dashed border-border px-4 py-3 text-center text-sm text-muted-foreground',
)}
>
Highlighted media
</div>
);
}
return (
<blockquote
className={cn(
'relative rounded-r-xl border-l-4 border-primary/70 bg-primary/5 pl-4 pr-4 py-3',
)}
>
<Highlighter
className={cn(
'absolute right-3 top-3 text-primary/60',
expanded ? 'size-4' : 'size-3.5',
)}
aria-hidden
/>
<p
className={cn(
'whitespace-pre-wrap break-words font-serif text-foreground',
expanded ? 'text-[17px] leading-relaxed' : 'text-[15px] leading-relaxed',
'pr-6',
)}
>
{text}
</p>
</blockquote>
);
}
/**
* Render the `context` paragraph with the highlighted portion emphasized.
*
* If the highlight can be located verbatim inside the context, the matching
* span is wrapped in `<mark>`. Otherwise the context is shown as-is followed
* by the highlight as a pull-quote (fallback, shouldn't happen per spec).
*/
function ContextualHighlight({
context,
highlight,
expanded,
}: {
context: string;
highlight: string;
expanded: boolean;
}) {
const matchIndex = highlight ? context.indexOf(highlight) : -1;
if (matchIndex < 0 || !highlight) {
// Fallback: show context above, then the highlight as a quote.
return (
<div className="space-y-2">
<p
className={cn(
'whitespace-pre-wrap break-words text-muted-foreground',
expanded ? 'text-[15px] leading-relaxed' : 'text-sm leading-relaxed',
)}
>
{context}
</p>
<Blockquote text={highlight} expanded={expanded} />
</div>
);
}
const before = context.slice(0, matchIndex);
const after = context.slice(matchIndex + highlight.length);
return (
<blockquote
className={cn(
'relative rounded-r-xl border-l-4 border-primary/70 bg-primary/5 pl-4 pr-4 py-3',
)}
>
<Highlighter
className={cn(
'absolute right-3 top-3 text-primary/60',
expanded ? 'size-4' : 'size-3.5',
)}
aria-hidden
/>
<p
className={cn(
'whitespace-pre-wrap break-words font-serif pr-6',
expanded ? 'text-[17px] leading-relaxed' : 'text-[15px] leading-relaxed',
)}
>
<span className="text-muted-foreground">{before}</span>
<mark className="rounded bg-primary/25 px-0.5 text-foreground">{highlight}</mark>
<span className="text-muted-foreground">{after}</span>
</p>
</blockquote>
);
}
/** External-URL source chip (rendered when the highlight came from a plain web page). */
function SourceUrlChip({ url }: { url: string }) {
const host = hostnameOf(url);
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer nofollow ugc"
className="flex items-center gap-2 rounded-xl border border-border bg-secondary/30 px-3 py-2 text-sm transition-colors hover:bg-secondary/60"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
{host && <div className="truncate text-xs font-medium text-muted-foreground">{host}</div>}
<div className="truncate text-[13px] text-foreground">{url}</div>
</div>
</a>
);
}
/** Compact source indicator used when embeds are suppressed. */
function SourceChipCompact({
source,
}: {
source:
| { kind: 'addr'; addr: { kind: number; pubkey: string; identifier: string } }
| { kind: 'event'; id: string }
| { kind: 'url'; url: string };
}) {
if (source.kind === 'url') {
const host = hostnameOf(source.url) ?? source.url;
return (
<a
href={source.url}
target="_blank"
rel="noopener noreferrer nofollow ugc"
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
<ExternalLink className="size-3" />
{host}
</a>
);
}
const to = source.kind === 'addr'
? `/${nip19.naddrEncode(source.addr)}`
: `/${nip19.neventEncode({ id: source.id })}`;
return (
<Link
to={to}
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:underline"
onClick={(e) => e.stopPropagation()}
>
<Quote className="size-3" />
Highlighted from Nostr
</Link>
);
}
-77
View File
@@ -1,77 +0,0 @@
import { ExternalLink, Info } from 'lucide-react';
import type { ExtraKindDef } from '@/lib/extraKinds';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { ExternalFavicon } from '@/components/ExternalFavicon';
interface KindInfoButtonProps {
kindDef: ExtraKindDef;
icon?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/** Info button that opens a modal with a blurb and external site links for an extra kind. */
export function KindInfoButton({ kindDef, icon, open, onOpenChange }: KindInfoButtonProps) {
const { label, blurb, sites } = kindDef;
if (!sites?.length && !blurb) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 rounded-full text-muted-foreground hover:text-foreground">
<Info className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-xs rounded-xl p-6">
<div className="flex flex-col items-center text-center gap-4">
{icon && (
<div className="text-primary [&>svg]:size-10">
{icon}
</div>
)}
<DialogTitle className="text-lg">{label}</DialogTitle>
{blurb && (
<DialogDescription className="text-sm leading-relaxed">
{blurb}
</DialogDescription>
)}
{sites && sites.length > 0 && (
<div className="w-full space-y-1.5 pt-1">
{sites.map((site) => {
const hostname = new URL(site.url).hostname;
const name = site.name ?? hostname.split('.')[0].replace(/^./, (c) => c.toUpperCase());
return (
<a
key={site.url}
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<ExternalFavicon url={site.url} size={16} />
{name}
<ExternalLink className="size-3.5 opacity-70" />
</a>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
-345
View File
@@ -1,345 +0,0 @@
import { useState, useCallback, useMemo } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import {
UserPlus, LogOut,
Loader2, QrCode,
} from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import AuthDialog from '@/components/auth/AuthDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useAppContext } from '@/hooks/useAppContext';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isItemActive, getSidebarItem } from '@/lib/sidebarItems';
import { isAdmin } from '@/lib/admins';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
import { Input } from '@/components/ui/input';
export function LeftSidebar() {
const location = useLocation();
const navigate = useNavigate();
const { user, metadata, event: currentUserEvent, isLoading: isProfileLoading } = useCurrentUser();
const { currentUser, otherUsers, setLogin } = useLoggedInAccounts();
const { logout } = useLoginActions();
const {
orderedItems, hiddenItems, updateSidebarOrder, addToSidebar, addDividerToSidebar, removeFromSidebar,
} = useFeedSettings();
const { config } = useAppContext();
const visibleItems = useMemo(() => {
// Phase 1: filter out admin-only items for non-admins.
const filtered = orderedItems.filter((id) => {
if (id === 'divider') return true; // pass through, cleaned in phase 2
const def = getSidebarItem(id);
if (!def) return true;
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
return true;
});
// Phase 2: remove leading, trailing, and consecutive dividers.
return filtered.filter((id, i, arr) => {
if (id !== 'divider') return true;
if (i === 0) return false;
if (i === arr.length - 1) return false;
if (arr[i - 1] === 'divider') return false;
return true;
});
}, [orderedItems, user]);
const visibleHiddenItems = hiddenItems.filter((item) => {
const def = getSidebarItem(item.id);
if (!def) return true;
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
return true;
});
const hasUnread = useHasUnreadNotifications();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [accountPopoverOpen, setAccountPopoverOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
// NIP-38 status
const userStatus = useUserStatus(user?.pubkey);
const publishStatus = usePublishStatus();
const { toast } = useToast();
const [statusEditing, setStatusEditing] = useState(false);
const [statusDraft, setStatusDraft] = useState('');
const homePage = config.homePage;
const scrollToTopIfCurrent = useCallback((to: string) => (e: React.MouseEvent) => {
if (location.pathname === to) {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [location.pathname]);
const getDisplayName = (account: Account) => account.metadata.name || account.metadata.display_name || genUserName(account.pubkey);
const handleLogout = async () => {
setAccountPopoverOpen(false);
await logout();
navigate('/');
};
return (
<aside className="hidden sidebar:flex flex-col h-screen sticky top-0 py-3 px-4 w-[300px] lg:w-1/4 lg:max-w-[300px] shrink-0">
{/* Logo */}
<div className="flex items-center px-3 mb-1">
<Link to="/" onClick={scrollToTopIfCurrent('/')} className="flex items-center gap-2.5">
<div className="bg-background/85 rounded-full p-0.5">
<AgoraBoltIcon className="size-11 drop-shadow-sm" />
</div>
<div className="flex flex-col leading-none">
<span className="text-xl font-black tracking-tight text-foreground">ÁGORA</span>
<span className="text-[8px] uppercase tracking-wider text-muted-foreground font-semibold mt-0.5">
Power to the people
</span>
</div>
</Link>
</div>
{/* Search */}
<div className="px-2 py-4">
<ProfileSearchDropdown placeholder="Search..." inputClassName="py-3.5" enableTextSearch />
</div>
{/* Nav */}
<nav className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<SidebarNavList
items={visibleItems}
editing={editing}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={(id) => id === homePage ? scrollToTopIfCurrent('/') : undefined}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
homePage={homePage}
/>
<SidebarMoreMenu
editing={editing}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
</nav>
{/* Logged-out join pill — same position as account button, pushed up from bottom */}
{!user && location.pathname !== '/' && (
<div className="pt-2 pb-1">
<button
onClick={() => setLoginDialogOpen(true)}
className="flex items-center justify-center w-full h-10 rounded-full bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors cursor-pointer"
>
Join
</button>
</div>
)}
{/* User profile at bottom */}
{user && currentUser && (
<div className="pt-2">
<Popover open={accountPopoverOpen} onOpenChange={setAccountPopoverOpen}>
<PopoverTrigger asChild>
<button className="flex items-center gap-3 p-3 rounded-full hover:bg-secondary/60 transition-colors cursor-pointer w-full text-left bg-background/85">
{isProfileLoading ? (
<Skeleton className="size-10 shrink-0 rounded-full" />
) : (
<Avatar className="size-10 shrink-0">
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{(metadata?.name || metadata?.display_name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
</AvatarFallback>
</Avatar>
)}
<div className="flex flex-col min-w-0 flex-1 gap-1">
{isProfileLoading ? (
<><Skeleton className="h-3.5 w-24" /><Skeleton className="h-3 w-16" /></>
) : (
<>
<span className="font-semibold text-sm truncate">
{currentUserEvent && (metadata?.name || metadata?.display_name)
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name || metadata.display_name || ''}</EmojifiedText>
: (metadata?.name || metadata?.display_name || genUserName(user.pubkey))}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={user.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</>
)}
</div>
</button>
</PopoverTrigger>
<PopoverContent side="top" align="start" sideOffset={8} className="w-[260px] p-0 rounded-2xl shadow-xl border border-border overflow-hidden">
{/* Current user */}
<Link to={userProfileUrl} onClick={() => setAccountPopoverOpen(false)} className="block p-4 border-b border-border hover:bg-secondary/60 transition-colors">
<div className="flex items-center gap-3">
<Avatar className="size-11 shrink-0">
<AvatarImage src={currentUser.metadata.picture} alt={getDisplayName(currentUser)} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">{getDisplayName(currentUser).charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="font-bold text-sm truncate">
{currentUser.event ? <EmojifiedText tags={currentUser.event.tags}>{getDisplayName(currentUser)}</EmojifiedText> : getDisplayName(currentUser)}
</span>
{currentUser.metadata.nip05 && (
<VerifiedNip05Text nip05={currentUser.metadata.nip05} pubkey={currentUser.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
</div>
</Link>
{/* Status editor */}
<div className="border-b border-border">
{statusEditing ? (
<div className="p-3 space-y-2">
<Input
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
} else if (e.key === 'Escape') {
setStatusEditing(false);
setStatusDraft('');
}
}}
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => {
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
>
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</button>
{userStatus.status && (
<button
onClick={() => {
publishStatus.mutateAsync({ status: '' }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
>
Clear
</button>
)}
<button
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
className="text-xs text-muted-foreground hover:underline ml-auto"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => {
setStatusEditing(true);
setStatusDraft(userStatus.status ?? '');
}}
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
) : (
<span className="text-muted-foreground">Set a status</span>
)}
</button>
)}
</div>
{/* Other accounts */}
{otherUsers.length > 0 && (
<div className="border-b border-border">
{otherUsers.map((account) => (
<button key={account.id} onClick={() => { setLogin(account.id); setAccountPopoverOpen(false); }} className="flex items-center gap-3 w-full px-4 py-3 hover:bg-secondary/60 transition-colors">
<Avatar className="size-9 shrink-0">
<AvatarImage src={account.metadata.picture} alt={getDisplayName(account)} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">{getDisplayName(account).charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">
{account.event ? <EmojifiedText tags={account.event.tags}>{getDisplayName(account)}</EmojifiedText> : getDisplayName(account)}
</span>
{account.metadata.nip05 && <VerifiedNip05Text nip05={account.metadata.nip05} pubkey={account.pubkey} className="text-xs text-muted-foreground truncate" />}
</div>
</button>
))}
</div>
)}
{/* Actions */}
<div className="py-1">
<button onClick={() => { setAccountPopoverOpen(false); setFollowQROpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<QrCode className="size-4 text-muted-foreground" />
<span>Share profile</span>
</button>
<button onClick={() => { setAccountPopoverOpen(false); setLoginDialogOpen(true); }} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium hover:bg-secondary/60 transition-colors">
<UserPlus className="size-4 text-muted-foreground" />
<span>Add another account</span>
</button>
<button onClick={handleLogout} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors">
<LogOut className="size-4" />
<span>Log out @{metadata?.name || metadata?.display_name || genUserName(user.pubkey)}</span>
</button>
</div>
</PopoverContent>
</Popover>
</div>
)}
<AuthDialog isOpen={loginDialogOpen} onClose={() => setLoginDialogOpen(false)} />
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</aside>
);
}
-144
View File
@@ -1,144 +0,0 @@
import { Suspense, useState, useMemo, useCallback, useRef, lazy } from 'react';
import { Outlet } from 'react-router-dom';
import { LeftSidebar } from '@/components/LeftSidebar';
import { MobileTopBar } from '@/components/MobileTopBar';
import { MobileDrawer } from '@/components/MobileDrawer';
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
import { Skeleton } from '@/components/ui/skeleton';
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { cn } from '@/lib/utils';
const WidgetSidebar = lazy(() => import('@/components/WidgetSidebar').then((m) => ({ default: m.WidgetSidebar })));
/** Skeleton shown in the content area while a lazy page chunk is loading. */
function PageSkeleton() {
return (
<>
{/* Main column skeleton — mirrors the Outlet wrapper's border + bg classes */}
<main className="flex-1 min-w-0 min-h-screen sidebar:border-l sidebar:border-r border-border bg-background/85 sidebar:max-w-[600px]">
{/* Header skeleton */}
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
<Skeleton className="h-6 w-32" />
</div>
{/* Content skeletons */}
<div className="space-y-4 px-4 min-h-dvh">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-3 py-4 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
))}
</div>
</main>
{/* Right sidebar placeholder — preserves layout width */}
<div className="w-[300px] shrink-0 hidden xl:block" />
</>
);
}
/** Inner component that reads layout options from the context store. */
function MainLayoutInner() {
const { rightSidebar, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu, wrapperClassName, noOverscroll, noMaxWidth, scrollContainer, hasSubHeader, hideTopBar } = useLayoutSnapshot();
const [drawerOpen, setDrawerOpen] = useState(false);
const openDrawer = useCallback(() => setDrawerOpen(true), []);
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
const { hidden: navHidden } = useScrollDirection(scrollContainer);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={navHidden}>
{/* Mobile top bar - only on small screens, hidden when page requests immersive mode */}
{!hideTopBar && <MobileTopBar onAvatarClick={() => setDrawerOpen(true)} hasSubHeader={hasSubHeader} />}
{/* Mobile drawer */}
<MobileDrawer open={drawerOpen} onOpenChange={setDrawerOpen} />
{/* Main layout - three column on desktop */}
<div className={cn("flex justify-center mx-auto max-w-[1200px]", wrapperClassName)}>
{/* Desktop left sidebar - hidden below sidebar breakpoint */}
<LeftSidebar />
{/* Main content + right sidebar: inside Suspense so the left sidebar persists while lazy pages load */}
<Suspense fallback={<PageSkeleton />}>
{/* -mt-mobile-bar pulls content up behind the mobile top bar so the
transparent SVG header arc and page content overlap seamlessly.
The corresponding padding-top (set in CSS) prevents content from
being hidden. This depends on MobileTopBar having a transparent /
semi-transparent background — a solid top bar would obscure the
content underneath. Only active below the sidebar breakpoint. */}
<div
ref={(el) => { centerColumnRef.current = el; setCenterColumnEl(el); }}
className={cn("relative z-0 flex-1 min-w-0 sidebar:border-l sidebar:border-r border-border bg-background/85", !hideTopBar && "-mt-mobile-bar", !noMaxWidth && "sidebar:max-w-[600px]", !noOverscroll && "pb-overscroll")}
>
<Outlet />
{/* Desktop FAB — sticky within the feed column so it stays
anchored to the bottom-right of the content area, not the
viewport. Hidden below the sidebar breakpoint where the
mobile fixed FAB takes over. */}
{showFAB && (
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
</div>
</div>
</div>
)}
</div>
{/* Right sidebar — render page-provided sidebar, or the default
widget sidebar. `null` (explicit) means "no sidebar"; `undefined`
(unset) falls back to the default. We distinguish these because
`??` would otherwise treat `null` the same as unset and render
the default — which silently breaks pages that intend to be
full-bleed (e.g. /world). */}
{rightSidebar === undefined
? <Suspense fallback={<div className="w-[300px] shrink-0 hidden xl:block" />}><WidgetSidebar /></Suspense>
: rightSidebar}
</Suspense>
</div>
{/* Mobile FAB — fixed to viewport, hidden on desktop where the
in-column sticky FAB (above) takes over. Mirrors bottom nav
hide/show transition on scroll. */}
{showFAB && (
<div
className="fixed bottom-fab right-6 z-30 pointer-events-none transition-transform duration-300 ease-in-out sidebar:hidden"
style={navHidden ? { transform: `translateY(calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))))` } : undefined}
>
<div className="pointer-events-auto">
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
</div>
</div>
)}
</NavHiddenContext.Provider>
</DrawerContext.Provider>
</CenterColumnContext.Provider>
);
}
/**
* Persistent layout shell rendered once by the router.
* Provides a LayoutStore so child pages can configure layout options
* (e.g. showFAB, custom right sidebar) via the `useLayoutOptions` hook.
*/
export function MainLayout() {
const store = useMemo(() => new LayoutStore(), []);
return (
<LayoutStoreContext.Provider value={store}>
<MainLayoutInner />
</LayoutStoreContext.Provider>
);
}
-473
View File
@@ -1,473 +0,0 @@
/**
* MediaCollage — justified row-based collage for Nostr media events (Google Photos style).
* Supports images, video, and audio. Images respect their aspect ratios from imeta `dim` tags.
* All media across all events is flattened into one array so the Lightbox strip swipe
* just advances through them in order.
*/
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { Images, Play, ShieldAlert } from 'lucide-react';
import { Blurhash } from 'react-blurhash';
import type { NostrEvent } from '@nostrify/nostrify';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Lightbox, LOADING_SENTINEL } from '@/components/ImageGallery';
import { PhotoBottomBar } from '@/components/PhotoBottomBar';
import { useAuthor } from '@/hooks/useAuthor';
import { useAppContext } from '@/hooks/useAppContext';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { useIsMobile } from '@/hooks/useIsMobile';
import { parseDimToAspectRatio, eventToMediaItem, type MediaType, type MediaItem } from '@/lib/mediaUtils';
/** A row of items in the justified layout. */
interface JustifiedRow<T> {
items: T[];
/** The height of this row as a fraction of containerWidth. */
heightFraction: number;
}
interface JustifiedLayoutResult<T> {
rows: JustifiedRow<T>[];
/** True when the last row was not fully packed (trailing/orphan row). */
lastRowIncomplete: boolean;
}
/**
* Compute a justified (Google Photosstyle) row layout.
* Packs items into rows so each row fills the container width.
* Each item's width in the row is proportional to its aspect ratio.
*
* @param items - Items with aspect ratios.
* @param getAspectRatio - Function to extract aspect ratio from an item.
* @param targetRowHeight - Ideal row height as a fraction of container width (e.g. 0.3 = 30% of width).
* @param maxRowItems - Maximum items per row.
*/
function justifiedLayout<T>(
items: T[],
getAspectRatio: (item: T) => number,
targetRowHeight: number = 0.3,
maxRowItems: number = 5,
): JustifiedLayoutResult<T> {
if (items.length === 0) return { rows: [], lastRowIncomplete: false };
const rows: JustifiedRow<T>[] = [];
let currentRow: T[] = [];
let currentAspectSum = 0;
for (const item of items) {
const ar = getAspectRatio(item);
currentRow.push(item);
currentAspectSum += ar;
// The row height (as fraction of container width) = 1 / sum(aspect ratios)
const rowHeightFraction = 1 / currentAspectSum;
// If row is full enough (height is at or below target) or max items reached, finalize it
if (rowHeightFraction <= targetRowHeight || currentRow.length >= maxRowItems) {
rows.push({ items: [...currentRow], heightFraction: rowHeightFraction });
currentRow = [];
currentAspectSum = 0;
}
}
// Handle remaining items in the last incomplete row
if (currentRow.length > 0) {
const rowHeightFraction = 1 / currentAspectSum;
// Cap the last row height to target so items don't get too large
rows.push({
items: currentRow,
heightFraction: Math.min(rowHeightFraction, targetRowHeight),
});
return { rows, lastRowIncomplete: true };
}
return { rows, lastRowIncomplete: false };
}
// ── Flat entry — one per media URL across all events ─────────────────────────
interface FlatEntry {
url: string;
type: MediaType;
mime?: string;
dim?: string;
blurhash?: string;
pubkey: string;
event: NostrEvent;
indexInEvent: number;
countInEvent: number;
}
// ── Audio thumbnail — idle visualizer with author avatar ──────────────────────
function AudioThumb({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const name = metadata?.name ?? genUserName(pubkey);
return (
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary/20 via-background/40 to-primary/5">
{/* Idle sine-wave rings */}
<div className="absolute inset-0 flex items-center justify-center opacity-20">
<div className="size-24 rounded-full border border-primary animate-ping" style={{ animationDuration: '3s' }} />
<div className="absolute size-16 rounded-full border border-primary animate-ping" style={{ animationDuration: '2.3s', animationDelay: '0.5s' }} />
</div>
<Avatar className="size-12 relative ring-2 ring-primary/40">
<AvatarImage src={metadata?.picture} alt={name} />
<AvatarFallback className="text-base">{name[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
</div>
);
}
// ── Grid thumbnail ────────────────────────────────────────────────────────────
function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void }) {
const [loaded, setLoaded] = useState(false);
const { config } = useAppContext();
const hasCW = item.contentWarning !== undefined;
const policy = config.contentWarningPolicy;
const [cwRevealed, setCwRevealed] = useState(false);
const showBlur = hasCW && policy !== 'show' && !cwRevealed;
return (
<button
className="relative overflow-hidden rounded-lg bg-muted group focus:outline-none focus-visible:ring-2 focus-visible:ring-primary w-full h-full"
onClick={showBlur ? (e) => { e.stopPropagation(); setCwRevealed(true); } : onClick}
aria-label={showBlur ? 'Reveal sensitive content' : 'View media'}
>
{item.blurhash && (
<Blurhash
hash={item.blurhash}
width="100%"
height="100%"
resolutionX={32}
resolutionY={32}
punch={1}
className={cn('absolute inset-0 transition-opacity duration-300', loaded && !showBlur ? 'opacity-0' : 'opacity-100')}
style={{ width: '100%', height: '100%' }}
/>
)}
{!item.blurhash && !loaded && item.type !== 'audio' && (
<Skeleton className="absolute inset-0 w-full h-full rounded-none" />
)}
{item.type === 'video' && !showBlur && (
<video
src={item.url}
className={cn('absolute inset-0 w-full h-full object-cover transition-opacity duration-300 group-hover:scale-[1.04]', loaded ? 'opacity-100' : 'opacity-0')}
muted
autoPlay
loop
playsInline
preload="metadata"
onLoadedData={() => setLoaded(true)}
/>
)}
{item.type === 'image' && !showBlur && (
<img
src={item.url}
alt={item.alt ?? ''}
className={cn('absolute inset-0 w-full h-full object-cover transition-all duration-300 group-hover:scale-[1.04]', loaded ? 'opacity-100' : 'opacity-0')}
loading="lazy"
onLoad={() => setLoaded(true)}
/>
)}
{item.type === 'audio' && !showBlur && (
<AudioThumb pubkey={item.event.pubkey} />
)}
{/* Content warning overlay — matches sidebar presentation */}
{showBlur && (
<>
<div className="absolute inset-0 bg-muted/60 blur-lg" />
<div className="absolute inset-0 flex items-center justify-center z-10">
<ShieldAlert className="size-5 text-muted-foreground" />
</div>
</>
)}
{/* Play badge for video */}
{item.type === 'video' && !showBlur && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="bg-black/50 rounded-full p-2">
<Play className="size-5 text-white fill-white" />
</div>
</div>
)}
{item.hasMultiple && item.type === 'image' && !showBlur && (
<div className="absolute top-1.5 right-1.5 bg-black/60 text-white rounded p-0.5">
<Images className="size-3.5" />
</div>
)}
{!showBlur && (
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/15 transition-colors duration-200" />
)}
</button>
);
}
// ── Skeleton ──────────────────────────────────────────────────────────────────
/** Pre-defined aspect ratios for skeleton rows to approximate a collage. */
const SKELETON_ROWS_DESKTOP = [
[1.5, 0.8, 1.2],
[1, 1.3, 0.9],
[0.75, 1.5, 1],
[1.2, 1, 1.3],
[1, 0.8, 1.5],
];
const SKELETON_ROWS_MOBILE = [
[1.4, 0.9],
[0.8, 1.3],
[1.2, 1],
[1, 1.5],
[1.3, 0.7],
[0.9, 1.1],
[1.5, 0.8],
];
export function MediaCollageSkeleton({ count = 15 }: { count?: number }) {
const isMobile = useIsMobile();
const skeletonRows = isMobile ? SKELETON_ROWS_MOBILE : SKELETON_ROWS_DESKTOP;
const perRow = isMobile ? 2 : 3;
const rowCount = Math.ceil(count / perRow);
return (
<div className="flex flex-col gap-1.5 p-1.5">
{Array.from({ length: rowCount }).map((_, rowIdx) => {
const ratios = skeletonRows[rowIdx % skeletonRows.length];
const rowAR = ratios.reduce((s, r) => s + r, 0);
return (
<div key={rowIdx} className="flex gap-1.5" style={{ aspectRatio: `${rowAR}` }}>
{ratios.map((ar, colIdx) => {
const itemIdx = rowIdx * perRow + colIdx;
if (itemIdx >= count) return null;
return (
<Skeleton
key={colIdx}
className="rounded-lg h-full"
style={{
flexGrow: ar,
flexBasis: 0,
}}
/>
);
})}
</div>
);
})}
</div>
);
}
// ── MediaCollage ─────────────────────────────────────────────────────────────────
interface MediaCollageProps {
events: NostrEvent[];
className?: string;
/** If set, the lightbox opens at this URL on mount (used by sidebar click). */
initialOpenUrl?: string;
onInitialOpenConsumed?: () => void;
/** Called when the lightbox reaches the last item — use to trigger pagination. */
onNearEnd?: () => void;
/** Whether there are more pages to load — keeps the lightbox swipeable past the last item. */
hasNextPage?: boolean;
/** Whether a next page is currently being fetched — shown as a spinner slot. */
isFetchingNextPage?: boolean;
}
export function MediaCollage({ events, className, initialOpenUrl, onInitialOpenConsumed, onNearEnd, hasNextPage }: MediaCollageProps) {
const isMobile = useIsMobile();
const { config } = useAppContext();
const items = useMemo(
() => events
.map(eventToMediaItem)
.filter((x): x is MediaItem => x !== null)
// Filter out content-warned items when policy is 'hide'
.filter((x) => !(x.contentWarning !== undefined && config.contentWarningPolicy === 'hide')),
[events, config.contentWarningPolicy],
);
const flat = useMemo<FlatEntry[]>(
() => items.flatMap((item) =>
item.allUrls.map((url, indexInEvent) => ({
url,
type: item.allTypes[indexInEvent] ?? item.type,
mime: item.mime,
dim: item.allDims[indexInEvent] ?? item.dim,
blurhash: item.blurhash,
pubkey: item.event.pubkey,
event: item.event,
indexInEvent,
countInEvent: item.allUrls.length,
})),
),
[items],
);
const itemStartIndex = useMemo(() => {
const starts: number[] = [];
let cursor = 0;
for (const item of items) {
starts.push(cursor);
cursor += item.allUrls.length;
}
return starts;
}, [items]);
// Compute justified row layout — fewer items per row on mobile for larger thumbnails
const { rows, lastRowIncomplete } = useMemo(
() => justifiedLayout(
items.map((item, i) => ({ item, index: i })),
({ item }) => parseDimToAspectRatio(item.dim),
isMobile ? 0.45 : 0.3,
isMobile ? 2 : 5,
),
[items, isMobile],
);
// When more pages are coming, hide the trailing incomplete row to avoid
// oversized orphan thumbnails. Show a skeleton placeholder instead.
const visibleRows = hasNextPage && lastRowIncomplete ? rows.slice(0, -1) : rows;
// Open at initialOpenUrl if provided
const initialIndex = useMemo(() => {
if (!initialOpenUrl) return null;
const idx = flat.findIndex((e) => e.url === initialOpenUrl);
return idx >= 0 ? idx : null;
}, [flat, initialOpenUrl]);
const [flatIndex, setFlatIndex] = useState<number | null>(initialIndex);
// Sync flatIndex when initialOpenUrl changes while the component is already mounted
// (e.g., sidebar click while media tab is already the active tab).
useEffect(() => {
if (initialIndex !== null) {
setFlatIndex(initialIndex);
}
}, [initialIndex]);
const activeEntry = flatIndex !== null ? flat[flatIndex] : null;
// Append a loading sentinel when there are more pages so the lightbox
// stays swipeable past the last real item.
const images = useMemo(() => {
const urls = flat.map((e) => e.url);
if (hasNextPage) urls.push(LOADING_SENTINEL);
return urls;
}, [flat, hasNextPage]);
const mediaTypes = useMemo(() => flat.map((e) => e.type as 'image' | 'video' | 'audio'), [flat]);
const mediaMeta = useMemo(() => flat.map((e) => ({ mime: e.mime, dim: e.dim, blurhash: e.blurhash, pubkey: e.pubkey })), [flat]);
// When flat grows (new page loaded) while parked on the sentinel, auto-advance.
const waitingForMore = useRef(false);
const prevFlatLength = useRef(flat.length);
useEffect(() => {
const prev = prevFlatLength.current;
prevFlatLength.current = flat.length;
if (waitingForMore.current && flat.length > prev && flatIndex !== null) {
waitingForMore.current = false;
setFlatIndex(flatIndex + 1);
}
}, [flat.length]); // eslint-disable-line react-hooks/exhaustive-deps
const handleNext = useCallback(() => {
setFlatIndex((i) => {
if (i === null) return null;
if (i >= flat.length - 1) {
// At or past the last real item — trigger fetch and park on sentinel
waitingForMore.current = true;
onNearEnd?.();
return i; // stay; sentinel is already in images array
}
const next = i + 1;
if (next >= flat.length - 1) onNearEnd?.();
return next;
});
}, [flat.length, onNearEnd]);
if (items.length === 0) return null;
return (
<>
<div className={cn('flex flex-col gap-1.5 p-1.5', className)}>
{visibleRows.map((row, rowIdx) => {
// The row's aspect ratio is the sum of all item aspect ratios
// (at equal height, total width = sum of ARs * height)
const rowAR = row.items.reduce((s, { item }) => s + parseDimToAspectRatio(item.dim), 0);
return (
<div
key={rowIdx}
className="flex gap-1.5"
style={{ aspectRatio: `${rowAR}` }}
>
{row.items.map(({ item, index }) => {
const ar = parseDimToAspectRatio(item.dim);
return (
<div
key={item.event.id}
className="relative h-full"
style={{
flexGrow: ar,
flexBasis: 0,
}}
>
<MediaThumb
item={item}
onClick={() => setFlatIndex(itemStartIndex[index])}
/>
</div>
);
})}
</div>
);
})}
{/* Skeleton placeholder while next page loads */}
{hasNextPage && (
<>
{(isMobile ? SKELETON_ROWS_MOBILE : SKELETON_ROWS_DESKTOP).slice(0, 2).map((ratios, i) => {
const rowAR = ratios.reduce((s, r) => s + r, 0);
return (
<div key={`skel-${i}`} className="flex gap-1.5" style={{ aspectRatio: `${rowAR}` }}>
{ratios.map((ar, j) => (
<Skeleton
key={j}
className="rounded-lg h-full animate-pulse"
style={{ flexGrow: ar, flexBasis: 0 }}
/>
))}
</div>
);
})}
</>
)}
</div>
{flatIndex !== null && (
<Lightbox
images={images}
mediaTypes={mediaTypes}
mediaMeta={mediaMeta}
currentIndex={flatIndex}
onClose={() => { setFlatIndex(null); onInitialOpenConsumed?.(); waitingForMore.current = false; }}
onNext={handleNext}
onPrev={() => setFlatIndex((i) => (i !== null ? Math.max(i - 1, 0) : null))}
topBarLeft={
activeEntry && activeEntry.countInEvent > 1 ? (
<span className="text-white/80 text-sm font-medium tabular-nums">
{activeEntry.indexInEvent + 1} / {activeEntry.countInEvent}
</span>
) : <span />
}
bottomBar={activeEntry ? <PhotoBottomBar event={activeEntry.event} /> : undefined}
/>
)}
</>
);
}
-205
View File
@@ -1,205 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Pause, SkipBack, SkipForward, Maximize2, X, GripVertical } from 'lucide-react';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { cn } from '@/lib/utils';
const POSITION_KEY = 'audio-minibar-position';
const DRAG_THRESHOLD = 4;
function getStoredPosition(): { x: number; y: number } | null {
try {
const raw = localStorage.getItem(POSITION_KEY);
if (raw) return JSON.parse(raw);
} catch { /* ignore */ }
return null;
}
function getBottomOffset() {
// On mobile (below sidebar breakpoint), reserve space for the bottom nav (56px)
const hasSidebar = window.matchMedia('(min-width: 900px)').matches;
return hasSidebar ? 0 : 56;
}
function clampToViewport(x: number, y: number, w: number, h: number) {
const maxX = window.innerWidth - w;
const maxY = window.innerHeight - h - getBottomOffset();
return {
x: Math.max(0, Math.min(x, maxX)),
y: Math.max(0, Math.min(y, maxY)),
};
}
/**
* Floating draggable mini-pill audio player.
* Uses PointerEvents drag with setPointerCapture, 4px threshold, viewport clamping.
* Position persisted to localStorage.
*/
export function MinimizedAudioBar() {
const player = useAudioPlayer();
const { currentTrack, minimized, isPlaying, currentTime, duration, playlist, currentIndex } = player;
const navigate = useNavigate();
const barRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState(() => {
const stored = getStoredPosition();
const defaultPos = { x: 16, y: window.innerHeight - 80 - getBottomOffset() };
if (!stored) return defaultPos;
// Clamp stored position in case viewport or bottom offset changed
return clampToViewport(stored.x, stored.y, 300, 64);
});
// Drag state
const dragging = useRef(false);
const dragStarted = useRef(false);
const startPointer = useRef({ x: 0, y: 0 });
const startPos = useRef({ x: 0, y: 0 });
// Reclamp on resize
useEffect(() => {
const onResize = () => {
setPos((p) => {
const el = barRef.current;
const w = el?.offsetWidth ?? 300;
const h = el?.offsetHeight ?? 64;
return clampToViewport(p.x, p.y, w, h);
});
};
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
// Persist position
useEffect(() => {
try { localStorage.setItem(POSITION_KEY, JSON.stringify(pos)); } catch { /* ignore */ }
}, [pos]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
// Only drag from the grip handle
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragging.current = true;
dragStarted.current = false;
startPointer.current = { x: e.clientX, y: e.clientY };
startPos.current = { ...pos };
}, [pos]);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
const dx = e.clientX - startPointer.current.x;
const dy = e.clientY - startPointer.current.y;
if (!dragStarted.current && Math.abs(dx) + Math.abs(dy) < DRAG_THRESHOLD) return;
dragStarted.current = true;
const el = barRef.current;
const w = el?.offsetWidth ?? 300;
const h = el?.offsetHeight ?? 64;
setPos(clampToViewport(startPos.current.x + dx, startPos.current.y + dy, w, h));
}, []);
const onPointerUp = useCallback((e: React.PointerEvent) => {
if (dragging.current) {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
dragging.current = false;
}
}, []);
if (!currentTrack || !minimized) return null;
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
const hasPlaylist = playlist.length > 1;
const canPrev = hasPlaylist && (currentIndex > 0 || currentTime > 3);
const canNext = hasPlaylist && currentIndex < playlist.length - 1;
return (
<div
ref={barRef}
className="fixed z-30 select-none touch-none sidebar:block hidden"
style={{ left: pos.x, top: pos.y }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div className="flex items-center gap-2 rounded-2xl bg-background/95 backdrop-blur-md border border-border shadow-lg px-2 py-1.5 min-w-[280px] max-w-[360px]">
{/* Drag handle */}
<div data-drag-handle className="cursor-grab active:cursor-grabbing shrink-0 p-1 -ml-0.5 text-muted-foreground/50 hover:text-muted-foreground">
<GripVertical className="size-4" />
</div>
{/* Artwork thumbnail */}
{currentTrack.artwork ? (
<img src={currentTrack.artwork} alt="" className="size-10 rounded-lg object-cover shrink-0" />
) : (
<div className="size-10 rounded-lg bg-primary/15 flex items-center justify-center shrink-0">
<Play className="size-4 text-primary" />
</div>
)}
{/* Title + Artist */}
<div className="flex-1 min-w-0 px-1">
<p className="text-sm font-medium truncate leading-tight">{currentTrack.title}</p>
<p className="text-xs text-muted-foreground truncate">{currentTrack.artist}</p>
</div>
{/* Controls */}
<div className="flex items-center gap-0.5 shrink-0">
{hasPlaylist && (
<button
onClick={() => player.prevTrack()}
disabled={!canPrev}
className="p-1.5 rounded-full hover:bg-secondary transition-colors disabled:opacity-30"
aria-label="Previous"
>
<SkipBack className="size-3.5" />
</button>
)}
<button
onClick={() => isPlaying ? player.pause() : player.resume()}
className="p-1.5 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="size-3.5" fill="currentColor" /> : <Play className="size-3.5 ml-0.5" fill="currentColor" />}
</button>
{hasPlaylist && (
<button
onClick={() => player.nextTrack()}
disabled={!canNext}
className="p-1.5 rounded-full hover:bg-secondary transition-colors disabled:opacity-30"
aria-label="Next"
>
<SkipForward className="size-3.5" />
</button>
)}
<button
onClick={() => {
player.expand();
if (currentTrack.path) navigate(currentTrack.path);
}}
className="p-1.5 rounded-full hover:bg-secondary transition-colors"
aria-label="Expand"
>
<Maximize2 className="size-3.5" />
</button>
<button
onClick={() => player.stop()}
className="p-1.5 rounded-full hover:bg-secondary transition-colors"
aria-label="Close"
>
<X className="size-3.5" />
</button>
</div>
</div>
{/* Progress bar at bottom */}
<div className="mx-3 h-0.5 rounded-full bg-border overflow-hidden -mt-0.5">
<div className={cn('h-full bg-primary transition-[width] duration-200')} style={{ width: `${progress}%` }} />
</div>
</div>
);
}
-171
View File
@@ -1,171 +0,0 @@
import { useCallback, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Bell, Earth, Search, Users } from 'lucide-react';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { cn } from '@/lib/utils';
import { selectionChanged } from '@/lib/haptics';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useScrollDirection } from '@/hooks/useScrollDirection';
import { useLayoutSnapshot } from '@/contexts/LayoutContext';
import { ArcBackground, ARC_UP_OVERHANG_PX } from '@/components/ArcBackground';
import { MobileSearchSheet } from '@/components/MobileSearchSheet';
/** Transform style applied when the bottom nav is hidden (scrolled away). */
const hiddenStyle: React.CSSProperties = {
transform: `translateY(calc(100% + ${ARC_UP_OVERHANG_PX}px))`,
};
interface NavItemProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
active: boolean;
badge?: boolean;
onClick?: (e: React.MouseEvent) => void;
to?: string;
/** 'sm' shrinks the slot (smaller flex basis + smaller icon/label) for outer items. */
size?: 'sm' | 'md';
}
/** A side item in the bottom nav row. */
function NavItem({ icon: Icon, label, active, badge, onClick, to, size = 'md' }: NavItemProps) {
const isSm = size === 'sm';
const className = cn(
'flex flex-col items-center justify-center gap-0.5 py-2 transition-colors min-w-0',
isSm ? 'flex-[0.7]' : 'flex-1',
active ? 'text-primary' : 'text-muted-foreground',
);
const inner = (
<>
<span className="relative">
<Icon className={isSm ? 'size-4' : 'size-5'} />
{badge && (
<span className="absolute -top-1 right-0 size-2 bg-primary rounded-full" />
)}
</span>
<span className={cn('font-medium truncate', isSm ? 'text-[9px]' : 'text-[10px]')}>{label}</span>
</>
);
if (to) return <Link to={to} onClick={onClick} className={className}>{inner}</Link>;
return <button onClick={onClick} className={className}>{inner}</button>;
}
export function MobileBottomNav() {
const location = useLocation();
const { user } = useCurrentUser();
const hasUnread = useHasUnreadNotifications();
const { scrollContainer, noArcs } = useLayoutSnapshot();
const { hidden } = useScrollDirection(scrollContainer);
const [searchOpen, setSearchOpen] = useState(false);
const handleSearchClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
selectionChanged();
setSearchOpen((v) => !v);
}, []);
const handleWalletClick = useCallback((e: React.MouseEvent) => {
selectionChanged();
setSearchOpen(false);
if (location.pathname === '/wallet') {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [location.pathname]);
// Hide the nav when search sheet is open so it doesn't compete for space
const isHidden = hidden || searchOpen;
const isOnWallet = location.pathname === '/wallet';
const isOnCommunities = location.pathname === '/groups'
|| location.pathname.startsWith('/groups/')
|| location.pathname === '/communities'
|| location.pathname.startsWith('/communities/');
const isOnWorld = location.pathname === '/world' || location.pathname.startsWith('/world/');
const isOnNotifications = location.pathname === '/notifications';
return (
<>
<MobileSearchSheet open={searchOpen} onClose={() => setSearchOpen(false)} />
<nav
className={cn(
'fixed bottom-0 left-0 right-0 z-40 will-change-transform',
'transition-transform duration-300 ease-in-out',
)}
style={isHidden ? hiddenStyle : undefined}
>
{/* Arc + items wrapper */}
<div className="relative">
<ArcBackground variant={noArcs ? 'rect' : 'up'} />
<div className="h-12 flex items-end pb-0 relative translate-y-2">
{/* Search */}
<NavItem
icon={Search}
label="Search"
active={searchOpen}
onClick={handleSearchClick}
size="sm"
/>
{/* Organizations */}
<NavItem
icon={Users}
label="Groups"
active={isOnCommunities}
to="/groups"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
/>
{/* Center spacer — reserved for the apex Feed button */}
<div className="flex-[0.4]" aria-hidden="true" />
{/* Notifications */}
<NavItem
icon={Bell}
label="Notifications"
active={isOnNotifications}
badge={!!user && hasUnread}
to="/notifications"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
/>
{/* World */}
<NavItem
icon={Earth}
label="World"
active={isOnWorld}
to="/world"
onClick={() => { selectionChanged(); setSearchOpen(false); }}
size="sm"
/>
</div>
{/* Apex Wallet button — Agora bolt mark cradled in the V notch. */}
<Link
to="/wallet"
onClick={handleWalletClick}
aria-label="Wallet"
className={cn(
'absolute left-1/2 -translate-x-1/2 z-10 -top-6',
'flex items-center',
'transition-transform hover:scale-105 active:scale-95',
)}
>
<AgoraBoltIcon
className={cn(
'size-16 drop-shadow-md',
isOnWallet && 'drop-shadow-[0_0_8px_hsl(var(--primary)/0.6)]',
)}
/>
</Link>
</div>
{/* Safe area fill — matches the arc's semi-transparent background */}
<div className="safe-area-bottom bg-background/85" />
</nav>
</>
);
}
-386
View File
@@ -1,386 +0,0 @@
import { useState, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { ChevronDown, ChevronUp, LogOut, UserPlus, Loader2, QrCode } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
import { SidebarNavList } from '@/components/SidebarNavItem';
import { SidebarMoreMenu } from '@/components/SidebarMoreMenu';
import { LoginArea } from '@/components/auth/LoginArea';
import { LinkFooter } from '@/components/LinkFooter';
import { EmojifiedText } from '@/components/CustomEmoji';
import AuthDialog from '@/components/auth/AuthDialog';
import { FollowQRDialog } from '@/components/FollowQRDialog';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLoginActions } from '@/hooks/useLoginActions';
import { useLoggedInAccounts, type Account } from '@/hooks/useLoggedInAccounts';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { useHasUnreadNotifications } from '@/hooks/useHasUnreadNotifications';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { isItemActive, getSidebarItem } from '@/lib/sidebarItems';
import { isAdmin } from '@/lib/admins';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { useUserStatus } from '@/hooks/useUserStatus';
import { usePublishStatus } from '@/hooks/usePublishStatus';
import { useToast } from '@/hooks/useToast';
import { Input } from '@/components/ui/input';
import { resolveTheme, resolveThemeConfig } from '@/themes';
interface MobileDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
const location = useLocation();
const navigate = useNavigate();
const { user, metadata, event: currentUserEvent } = useCurrentUser();
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
const { logout } = useLoginActions();
const { otherUsers, setLogin } = useLoggedInAccounts();
const { orderedItems, hiddenItems, addToSidebar, addDividerToSidebar, removeFromSidebar, updateSidebarOrder } = useFeedSettings();
const { config } = useAppContext();
const homePage = config.homePage;
const hasUnread = useHasUnreadNotifications();
const [editing, setEditing] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [accountExpanded, setAccountExpanded] = useState(false);
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [followQROpen, setFollowQROpen] = useState(false);
const { theme, customTheme, themes } = useTheme();
// NIP-38 status
const userStatus = useUserStatus(user?.pubkey);
const publishStatus = usePublishStatus();
const { toast } = useToast();
const [statusEditing, setStatusEditing] = useState(false);
const [statusDraft, setStatusDraft] = useState('');
/** Compute the background image style for the drawer, mirroring the body background. */
const bgStyle = useMemo<React.CSSProperties>(() => {
const resolved = resolveTheme(theme);
const activeConfig = resolved === 'custom' ? customTheme : resolveThemeConfig(resolved, themes);
const bgUrl = activeConfig?.background?.url;
if (!bgUrl) return {};
const bgMode = activeConfig?.background?.mode ?? 'cover';
if (bgMode === 'tile') {
return { backgroundColor: 'transparent', backgroundImage: `url("${bgUrl}")`, backgroundRepeat: 'repeat', backgroundSize: 'auto' };
}
return { backgroundColor: 'transparent', backgroundImage: `url("${bgUrl}")`, backgroundSize: 'cover', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' };
}, [theme, customTheme, themes]);
const hasBgImage = Object.keys(bgStyle).length > 0;
const visibleItems = useMemo(() => {
// Phase 1: filter out non-visible items (admin-only for non-admins).
const filtered = orderedItems.filter((id) => {
if (id === 'divider') return true; // pass through, cleaned in phase 2
const def = getSidebarItem(id);
if (!def) return true;
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
return true;
});
// Phase 1b: when logged out, ensure Help is visible in the main menu.
// Logged-in users access Help via the account menu (AccountSwitcher),
// but logged-out users have no equivalent affordance — surface it here.
if (!user && !filtered.includes('help')) {
filtered.push('help');
}
// Phase 2: remove leading, trailing, and consecutive dividers.
return filtered.filter((id, i, arr) => {
if (id !== 'divider') return true;
if (i === 0) return false;
if (i === arr.length - 1) return false;
if (arr[i - 1] === 'divider') return false;
return true;
});
}, [orderedItems, user]);
const visibleHiddenItems = useMemo(() => {
return hiddenItems.filter((item) => {
const def = getSidebarItem(item.id);
if (!def) return true;
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
// When logged out, Help is forced into the main list, so hide it
// from the "More…" menu to avoid duplication.
if (!user && item.id === 'help') return false;
return true;
});
}, [hiddenItems, user]);
const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); };
const handleLogout = async () => { await logout(); handleClose(); navigate('/'); };
const getDisplayName = (account: Account) => account.metadata.name || account.metadata.display_name || genUserName(account.pubkey);
const displayName = metadata?.name || metadata?.display_name || (user ? genUserName(user.pubkey) : 'Anonymous');
return (
<>
<Sheet open={open} onOpenChange={(v) => { if (!v) setMoreMenuOpen(false); onOpenChange(v); }}>
<SheetContent side="left" className="w-[300px] p-0 gap-0 border-r-border flex flex-col overflow-hidden">
<div
className="absolute inset-0 pointer-events-none bg-background"
style={bgStyle}
/>
{hasBgImage && (
<div
className="absolute inset-0 bg-background/70 pointer-events-none"
/>
)}
<SheetTitle className="sr-only">Navigation menu</SheetTitle>
{user ? (
<div className="flex flex-col h-full relative">
{/* User row with caret */}
<button
onClick={() => setAccountExpanded((v) => !v)}
className="flex items-center gap-3 px-3 hover:bg-secondary/60 transition-colors w-full text-left"
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
>
<Avatar className="size-7 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 flex-1">
<span className="font-semibold text-sm truncate">
{currentUserEvent && (metadata?.name || metadata?.display_name)
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name || metadata.display_name || ''}</EmojifiedText>
: displayName}
</span>
{metadata?.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={user.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
{accountExpanded
? <ChevronUp className="size-4 text-muted-foreground shrink-0 mr-1" />
: <ChevronDown className="size-4 text-muted-foreground shrink-0 mr-1" />
}
</button>
{/* Expanded account actions */}
{accountExpanded && (
<div>
{/* Status editor */}
<div className="border-b border-border">
{statusEditing ? (
<div className="px-3 py-2 space-y-2">
<Input
value={statusDraft}
onChange={(e) => setStatusDraft(e.target.value.slice(0, 80))}
placeholder="What are you up to?"
className="h-8 text-base md:text-sm"
maxLength={80}
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
} else if (e.key === 'Escape') {
setStatusEditing(false);
setStatusDraft('');
}
}}
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => {
const text = statusDraft.trim();
publishStatus.mutateAsync({ status: text }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: text ? 'Status updated' : 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-primary hover:underline disabled:opacity-50"
>
{publishStatus.isPending ? <Loader2 className="size-3 animate-spin" /> : 'Save'}
</button>
{userStatus.status && (
<button
onClick={() => {
publishStatus.mutateAsync({ status: '' }).then(() => {
setStatusEditing(false);
setStatusDraft('');
toast({ title: 'Status cleared' });
});
}}
disabled={publishStatus.isPending}
className="text-xs font-medium text-destructive hover:underline disabled:opacity-50"
>
Clear
</button>
)}
<button
onClick={() => { setStatusEditing(false); setStatusDraft(''); }}
className="text-xs text-muted-foreground hover:underline ml-auto"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => {
setStatusEditing(true);
setStatusDraft(userStatus.status ?? '');
}}
className="flex items-center gap-3 w-full px-3 py-2.5 text-sm hover:bg-secondary/60 transition-colors"
>
{userStatus.status ? (
<span className="truncate text-muted-foreground italic text-xs pr-1">{userStatus.status}</span>
) : (
<span className="text-muted-foreground">Set a status</span>
)}
</button>
)}
</div>
{otherUsers.map((account) => (
<button
key={account.id}
onClick={() => { setLogin(account.id); handleClose(); }}
className="flex items-center gap-3 w-full px-3 py-2 hover:bg-secondary/60 transition-colors"
>
<Avatar className="size-7 shrink-0">
<AvatarImage src={account.metadata.picture} alt={getDisplayName(account)} />
<AvatarFallback className="bg-primary/20 text-primary text-xs">
{getDisplayName(account).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">
{account.event
? <EmojifiedText tags={account.event.tags}>{getDisplayName(account)}</EmojifiedText>
: getDisplayName(account)}
</span>
{account.metadata.nip05 && (
<VerifiedNip05Text nip05={account.metadata.nip05} pubkey={account.pubkey} className="text-xs text-muted-foreground truncate" />
)}
</div>
</button>
))}
<button
onClick={() => { handleClose(); setFollowQROpen(true); }}
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
>
<QrCode className="size-5 shrink-0" />
<span>Share profile</span>
</button>
<button
onClick={() => { handleClose(); setLoginDialogOpen(true); }}
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-muted-foreground hover:bg-secondary/60 transition-colors"
>
<UserPlus className="size-5 shrink-0" />
<span>Add another account</span>
</button>
<button
onClick={handleLogout}
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-destructive hover:bg-destructive/10 transition-colors"
>
<LogOut className="size-5 shrink-0" />
<span>Log out @{metadata?.name || metadata?.display_name || genUserName(user.pubkey)}</span>
</button>
</div>
)}
{/* Nav items — scrollable */}
<nav
className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-1"
>
<div className="contents">
<SidebarNavList
items={visibleItems}
editing={editing}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={() => handleClose}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing={editing}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
</div>
</nav>
<div className="px-2 safe-area-bottom">
<LinkFooter onNavigate={handleClose} />
</div>
</div>
) : (
<div className="flex flex-col h-full relative">
{/* Login prompt */}
<div
className="flex items-center gap-3 px-4 border-b border-border"
style={{ minHeight: `calc(3rem + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)))`, paddingTop: `var(--safe-area-inset-top, env(safe-area-inset-top, 0px))` }}
>
<LoginArea className="w-full flex" />
</div>
{/* Nav items — scrollable */}
<nav className="flex flex-col gap-0.5 flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-1">
<div className="contents">
<SidebarNavList
items={visibleItems}
editing={false}
onRemove={removeFromSidebar}
onReorder={updateSidebarOrder}
isActive={(id) => isItemActive(id, location.pathname, location.search, userProfileUrl, homePage)}
getOnClick={() => handleClose}
getProfilePath={(id) => id === 'profile' ? userProfileUrl : undefined}
getShowIndicator={(id) => id === 'notifications' ? hasUnread : undefined}
linkClassName="text-base"
homePage={homePage}
/>
<SidebarMoreMenu
editing={false}
hiddenItems={visibleHiddenItems}
onDoneEditing={() => setEditing(false)}
onStartEditing={() => setEditing(true)}
onAdd={addToSidebar}
onAddDivider={addDividerToSidebar}
onNavigate={handleClose}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
homePage={homePage}
/>
</div>
</nav>
<div className="px-2 safe-area-bottom">
<LinkFooter onNavigate={handleClose} />
</div>
</div>
)}
</SheetContent>
</Sheet>
<AuthDialog
isOpen={loginDialogOpen}
onClose={() => setLoginDialogOpen(false)}
/>
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
</>
);
}
-846
View File
@@ -1,846 +0,0 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Search, UserRoundCheck, X, MessageSquare, FileText, Hash, Archive } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useSearchProfiles, type SearchProfile } from '@/hooks/useSearchProfiles';
import { genUserName } from '@/lib/genUserName';
import { useNip05Verify } from '@/hooks/useNip05Verify';
import { isFullUrl, detectIdentifier, type IdentifierMatch } from '@/lib/nostrIdentifier';
import { getProfileUrl } from '@/lib/profileUrl';
import { searchCountry, type CountryEntry } from '@/lib/countries';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useQueryClient } from '@tanstack/react-query';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdmin } from '@/lib/admins';
import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
import { useArchiveSearch, type ArchiveSearchResult } from '@/hooks/useArchiveSearch';
import { searchSidebarItems, type SidebarItemDef } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
interface MobileSearchSheetProps {
open: boolean;
onClose: () => void;
}
export function MobileSearchSheet({ open, onClose }: MobileSearchSheetProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const { user: currentUser } = useCurrentUser();
const { data: rawProfiles, isFetching, followedPubkeys } = useSearchProfiles(query);
// Archive search (async, debounced by the hook at >=2 chars)
const { data: archiveResults } = useArchiveSearch(query);
// Take at most 1 result from each external source
const archiveResult: ArchiveSearchResult | null = archiveResults?.[0] ?? null;
// Country suggestion (local, synchronous)
const countryMatch = useMemo(() => searchCountry(query), [query]);
// Nav item suggestions (local, synchronous) — hide admin-only items from non-admins
const navItems = useMemo(() => searchSidebarItems(query).filter(item => !item.requiresAdmin || isAdmin(currentUser?.pubkey)), [query, currentUser]);
// URL detection — show "Comment on" option when query is a full URL
const queryIsUrl = useMemo(() => isFullUrl(query), [query]);
const hasUrlComment = queryIsUrl;
// Identifier detection — NIP-05, NIP-19, hex
const identifierMatch = useMemo(() => detectIdentifier(query), [query]);
// Resolve NIP-05 identifier pubkey for deduplication
const nip05Identifier = identifierMatch?.type === 'nip05' ? identifierMatch.identifier : undefined;
const { data: nip05Pubkey } = useNip05Resolve(nip05Identifier);
// The pubkey that the identifier item will show (for deduplication)
const identifierPubkey = useMemo(() => {
if (!identifierMatch) return undefined;
if (identifierMatch.type === 'npub' || identifierMatch.type === 'nprofile') return identifierMatch.pubkey;
if (identifierMatch.type === 'nip05' && nip05Pubkey) return nip05Pubkey;
return undefined;
}, [identifierMatch, nip05Pubkey]);
// Filter out the identifier-resolved profile from search results
const profiles = useMemo(() => {
if (!rawProfiles || !identifierPubkey) return rawProfiles;
return rawProfiles.filter((p) => p.pubkey !== identifierPubkey);
}, [rawProfiles, identifierPubkey]);
const profileCount = profiles?.length ?? 0;
const hasCountry = !!countryMatch;
// Show country at top only for exact matches; otherwise at bottom (after profiles)
const countryAtTop = hasCountry && (countryMatch.exact || profileCount === 0);
const hasIdentifier = !!identifierMatch;
const hasArchive = !!archiveResult;
const navItemCount = navItems.length;
const totalItems = navItemCount + profileCount + (hasCountry ? 1 : 0) + (hasUrlComment ? 1 : 0) + (hasIdentifier ? 1 : 0) + (hasArchive ? 1 : 0);
// Order: [...navItems, identifier?, commentUrl?, country?(top), ...profiles, country?(bottom), archive?]
let nextMobileIdx = 0;
const navItemStartIndex = nextMobileIdx;
nextMobileIdx += navItemCount;
const identifierIndex = hasIdentifier ? nextMobileIdx++ : -1;
const urlCommentIndex = hasUrlComment ? nextMobileIdx++ : -1;
const countryTopIndex = (hasCountry && countryAtTop) ? nextMobileIdx++ : -1;
const profileStartIndex = nextMobileIdx;
nextMobileIdx += profileCount;
const countryBottomIndex = (hasCountry && !countryAtTop) ? nextMobileIdx++ : -1;
const countryIndex = countryAtTop ? countryTopIndex : countryBottomIndex;
const archiveIndex = hasArchive ? nextMobileIdx++ : -1;
// Lock body scroll while the search sheet is open.
// overflow:hidden alone is unreliable on mobile Safari, so we also
// block touchmove on the document (except inside the results scroller).
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const preventScroll = (e: TouchEvent) => {
// Allow scrolling inside the results list
const target = e.target as HTMLElement;
if (target.closest?.('[data-mobile-search-results]')) return;
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener('touchmove', preventScroll);
};
}, [open]);
// Focus input when opened
useEffect(() => {
if (open) {
// Small delay to let the animation settle and keyboard to appear
const t = setTimeout(() => inputRef.current?.focus(), 80);
return () => clearTimeout(t);
} else {
setQuery('');
setSelectedIndex(-1);
}
}, [open]);
// Reset selected index when results change
useEffect(() => {
setSelectedIndex(-1);
}, [profiles]);
const handleClose = useCallback(() => {
setQuery('');
onClose();
}, [onClose]);
const handleCommentOnUrl = useCallback(() => {
if (!queryIsUrl) return;
handleClose();
navigate(`/i/${encodeURIComponent(query.trim())}`);
}, [queryIsUrl, query, navigate, handleClose]);
const handleSelectCountry = useCallback((country: CountryEntry) => {
handleClose();
navigate(`/i/iso3166:${country.code}`);
}, [navigate, handleClose]);
const handleSelectIdentifier = useCallback((path: string) => {
handleClose();
navigate(path);
}, [navigate, handleClose]);
const handleSelectNavItem = useCallback((item: SidebarItemDef) => {
handleClose();
navigate(item.path);
}, [navigate, handleClose]);
const handleSelectArchive = useCallback((result: ArchiveSearchResult) => {
handleClose();
navigate(`/i/${encodeURIComponent(`https://archive.org/details/${result.identifier}`)}`);
}, [navigate, handleClose]);
const handleSelect = useCallback((profile: SearchProfile) => {
const nip05 = profile.metadata.nip05;
const nip05Verified = !!nip05 && queryClient.getQueryData<boolean>(['nip05-verify', nip05, profile.pubkey]) === true;
const profileUrl = getProfileUrl(profile.pubkey, profile.metadata, nip05Verified);
handleClose();
navigate(profileUrl);
}, [navigate, handleClose, queryClient]);
const handleTextSearch = useCallback(() => {
if (!query.trim()) return;
handleClose();
navigate(`/search?q=${encodeURIComponent(query.trim())}`);
}, [query, navigate, handleClose]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleClose();
return;
}
if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && selectedIndex < totalItems) {
if (navItemCount > 0 && selectedIndex >= navItemStartIndex && selectedIndex < navItemStartIndex + navItemCount) {
handleSelectNavItem(navItems[selectedIndex - navItemStartIndex]);
} else if (hasIdentifier && selectedIndex === identifierIndex) {
// Identifier item navigation path is determined by the component
// Trigger via its onClick handler
const sheet = document.querySelector('[data-mobile-search-results]');
const items = sheet?.querySelectorAll('[data-search-item]');
(items?.[selectedIndex] as HTMLElement)?.click();
} else if (hasUrlComment && selectedIndex === urlCommentIndex) {
handleCommentOnUrl();
} else if (hasCountry && selectedIndex === countryIndex) {
handleSelectCountry(countryMatch!.country);
} else if (hasArchive && selectedIndex === archiveIndex) {
handleSelectArchive(archiveResult!);
} else {
handleSelect(profiles![selectedIndex - profileStartIndex]);
}
} else {
handleTextSearch();
}
return;
}
if (totalItems === 0) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
}
};
const hasResults = query.trim().length > 0 && (navItemCount > 0 || hasIdentifier || hasUrlComment || hasCountry || hasArchive || (profiles && profiles.length > 0));
if (!open) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40 bg-black/60 sidebar:hidden animate-in fade-in-0 duration-150"
onClick={handleClose}
/>
{/* Bottom sheet — sits at the bottom of the screen with safe area clearance */}
<div className="fixed left-0 right-0 bottom-0 z-[49] sidebar:hidden animate-in slide-in-from-bottom-4 duration-200 pb-6">
{/* Results list — reversed so closest to input = most relevant */}
{hasResults && (
<div data-mobile-search-results className="flex flex-col-reverse bg-popover/95 rounded-2xl mx-6 mb-0.5 overflow-hidden max-h-[55vh] overflow-y-auto shadow-lg">
{navItems.map((item, index) => (
<MobileNavItem
key={item.id}
item={item}
isSelected={index + navItemStartIndex === selectedIndex}
onClick={handleSelectNavItem}
/>
))}
{hasIdentifier && (
<MobileIdentifierItem
match={identifierMatch!}
isSelected={selectedIndex === identifierIndex}
onNavigate={handleSelectIdentifier}
/>
)}
{hasUrlComment && (
<MobileCommentOnUrlItem
url={query.trim()}
isSelected={selectedIndex === urlCommentIndex}
onClick={handleCommentOnUrl}
/>
)}
{hasCountry && countryAtTop && (
<SearchCountryItem
country={countryMatch!.country}
isSelected={selectedIndex === countryIndex}
onClick={handleSelectCountry}
/>
)}
{profiles && profiles.map((profile, index) => (
<SearchProfileItem
key={profile.pubkey}
profile={profile}
isSelected={index + profileStartIndex === selectedIndex}
isFollowed={followedPubkeys.has(profile.pubkey)}
onClick={handleSelect}
/>
))}
{hasCountry && !countryAtTop && (
<SearchCountryItem
country={countryMatch!.country}
isSelected={selectedIndex === countryIndex}
onClick={handleSelectCountry}
/>
)}
{hasArchive && (
<MobileArchiveItem
result={archiveResult!}
isSelected={selectedIndex === archiveIndex}
onClick={handleSelectArchive}
/>
)}
</div>
)}
{/* Input bar */}
<div className="flex items-center px-6 py-3 safe-area-bottom">
<div className="flex items-center gap-2 flex-1 bg-secondary rounded-full px-4 py-2.5">
{isFetching ? (
<svg
className="size-4 shrink-0 text-muted-foreground"
style={{ animation: 'spin 1s linear infinite' }}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
) : (
<Search strokeWidth={4} className="size-4 shrink-0 text-muted-foreground" />
)}
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search people or topics..."
className="flex-1 bg-transparent text-base outline-none placeholder:text-muted-foreground"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
/>
<button
onClick={handleClose}
className="size-5 shrink-0 flex items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80 transition-colors"
>
<X strokeWidth={4} className="size-3" />
</button>
</div>
</div>
</div>
</>
);
}
function MobileNavItem({
item,
isSelected,
onClick,
}: {
item: SidebarItemDef;
isSelected: boolean;
onClick: (item: SidebarItemDef) => void;
}) {
const Icon = item.icon;
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(item)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="size-3.5 text-primary" />
</div>
<span className="font-semibold text-sm truncate">{item.label}</span>
</button>
);
}
/**
* Mobile autocomplete item for a detected Nostr identifier.
*/
function MobileIdentifierItem({
match,
isSelected,
onNavigate,
}: {
match: IdentifierMatch;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
switch (match.type) {
case 'nip05':
return <MobileNip05Item identifier={match.identifier} isSelected={isSelected} onNavigate={onNavigate} />;
case 'npub':
case 'nprofile':
return <MobilePubkeyItem pubkey={match.pubkey} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'note':
return <MobileEventItem eventId={match.eventId} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'nevent':
return <MobileEventItem eventId={match.eventId} relays={match.relays} authorHint={match.authorHint} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'naddr':
return <MobileAddrItem addr={match.addr} relays={match.relays} raw={match.raw} isSelected={isSelected} onNavigate={onNavigate} />;
case 'hex':
return <MobileHexItem hex={match.hex} isSelected={isSelected} onNavigate={onNavigate} />;
}
}
function MobileNip05Item({
identifier,
isSelected,
onNavigate,
}: {
identifier: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: pubkey, isLoading } = useNip05Resolve(identifier);
const author = useAuthor(pubkey ?? undefined);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || (pubkey ? genUserName(pubkey) : identifier);
const tags = author.data?.event?.tags ?? [];
if (isLoading) {
return (
<div data-search-item className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : '',
)}>
<div className="size-9 shrink-0 rounded-full bg-secondary animate-pulse" />
<div className="flex-1 min-w-0 space-y-1">
<div className="h-4 w-24 bg-secondary animate-pulse rounded" />
<div className="h-3 w-32 bg-secondary animate-pulse rounded" />
</div>
</div>
);
}
if (!pubkey) return null;
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onNavigate(`/${identifier}`)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar className="size-9 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate block">
<EmojifiedText tags={tags}>{displayName}</EmojifiedText>
</span>
<span className="text-xs text-muted-foreground truncate block">{identifier}</span>
</div>
</button>
);
}
function MobilePubkeyItem({
pubkey,
raw,
isSelected,
onNavigate,
}: {
pubkey: string;
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
const tags = author.data?.event?.tags ?? [];
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<Avatar className="size-9 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate block">
{author.isLoading ? (
<span className="text-muted-foreground">Loading profile...</span>
) : (
<EmojifiedText tags={tags}>{displayName}</EmojifiedText>
)}
</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{raw.slice(0, 8)}...{raw.slice(-4)}
</span>
</div>
</button>
);
}
function MobileEventItem({
eventId,
relays,
authorHint,
raw,
isSelected,
onNavigate,
}: {
eventId: string;
relays?: string[];
authorHint?: string;
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: event, isLoading } = useEvent(eventId, relays, authorHint);
const author = useAuthor(event?.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || (event ? genUserName(event.pubkey) : undefined);
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<FileText className="size-3.5 text-primary" />
</div>
<div className="flex-1 min-w-0">
{isLoading ? (
<span className="text-sm text-muted-foreground">Loading event...</span>
) : event ? (
<>
<span className="text-sm truncate block">{event.content.slice(0, 80) || `Kind ${event.kind} event`}</span>
<span className="text-xs text-muted-foreground truncate block">
{displayName ? `by ${displayName}` : raw.slice(0, 8) + '...' + raw.slice(-4)}
</span>
</>
) : (
<>
<span className="text-sm font-medium truncate block">Go to event</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{raw.slice(0, 8)}...{raw.slice(-4)}
</span>
</>
)}
</div>
</button>
);
}
function MobileAddrItem({
addr,
relays,
raw,
isSelected,
onNavigate,
}: {
addr: AddrCoords;
relays?: string[];
raw: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
const { data: event, isLoading } = useAddrEvent(addr, relays);
const author = useAuthor(event?.pubkey ?? addr.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(addr.pubkey);
const title = event?.tags.find(([t]) => t === 'title')?.[1];
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onNavigate(`/${raw}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<FileText className="size-3.5 text-primary" />
</div>
<div className="flex-1 min-w-0">
{isLoading ? (
<span className="text-sm text-muted-foreground">Loading...</span>
) : (
<>
<span className="text-sm truncate block">
{title || event?.content.slice(0, 80) || `Kind ${addr.kind} event`}
</span>
<span className="text-xs text-muted-foreground truncate block">
by {displayName}
</span>
</>
)}
</div>
</button>
);
}
function MobileHexItem({
hex,
isSelected,
onNavigate,
}: {
hex: string;
isSelected: boolean;
onNavigate: (path: string) => void;
}) {
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onNavigate(`/${hex}`)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-lg bg-primary/10 flex items-center justify-center">
<Hash className="size-3.5 text-primary" />
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">Go to identifier</span>
<span className="text-xs text-muted-foreground truncate block font-mono">
{hex.slice(0, 8)}...{hex.slice(-4)}
</span>
</div>
</button>
);
}
function SearchCountryItem({
country,
isSelected,
onClick,
}: {
country: CountryEntry;
isSelected: boolean;
onClick: (country: CountryEntry) => void;
}) {
return (
<button
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(country)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-full bg-secondary flex items-center justify-center">
<span className="text-lg leading-none" role="img" aria-label={`Flag of ${country.name}`}>
{country.flag}
</span>
</div>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate">{country.name}</span>
<div className="text-xs text-muted-foreground">{country.code}</div>
</div>
</button>
);
}
function MobileCommentOnUrlItem({
url,
isSelected,
onClick,
}: {
url: string;
isSelected: boolean;
onClick: () => void;
}) {
const { data: preview } = useLinkPreview(url);
const thumbnailUrl = preview?.thumbnail_url;
return (
<button
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={onClick}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-lg overflow-hidden bg-primary/10 flex items-center justify-center">
{thumbnailUrl ? (
<img
src={thumbnailUrl}
alt=""
className="size-9 object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
(e.currentTarget.nextElementSibling as HTMLElement).style.display = 'flex';
}}
/>
) : null}
<div
className={cn('items-center justify-center size-9', thumbnailUrl ? 'hidden' : 'flex')}
>
<ExternalFavicon url={url} size={16} fallback={<MessageSquare className="size-3.5 text-primary" />} />
</div>
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
{preview?.title ?? 'Comment on this link'}
</span>
<span className="text-xs text-muted-foreground truncate block">
{(() => {
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; }
})()}
</span>
</div>
</button>
);
}
function SearchProfileItem({
profile,
isSelected,
isFollowed,
onClick,
}: {
profile: SearchProfile;
isSelected: boolean;
isFollowed: boolean;
onClick: (profile: SearchProfile) => void;
}) {
const { metadata, pubkey } = profile;
const displayName = metadata.name || metadata.display_name || genUserName(pubkey);
const nip05 = metadata.nip05;
const { data: nip05Verified } = useNip05Verify(nip05, pubkey);
const nip05Display = nip05Verified && nip05 ? (nip05.startsWith('_@') ? nip05.slice(2) : nip05) : undefined;
const identifier = nip05Display || nip19.npubEncode(pubkey);
return (
<button
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(profile)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="relative shrink-0">
<Avatar className="size-9">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
{isFollowed && (
<span className="absolute -bottom-0.5 -right-0.5 size-4 rounded-full bg-primary flex items-center justify-center ring-2 ring-popover">
<UserRoundCheck className="size-2.5 text-primary-foreground" strokeWidth={3} />
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-sm truncate">
<EmojifiedText tags={profile.event.tags}>{displayName}</EmojifiedText>
</span>
{metadata.bot && <span className="text-xs text-primary">🤖</span>}
</div>
<div className="text-xs text-muted-foreground truncate">
{nip05Display
? <span>{identifier}</span>
: <span className="font-mono text-[11px]">{identifier}</span>
}
</div>
</div>
</button>
);
}
function MobileArchiveItem({
result,
isSelected,
onClick,
}: {
result: ArchiveSearchResult;
isSelected: boolean;
onClick: (result: ArchiveSearchResult) => void;
}) {
const thumbnail = `https://archive.org/services/img/${result.identifier}`;
return (
<button
data-search-item
role="option"
aria-selected={isSelected}
className={cn(
'w-full flex items-center gap-3 px-4 py-3 text-left transition-colors',
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-secondary/60',
)}
onClick={() => onClick(result)}
onMouseDown={(e) => e.preventDefault()}
>
<div className="size-9 shrink-0 rounded-full bg-secondary flex items-center justify-center overflow-hidden">
<img
src={thumbnail}
alt=""
className="size-9 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
(e.currentTarget.nextElementSibling as HTMLElement).style.display = 'flex';
}}
/>
<div className="hidden items-center justify-center size-9">
<Archive className="size-3.5 text-muted-foreground" />
</div>
</div>
<div className="flex-1 min-w-0">
<span className="font-semibold text-sm truncate block">{result.title}</span>
<div className="text-xs text-muted-foreground truncate">Internet Archive</div>
</div>
</button>
);
}
-72
View File
@@ -1,72 +0,0 @@
import { useCallback } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { BarsStaggeredIcon } from '@/components/icons/BarsStaggeredIcon';
import { AgoraBoltIcon } from '@/components/icons/AgoraBoltIcon';
import { useNavHidden } from '@/contexts/LayoutContext';
const SAFE_AREA_TOP_HEIGHT = 'var(--safe-area-inset-top, env(safe-area-inset-top, 0px))';
const HIDDEN_TOP_BAR_TRANSFORM = 'translateY(calc(-100% - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))))';
const TRANSLUCENT_HEADER_STYLE: React.CSSProperties = { backgroundColor: 'hsl(var(--background) / 0.85)' };
interface MobileTopBarProps {
onAvatarClick: () => void;
/** When true, a SubHeaderBar with an arc follows immediately below — skip the arc here to avoid doubling up. */
hasSubHeader?: boolean;
}
export function MobileTopBar({ onAvatarClick, hasSubHeader: _hasSubHeader }: MobileTopBarProps) {
const location = useLocation();
const navHidden = useNavHidden();
const handleLogoClick = useCallback((e: React.MouseEvent) => {
if (location.pathname === '/') {
e.preventDefault();
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [location.pathname]);
return (
<>
{/* Keep the top unsafe region covered even when the top bar slides away. */}
{navHidden && (
<div
aria-hidden="true"
className="pointer-events-none fixed left-0 right-0 top-0 z-20 sidebar:hidden"
style={{ ...TRANSLUCENT_HEADER_STYLE, height: SAFE_AREA_TOP_HEIGHT }}
/>
)}
<header
className="sticky top-0 z-20 sidebar:hidden transition-transform duration-300 ease-in-out"
style={navHidden ? { ...TRANSLUCENT_HEADER_STYLE, transform: HIDDEN_TOP_BAR_TRANSFORM } : TRANSLUCENT_HEADER_STYLE}
>
<div className="relative safe-area-top">
<div className="flex items-center px-3 h-10">
{/* Left: hamburger menu icon */}
<div className="flex items-center justify-center w-7 shrink-0">
<button onClick={onAvatarClick} className="rounded-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:ring-offset-background text-muted-foreground hover:text-foreground transition-colors">
<BarsStaggeredIcon className="size-5" />
</button>
</div>
{/* Center: Agora lockup */}
<div className="flex-1 flex items-center justify-center">
<Link to="/" onClick={handleLogoClick} className="flex items-center gap-2">
<AgoraBoltIcon className="size-7 drop-shadow-sm" />
<div className="flex flex-col leading-none">
<span className="text-sm font-black tracking-tight text-foreground">ÁGORA</span>
<span className="text-[7px] uppercase tracking-wider text-muted-foreground font-semibold">
Power to the people
</span>
</div>
</Link>
</div>
{/* Right: spacer for symmetry */}
<div className="w-7 shrink-0" />
</div>
</div>
</header>
</>
);
}
-451
View File
@@ -1,451 +0,0 @@
import { useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { NostrEvent, NostrFilter, NostrSigner } 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 { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useToast } from '@/hooks/useToast';
import { setCachedMuteItems, parseMuteTags, type MuteListItem } from '@/hooks/useMuteList';
import { cn } from '@/lib/utils';
import { Check, Loader2, RotateCcw, ShieldOff, UserX, Hash, MessageSquareOff, AlertTriangle } 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<NostrEvent[]> {
const events: NostrEvent[] = [];
const seen = new Set<string>();
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 MuteListRecoveryDialogProps {
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',
});
}
/**
* Detect whether encrypted content uses NIP-04 (legacy) or NIP-44 encoding.
*/
function isNip04Encrypted(content: string): boolean {
return content.includes('?iv=');
}
/**
* Decrypt encrypted content from a kind 10000 event, handling both NIP-44 and
* legacy NIP-04 formats for backward compatibility per NIP-51.
*/
async function decryptContent(
content: string,
signer: NostrSigner,
pubkey: string,
): Promise<string | null> {
if (!content) return null;
try {
if (isNip04Encrypted(content)) {
if (signer.nip04) {
return await signer.nip04.decrypt(pubkey, content);
}
return null;
} else {
if (signer.nip44) {
return await signer.nip44.decrypt(pubkey, content);
}
return null;
}
} catch {
return null;
}
}
/** Summary of mute items parsed from a snapshot. */
interface MuteSummary {
items: MuteListItem[];
pubkeys: number;
hashtags: number;
words: number;
threads: number;
total: number;
decryptionFailed: boolean;
}
/**
* Parse all mute items from a kind 10000 event, combining public tags
* and encrypted (private) content.
*/
async function parseMuteSnapshot(
event: NostrEvent,
signer: NostrSigner,
pubkey: string,
): Promise<MuteSummary> {
const publicItems = parseMuteTags(event.tags);
let privateItems: MuteListItem[] = [];
let decryptionFailed = false;
if (event.content) {
const decrypted = await decryptContent(event.content, signer, pubkey);
if (decrypted) {
try {
const tags = JSON.parse(decrypted) as string[][];
privateItems = parseMuteTags(tags);
} catch {
decryptionFailed = true;
}
} else {
decryptionFailed = true;
}
}
// Deduplicate
const seen = new Set<string>();
const combined: MuteListItem[] = [];
for (const item of [...publicItems, ...privateItems]) {
const key = `${item.type}:${item.value}`;
if (!seen.has(key)) {
seen.add(key);
combined.push(item);
}
}
return {
items: combined,
pubkeys: combined.filter((i) => i.type === 'pubkey').length,
hashtags: combined.filter((i) => i.type === 'hashtag').length,
words: combined.filter((i) => i.type === 'word').length,
threads: combined.filter((i) => i.type === 'thread').length,
total: combined.length,
decryptionFailed,
};
}
// ─── Mute Snapshot Card ───────────────────────────────────────────────
function MuteSnapshotCard({
summary,
event,
isCurrent,
onRestore,
isRestoring,
}: {
summary: MuteSummary;
event: NostrEvent;
isCurrent: boolean;
onRestore: () => void;
isRestoring: boolean;
}) {
const parts: string[] = [];
if (summary.pubkeys > 0) parts.push(`${summary.pubkeys} ${summary.pubkeys === 1 ? 'user' : 'users'}`);
if (summary.hashtags > 0) parts.push(`${summary.hashtags} ${summary.hashtags === 1 ? 'hashtag' : 'hashtags'}`);
if (summary.words > 0) parts.push(`${summary.words} ${summary.words === 1 ? 'word' : 'words'}`);
if (summary.threads > 0) parts.push(`${summary.threads} ${summary.threads === 1 ? 'thread' : 'threads'}`);
return (
<div
className={cn(
'group relative rounded-xl border p-4 transition-all',
isCurrent
? 'border-primary/40 bg-primary/5'
: 'border-border hover:border-primary/20 hover:bg-secondary/30',
)}
>
{isCurrent && (
<div className="absolute top-3 right-3 flex items-center gap-1 text-xs font-medium text-primary">
<Check className="size-3.5" />
Current
</div>
)}
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-11 shrink-0 rounded-full bg-primary/10">
<ShieldOff className="size-5 text-primary" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="font-semibold text-sm">
{summary.total.toLocaleString()} {summary.total === 1 ? 'muted item' : 'muted items'}
</div>
{parts.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{summary.pubkeys > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<UserX className="size-3" />
{summary.pubkeys}
</span>
)}
{summary.hashtags > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Hash className="size-3" />
{summary.hashtags}
</span>
)}
{summary.words > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<MessageSquareOff className="size-3" />
{summary.words}
</span>
)}
{summary.threads > 0 && (
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<MessageSquareOff className="size-3" />
{summary.threads} threads
</span>
)}
</div>
)}
{summary.decryptionFailed && (
<div className="flex items-center gap-1 text-[11px] text-amber-500">
<AlertTriangle className="size-3" />
Could not decrypt private items
</div>
)}
<div className="text-[11px] text-muted-foreground/70 pt-0.5">
{formatDate(event.created_at)}
</div>
</div>
</div>
{!isCurrent && (
<div className="mt-3 flex justify-end">
<Button
variant="outline"
size="sm"
className="h-8 text-xs rounded-lg gap-1.5"
onClick={onRestore}
disabled={isRestoring}
>
{isRestoring ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<RotateCcw className="size-3.5" />
)}
Restore
</Button>
</div>
)}
</div>
);
}
// ─── Empty State ──────────────────────────────────────────────────────
function EmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
No mute list history found. Your relay may not store historical events.
</p>
</div>
);
}
// ─── Loading Skeleton ─────────────────────────────────────────────────
function SnapshotSkeleton() {
return (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-xl border p-4 space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-20" />
</div>
</div>
</div>
))}
</div>
);
}
// ─── Mute History Content ─────────────────────────────────────────────
function MuteHistoryContent({ onClose }: { onClose: () => void }) {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const { config } = useAppContext();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
const [restoringId, setRestoringId] = useState<string | null>(null);
const pubkey = user?.pubkey;
// Fetch all historical kind 10000 events
const muteHistory = useQuery<NostrEvent[]>({
queryKey: ['mute-recovery', 'kind10000', pubkey],
queryFn: async () => {
if (!pubkey) return [];
const events = await queryAllEvents(
nostr,
[{ kinds: [10000], authors: [pubkey] }],
AbortSignal.timeout(10000),
);
return events.sort((a, b) => b.created_at - a.created_at);
},
enabled: !!pubkey,
staleTime: 30_000,
});
// Decrypt and parse all snapshots
const parsedSnapshots = useQuery<Map<string, MuteSummary>>({
queryKey: ['mute-recovery', 'parsed', pubkey, muteHistory.data?.map((e) => e.id).join(',')],
queryFn: async () => {
if (!user || !muteHistory.data) return new Map();
const results = new Map<string, MuteSummary>();
// Parse all snapshots in parallel
const entries = await Promise.all(
muteHistory.data.map(async (event) => {
const summary = await parseMuteSnapshot(event, user.signer, user.pubkey);
return [event.id, summary] as const;
}),
);
for (const [id, summary] of entries) {
results.set(id, summary);
}
return results;
},
enabled: !!user && !!muteHistory.data && muteHistory.data.length > 0,
});
const muteEvents = muteHistory.data ?? [];
const currentMuteId = muteEvents[0]?.id;
const summaries = parsedSnapshots.data;
const handleRestore = async (event: NostrEvent) => {
setRestoringId(event.id);
try {
// Re-publish the old event's content and tags with the current timestamp.
// The content is already encrypted, so we just re-publish as-is.
await publishEvent({
kind: event.kind,
content: event.content,
tags: event.tags,
created_at: Math.floor(Date.now() / 1000),
});
// Update the local mute cache with the restored items
const summary = summaries?.get(event.id);
if (summary && user) {
setCachedMuteItems(config.appId, user.pubkey, summary.items);
}
toast({
title: 'Mute list restored',
description: `Successfully restored from ${formatDate(event.created_at)}.`,
});
queryClient.invalidateQueries({ queryKey: ['mute-recovery', 'kind10000', pubkey] });
queryClient.invalidateQueries({ queryKey: ['muteList', pubkey] });
queryClient.invalidateQueries({ queryKey: ['muteItems'] });
onClose();
} catch (error) {
console.error('Failed to restore mute list:', error);
toast({
title: 'Restore failed',
description: 'Could not republish the mute list. Please try again.',
variant: 'destructive',
});
} finally {
setRestoringId(null);
}
};
if (muteHistory.isLoading || (muteHistory.data && muteHistory.data.length > 0 && parsedSnapshots.isLoading)) {
return <SnapshotSkeleton />;
}
if (muteEvents.length === 0) {
return <EmptyState />;
}
return (
<>
{muteEvents.map((event) => {
const summary = summaries?.get(event.id);
if (!summary) return null;
return (
<MuteSnapshotCard
key={event.id}
event={event}
summary={summary}
isCurrent={event.id === currentMuteId}
onRestore={() => handleRestore(event)}
isRestoring={restoringId === event.id}
/>
);
})}
</>
);
}
// ─── Main Dialog ──────────────────────────────────────────────────────
export function MuteListRecoveryDialog({ open, onOpenChange }: MuteListRecoveryDialogProps) {
const close = () => onOpenChange(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 rounded-2xl overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="text-lg font-bold">Mute List Recovery</DialogTitle>
<p className="text-sm text-muted-foreground mt-1">
Browse and restore previous versions of your mute list.
</p>
</DialogHeader>
<ScrollArea className="h-[420px]">
<div className="p-4 space-y-3">
<MuteHistoryContent onClose={close} />
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
-209
View File
@@ -1,209 +0,0 @@
import { Link } from 'react-router-dom';
import { GripVertical, X, FileText, Scroll } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { nip19 } from 'nostr-tools';
import type { NostrMetadata } from '@nostrify/nostrify';
import type { ComponentType } from 'react';
import { cn } from '@/lib/utils';
import { nostrUriToNip19 } from '@/lib/sidebarItems';
import { useAuthor } from '@/hooks/useAuthor';
import { getKindIcon } from '@/lib/extraKinds';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { genUserName } from '@/lib/genUserName';
import { Skeleton } from '@/components/ui/skeleton';
import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
/**
* Icons for well-known kinds that aren't in EXTRA_KINDS.
* Used as a fallback when getKindIcon() returns undefined.
*/
const KNOWN_KIND_ICONS: Record<number, ComponentType<{ className?: string }>> = {
30000: Scroll, // NIP-51 lists
};
// ── Types ─────────────────────────────────────────────────────────────────────
export interface NostrEventSidebarItemProps {
/** The full nostr: URI, e.g. "nostr:npub1..." */
id: string;
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
}
// ── Profile sidebar item ──────────────────────────────────────────────────────
function ProfileSidebarIcon({ pubkey, className }: { pubkey: string; className?: string }) {
const { data } = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = data?.metadata;
return (
<Avatar className={cn('size-6 shrink-0', className)}>
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{(metadata?.name?.[0] || '?').toUpperCase()}
</AvatarFallback>
</Avatar>
);
}
function ProfileSidebarLabel({ pubkey }: { pubkey: string }) {
const { data, isLoading } = useAuthor(pubkey);
const metadata: NostrMetadata | undefined = data?.metadata;
if (isLoading && !metadata) {
return <Skeleton className="h-4 w-20" />;
}
return (
<span className="truncate">
{metadata?.name || metadata?.display_name || genUserName(pubkey)}
</span>
);
}
// ── Event sidebar item (non-profile) ──────────────────────────────────────────
function EventSidebarIcon({ kind, className }: { kind: number; className?: string }) {
const Icon = getKindIcon(kind) ?? KNOWN_KIND_ICONS[kind] ?? FileText;
return <Icon className={cn('size-6', className)} />;
}
interface EventSidebarLabelProps {
decoded: DecodedNostrId;
}
function EventSidebarLabel({ decoded }: EventSidebarLabelProps) {
const params = decoded.type === 'naddr' && decoded.identifier !== undefined
? { addr: { kind: decoded.kind!, pubkey: decoded.pubkey, identifier: decoded.identifier } }
: { eventId: decoded.eventId };
const { data, isLoading } = useNostrEventSidebar(params);
if (isLoading && !data) {
return <Skeleton className="h-4 w-20" />;
}
return (
<span className="truncate">
{data?.label ?? 'Event'}
</span>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function NostrEventSidebarItem({
id, active, editing, onRemove, onClick, linkClassName,
}: NostrEventSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const nip19Id = nostrUriToNip19(id);
const decoded = decodeNostrId(nip19Id);
if (!decoded) {
// Invalid nostr URI — render nothing
return null;
}
const path = `/${nip19Id}`;
const isProfile = decoded.type === 'npub' || decoded.type === 'nprofile';
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<Link
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0">
{isProfile ? (
<ProfileSidebarIcon pubkey={decoded.pubkey} />
) : (
<EventSidebarIcon kind={decoded.kind ?? 1} />
)}
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
{isProfile ? (
<ProfileSidebarLabel pubkey={decoded.pubkey} />
) : (
<EventSidebarLabel decoded={decoded} />
)}
</span>
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
)}
</div>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
interface DecodedNostrId {
type: 'npub' | 'nprofile' | 'note' | 'nevent' | 'naddr';
pubkey: string;
eventId?: string;
kind?: number;
identifier?: string;
relays?: string[];
}
function decodeNostrId(nip19Id: string): DecodedNostrId | null {
try {
const decoded = nip19.decode(nip19Id);
switch (decoded.type) {
case 'npub':
return { type: 'npub', pubkey: decoded.data as string };
case 'nprofile': {
const data = decoded.data as { pubkey: string; relays?: string[] };
return { type: 'nprofile', pubkey: data.pubkey, relays: data.relays };
}
case 'note':
return { type: 'note', pubkey: '', eventId: decoded.data as string, kind: 1 };
case 'nevent': {
const data = decoded.data as { id: string; relays?: string[]; author?: string; kind?: number };
return { type: 'nevent', pubkey: data.author ?? '', eventId: data.id, kind: data.kind, relays: data.relays };
}
case 'naddr': {
const data = decoded.data as { pubkey: string; kind: number; identifier: string; relays?: string[] };
return { type: 'naddr', pubkey: data.pubkey, kind: data.kind, identifier: data.identifier, relays: data.relays };
}
default:
return null;
}
} catch {
return null;
}
}
-154
View File
@@ -1,154 +0,0 @@
import { useNavigate } from 'react-router-dom';
import { GripVertical, Rocket, X } from 'lucide-react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { nip19 } from 'nostr-tools';
import { useCallback, useMemo } from 'react';
import { cn } from '@/lib/utils';
import { nsiteUriToSubdomain } from '@/lib/sidebarItems';
import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { useNsitePlayer } from '@/contexts/NsitePlayerContext';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
import { Skeleton } from '@/components/ui/skeleton';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface NsiteSidebarItemProps {
/** The full nsite:// URI, e.g. "nsite://3cbg51pm00nms2dp8rm..." */
id: string;
/** Ignored -- active state is derived from NsitePlayerContext instead. Kept for caller consistency with other sidebar item types. */
active?: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onClick?: (e: React.MouseEvent) => void;
/** Extra classes on the link. */
linkClassName?: string;
}
// ── Label sub-component ───────────────────────────────────────────────────────
function NsiteSidebarLabel({ subdomain, parsed }: { subdomain: string; parsed: ReturnType<typeof parseNsiteSubdomain> }) {
const siteUrl = `https://${subdomain}.nsite.lol`;
const { data: preview } = useLinkPreview(siteUrl);
const addr = parsed && parsed.kind === 35128
? { kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier }
: undefined;
const { data: eventData, isLoading } = useNostrEventSidebar({ addr });
if (isLoading && !eventData && !preview) {
return <Skeleton className="h-4 w-20" />;
}
// Prefer the link preview title (the live site <title>), then the event tag label
const label = preview?.title || eventData?.label || 'Nsite';
return (
<span className="truncate">
{label}
</span>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function NsiteSidebarItem({
id, editing, onRemove, onClick, linkClassName,
}: NsiteSidebarItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const navigate = useNavigate();
const subdomain = nsiteUriToSubdomain(id);
const parsed = useMemo(() => parseNsiteSubdomain(subdomain), [subdomain]);
// Highlight when the nsite player is open for this subdomain.
const { activeSubdomain } = useNsitePlayer();
const active = activeSubdomain === subdomain;
// Build the naddr path for navigation. For named sites (35128), encode as naddr.
// For root sites (15128), we'd need a nevent which requires the event ID — fall back to null.
const naddrPath = useMemo(() => {
if (!parsed) return null;
if (parsed.kind === 35128) {
const naddr = nip19.naddrEncode({
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier,
});
return `/${naddr}`;
}
// Root site (15128) — we can't construct an naddr without a d-tag,
// and nevent requires event ID. For now, root site nsite:// URIs are not supported.
return null;
}, [parsed]);
// Navigate with a fresh timestamp on every click so the detail page
// can detect repeated clicks and re-open the player.
const handleClick = useCallback((e: React.MouseEvent) => {
onClick?.(e);
if (e.defaultPrevented || !naddrPath) return;
e.preventDefault();
navigate(naddrPath, { state: { nsiteAutoPlay: true, nsiteAutoPlayTs: Date.now() } });
}, [naddrPath, navigate, onClick]);
if (!parsed || !naddrPath) {
// Invalid or unsupported nsite URI — render nothing
return null;
}
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<a
href={naddrPath}
onClick={handleClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0">
<ExternalFavicon
url={`https://${subdomain}.nsite.lol`}
size={20}
fallback={<Rocket className="size-5" />}
className="size-6 flex items-center justify-center"
/>
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
<NsiteSidebarLabel subdomain={subdomain} parsed={parsed} />
</span>
</a>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove"
>
<X className="size-4" />
</button>
)}
</div>
);
}
-705
View File
@@ -1,705 +0,0 @@
/**
* PeopleListDetailContent
*
* Unified full-page detail view for all "people list" event kinds:
* - Kind 3 (NIP-02 follow list)
* - Kind 30000 (NIP-51 follow set)
* - Kind 39089 (follow pack / starter pack)
*
* Renders a hero image, author row, title + description, action row (Follow All,
* Save, Share, Add-to-sidebar, etc.), and tabs for Feed and Members.
*
* Owner-mode features (remove members, add members) are enabled automatically
* when the current user owns a kind 30000 list.
*/
import { useEffect, useMemo, useState, useCallback } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useInView } from 'react-intersection-observer';
import {
Users,
UserPlus,
Loader2,
Copy,
X,
MessageCircle,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import type { NostrEvent, NostrFilter, NostrMetadata } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarShape } from '@/lib/avatarShape';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteCard } from '@/components/NoteCard';
import { TabButton } from '@/components/TabButton';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { AddMembersDialog } from '@/components/AddMembersDialog';
import { ComposeBox } from '@/components/ComposeBox';
import { FlatThreadedReplyList } from '@/components/ThreadedReplyList';
import { PostActionBar } from '@/components/PostActionBar';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { FollowAllSplitButton } from '@/components/FollowAllSplitButton';
import { useToast } from '@/hooks/useToast';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthors } from '@/hooks/useAuthors';
import { useComments } from '@/hooks/useComments';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
import { useTabFeed } from '@/hooks/useProfileFeed';
import { useMuteList } from '@/hooks/useMuteList';
import { useUserLists } from '@/hooks/useUserLists';
import { isEventMuted } from '@/lib/muteHelpers';
import { shouldHideFeedEvent } from '@/lib/feedUtils';
import { genUserName } from '@/lib/genUserName';
import { isReplyEvent } from '@/lib/nostrEvents';
import { getDisplayPubkeys, parsePeopleList } from '@/lib/packUtils';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
type Tab = 'feed' | 'members' | 'comments';
// ─── Feed Tab ─────────────────────────────────────────────────────────────────
/**
* Paginated feed of posts from a list of member pubkeys.
*
* Uses `useTabFeed` (TanStack Query-backed infinite scroll) plus an
* IntersectionObserver sentinel for infinite scroll. Filters kind 1 posts
* (excluding replies) and kinds 6/16 reposts from the given authors.
*
* @param tabKey - A stable cache namespace, typically the list's naddr.
*/
export function PeopleListFeedTab({ pubkeys, tabKey }: { pubkeys: string[]; tabKey: string }) {
const { muteItems } = useMuteList();
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
// Build the TabFeed filter. Scope to kind 1 posts + kind 6/16 reposts so the
// feed behaves like a normal timeline of people's posts (not their follow
// sets, emoji packs, etc.). Replies are filtered out below in the render
// step since the relay doesn't expose a "no-replies" filter.
const filter = useMemo<NostrFilter | null>(
() => (pubkeys.length > 0 ? { kinds: [1, 6, 16], authors: pubkeys } : null),
[pubkeys],
);
const {
data,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useTabFeed(filter, `people-list-${tabKey}`, pubkeys.length > 0);
// Fetch next page when the sentinel scrolls into view.
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten pages, dedupe, and apply mute / content-warning / reply filters.
const feedItems = useMemo(() => {
if (!data?.pages) return [];
const seen = new Set<string>();
return data.pages
.flatMap((page) => page.items)
.filter((item) => {
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
if (seen.has(key)) return false;
seen.add(key);
if (shouldHideFeedEvent(item.event)) return false;
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
// Hide replies — this tab should show top-level posts only (reposts of
// replies are fine, so only check original kind 1 events, not reposts).
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
return false;
}
return true;
});
}, [data?.pages, muteItems]);
if (pubkeys.length === 0) {
return (
<div className="py-16 text-center text-muted-foreground">
<Users className="size-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No members in this list yet.</p>
</div>
);
}
if (isLoading && feedItems.length === 0) {
return (
<div className="divide-y divide-border">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-11 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
if (feedItems.length === 0) {
return (
<div className="py-16 text-center text-muted-foreground text-sm">
No posts from list members yet.
</div>
);
}
return (
<div>
{feedItems.map((item) => (
<NoteCard
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
event={item.event}
repostedBy={item.repostedBy}
/>
))}
{hasNextPage && (
<div ref={sentinelRef} className="flex justify-center py-6">
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
</div>
)}
</div>
);
}
// ─── Members Tab ──────────────────────────────────────────────────────────────
interface MembersTabProps {
pubkeys: string[];
membersMap: Map<string, { metadata?: NostrMetadata }> | undefined;
membersLoading: boolean;
followedPubkeys: Set<string>;
currentUserPubkey: string | undefined;
/** When true, show per-member "Remove" buttons. Enabled for owners of kind 30000 lists. */
canRemove: boolean;
/** Kind 30000 d-tag — required when canRemove is true. */
listId?: string;
}
export function PeopleListMembersTab({
pubkeys,
membersMap,
membersLoading,
followedPubkeys,
currentUserPubkey,
canRemove,
listId,
}: MembersTabProps) {
if (membersLoading) {
return (
<div className="divide-y divide-border">
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
<MemberCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="divide-y divide-border">
{pubkeys.map((pk) => {
const member = membersMap?.get(pk);
const isFollowed = followedPubkeys.has(pk);
return (
<MemberCard
key={pk}
pubkey={pk}
metadata={member?.metadata}
isFollowed={isFollowed}
isSelf={pk === currentUserPubkey}
canRemove={canRemove}
listId={listId}
/>
);
})}
</div>
);
}
// ─── Comments Tab ─────────────────────────────────────────────────────────────
function PeopleListCommentsTab({
event,
orderedReplies,
commentsLoading,
}: {
event: NostrEvent;
orderedReplies: Array<{ reply: NostrEvent; firstSubReply?: NostrEvent }>;
commentsLoading: boolean;
}) {
return (
<div>
<ComposeBox compact replyTo={event} />
{commentsLoading ? (
<CommentsSkeleton />
) : orderedReplies.length > 0 ? (
<FlatThreadedReplyList replies={orderedReplies} />
) : (
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
<MessageCircle className="size-8 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
No comments yet. Be the first to comment.
</p>
</div>
)}
</div>
);
}
function CommentsSkeleton() {
return (
<div className="divide-y divide-border">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="px-4 py-3">
<div className="flex gap-3">
<Skeleton className="size-10 rounded-full shrink-0" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-3/4" />
</div>
</div>
</div>
))}
</div>
);
}
// ─── Main Detail Component ────────────────────────────────────────────────────
export function PeopleListDetailContent({ event }: { event: NostrEvent }) {
const { toast } = useToast();
const { user } = useCurrentUser();
const { data: followList } = useFollowList();
const { lists: ownLists, createList } = useUserLists();
const isOwnList = user && event.pubkey === user.pubkey;
const isFollowList = event.kind === 3;
const isFollowSet = event.kind === 30000;
const dTag = useMemo(
() => event.tags.find(([n]) => n === 'd')?.[1] ?? '',
[event.tags],
);
// Author
const author = useAuthor(event.pubkey);
const authorMetadata = author.data?.metadata;
const authorAvatarShape = getAvatarShape(authorMetadata);
const authorName = authorMetadata?.name || authorMetadata?.display_name || genUserName(event.pubkey);
const authorNpub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
// Parsed list (for kind 3 uses author metadata as fallback)
const { title, description, image, pubkeys } = useMemo(
() => parsePeopleList(event, {
authorMetadata,
authorDisplayName: authorName,
}),
[event, authorMetadata, authorName],
);
// Reversed for kind 3 follow lists so newest follows show first; identity
// for curated kinds. Used only for display — mutations and filters continue
// to use the original `pubkeys` array.
const displayPubkeys = useMemo(() => getDisplayPubkeys(event, pubkeys), [event, pubkeys]);
const safeImage = useMemo(() => sanitizeUrl(image), [image]);
// Batch-fetch all member profiles
const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys);
// Comments (NIP-22 kind 1111, indexed by #A for replaceable / addressable roots)
const { muteItems } = useMuteList();
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
const orderedReplies = useMemo(() => {
const topLevel = commentsData?.topLevelComments ?? [];
const filtered = muteItems.length > 0
? topLevel.filter((r) => !isEventMuted(r, muteItems))
: topLevel;
return [...filtered]
.sort((a, b) => b.created_at - a.created_at)
.map((reply) => {
const directReplies = commentsData?.getDirectReplies(reply.id) ?? [];
return {
reply,
firstSubReply: directReplies[0] as NostrEvent | undefined,
};
});
}, [commentsData, muteItems]);
// Follow state
const followedPubkeys = useMemo(
() => new Set(followList?.pubkeys ?? []),
[followList],
);
const newPubkeys = useMemo(
() => pubkeys.filter((pk) => !followedPubkeys.has(pk)),
[pubkeys, followedPubkeys],
);
const [activeTab, setActiveTab] = useState<Tab>('feed');
const [cloning, setCloning] = useState(false);
const [addMembersOpen, setAddMembersOpen] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
// Owner-mode remove is only available for lists we manage locally (kind 30000)
const ownerCanRemove = !!(isOwnList && isFollowSet && ownLists.some((l) => l.id === dTag));
// Stable cache-key for the feed tab — the naddr uniquely identifies this list.
const shareNip19 = useMemo(() => {
if (isFollowList) {
// Kind 3 is replaceable, no d-tag
return nip19.naddrEncode({ kind: 3, pubkey: event.pubkey, identifier: '' });
}
return nip19.naddrEncode({
kind: event.kind,
pubkey: event.pubkey,
identifier: dTag,
});
}, [event, dTag, isFollowList]);
// ── Clone (save a copy of this list as my own kind 30000) ─────────────────
const handleClone = useCallback(async () => {
if (!user || cloning) return;
setCloning(true);
try {
await createList.mutateAsync({
title,
description: description || undefined,
pubkeys,
});
toast({ title: `Saved "${title}" to your lists` });
} catch {
toast({ title: 'Failed to save list', variant: 'destructive' });
} finally {
setCloning(false);
}
}, [user, cloning, createList, title, description, pubkeys, toast]);
// When the user is viewing their own kind 3, Follow All makes no sense.
const showFollowAllButton = !(isOwnList && isFollowList);
return (
<>
{/* Hero image */}
{safeImage && (
<div className="w-full overflow-hidden bg-muted border-b border-border">
<img
src={safeImage}
alt={title}
className="w-full h-auto max-h-[300px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
<div className="px-4 pt-4 pb-3">
{/* Author row */}
<div className="flex items-center gap-3">
<Link to={`/${authorNpub}`}>
<Avatar shape={authorAvatarShape} className="size-11">
<AvatarImage src={authorMetadata?.picture} alt={authorName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{authorName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex-1 min-w-0">
<Link
to={`/${authorNpub}`}
className="font-bold text-[15px] hover:underline block truncate"
>
{authorName}
</Link>
{authorMetadata?.nip05 && (
<VerifiedNip05Text
nip05={authorMetadata.nip05}
pubkey={event.pubkey}
className="text-sm text-muted-foreground truncate block"
/>
)}
</div>
</div>
{/* Title */}
<h2 className="text-xl font-bold mt-4 leading-snug">{title}</h2>
{/* Description */}
{description && (
<p className="text-[15px] text-muted-foreground leading-relaxed mt-2 whitespace-pre-wrap">
{description}
</p>
)}
{/* "N new for you" hint */}
{newPubkeys.length > 0 && user && !isOwnList && (
<div className="mt-4 text-sm text-green-600 dark:text-green-400">
{newPubkeys.length} new for you
</div>
)}
{/* Actions */}
<div className="flex gap-2 mt-3">
{showFollowAllButton && (
<FollowAllSplitButton
pubkeys={pubkeys}
followedPubkeys={followedPubkeys}
listNoun={isFollowList ? "this person's follow list" : 'this list'}
includeAuthorPubkey={isFollowList ? event.pubkey : undefined}
className="flex-1"
/>
)}
{/* Save (clone) — available to logged-in viewers who don't own the list, not for kind 3 (that's your follow list, you don't clone it) */}
{user && !isOwnList && !isFollowList && (
<Button
variant="outline"
className={showFollowAllButton ? undefined : 'flex-1'}
onClick={handleClone}
disabled={cloning}
title="Save a copy to your lists"
>
{cloning ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Copy className="size-4" />
)}
Save
</Button>
)}
</div>
</div>
{/* Interaction bar — reply / repost / react / zap / share / more */}
<PostActionBar
event={event}
replyLabel="Comments"
onReply={() => setActiveTab('comments')}
onMore={() => setMoreMenuOpen(true)}
className="px-4"
/>
{/* Tab bar */}
<SubHeaderBar pinned>
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
<TabButton
label="Members"
active={activeTab === 'members'}
onClick={() => setActiveTab('members')}
>
<span className="flex items-center justify-center gap-1.5">
Members
<span className="text-xs text-muted-foreground">({pubkeys.length})</span>
</span>
</TabButton>
<TabButton
label="Comments"
active={activeTab === 'comments'}
onClick={() => setActiveTab('comments')}
/>
</SubHeaderBar>
{/* Owner "Add members" row — above members tab content */}
{ownerCanRemove && activeTab === 'members' && (
<div className="px-4 py-3 border-b border-border">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => setAddMembersOpen(true)}
>
<UserPlus className="size-4" />
Add Members
</Button>
</div>
)}
{/* Tab content */}
{activeTab === 'feed' ? (
<PeopleListFeedTab pubkeys={pubkeys} tabKey={shareNip19} />
) : activeTab === 'members' ? (
<PeopleListMembersTab
pubkeys={displayPubkeys}
membersMap={membersMap}
membersLoading={membersLoading}
followedPubkeys={followedPubkeys}
currentUserPubkey={user?.pubkey}
canRemove={ownerCanRemove}
listId={dTag}
/>
) : (
<PeopleListCommentsTab
event={event}
orderedReplies={orderedReplies}
commentsLoading={commentsLoading}
/>
)}
{ownerCanRemove && (
<AddMembersDialog
open={addMembersOpen}
onOpenChange={setAddMembersOpen}
listId={dTag}
listPubkeys={pubkeys}
/>
)}
<NoteMoreMenu
event={event}
open={moreMenuOpen}
onOpenChange={setMoreMenuOpen}
/>
</>
);
}
// ─── Member Card ──────────────────────────────────────────────────────────────
interface MemberCardProps {
pubkey: string;
metadata?: NostrMetadata;
isFollowed: boolean;
isSelf: boolean;
/** When true, renders a "remove" button that calls useUserLists().removeFromList. */
canRemove?: boolean;
/** Kind 30000 d-tag — required when canRemove is true. */
listId?: string;
}
export function MemberCard({
pubkey,
metadata,
isFollowed,
isSelf,
canRemove,
listId,
}: MemberCardProps) {
const navigate = useNavigate();
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
const about = metadata?.about;
const avatarShape = getAvatarShape(metadata);
const { follow, unfollow, isPending } = useFollowActions();
const { removeFromList } = useUserLists();
const { toast } = useToast();
const [removing, setRemoving] = useState(false);
const handleFollowToggle = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (isFollowed) {
await unfollow(pubkey);
} else {
await follow(pubkey);
}
},
[isFollowed, pubkey, follow, unfollow],
);
const handleRemove = useCallback(
async (e: React.MouseEvent) => {
e.stopPropagation();
if (!listId) return;
setRemoving(true);
try {
await removeFromList.mutateAsync({ listId, pubkey });
toast({ title: 'Removed from list' });
} catch {
toast({ title: 'Failed to remove', variant: 'destructive' });
} finally {
setRemoving(false);
}
},
[listId, pubkey, removeFromList, toast],
);
return (
<div
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors cursor-pointer group"
onClick={() => navigate(`/${npub}`)}
>
<Link to={`/${npub}`} className="shrink-0" onClick={(e) => e.stopPropagation()}>
<Avatar shape={avatarShape} className="size-11">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex-1 min-w-0">
<Link
to={`/${npub}`}
className="font-bold text-[15px] hover:underline block truncate"
onClick={(e) => e.stopPropagation()}
>
{displayName}
</Link>
{about && (
<p className="text-sm text-muted-foreground line-clamp-1">
{about}
</p>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{!isSelf && (
<Button
variant={isFollowed ? 'outline' : 'default'}
size="sm"
className="shrink-0"
onClick={handleFollowToggle}
disabled={isPending}
>
{isPending ? (
<Loader2 className="size-3.5 animate-spin" />
) : isFollowed ? (
'Following'
) : (
'Follow'
)}
</Button>
)}
{canRemove && listId && (
<button
onClick={handleRemove}
disabled={removing}
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-all"
aria-label="Remove from list"
>
{removing
? <Loader2 className="size-4 animate-spin" />
: <X className="size-4" />}
</button>
)}
</div>
</div>
);
}
export function MemberCardSkeleton() {
return (
<div className="flex items-center gap-3 px-4 py-3">
<Skeleton className="size-11 rounded-full shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-48" />
</div>
<Skeleton className="h-8 w-20 rounded-md" />
</div>
);
}
-132
View File
@@ -1,132 +0,0 @@
/**
* PhotoBottomBar — author info + reaction strip rendered at the bottom of
* the media Lightbox. Uses CommentsSheet for all event kinds; CommentsSheet
* adapts its query, label, and placeholder based on event.kind.
*/
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { MessageCircle, Zap, MoreHorizontal } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { ReactionButton } from '@/components/ReactionButton';
import { RepostMenu } from '@/components/RepostMenu';
import { ZapDialog } from '@/components/ZapDialog';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { CommentsSheet } from '@/components/CommentsSheet';
import { RepostIcon } from '@/components/icons/RepostIcon';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useProfileUrl } from '@/hooks/useProfileUrl';
import { useEventStats } from '@/hooks/useTrending';
import { useUserZap } from '@/hooks/useUserZap';
import { getDisplayName } from '@/lib/getDisplayName';
import { genUserName } from '@/lib/genUserName';
import { formatNumber } from '@/lib/formatNumber';
interface PhotoBottomBarProps {
event: NostrEvent;
}
export function PhotoBottomBar({ event }: PhotoBottomBarProps) {
const { user } = useCurrentUser();
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
const profileUrl = useProfileUrl(event.pubkey, metadata);
const { data: stats } = useEventStats(event.id, event);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [commentsOpen, setCommentsOpen] = useState(false);
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
const isZapped = useUserZap(canZapAuthor ? event.id : undefined) === true;
return (
<>
{/* Action strip — mirrors top bar: px-4 py-3 + safe-area */}
<div className="relative safe-area-bottom">
{/* Gradient scrim */}
<div className="absolute inset-x-0 bottom-0 h-32 bg-gradient-to-t from-black/70 to-transparent pointer-events-none" />
<div className="relative flex items-center gap-1 px-4 py-3 max-w-xl mx-auto">
{/* Avatar + name */}
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="shrink-0">
<Avatar className="size-7">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-white/20 text-white text-xs">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
</ProfileHoverCard>
<ProfileHoverCard pubkey={event.pubkey} asChild>
<Link to={profileUrl} className="font-semibold text-sm text-white hover:underline truncate mr-1">
{displayName}
</Link>
</ProfileHoverCard>
{/* Actions */}
<div className="flex items-center ml-auto shrink-0">
<ReactionButton
eventId={event.id}
eventPubkey={event.pubkey}
eventKind={event.kind}
reactionCount={stats?.reactions}
filledHeart
className="text-white hover:text-pink-400 hover:bg-white/10 p-2.5 [&_svg]:size-5"
/>
<button
className="flex items-center gap-1 p-2.5 text-white hover:text-blue-400 transition-colors"
onClick={() => setCommentsOpen(true)}
>
<MessageCircle className="size-5" />
{!!stats?.replies && <span className="text-sm tabular-nums drop-shadow">{formatNumber(stats.replies)}</span>}
</button>
<RepostMenu event={event}>
{(isReposted: boolean) => (
<button className={`flex items-center gap-1 p-2.5 transition-colors ${isReposted ? 'text-accent' : 'text-white hover:text-accent'}`}>
<RepostIcon className="size-5" />
{!!((stats?.reposts ?? 0) + (stats?.quotes ?? 0)) && (
<span className="text-sm tabular-nums drop-shadow">{formatNumber((stats?.reposts ?? 0) + (stats?.quotes ?? 0))}</span>
)}
</button>
)}
</RepostMenu>
{canZapAuthor && (
<ZapDialog target={event}>
<button
className={`flex items-center gap-1 p-2.5 transition-colors ${
isZapped ? 'text-amber-400 hover:text-amber-300' : 'text-white hover:text-amber-400'
}`}
title={isZapped ? 'Zapped' : 'Zap'}
>
<Zap className="size-5" fill={isZapped ? 'currentColor' : 'none'} />
{!!stats?.zapAmount && <span className="text-sm tabular-nums drop-shadow">{formatNumber(stats.zapAmount)}</span>}
</button>
</ZapDialog>
)}
<button
className="p-2.5 text-white/70 hover:text-white transition-colors"
onClick={() => setMoreMenuOpen(true)}
>
<MoreHorizontal className="size-5" />
</button>
</div>
</div>
</div>
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
<CommentsSheet
event={event}
open={commentsOpen}
onClose={() => setCommentsOpen(false)}
/>
</>
);
}
-491
View File
@@ -1,491 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { AlertTriangle, ImagePlus, Loader2, X } from 'lucide-react';
import { encode as blurhashEncode } from 'blurhash';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useQueryClient } from '@tanstack/react-query';
import { useToast } from '@/hooks/useToast';
import { useAppContext } from '@/hooks/useAppContext';
import { resizeImage } from '@/lib/resizeImage';
import { extractHashtags } from '@/lib/hashtag';
import { cn } from '@/lib/utils';
const MAX_CAPTION_CHARS = 2000;
/** Uploaded image with its metadata and NIP-94 tags. */
interface UploadedImage {
/** Display URL for preview. */
url: string;
/** NIP-94 tags from the upload (url, m, size, etc.). */
tags: string[][];
/** Image dimensions string, e.g. "1920x1080". */
dim?: string;
/** BlurHash for loading previews. */
blurhash?: string;
/** Alt text for accessibility. */
alt: string;
}
/**
* Compute image dimensions and blurhash for a File.
* Returns empty values for non-image files or on failure.
*/
async function getImageMeta(file: File): Promise<{ dim?: string; blurhash?: string }> {
if (!file.type.startsWith('image/')) return {};
try {
const url = URL.createObjectURL(file);
try {
const img = await new Promise<HTMLImageElement>((resolve, reject) => {
const el = new Image();
el.onload = () => resolve(el);
el.onerror = reject;
el.src = url;
});
const naturalWidth = img.naturalWidth;
const naturalHeight = img.naturalHeight;
if (!naturalWidth || !naturalHeight) return {};
const dim = `${naturalWidth}x${naturalHeight}`;
const SAMPLE_W = 64;
const scale = SAMPLE_W / naturalWidth;
const sampleW = SAMPLE_W;
const sampleH = Math.max(1, Math.round(naturalHeight * scale));
const canvas = document.createElement('canvas');
canvas.width = sampleW;
canvas.height = sampleH;
const ctx = canvas.getContext('2d');
if (!ctx) return { dim };
ctx.drawImage(img, 0, 0, sampleW, sampleH);
const { data } = ctx.getImageData(0, 0, sampleW, sampleH);
const blurhash = blurhashEncode(data, sampleW, sampleH, 4, 3);
return { dim, blurhash };
} finally {
URL.revokeObjectURL(url);
}
} catch {
return {};
}
}
interface PhotoComposeModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
export function PhotoComposeModal({ open, onOpenChange, onSuccess }: PhotoComposeModalProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const queryClient = useQueryClient();
const { toast } = useToast();
const { config } = useAppContext();
const imageQuality = config.imageQuality;
const [images, setImages] = useState<UploadedImage[]>([]);
const [title, setTitle] = useState('');
const [caption, setCaption] = useState('');
const [cwEnabled, setCwEnabled] = useState(false);
const [cwText, setCwText] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const charCount = caption.length;
const canPublish = images.length > 0 && title.trim().length > 0 && !isUploading && !isPublishing;
const resetForm = useCallback(() => {
setImages([]);
setTitle('');
setCaption('');
setCwEnabled(false);
setCwText('');
}, []);
const handleFileUpload = useCallback(async (file: File) => {
if (!file.type.startsWith('image/')) {
toast({ title: 'Invalid file', description: 'Only image files are allowed.', variant: 'destructive' });
return;
}
try {
let uploadableFile: File;
let resizedDim: string | undefined;
if (imageQuality === 'compressed') {
const resized = await resizeImage(file);
uploadableFile = resized.file;
resizedDim = resized.dimensions;
} else {
uploadableFile = file;
}
const tags = await uploadFile(uploadableFile);
const [[, url]] = tags;
// Compute dim + blurhash
let dim = resizedDim;
let blurhash: string | undefined;
if (!dim) {
const meta = await getImageMeta(uploadableFile);
dim = meta.dim;
blurhash = meta.blurhash;
} else {
const meta = await getImageMeta(uploadableFile);
blurhash = meta.blurhash;
}
setImages((prev) => [...prev, {
url,
tags,
dim,
blurhash,
alt: '',
}]);
} catch {
toast({ title: 'Upload failed', description: 'Could not upload image.', variant: 'destructive' });
}
}, [uploadFile, toast, imageQuality]);
const handleRemoveImage = useCallback((index: number) => {
setImages((prev) => prev.filter((_, i) => i !== index));
}, []);
const handleAltChange = useCallback((index: number, alt: string) => {
setImages((prev) => prev.map((img, i) => i === index ? { ...img, alt } : img));
}, []);
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) {
await handleFileUpload(file);
}
break;
}
}
}, [handleFileUpload]);
const handleSubmit = async () => {
if (!canPublish || !user) return;
try {
const tags: string[][] = [];
// Title tag (required by NIP-68)
tags.push(['title', title.trim()]);
// Build imeta tags for each uploaded image
for (const img of images) {
const imetaFields: string[] = [
`url ${img.url}`,
];
// Add mime type from upload tags
const mimeTag = img.tags.find(t => t[0] === 'm');
if (mimeTag) {
imetaFields.push(`m ${mimeTag[1]}`);
}
if (img.dim) {
imetaFields.push(`dim ${img.dim}`);
}
if (img.blurhash) {
imetaFields.push(`blurhash ${img.blurhash}`);
}
if (img.alt.trim()) {
imetaFields.push(`alt ${img.alt.trim()}`);
}
// Add hash if present in upload tags
const hashTag = img.tags.find(t => t[0] === 'x');
if (hashTag) {
imetaFields.push(`x ${hashTag[1]}`);
}
tags.push(['imeta', ...imetaFields]);
}
// Extract hashtags from caption
const captionText = caption.trim();
for (const tag of extractHashtags(captionText)) {
tags.push(['t', tag]);
}
// Content warning
if (cwEnabled) {
tags.push(['content-warning', cwText || '']);
tags.push(['L', 'content-warning']);
if (cwText) {
tags.push(['l', cwText, 'content-warning']);
}
}
// NIP-31 alt tag for clients that don't support kind 20
tags.push(['alt', `Photo: ${title.trim()}`]);
await createEvent({
kind: 20,
content: captionText,
tags,
});
// Invalidate feeds to show the new photo
queryClient.invalidateQueries({ queryKey: ['feed'] });
queryClient.invalidateQueries({ queryKey: ['trending'] });
toast({ title: 'Photo published!', description: 'Your photo has been shared.' });
resetForm();
onOpenChange(false);
onSuccess?.();
} catch {
toast({ title: 'Error', description: 'Failed to publish photo.', variant: 'destructive' });
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-[520px] max-h-[85vh] rounded-2xl p-0 gap-0 border-border overflow-hidden [&>button]:hidden flex flex-col"
onPaste={handlePaste}
>
{/* Header */}
<div className="flex items-center justify-between px-4 h-12 shrink-0 border-b border-border/50">
<DialogTitle className="text-base font-semibold">
New photo
</DialogTitle>
<button
onClick={() => onOpenChange(false)}
className="p-1.5 -mr-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors"
>
<X className="size-5" />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="p-4 space-y-4">
{/* Image upload area */}
{images.length === 0 ? (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className={cn(
'w-full aspect-[4/3] rounded-xl border-2 border-dashed transition-colors flex flex-col items-center justify-center gap-3',
isUploading
? 'border-primary/30 bg-primary/5 cursor-wait'
: 'border-border hover:border-primary/50 hover:bg-primary/5 cursor-pointer',
)}
>
{isUploading ? (
<>
<Loader2 className="size-8 text-primary animate-spin" />
<p className="text-sm text-muted-foreground">Uploading...</p>
</>
) : (
<>
<div className="size-14 rounded-full bg-primary/10 flex items-center justify-center">
<ImagePlus className="size-7 text-primary" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-foreground">Choose a photo</p>
<p className="text-xs text-muted-foreground mt-0.5">or paste from clipboard</p>
</div>
</>
)}
</button>
) : (
<div className="space-y-3">
{/* Image previews */}
<div className={cn(
'grid gap-2',
images.length === 1 ? 'grid-cols-1' : 'grid-cols-2',
)}>
{images.map((img, index) => (
<div key={img.url} className="relative group rounded-xl overflow-hidden bg-secondary/30">
<img
src={img.url}
alt={img.alt || `Photo ${index + 1}`}
className="w-full aspect-square object-cover"
/>
{/* Remove button */}
<button
type="button"
onClick={() => handleRemoveImage(index)}
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/60 text-white opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80"
>
<X className="size-4" />
</button>
{/* Alt text input overlay */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 pt-6">
<input
type="text"
dir="auto"
value={img.alt}
onChange={(e) => handleAltChange(index, e.target.value)}
placeholder="Alt text (accessibility)"
className="w-full bg-black/30 backdrop-blur-sm text-white placeholder:text-white/50 text-xs rounded-lg px-2.5 py-1.5 outline-none focus:ring-1 focus:ring-white/40"
/>
</div>
</div>
))}
</div>
{/* Add more button */}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full py-2.5 rounded-xl border border-dashed border-border hover:border-primary/50 hover:bg-primary/5 transition-colors flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-primary"
>
{isUploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ImagePlus className="size-4" />
)}
{isUploading ? 'Uploading...' : 'Add more photos'}
</button>
</div>
)}
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files) {
Array.from(files).forEach((file) => handleFileUpload(file));
}
e.target.value = '';
}}
/>
{/* Title field */}
<div className="space-y-1.5">
<label htmlFor="photo-title" className="text-xs font-medium text-muted-foreground">
Title <span className="text-destructive">*</span>
</label>
<Input
id="photo-title"
dir="auto"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Give your photo a title"
maxLength={200}
className="bg-secondary/40 border-0 rounded-lg focus-visible:ring-1 focus-visible:ring-primary/40"
/>
</div>
{/* Caption field */}
<div className="space-y-1.5">
<label htmlFor="photo-caption" className="text-xs font-medium text-muted-foreground">
Caption
</label>
<textarea
id="photo-caption"
dir="auto"
value={caption}
onChange={(e) => setCaption(e.target.value)}
placeholder="Write a caption... (supports #hashtags)"
rows={3}
maxLength={MAX_CAPTION_CHARS}
className="w-full bg-secondary/40 rounded-lg px-3 py-2 text-sm outline-none resize-none focus:ring-1 focus:ring-primary/40 placeholder:text-muted-foreground"
/>
{charCount > 0 && (
<p className={cn(
'text-xs text-right tabular-nums',
charCount > MAX_CAPTION_CHARS * 0.9 ? 'text-amber-500' : 'text-muted-foreground',
)}>
{charCount}/{MAX_CAPTION_CHARS}
</p>
)}
</div>
{/* Content warning toggle */}
<div>
<button
type="button"
onClick={() => setCwEnabled((v) => !v)}
className={cn(
'flex items-center gap-2 text-xs font-medium transition-colors',
cwEnabled ? 'text-amber-500' : 'text-muted-foreground hover:text-foreground',
)}
>
<AlertTriangle className="size-3.5" />
{cwEnabled ? 'Content warning enabled' : 'Add content warning'}
</button>
{cwEnabled && (
<div className="flex items-center gap-2 mt-2">
<Input
value={cwText}
onChange={(e) => setCwText(e.target.value)}
placeholder="Content warning reason (optional)"
className="h-8 text-sm bg-secondary/40 border-0 rounded-lg"
/>
<button
onClick={() => { setCwEnabled(false); setCwText(''); }}
className="p-1 rounded-full text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<X className="size-4" />
</button>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-4 py-3 border-t border-border/50 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
className="rounded-full"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!canPublish}
className="rounded-full px-5 font-bold"
size="sm"
>
{isPublishing ? (
<>
<Loader2 className="size-4 animate-spin mr-1.5" />
Publishing...
</>
) : (
'Publish'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
-290
View File
@@ -1,290 +0,0 @@
/**
* ProfileTabEditModal
*
* Modal for adding or editing a custom profile tab (kind 16769).
* Opens with an optional existing tab to edit; otherwise creates a new one.
*
* Streamlined for profile tabs: only Search Query, Author Scope (Me / Contacts / People / Global),
* and multi-select Kind picker.
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import {
Loader2, Check, Globe, Users, User, UserSearch,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { buildKindOptions, parseSelectedKinds } from '@/lib/feedFilterUtils';
import {
MultiKindPicker,
ScopeToggle,
AuthorChip,
AuthorFilterDropdown,
ListPackPicker,
} from '@/components/SavedFeedFiltersEditor';
import type { ScopeOption } from '@/components/SavedFeedFiltersEditor';
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
import { useUserLists, useMatchedListId } from '@/hooks/useUserLists';
import { useFollowPacks } from '@/hooks/useFollowPacks';
import type { ProfileTab, TabFilter } from '@/lib/profileTabsEvent';
interface ProfileTabEditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Existing tab to edit. If undefined, creates a new tab. */
tab?: ProfileTab;
/** The profile owner's pubkey — used to pre-populate authors when scope is 'me'. */
ownerPubkey: string;
/** Called with the resulting tab on save. */
onSave: (tab: ProfileTab) => Promise<void>;
isPending?: boolean;
}
// ─── Author scope type for the 4-way toggle ───────────────────────────────────
type ProfileAuthorScope = 'me' | 'contacts' | 'people' | 'global';
/** Map from simplified scope to filter fields (excluding people's authors which are stored separately). */
function scopeToFilter(scope: ProfileAuthorScope, ownerPubkey: string, peoplePubkeys: string[]): Partial<TabFilter> {
switch (scope) {
case 'me':
return { authors: [ownerPubkey] };
case 'contacts':
// Uses $follows variable — handled at event level via var tags
return { authors: ['$follows'] };
case 'people':
return peoplePubkeys.length > 0 ? { authors: peoplePubkeys } : {};
case 'global':
return {};
}
}
/** Derive the simplified scope from a TabFilter. */
function filterToScope(filter: TabFilter, ownerPubkey: string): ProfileAuthorScope {
const authors = Array.isArray(filter.authors) ? filter.authors as string[] : [];
if (authors.length === 1 && authors[0] === ownerPubkey) return 'me';
if (authors.includes('$follows')) return 'contacts';
if (authors.length > 0) return 'people'; // has specific authors → people scope
return 'global';
}
/** Extract people pubkeys from a TabFilter (non-variable, non-owner pubkeys). */
function filterToPeoplePubkeys(filter: TabFilter, ownerPubkey: string): string[] {
const authors = Array.isArray(filter.authors) ? filter.authors as string[] : [];
if (authors.includes('$follows')) return [];
if (authors.length === 1 && authors[0] === ownerPubkey) return [];
return authors.filter((a) => a !== ownerPubkey && !a.startsWith('$'));
}
/** Serialize selected kind values into a kinds array for the filter. */
function serializeSelectedKinds(kinds: string[]): number[] {
return kinds.map(Number).filter((n) => !isNaN(n) && n > 0);
}
// ─── Author Scope Options ─────────────────────────────────────────────────────
const PROFILE_SCOPE_OPTIONS: ScopeOption<ProfileAuthorScope>[] = [
{ value: 'me', label: 'Me', icon: User },
{ value: 'contacts', label: 'Contacts', icon: Users },
{ value: 'people', label: 'People', icon: UserSearch },
{ value: 'global', label: 'Global', icon: Globe },
];
// ─── Main Modal ───────────────────────────────────────────────────────────────
export function ProfileTabEditModal({
open,
onOpenChange,
tab,
ownerPubkey,
onSave,
isPending = false,
}: ProfileTabEditModalProps) {
const kindOptions = useMemo(() => buildKindOptions(), []);
const { lists } = useUserLists();
const { data: followPacks = [] } = useFollowPacks();
const isNew = !tab;
const initialFilter = useMemo<TabFilter>(() => {
if (tab) return tab.filter;
return { authors: [ownerPubkey] };
}, [tab, ownerPubkey]);
const [label, setLabel] = useState(tab?.label ?? '');
const [query, setQuery] = useState(
typeof initialFilter.search === 'string' ? initialFilter.search : '',
);
const [authorScope, setAuthorScope] = useState<ProfileAuthorScope>(
filterToScope(initialFilter, ownerPubkey),
);
const [peoplePubkeys, setPeoplePubkeys] = useState<string[]>(
filterToPeoplePubkeys(initialFilter, ownerPubkey),
);
const [selectedKinds, setSelectedKinds] = useState<string[]>(
parseSelectedKinds(initialFilter),
);
const [portalContainer, setPortalContainer] = useState<HTMLElement | undefined>(undefined);
const listPickerValue = useMatchedListId(peoplePubkeys);
const dialogContentRef = useCallback((node: HTMLElement | null) => {
setPortalContainer(node ?? undefined);
}, []);
const addPerson = useCallback((pubkey: string) => {
setPeoplePubkeys((prev) => prev.includes(pubkey) ? prev : [...prev, pubkey]);
setAuthorScope('people');
}, []);
const removePerson = useCallback((pubkey: string) => {
setPeoplePubkeys((prev) => prev.filter((p) => p !== pubkey));
}, []);
const handleAuthorScopeChange = useCallback((scope: ProfileAuthorScope) => {
setAuthorScope(scope);
if (scope !== 'people') {
setPeoplePubkeys([]);
}
}, []);
// Reset form state whenever the modal opens or the tab being edited changes.
// This runs as an effect rather than inside onOpenChange because the Dialog
// does not fire onOpenChange when opened programmatically via the `open` prop.
useEffect(() => {
if (open) {
const f = tab ? tab.filter : { authors: [ownerPubkey] };
setLabel(tab?.label ?? '');
setQuery(typeof f.search === 'string' ? f.search : '');
setAuthorScope(filterToScope(f, ownerPubkey));
setPeoplePubkeys(filterToPeoplePubkeys(f, ownerPubkey));
setSelectedKinds(parseSelectedKinds(f));
}
}, [open, tab, ownerPubkey]);
const handleSave = async () => {
if (!label.trim() || isPending) return;
const filter: TabFilter = {
...scopeToFilter(authorScope, ownerPubkey, peoplePubkeys),
};
if (query.trim()) {
filter.search = query.trim();
}
const kinds = serializeSelectedKinds(selectedKinds);
if (kinds.length > 0) {
filter.kinds = kinds;
}
await onSave({ label: label.trim(), filter });
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent ref={dialogContentRef} className="max-w-sm max-h-[90dvh] overflow-y-auto">
<PortalContainerProvider value={portalContainer}>
<DialogHeader>
<DialogTitle>{isNew ? 'Add profile tab' : 'Edit tab'}</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-1">
{/* Tab name */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Tab name</span>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
placeholder="e.g. My Art, Bitcoin posts..."
autoFocus
className="h-9"
/>
</div>
<Separator />
{/* Kind multi-select */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Content Kinds</span>
<MultiKindPicker
selectedKinds={selectedKinds}
options={kindOptions}
onChange={setSelectedKinds}
/>
</div>
<Separator />
{/* Filter by word */}
<div className="space-y-1.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Filter by Word</span>
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="e.g. photography, travel..."
className="bg-secondary/50 border-border focus-visible:ring-1 h-9 text-base md:text-sm"
/>
</div>
<Separator />
{/* Author scope */}
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Authors</span>
<ScopeToggle<ProfileAuthorScope> value={authorScope} options={PROFILE_SCOPE_OPTIONS} onChange={handleAuthorScopeChange} size="md" />
{authorScope === 'people' ? (
<div className="space-y-1.5">
{peoplePubkeys.length > 0 && (
<div className="flex flex-wrap gap-1">
{peoplePubkeys.map((pk) => (
<AuthorChip key={pk} pubkey={pk} onRemove={() => removePerson(pk)} />
))}
</div>
)}
<AuthorFilterDropdown onCommit={(pubkey) => addPerson(pubkey)} />
<ListPackPicker
lists={lists}
followPacks={followPacks}
value={listPickerValue}
onSelectPubkeys={(pubkeys) => {
setPeoplePubkeys(pubkeys);
if (pubkeys.length > 0) setAuthorScope('people');
}}
/>
</div>
) : (
<p className="text-xs text-muted-foreground leading-relaxed">
{authorScope === 'me' && 'Only show your own posts.'}
{authorScope === 'contacts' && 'Show posts from people you follow.'}
{authorScope === 'global' && 'Show posts from everyone.'}
</p>
)}
</div>
</div>
<DialogFooter className="flex-col gap-2 pt-3 sm:flex-col">
<Button className="w-full gap-2" onClick={handleSave} disabled={!label.trim() || isPending}>
{isPending
? <Loader2 className="size-4 animate-spin" />
: <Check className="size-4" />}
{isNew ? 'Add tab' : 'Save changes'}
</Button>
<Button variant="ghost" className="w-full" onClick={() => onOpenChange(false)} disabled={isPending}>
Cancel
</Button>
</DialogFooter>
</PortalContainerProvider>
</DialogContent>
</Dialog>
);
}
-217
View File
@@ -1,217 +0,0 @@
/**
* ProfileTabsManagerModal
*
* Sheet-style modal for managing custom profile tabs:
* - Drag to reorder (dnd-kit)
* - Remove individual tabs
* - Edit a tab (opens ProfileTabEditModal)
* - Add a custom tab
*/
import { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Pencil, Trash2, Plus, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ProfileTabEditModal } from '@/components/ProfileTabEditModal';
import { cn } from '@/lib/utils';
import type { ProfileTab } from '@/lib/profileTabsEvent';
interface ProfileTabsManagerModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tabs: ProfileTab[];
ownerPubkey: string;
onSave: (tabs: ProfileTab[]) => Promise<void>;
isPending?: boolean;
}
export function ProfileTabsManagerModal({
open,
onOpenChange,
tabs,
ownerPubkey,
onSave,
isPending = false,
}: ProfileTabsManagerModalProps) {
const [localTabs, setLocalTabs] = useState<ProfileTab[]>(tabs);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingTab, setEditingTab] = useState<ProfileTab | undefined>(undefined);
// Sync from parent when modal opens
const handleOpenChange = (o: boolean) => {
if (o) setLocalTabs(tabs);
onOpenChange(o);
};
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
setLocalTabs((prev) => {
const oldIndex = prev.findIndex((t) => t.label === active.id);
const newIndex = prev.findIndex((t) => t.label === over.id);
return arrayMove(prev, oldIndex, newIndex);
});
}
};
const handleRemove = (label: string) => {
setLocalTabs((prev) => prev.filter((t) => t.label !== label));
};
const handleEditTab = (tab: ProfileTab) => {
setEditingTab(tab);
setEditModalOpen(true);
};
const handleAddCustom = () => {
setEditingTab(undefined);
setEditModalOpen(true);
};
const handleTabSaved = async (tab: ProfileTab) => {
setLocalTabs((prev) => {
if (editingTab) {
return prev.map((t) => t.label === editingTab.label ? tab : t);
}
return [...prev, tab];
});
};
const handleSaveAll = async () => {
await onSave(localTabs);
onOpenChange(false);
};
return (
<>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Manage profile tabs</DialogTitle>
</DialogHeader>
<div className="space-y-1 py-1">
{localTabs.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No custom tabs yet.</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={localTabs.map((t) => t.label)} strategy={verticalListSortingStrategy}>
{localTabs.map((tab) => (
<SortableTabRow
key={tab.label}
tab={tab}
onEdit={() => handleEditTab(tab)}
onRemove={() => handleRemove(tab.label)}
/>
))}
</SortableContext>
</DndContext>
)}
<button
onClick={handleAddCustom}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
>
<Plus className="size-4 shrink-0" />
Add custom tab
</button>
</div>
<Button
className="w-full gap-2 mt-2"
onClick={handleSaveAll}
disabled={isPending}
>
{isPending ? <Loader2 className="size-4 animate-spin" /> : null}
Save
</Button>
</DialogContent>
</Dialog>
{/* Nested edit modal */}
<ProfileTabEditModal
open={editModalOpen}
onOpenChange={setEditModalOpen}
tab={editingTab}
ownerPubkey={ownerPubkey}
onSave={handleTabSaved}
isPending={false}
/>
</>
);
}
function SortableTabRow({
tab,
onEdit,
onRemove,
}: {
tab: ProfileTab;
onEdit: () => void;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.label });
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
'flex items-center gap-2 px-2 py-2 rounded-lg bg-secondary/20 border border-border/50',
isDragging && 'opacity-50 shadow-lg z-50',
)}
>
<button
{...attributes}
{...listeners}
className="shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground touch-none"
aria-label="Drag to reorder"
>
<GripVertical className="size-4" />
</button>
<span className="flex-1 text-sm font-medium truncate">{tab.label}</span>
<button
onClick={onEdit}
className="shrink-0 size-7 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
aria-label={`Edit ${tab.label}`}
>
<Pencil className="size-3.5" />
</button>
<button
onClick={onRemove}
className="shrink-0 size-7 flex items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
aria-label={`Remove ${tab.label}`}
>
<Trash2 className="size-3.5" />
</button>
</div>
);
}
-266
View File
@@ -1,266 +0,0 @@
import { Link } from 'react-router-dom';
import { LinkFooter } from '@/components/LinkFooter';
import { useOpenPost } from '@/hooks/useOpenPost';
import { X } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { EmojifiedText } from '@/components/CustomEmoji';
import { useTrendingTags, useLatestAccounts, useSortedPosts, useTagSparklines } from '@/hooks/useTrending';
import { useAuthor } from '@/hooks/useAuthor';
import { useMuteList } from '@/hooks/useMuteList';
import { isEventMuted } from '@/lib/muteHelpers';
import { genUserName } from '@/lib/genUserName';
import { VerifiedNip05Text } from '@/components/Nip05Badge';
import { formatNumber } from '@/lib/formatNumber';
import { timeAgo } from '@/lib/timeAgo';
import { NSchema as n } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
const XL_BREAKPOINT = 1280;
/** Returns true when the viewport is at least the xl breakpoint (1280px). */
function useIsXl(): boolean {
const [isXl, setIsXl] = useState(window.innerWidth >= XL_BREAKPOINT);
useEffect(() => {
const mql = window.matchMedia(`(min-width: ${XL_BREAKPOINT}px)`);
const onChange = () => setIsXl(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return isXl;
}
// Re-export TrendSparkline from its dedicated module for backwards compatibility.
import { TrendSparkline } from '@/components/TrendSparkline';
export { TrendSparkline };
export function RightSidebar() {
const isXl = useIsXl();
const { data: trendingTagsResult, isLoading: tagsLoading } = useTrendingTags(isXl);
const { data: rawHotPosts, isLoading: hotLoading } = useSortedPosts('hot', 5, isXl);
const { data: latestAccounts, isLoading: accountsLoading } = useLatestAccounts(isXl);
const { muteItems } = useMuteList();
const [dismissedAccounts, setDismissedAccounts] = useLocalStorage<string[]>('dismissed-new-accounts', []);
const dismissAccount = useCallback((pubkey: string) => {
setDismissedAccounts((prev) => [...prev, pubkey]);
}, [setDismissedAccounts]);
const filteredAccounts = useMemo(() => {
if (!latestAccounts || dismissedAccounts.length === 0) return latestAccounts;
return latestAccounts.filter((e) => !dismissedAccounts.includes(e.pubkey));
}, [latestAccounts, dismissedAccounts]);
const trendingTags = trendingTagsResult?.tags;
const labelCreatedAt = trendingTagsResult?.labelCreatedAt ?? 0;
const hotPosts = useMemo(() => {
if (!rawHotPosts || muteItems.length === 0) return rawHotPosts;
return rawHotPosts.filter((e) => !isEventMuted(e, muteItems));
}, [rawHotPosts, muteItems]);
// Fetch real sparkline data for the visible trending tags
const visibleTags = useMemo(() => (trendingTags ?? []).slice(0, 5).map((t) => t.tag), [trendingTags]);
const { data: sparklineData, isLoading: sparklinesLoading } = useTagSparklines(visibleTags, labelCreatedAt, isXl && visibleTags.length > 0);
return (
<aside className="w-[300px] shrink-0 hidden xl:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3">
{/* Trending Tags */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold text-foreground" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Trends</h2>
<Link to="/trends" className="text-sm text-primary hover:underline">View all</Link>
</div>
{tagsLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex justify-between items-center">
<div className="space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-8 w-12" />
</div>
))}
</div>
) : trendingTags && trendingTags.length > 0 ? (
<div className="space-y-3">
{trendingTags.slice(0, 5).map((item) => (
<Link
key={item.tag}
to={`/t/${item.tag}`}
className="flex items-center justify-between group hover:bg-secondary/40 -mx-2 px-2 py-1.5 rounded-lg transition-colors"
>
<div>
<div className="font-bold text-sm">#{item.tag}</div>
{item.accounts > 0 && (
<div className="text-xs text-muted-foreground">
<span className="text-primary font-semibold">{formatNumber(item.accounts)}</span> people talking
</div>
)}
</div>
{sparklinesLoading ? (
<Skeleton className="h-[35px] w-[50px] rounded" />
) : (
<TrendSparkline data={sparklineData?.get(item.tag) ?? []} />
)}
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No trends available.</p>
)}
</section>
{/* Hot Posts */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold text-foreground" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Hot Posts</h2>
<Link to="/trends" className="text-sm text-primary hover:underline">More</Link>
</div>
{hotLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-1.5">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-3/4" />
</div>
))}
</div>
) : hotPosts && hotPosts.length > 0 ? (
<div className="space-y-1">
{hotPosts.slice(0, 5).map((event) => (
<HotPostCard key={event.id} event={event} />
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No hot posts right now.</p>
)}
</section>
{/* Latest Accounts */}
<section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-bold text-foreground" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>New Accounts</h2>
</div>
{accountsLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5 flex-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-36" />
</div>
</div>
))}
</div>
) : (
<div className="space-y-2">
{filteredAccounts?.map((event) => (
<LatestAccountCard key={event.id} event={event} onDismiss={dismissAccount} />
))}
</div>
)}
</section>
<LinkFooter />
</aside>
);
}
/** Compact hot post card for the sidebar. */
function HotPostCard({ event }: { event: NostrEvent }) {
const author = useAuthor(event.pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || genUserName(event.pubkey);
const encodedId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event]);
const { onClick: openPost, onAuxClick } = useOpenPost(`/${encodedId}`);
// Truncate content for sidebar display
const snippet = useMemo(() => {
// Strip URLs for a cleaner snippet
const clean = event.content.replace(/https?:\/\/\S+/g, '').trim();
if (clean.length > 100) return clean.slice(0, 100) + '…';
return clean || '(media)';
}, [event.content]);
return (
<button
onClick={openPost}
onAuxClick={onAuxClick}
className="block w-full text-left hover:bg-secondary/40 -mx-2 px-2 py-2 rounded-lg transition-colors"
>
<div className="flex items-center gap-1.5 mb-0.5">
<Avatar className="size-4">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs font-semibold truncate">
{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : displayName}
</span>
<span className="text-xs text-muted-foreground shrink-0">· {timeAgo(event.created_at)}</span>
</div>
<p className="text-[13px] text-muted-foreground leading-snug line-clamp-2">{snippet}</p>
</button>
);
}
function LatestAccountCard({ event, onDismiss }: { event: NostrEvent; onDismiss: (pubkey: string) => void }) {
let metadata: { name?: string; nip05?: string; picture?: string } = {};
try {
metadata = n.json().pipe(n.metadata()).parse(event.content);
} catch {
// Invalid metadata
}
const displayName = metadata.name || genUserName(event.pubkey);
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
return (
<div className="flex items-center gap-3 group hover:bg-secondary/40 -mx-2 px-2 py-2 rounded-lg transition-colors">
<Link to={`/${npub}`} className="shrink-0">
<Avatar className="size-10">
<AvatarImage src={metadata.picture} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-sm">
{displayName[0].toUpperCase()}
</AvatarFallback>
</Avatar>
</Link>
<div className="flex-1 min-w-0">
<Link to={`/${npub}`} className="font-bold text-sm hover:underline truncate block">
<EmojifiedText tags={event.tags}>{displayName}</EmojifiedText>
</Link>
{metadata.nip05 && (
<VerifiedNip05Text nip05={metadata.nip05} pubkey={event.pubkey} className="text-xs text-muted-foreground truncate block" />
)}
</div>
<button
onClick={() => onDismiss(event.pubkey)}
className="p-1 rounded-full text-muted-foreground hover:bg-secondary transition-colors opacity-0 group-hover:opacity-100"
aria-label={`Dismiss ${displayName}`}
>
<X className="size-4" />
</button>
</div>
);
}
-40
View File
@@ -1,40 +0,0 @@
import { useMemo, type ReactNode } from 'react';
import type { CoreThemeColors } from '@/themes';
import { coreToTokens, toThemeVar } from '@/themes';
import { isDarkTheme } from '@/lib/colorUtils';
interface ScopedThemeProps {
/** The core theme colors to apply within this scope */
colors: CoreThemeColors;
/** Content to render within the themed scope */
children: ReactNode;
/** Additional className for the wrapper div */
className?: string;
}
/**
* Applies custom CSS variable overrides scoped to a container.
* Child components using `bg-background`, `text-foreground`, etc. will
* pick up the scoped values instead of the global ones.
*
* Also sets a `data-theme-mode` attribute for CSS targeting.
*/
export function ScopedTheme({ colors, children, className }: ScopedThemeProps) {
const style = useMemo(() => {
const tokens = coreToTokens(colors);
const vars: Record<string, string> = {};
for (const [key, val] of Object.entries(tokens) as [string, string][]) {
vars[toThemeVar(key)] = val;
}
return vars;
}, [colors]);
const mode = isDarkTheme(colors.background) ? 'dark' : 'light';
return (
<div style={style} data-theme-mode={mode} className={className}>
{children}
</div>
);
}
-319
View File
@@ -1,319 +0,0 @@
import { Link } from 'react-router-dom';
import { Plus, Pencil, Check, SeparatorHorizontal, Search, ChevronDown, ChevronUp, LinkIcon } from 'lucide-react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { sidebarItemIcon, itemPath } from '@/lib/sidebarItems';
import type { HiddenSidebarItem } from '@/hooks/useFeedSettings';
import { nip19 } from 'nostr-tools';
import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain';
interface SidebarMoreMenuProps {
editing: boolean;
hiddenItems: HiddenSidebarItem[];
onDoneEditing: () => void;
onStartEditing: () => void;
onAdd: (id: string) => void;
onAddDivider: () => void;
onNavigate?: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
/** Sidebar item ID configured as the homepage. */
homePage?: string;
}
function useScrollCarets(centerOnOpen = false) {
const scrollRef = useRef<HTMLDivElement>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const [canScrollUp, setCanScrollUp] = useState(false);
const [canScrollDown, setCanScrollDown] = useState(false);
const update = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollUp(el.scrollTop > 0);
setCanScrollDown(el.scrollTop + el.clientHeight < el.scrollHeight - 1);
}, []);
const refCallback = useCallback((el: HTMLDivElement | null) => {
// Disconnect previous observer if any
roRef.current?.disconnect();
roRef.current = null;
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
if (!el) return;
if (centerOnOpen) {
el.scrollTop = (el.scrollHeight - el.clientHeight) / 2;
}
const ro = new ResizeObserver(update);
ro.observe(el);
roRef.current = ro;
update();
}, [centerOnOpen, update]);
const stopScroll = useCallback(() => {
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
}, []);
const startScroll = useCallback((direction: 'up' | 'down') => {
stopScroll();
intervalRef.current = setInterval(() => {
const el = scrollRef.current;
if (!el) return stopScroll();
el.scrollBy({ top: direction === 'up' ? -8 : 8 });
update();
// stop automatically when the limit is reached
const atLimit = direction === 'up' ? el.scrollTop <= 0 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
if (atLimit) stopScroll();
}, 16);
}, [update, stopScroll]);
// clean up interval on unmount
useEffect(() => stopScroll, [stopScroll]);
return { scrollRef, refCallback, canScrollUp, canScrollDown, onScroll: update, startScroll, stopScroll };
}
function ScrollCaret({ direction, onMouseEnter, onMouseLeave }: { direction: 'up' | 'down'; onMouseEnter: () => void; onMouseLeave: () => void }) {
return (
<button className="flex cursor-default items-center justify-center py-1 w-full shrink-0" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{direction === 'up' ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</button>
);
}
function ItemRow({ item, onAdd, onClose }: { item: HiddenSidebarItem; onAdd: (id: string) => void; onClose: () => void }) {
return (
<div className="flex items-center">
<button
onClick={() => { onAdd(item.id); onClose(); }}
className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer"
>
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</button>
<button
onClick={() => { onAdd(item.id); onClose(); }}
className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors"
title={`Add ${item.label} to sidebar`}
>
<Plus className="size-4" strokeWidth={4} />
</button>
</div>
);
}
export function SidebarMoreMenu({
editing, hiddenItems, onDoneEditing, onStartEditing, onAdd, onAddDivider, onNavigate, open, onOpenChange, homePage,
}: SidebarMoreMenuProps) {
const [query, setQuery] = useState('');
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [addQuery, setAddQuery] = useState('');
const [linkInput, setLinkInput] = useState(false);
const [linkValue, setLinkValue] = useState('');
const [linkError, setLinkError] = useState('');
const filtered = hiddenItems.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()));
const addFiltered = hiddenItems.filter((item) => item.label.toLowerCase().includes(addQuery.toLowerCase()));
const handleAddLink = () => {
const raw = linkValue.trim();
if (!raw) return;
// External content: URLs
if (raw.startsWith('https://') || raw.startsWith('http://')) {
onAdd(raw);
setLinkInput(false);
setLinkValue('');
setLinkError('');
return;
}
// External content: iso3166 codes
if (raw.startsWith('iso3166:')) {
const code = raw.slice('iso3166:'.length);
if (!/^[A-Za-z]{2}(-[A-Za-z0-9]+)?$/.test(code)) {
setLinkError('Invalid country/region code');
return;
}
onAdd(raw);
setLinkInput(false);
setLinkValue('');
setLinkError('');
return;
}
// External content: isbn
if (raw.startsWith('isbn:')) {
onAdd(raw);
setLinkInput(false);
setLinkValue('');
setLinkError('');
return;
}
// Nsite URI: nsite://<subdomain>
if (raw.startsWith('nsite://')) {
const subdomain = raw.slice('nsite://'.length);
const parsed = parseNsiteSubdomain(subdomain);
if (!parsed || parsed.kind !== 35128) {
setLinkError('Invalid nsite identifier (only named sites are supported)');
return;
}
onAdd(raw);
setLinkInput(false);
setLinkValue('');
setLinkError('');
return;
}
// Nostr: strip "nostr:" prefix if present for validation
const bech32 = raw.startsWith('nostr:') ? raw.slice(6) : raw;
// Validate it's a valid NIP-19 identifier
try {
const decoded = nip19.decode(bech32);
const validTypes = ['npub', 'nprofile', 'note', 'nevent', 'naddr'];
if (!validTypes.includes(decoded.type)) {
setLinkError('Unsupported identifier type');
return;
}
} catch {
setLinkError('Invalid identifier');
return;
}
// Normalize to "nostr:" prefixed form
const nostrUri = `nostr:${bech32}`;
onAdd(nostrUri);
setLinkInput(false);
setLinkValue('');
setLinkError('');
};
const main = useScrollCarets(true);
const add = useScrollCarets();
if (editing) {
return (
<div className="flex flex-col gap-0.5">
<DropdownMenu open={addMenuOpen} onOpenChange={(o) => { setAddMenuOpen(o); if (!o) setAddQuery(''); }}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
<Plus className="size-4" />
<span>Add</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={addQuery} onChange={(e) => setAddQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{add.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => add.startScroll('up')} onMouseLeave={add.stopScroll} />}
<div ref={add.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={add.onScroll}>
{addFiltered.map((item) => <ItemRow key={item.id} item={item} onAdd={onAdd} onClose={() => setAddMenuOpen(false)} />)}
{addFiltered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
</div>
{add.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => add.startScroll('down')} onMouseLeave={add.stopScroll} />}
</DropdownMenuContent>
</DropdownMenu>
<button onClick={onAddDivider} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85">
<SeparatorHorizontal className="size-4" />
<span>Add divider</span>
</button>
{linkInput ? (
<div className="flex flex-col gap-1 px-4 py-2 bg-background/85 rounded-2xl">
<div className="flex items-center gap-2">
<LinkIcon className="size-4 text-muted-foreground shrink-0" />
<input
value={linkValue}
onChange={(e) => { setLinkValue(e.target.value); setLinkError(''); }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddLink();
} else if (e.key === 'Escape') {
setLinkInput(false);
setLinkValue('');
setLinkError('');
}
}}
placeholder="URL, npub1..., nsite://..., ..."
className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60"
autoFocus
/>
</div>
{linkError && <p className="text-xs text-destructive pl-6">{linkError}</p>}
<div className="flex items-center gap-1.5 pl-6">
<button
onClick={handleAddLink}
className="text-xs font-medium text-primary hover:underline"
>
Add
</button>
<button
onClick={() => { setLinkInput(false); setLinkValue(''); setLinkError(''); }}
className="text-xs text-muted-foreground hover:underline"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={() => setLinkInput(true)}
className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground hover:text-foreground hover:bg-secondary/60 bg-background/85"
>
<LinkIcon className="size-4" />
<span>Add link</span>
</button>
)}
<button onClick={onDoneEditing} className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-primary font-medium hover:bg-primary/10 bg-background/85">
<Check className="size-4" />
<span>Done editing</span>
</button>
</div>
);
}
return (
<DropdownMenu open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setQuery(''); }}>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-4 px-4 py-2.5 rounded-full transition-colors text-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-secondary/40 bg-background/85">
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
<span>More...</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start" collisionPadding={8} className="w-[240px] p-1 flex flex-col max-h-[calc(var(--radix-dropdown-menu-content-available-height)-12px)]">
<div className="flex items-center gap-3 px-2 py-2 shrink-0">
<Search className="size-5 shrink-0" />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search..." className="flex-1 min-w-0 bg-transparent text-base md:text-sm outline-none placeholder:text-muted-foreground/60" autoFocus />
</div>
<div className="h-px bg-border mb-1 shrink-0" />
{main.canScrollUp && <ScrollCaret direction="up" onMouseEnter={() => main.startScroll('up')} onMouseLeave={main.stopScroll} />}
<div ref={main.refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={main.onScroll}>
{filtered.map((item) => (
<div key={item.id} className="flex items-center">
<Link to={itemPath(item.id, undefined, homePage)} onClick={() => { onOpenChange(false); onNavigate?.(); }} className="flex items-center gap-3 flex-1 min-w-0 px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors">
{sidebarItemIcon(item.id, 'size-5 shrink-0')}
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{item.label}</span>
</Link>
<button onClick={() => { onAdd(item.id); onOpenChange(false); }} className="size-8 flex items-center justify-center shrink-0 rounded-sm text-muted-foreground hover:text-primary hover:bg-primary/10 transition-colors" title={`Add ${item.label} to sidebar`}>
<Plus className="size-4" strokeWidth={4} />
</button>
</div>
))}
{filtered.length === 0 && <p className="px-2 py-3 text-sm text-muted-foreground text-center">No results</p>}
</div>
{main.canScrollDown && <ScrollCaret direction="down" onMouseEnter={() => main.startScroll('down')} onMouseLeave={main.stopScroll} />}
<div className="h-px bg-border my-1 shrink-0" />
<button onClick={() => { onStartEditing(); onOpenChange(false); }} className="flex items-center gap-3 w-full px-2 py-2 rounded-sm text-sm hover:bg-secondary/60 transition-colors cursor-pointer shrink-0">
<Pencil className="size-5" />
<span>Edit sidebar</span>
</button>
</DropdownMenuContent>
</DropdownMenu>
);
}
-242
View File
@@ -1,242 +0,0 @@
import { Link } from 'react-router-dom';
import { GripVertical, X } from 'lucide-react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sidebarItemIcon, itemLabel, itemPath, isSidebarDivider, isNostrUri, isExternalUri, isNsiteUri } from '@/lib/sidebarItems';
import { cn } from '@/lib/utils';
import { useCallback } from 'react';
import { NostrEventSidebarItem } from '@/components/NostrEventSidebarItem';
import { NsiteSidebarItem } from '@/components/NsiteSidebarItem';
import { ExternalContentSidebarItem } from '@/components/ExternalContentSidebarItem';
// ── Sortable item ─────────────────────────────────────────────────────────────
export interface SidebarNavItemProps {
id: string;
active: boolean;
editing: boolean;
onRemove: (id: string, index?: number) => void;
onClick?: (e: React.MouseEvent) => void;
profilePath?: string;
showIndicator?: boolean;
/** Extra classes on the link. Defaults to 'text-lg' for desktop. */
linkClassName?: string;
/** Sidebar item ID configured as the homepage. */
homePage?: string;
}
export function SidebarNavItem({
id, active, editing, onRemove, onClick, profilePath, showIndicator, linkClassName, homePage,
}: SidebarNavItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
const icon = sidebarItemIcon(id);
const label = itemLabel(id);
const path = itemPath(id, profilePath, homePage);
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<Link
to={path}
onClick={onClick}
className={cn(
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
editing ? 'px-2' : 'px-3',
active ? 'font-bold text-primary' : 'font-normal text-foreground',
linkClassName ?? 'text-lg',
)}
>
<span className="shrink-0 relative">
{icon}
{showIndicator && (
<span className="absolute -top-1 right-0 size-2.5 bg-primary rounded-full" />
)}
</span>
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>{label}</span>
</Link>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title={`Remove ${label}`}
>
<X className="size-4" />
</button>
)}
</div>
);
}
// ── Divider item ──────────────────────────────────────────────────────────────
interface SidebarDividerItemProps {
sortableId: string;
editing: boolean;
onRemove: () => void;
}
function SidebarDividerItem({ sortableId, editing, onRemove }: SidebarDividerItemProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: sortableId, disabled: !editing });
const style = { transform: CSS.Transform.toString(transform), transition };
return (
<div
ref={setNodeRef}
style={style}
className={cn('flex items-center rounded-full transition-colors relative', editing && 'bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
>
{editing && (
<button
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
)}
<div className={cn('flex-1 flex items-center py-3', editing ? 'px-2' : 'px-3')}>
<div className="h-px w-full bg-border" />
</div>
{editing && (
<button
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
title="Remove divider"
>
<X className="size-4" />
</button>
)}
</div>
);
}
// ── DnD-aware nav list ────────────────────────────────────────────────────────
export interface SidebarNavListProps {
items: string[];
editing: boolean;
onRemove: (id: string, index?: number) => void;
onReorder: (newOrder: string[]) => void;
isActive: (id: string) => boolean;
getOnClick?: (id: string) => ((e: React.MouseEvent) => void) | undefined;
getProfilePath?: (id: string) => string | undefined;
getShowIndicator?: (id: string) => boolean | undefined;
linkClassName?: string;
/** Sidebar item ID configured as the homepage. */
homePage?: string;
}
export function SidebarNavList({
items, editing, onRemove, onReorder, isActive, getOnClick, getProfilePath, getShowIndicator, linkClassName, homePage,
}: SidebarNavListProps) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor),
);
// Assign unique sortable IDs: regular items use their id, dividers get "divider-{index}"
const sortableIds = items.map((id, i) => isSidebarDivider(id) ? `divider-${i}` : id);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = sortableIds.indexOf(active.id as string);
const newIndex = sortableIds.indexOf(over.id as string);
if (oldIndex === -1 || newIndex === -1) return;
onReorder(arrayMove(items, oldIndex, newIndex));
}, [sortableIds, items, onReorder]);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{items.map((id, i) => {
const sortableId = sortableIds[i];
if (isSidebarDivider(id)) {
return (
<SidebarDividerItem
key={sortableId}
sortableId={sortableId}
editing={editing}
onRemove={() => onRemove(id, i)}
/>
);
}
if (isNostrUri(id)) {
return (
<NostrEventSidebarItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
);
}
if (isNsiteUri(id)) {
return (
<NsiteSidebarItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
);
}
if (isExternalUri(id)) {
return (
<ExternalContentSidebarItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
linkClassName={linkClassName}
/>
);
}
return (
<SidebarNavItem
key={id}
id={id}
active={isActive(id)}
editing={editing}
onRemove={(removeId) => onRemove(removeId, i)}
onClick={getOnClick?.(id)}
profilePath={getProfilePath?.(id)}
showIndicator={getShowIndicator?.(id)}
linkClassName={linkClassName}
homePage={homePage}
/>
);
})}
</SortableContext>
</DndContext>
);
}
-57
View File
@@ -1,57 +0,0 @@
import { useMemo } from 'react';
const SPARK_W = 50;
const SPARK_H = 28;
const SPARK_MARGIN = 2;
const SPARK_DIVISOR = 0.25;
/** Maps an array of values to {x, y} SVG coordinates, filling the full chart area. */
function dataToPoints(data: number[]): { x: number; y: number }[] {
const len = data.length;
if (len === 0) return [];
const min = Math.min(...data);
const max = Math.max(...data);
const vfactor = (SPARK_H - SPARK_MARGIN * 2) / ((max - min) || 2);
const hfactor = (SPARK_W - SPARK_MARGIN * 2) / ((len > 1 ? len - 1 : 1));
return data.map((d, i) => ({
x: i * hfactor + SPARK_MARGIN,
y: (max === min ? 1 : (max - d)) * vfactor + SPARK_MARGIN,
}));
}
/** Builds a smooth cubic-bezier SVG path string from {x,y} points. */
function pointsToCurvePath(points: { x: number; y: number }[]): string {
if (points.length === 0) return '';
const cmds: (string | number)[] = [];
let prev: { x: number; y: number } | undefined;
for (const p of points) {
if (!prev) {
cmds.push(p.x, p.y);
} else {
const len = (p.x - prev.x) * SPARK_DIVISOR;
cmds.push('C', prev.x + len, prev.y, p.x - len, p.y, p.x, p.y);
}
prev = p;
}
return 'M' + cmds.join(' ');
}
/** Small sparkline SVG using a smooth cubic-bezier curve. */
export function TrendSparkline({ data }: { data: number[] }) {
const d = useMemo(() => pointsToCurvePath(dataToPoints(data)), [data]);
if (!d) return null;
return (
<svg width={SPARK_W} height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} className="text-primary/60">
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
-58
View File
@@ -1,58 +0,0 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { getEventFallbackText } from '@/lib/extraKinds';
import { cn } from '@/lib/utils';
interface UnknownKindContentProps {
event: NostrEvent;
/** When true, renders a larger variant for the detail page. */
expanded?: boolean;
className?: string;
}
/**
* Fallback renderer for event kinds this client doesn't know how to display.
*
* Never runs the text-note tokenizer (URLs, hashtags, nostr: mentions) over
* arbitrary content — that would misinterpret JSON or empty bodies as kind 1.
* Surfaces the NIP-31 `alt` tag (with fallbacks to title/name/summary/d), or a
* neutral tombstone when nothing is available.
*/
export function UnknownKindContent({ event, expanded = false, className }: UnknownKindContentProps) {
const fallbackText = getEventFallbackText(event);
if (fallbackText) {
return (
<div
className={cn(
'rounded-xl border border-border bg-secondary/30 overflow-hidden',
expanded ? 'mt-3 p-4' : 'mt-2 p-3',
className,
)}
>
<p
className={cn(
'whitespace-pre-wrap break-words text-foreground',
expanded ? 'text-[15px] leading-relaxed' : 'text-sm leading-relaxed',
)}
>
{fallbackText}
</p>
</div>
);
}
return (
<div
className={cn(
'rounded-2xl border border-dashed border-border',
expanded ? 'mt-3' : 'mt-2',
className,
)}
>
<div className="px-3.5 py-4 text-center text-sm text-muted-foreground">
This event kind is not supported
</div>
</div>
);
}
-246
View File
@@ -1,246 +0,0 @@
import { useState, useCallback, useRef } from 'react';
import { Blocks, Upload, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useQueryClient } from '@tanstack/react-query';
import { extractWebxdcMeta } from '@/lib/webxdcMeta';
import { toast } from '@/hooks/useToast';
interface WebxdcUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function WebxdcUploadDialog({ open, onOpenChange }: WebxdcUploadDialogProps) {
const { user } = useCurrentUser();
const { mutateAsync: uploadFile } = useUploadFile();
const { mutateAsync: createEvent } = useNostrPublish();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [description, setDescription] = useState('');
const [appName, setAppName] = useState<string | undefined>();
const [iconUrl, setIconUrl] = useState<string | undefined>();
const [isUploading, setIsUploading] = useState(false);
const [isExtracting, setIsExtracting] = useState(false);
const reset = useCallback(() => {
setFile(null);
setDescription('');
setAppName(undefined);
setIconUrl(undefined);
setIsUploading(false);
setIsExtracting(false);
}, []);
const handleOpenChange = useCallback((next: boolean) => {
if (!next) reset();
onOpenChange(next);
}, [onOpenChange, reset]);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = e.target.files?.[0];
if (!selected) return;
setFile(selected);
setIsExtracting(true);
try {
const meta = await extractWebxdcMeta(selected);
setAppName(meta.name);
// Upload icon if present
if (meta.iconFile) {
try {
const iconTags = await uploadFile(meta.iconFile);
const [[, url]] = iconTags;
setIconUrl(url);
} catch {
// Icon upload failed, continue without it
}
}
} catch {
// Metadata extraction failed, continue without it
} finally {
setIsExtracting(false);
}
}, [uploadFile]);
const handleSubmit = useCallback(async () => {
if (!file || !user) return;
setIsUploading(true);
try {
// Re-wrap with correct MIME type (browsers don't know .xdc)
const uploadableFile = !file.type
? new File([file], file.name, { type: 'application/x-webxdc' })
: file;
const uploadTags = await uploadFile(uploadableFile);
let [[, url]] = uploadTags;
// Ensure URL ends with .xdc
if (!url.endsWith('.xdc')) {
url = url + '.xdc';
}
// Build the kind 1063 tags
const tags: string[][] = [
['url', url],
['m', 'application/x-webxdc'],
];
// Add hash from upload tags
const hashTag = uploadTags.find(t => t[0] === 'x');
if (hashTag) tags.push(['x', hashTag[1]]);
// Add original hash if present
const oxTag = uploadTags.find(t => t[0] === 'ox');
if (oxTag) tags.push(['ox', oxTag[1]]);
// Add file size
const sizeTag = uploadTags.find(t => t[0] === 'size');
if (sizeTag) tags.push(['size', sizeTag[1]]);
// Alt tag with app name
const altText = appName ? `Webxdc app: ${appName}` : 'Webxdc app';
tags.push(['alt', altText]);
// Webxdc UUID for state coordination
const uuid = crypto.randomUUID();
tags.push(['webxdc', uuid]);
// App icon thumbnail
if (iconUrl) tags.push(['image', iconUrl]);
await createEvent({
kind: 1063,
content: description || (appName ? `${appName}` : ''),
tags,
created_at: Math.floor(Date.now() / 1000),
});
toast({ title: 'Published', description: `${appName ?? 'Webxdc app'} shared successfully.` });
queryClient.invalidateQueries({ queryKey: ['feed'] });
handleOpenChange(false);
} catch {
toast({ title: 'Publish failed', description: 'Could not publish the webxdc app.', variant: 'destructive' });
} finally {
setIsUploading(false);
}
}, [file, user, appName, iconUrl, description, uploadFile, createEvent, queryClient, handleOpenChange]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Blocks className="size-5" />
Share Webxdc App
</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* File picker */}
<div className="space-y-2">
<Label>App file</Label>
{file ? (
<div className="flex items-center gap-3 p-3 rounded-xl border border-border bg-muted/30">
{iconUrl ? (
<img
src={iconUrl}
alt={appName ?? 'App icon'}
className="size-10 rounded-xl object-cover"
/>
) : (
<div className="flex items-center justify-center size-10 rounded-xl bg-primary/10">
<Blocks className="size-5 text-primary" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{appName ?? file.name}</p>
<p className="text-xs text-muted-foreground">
{isExtracting ? 'Reading metadata...' : file.name}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
reset();
fileInputRef.current?.click();
}}
>
Change
</Button>
</div>
) : (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex flex-col items-center gap-3 p-8 rounded-xl border-2 border-dashed border-border hover:border-primary/50 hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<Upload className="size-5 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium">Choose a .xdc file</p>
<p className="text-xs text-muted-foreground mt-0.5">Webxdc apps are sandboxed HTML5 archives</p>
</div>
</button>
)}
<input
ref={fileInputRef}
type="file"
accept=".xdc"
className="hidden"
onChange={handleFileSelect}
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="webxdc-description">Description</Label>
<Textarea
id="webxdc-description"
placeholder="What does this app do?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="resize-none"
/>
</div>
{/* Submit */}
<Button
onClick={handleSubmit}
disabled={!file || isUploading || isExtracting}
className="w-full rounded-full"
>
{isUploading ? (
<>
<Loader2 className="size-4 animate-spin mr-2" />
Publishing...
</>
) : (
'Publish'
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
-133
View File
@@ -1,133 +0,0 @@
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { GripVertical, X } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
import type { WidgetConfig } from '@/contexts/AppContext';
interface WidgetCardProps {
definition: WidgetDefinition;
config: WidgetConfig;
onRemove: () => void;
onHeightChange: (height: number) => void;
isDragging?: boolean;
dragHandleProps?: Record<string, unknown>;
children: ReactNode;
}
/** Wrapper for each widget in the sidebar — header, height control. */
export function WidgetCard({
definition,
config,
onRemove,
onHeightChange,
isDragging,
dragHandleProps,
children,
}: WidgetCardProps) {
const configHeight = config.height ?? definition.defaultHeight;
const Icon = definition.icon;
// Local height for smooth resize — only commits to config on pointer up.
const [liveHeight, setLiveHeight] = useState(configHeight);
const [resizing, setResizing] = useState(false);
const liveHeightRef = useRef(liveHeight);
// Sync local height when config changes externally (e.g. cross-device sync).
useEffect(() => {
if (!resizing) {
setLiveHeight(configHeight);
}
}, [configHeight, resizing]);
const handleResizeStart = useCallback((e: React.PointerEvent) => {
e.preventDefault();
setResizing(true);
const startY = e.clientY;
const startHeight = liveHeightRef.current;
const onMove = (ev: PointerEvent) => {
const newHeight = Math.max(
definition.minHeight,
Math.min(definition.maxHeight, startHeight + (ev.clientY - startY)),
);
liveHeightRef.current = newHeight;
setLiveHeight(newHeight);
};
const onUp = () => {
setResizing(false);
onHeightChange(liveHeightRef.current);
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
};
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
}, [definition.minHeight, definition.maxHeight, onHeightChange]);
return (
<div
className={cn(
'bg-background/85 rounded-xl overflow-hidden transition-shadow',
isDragging && 'shadow-lg ring-1 ring-primary/20',
resizing && 'select-none',
)}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-3 py-2">
{/* Icon + label */}
{definition.href ? (
<Link to={definition.href} className="flex items-center gap-1.5 flex-1 min-w-0 hover:text-primary transition-colors">
<Icon className="size-5 text-muted-foreground shrink-0" />
<span className="text-xl font-semibold truncate">{definition.label}</span>
</Link>
) : (
<>
<Icon className="size-5 text-muted-foreground shrink-0" />
<span className="text-xl font-semibold flex-1 truncate">{definition.label}</span>
</>
)}
{/* Remove */}
<button
onClick={onRemove}
className="p-0.5 rounded text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove widget"
>
<X className="size-3.5" />
</button>
{/* Drag handle */}
<button
className="p-0.5 rounded text-muted-foreground/50 hover:text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
{...dragHandleProps}
tabIndex={-1}
>
<GripVertical className="size-3.5" />
</button>
</div>
{/* Content */}
{definition.fillHeight ? (
<div style={{ height: liveHeight }} className={cn('p-2', !resizing && 'transition-[height] duration-200')}>
{children}
</div>
) : (
<ScrollArea style={{ maxHeight: liveHeight }} className={cn(!resizing && 'transition-[max-height] duration-200')}>
<div className="p-2">
{children}
</div>
</ScrollArea>
)}
{/* Resize handle */}
<div
onPointerDown={handleResizeStart}
className="h-1.5 cursor-ns-resize flex items-center justify-center hover:bg-secondary/60 transition-colors"
/>
</div>
);
}
-100
View File
@@ -1,100 +0,0 @@
import { useMemo } from 'react';
import { Check, Plus } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { WIDGET_DEFINITIONS, WIDGET_CATEGORIES } from '@/lib/sidebarWidgets';
import type { WidgetConfig } from '@/contexts/AppContext';
import { cn } from '@/lib/utils';
interface WidgetPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
currentWidgets: WidgetConfig[];
onAdd: (id: string) => void;
onRemove: (id: string) => void;
}
/** Dialog for adding/removing widgets from the sidebar. */
export function WidgetPickerDialog({ open, onOpenChange, currentWidgets, onAdd, onRemove }: WidgetPickerDialogProps) {
const activeIds = useMemo(() => new Set(currentWidgets.map((w) => w.id)), [currentWidgets]);
// Group widgets by category
const grouped = useMemo(() => {
const groups: Record<string, typeof WIDGET_DEFINITIONS> = {};
for (const w of WIDGET_DEFINITIONS) {
(groups[w.category] ??= []).push(w);
}
return groups;
}, []);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Widget</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="space-y-5 pr-2">
{Object.entries(grouped).map(([category, widgets]) => (
<div key={category}>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-1">
{WIDGET_CATEGORIES[category] ?? category}
</h3>
<div className="space-y-1">
{widgets.map((widget) => {
const isActive = activeIds.has(widget.id);
const Icon = widget.icon;
return (
<button
key={widget.id}
onClick={() => {
if (isActive) {
onRemove(widget.id);
} else {
onAdd(widget.id);
}
}}
className={cn(
'flex items-center gap-3 w-full px-3 py-2.5 rounded-xl transition-colors text-left',
isActive
? 'bg-primary/10 hover:bg-primary/15'
: 'hover:bg-secondary/60',
)}
>
<div className={cn(
'size-9 rounded-lg flex items-center justify-center shrink-0',
isActive ? 'bg-primary/20 text-primary' : 'bg-secondary text-muted-foreground',
)}>
<Icon className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{widget.label}</div>
<div className="text-xs text-muted-foreground truncate">{widget.description}</div>
</div>
<div className={cn(
'size-6 rounded-full flex items-center justify-center shrink-0 transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'border border-border text-muted-foreground/50',
)}>
{isActive ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
</div>
</button>
);
})}
</div>
</div>
))}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
-246
View File
@@ -1,246 +0,0 @@
import { useCallback, useMemo, useState, lazy, Suspense, memo } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Plus } from 'lucide-react';
import { WidgetCard } from '@/components/WidgetCard';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { LinkFooter } from '@/components/LinkFooter';
import { Skeleton } from '@/components/ui/skeleton';
import { useAppContext } from '@/hooks/useAppContext';
import { getWidgetDefinition } from '@/lib/sidebarWidgets';
import type { WidgetConfig } from '@/contexts/AppContext';
import type { WidgetDefinition } from '@/lib/sidebarWidgets';
// ── Lazy-loaded widget components ────────────────────────────────────────────
const TrendingWidget = lazy(() => import('@/components/widgets/TrendingWidget').then((m) => ({ default: m.TrendingWidget })));
const HotPostsWidget = lazy(() => import('@/components/widgets/HotPostsWidget').then((m) => ({ default: m.HotPostsWidget })));
const StatusWidget = lazy(() => import('@/components/widgets/StatusWidget').then((m) => ({ default: m.StatusWidget })));
const AIChatWidget = lazy(() => import('@/components/widgets/AIChatWidget').then((m) => ({ default: m.AIChatWidget })));
const BlueskyWidget = lazy(() => import('@/components/widgets/BlueskyWidget').then((m) => ({ default: m.BlueskyWidget })));
const PhotoWidget = lazy(() => import('@/components/widgets/PhotoWidget').then((m) => ({ default: m.PhotoWidget })));
const MusicWidget = lazy(() => import('@/components/widgets/MusicWidget').then((m) => ({ default: m.MusicWidget })));
const FeedWidget = lazy(() => import('@/components/widgets/FeedWidget').then((m) => ({ default: m.FeedWidget })));
const WidgetPickerDialog = lazy(() => import('@/components/WidgetPickerDialog').then((m) => ({ default: m.WidgetPickerDialog })));
// ── Widget content resolver ──────────────────────────────────────────────────
function WidgetContent({ id }: { id: string }) {
switch (id) {
case 'trends':
return <TrendingWidget />;
case 'hot-posts':
return <HotPostsWidget />;
case 'status':
return <StatusWidget />;
case 'ai-chat':
return <AIChatWidget />;
case 'bluesky':
return <BlueskyWidget />;
case 'feed:photos':
return <PhotoWidget />;
case 'feed:music':
return <MusicWidget />;
case 'feed:articles':
return <FeedWidget kinds={[30023]} feedPath="/articles" feedLabel="View all articles" />;
case 'feed:events':
return <FeedWidget kinds={[31922, 31923]} feedPath="/events" feedLabel="View all events" />;
default:
return <p className="text-xs text-muted-foreground p-1">Unknown widget.</p>;
}
}
/** Fallback while a widget component is loading. */
function WidgetSkeleton() {
return (
<div className="space-y-2 p-1">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</div>
);
}
/** Compact fallback shown when a widget crashes. */
function WidgetErrorFallback({ name }: { name: string }) {
return (
<div className="flex flex-col items-center gap-2 py-4 px-3 text-center">
<p className="text-xs text-muted-foreground">{name} failed to load.</p>
<button
onClick={() => window.location.reload()}
className="text-xs text-primary hover:underline"
>
Reload page
</button>
</div>
);
}
// ── Sortable widget wrapper ──────────────────────────────────────────────────
interface SortableWidgetProps {
config: WidgetConfig;
definition: WidgetDefinition;
onRemove: (id: string) => void;
onHeightChange: (id: string, height: number) => void;
}
const SortableWidget = memo(function SortableWidget({ config, definition, onRemove, onHeightChange }: SortableWidgetProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: config.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : undefined,
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<WidgetCard
definition={definition}
config={config}
onRemove={() => onRemove(config.id)}
onHeightChange={(h) => onHeightChange(config.id, h)}
isDragging={isDragging}
dragHandleProps={listeners}
>
<ErrorBoundary fallback={<WidgetErrorFallback name={definition.label} />} reportToSentry>
<Suspense fallback={<WidgetSkeleton />}>
<WidgetContent id={config.id} />
</Suspense>
</ErrorBoundary>
</WidgetCard>
</div>
);
});
// ── Main sidebar ─────────────────────────────────────────────────────────────
const EMPTY_WIDGETS: WidgetConfig[] = [];
export function WidgetSidebar() {
const { config, updateConfig } = useAppContext();
const widgets = config.sidebarWidgets ?? EMPTY_WIDGETS;
const [pickerOpen, setPickerOpen] = useState(false);
// Filter out widgets with unknown definitions
const validWidgets = useMemo(
() => widgets.filter((w) => getWidgetDefinition(w.id)),
[widgets],
);
const updateWidgets = useCallback((updater: (current: WidgetConfig[]) => WidgetConfig[]) => {
updateConfig((c) => ({
...c,
sidebarWidgets: updater(c.sidebarWidgets ?? widgets),
}));
}, [updateConfig, widgets]);
const removeWidget = useCallback((id: string) => {
updateWidgets((ws) => ws.filter((w) => w.id !== id));
}, [updateWidgets]);
const changeHeight = useCallback((id: string, height: number) => {
updateWidgets((ws) => ws.map((w) => w.id === id ? { ...w, height } : w));
}, [updateWidgets]);
const addWidget = useCallback((id: string) => {
updateWidgets((ws) => {
if (ws.some((w) => w.id === id)) return ws;
return [...ws, { id }];
});
}, [updateWidgets]);
// Drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor),
);
const sortableIds = useMemo(() => validWidgets.map((w) => w.id), [validWidgets]);
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
updateWidgets((ws) => {
const oldIndex = ws.findIndex((w) => w.id === active.id);
const newIndex = ws.findIndex((w) => w.id === over.id);
if (oldIndex === -1 || newIndex === -1) return ws;
return arrayMove(ws, oldIndex, newIndex);
});
}, [updateWidgets]);
return (
<aside className="w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-2">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
<div className="space-y-2 flex-1">
{validWidgets.map((w) => {
const def = getWidgetDefinition(w.id);
if (!def) return null;
return (
<SortableWidget
key={w.id}
config={w}
definition={def}
onRemove={removeWidget}
onHeightChange={changeHeight}
/>
);
})}
{/* Add widget button */}
<button
onClick={() => setPickerOpen(true)}
className="flex items-center justify-center gap-1.5 w-full py-2.5 rounded-xl bg-background/85 text-muted-foreground hover:text-foreground hover:bg-background transition-colors text-xs"
>
<Plus className="size-3.5" />
Add widget
</button>
</div>
</SortableContext>
</DndContext>
<div className="mt-3">
<LinkFooter />
</div>
{/* Widget picker dialog */}
<Suspense fallback={null}>
{pickerOpen && (
<WidgetPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
currentWidgets={widgets}
onAdd={addWidget}
onRemove={removeWidget}
/>
)}
</Suspense>
</aside>
);
}
-116
View File
@@ -1,116 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Zap } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { EmojifiedText } from '@/components/CustomEmoji';
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
import { useAuthor } from '@/hooks/useAuthor';
import { useFormatMoney } from '@/hooks/useFormatMoney';
import { useVerifiedOnchainZap } from '@/hooks/useOnchainZaps';
import { extractZapAmount, extractZapMessage } from '@/hooks/useEventInteractions';
import { genUserName } from '@/lib/genUserName';
import { isNostrId } from '@/lib/nostrId';
interface ZapContentProps {
/** The zap event itself (kind 9735 Lightning receipt or kind 8333 on-chain). */
event: NostrEvent;
/**
* If set, this is a profile-targeted zap and this pubkey is the
* recipient (from the event's `p` tag). Renders a muted
* "Zapped @recipient" context line above the amount. Omit for
* note-zaps — those use `zappedBy` overlays on the target note.
*/
recipientPubkey?: string;
}
/**
* Renders the body of a standalone zap card: a muted "Zapped @recipient"
* context line (for profile-targeted zaps), the prominent amber amount,
* and the optional sender comment. Used inside `NoteCard`'s content
* block for kind 9735 / 8333 events.
*/
export function ZapContent({ event, recipientPubkey }: ZapContentProps) {
const isOnchain = event.kind === 8333;
// For on-chain zaps, verify the claimed amount against the underlying
// Bitcoin transaction. Lightning zaps are trusted via the LNURL
// server's signature, so we read the amount directly.
const verified = useVerifiedOnchainZap(isOnchain ? event : undefined);
const isVerifying = isOnchain && verified === undefined;
const failedVerification = isOnchain && verified === null;
const sats = useMemo(() => {
if (isOnchain) {
if (verified?.amountSats) return verified.amountSats;
const amountTag = event.tags.find(([n]) => n === 'amount');
const n = amountTag?.[1] ? parseInt(amountTag[1], 10) : 0;
return Number.isFinite(n) && n > 0 ? n : 0;
}
return Math.floor(extractZapAmount(event) / 1000);
}, [event, isOnchain, verified]);
// Lightning zap messages live inside the embedded NIP-57 zap-request
// JSON; on-chain zaps put the comment directly in `content`.
const message = isOnchain ? event.content.trim() : extractZapMessage(event);
const { format: formatMoney } = useFormatMoney();
return (
<div className="mt-2 space-y-2">
{recipientPubkey && isNostrId(recipientPubkey) && (
<ZapRecipientLine pubkey={recipientPubkey} />
)}
{sats > 0 && (
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-3xl font-bold text-amber-500 tabular-nums">
{formatMoney(sats)}
</span>
{failedVerification ? (
<span className="text-xs text-muted-foreground">unverified</span>
) : isVerifying ? (
<span className="text-xs text-muted-foreground">verifying</span>
) : null}
</div>
)}
{message && (
<p className="text-[15px] leading-relaxed text-foreground whitespace-pre-wrap break-words">
{message}
</p>
)}
</div>
);
}
/** Muted "⚡ Zapped @recipient" context line, modeled on ProfileCommentContext. */
function ZapRecipientLine({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
return (
<div className="flex items-center gap-x-1 text-sm text-muted-foreground min-w-0 overflow-hidden">
<Zap className="size-3.5 text-amber-500 shrink-0" />
<span className="shrink-0">Zapped</span>
<ProfileHoverCard pubkey={pubkey} asChild>
<Link
to={`/${npubEncoded}`}
className="text-primary hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
@{author.data?.event ? (
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
) : (
displayName
)}
</Link>
</ProfileHoverCard>
</div>
);
}
File diff suppressed because it is too large Load Diff
-99
View File
@@ -1,99 +0,0 @@
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface LinkDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedText?: string;
onSubmit: (text: string, url: string) => void;
}
export function LinkDialog({ open, onOpenChange, selectedText, onSubmit }: LinkDialogProps) {
const [text, setText] = useState('');
const [url, setUrl] = useState('');
// Reset form when dialog opens
useEffect(() => {
if (open) {
setText(selectedText || '');
setUrl('');
}
}, [open, selectedText]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (url.trim()) {
const finalText = text.trim() || url.trim();
let finalUrl = url.trim();
// Add https:// if no protocol specified
if (!/^https?:\/\//i.test(finalUrl)) {
finalUrl = 'https://' + finalUrl;
}
onSubmit(finalText, finalUrl);
onOpenChange(false);
}
};
const hasSelectedText = !!selectedText;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Insert Link</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{!hasSelectedText && (
<div className="space-y-2">
<Label htmlFor="link-text">Link Text</Label>
<Input
id="link-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter link text..."
autoFocus={!hasSelectedText}
/>
</div>
)}
{hasSelectedText && (
<div className="space-y-2">
<Label className="text-muted-foreground">Link Text</Label>
<p className="text-sm bg-muted px-3 py-2 rounded-md">{selectedText}</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="link-url">URL</Label>
<Input
id="link-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
autoFocus={hasSelectedText}
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!url.trim()}>
Insert Link
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
-395
View File
@@ -1,395 +0,0 @@
import { useEffect, useRef, useCallback, useState } from 'react';
import { Editor, rootCtx, defaultValueCtx, editorViewCtx } from '@milkdown/core';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import { commonmark, toggleStrongCommand, toggleEmphasisCommand, wrapInBlockquoteCommand, insertHrCommand, turnIntoTextCommand, wrapInHeadingCommand, toggleInlineCodeCommand, wrapInBulletListCommand, wrapInOrderedListCommand } from '@milkdown/preset-commonmark';
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm';
import { history } from '@milkdown/plugin-history';
import { clipboard } from '@milkdown/plugin-clipboard';
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import { upload, uploadConfig } from '@milkdown/plugin-upload';
import { Decoration } from '@milkdown/prose/view';
import { replaceAll, callCommand } from '@milkdown/utils';
import { MilkdownToolbar } from './MilkdownToolbar';
import { LinkDialog } from './LinkDialog';
interface MilkdownEditorInnerProps {
value: string;
onChange: (markdown: string) => void;
onBlur?: () => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
showToolbar?: boolean;
sourceMode?: boolean;
onToggleSource?: () => void;
}
function MilkdownEditorInner({ value, onChange, onBlur, onUploadImage, placeholder, showToolbar = true, sourceMode, onToggleSource }: MilkdownEditorInnerProps) {
const initialValueRef = useRef(value);
const editorRef = useRef<Editor | null>(null);
const lastExternalValue = useRef(value);
const onUploadImageRef = useRef(onUploadImage);
const fileInputRef = useRef<HTMLInputElement>(null);
// Link dialog state
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [selectedTextForLink, setSelectedTextForLink] = useState<string>('');
const selectionRef = useRef<{ from: number; to: number } | null>(null);
// Keep refs in sync so Milkdown remounts (e.g. source mode toggle) use
// the latest value rather than the stale value captured on first render.
useEffect(() => {
initialValueRef.current = value;
onUploadImageRef.current = onUploadImage;
}, [value, onUploadImage]);
const { get } = useEditor((root) => {
const editor = Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialValueRef.current);
ctx.get(listenerCtx).markdownUpdated((_, markdown) => {
lastExternalValue.current = markdown;
onChange(markdown);
});
// Configure upload plugin
ctx.set(uploadConfig.key, {
uploader: async (files, schema) => {
const images: File[] = [];
for (let i = 0; i < files.length; i++) {
const file = files.item(i);
if (!file) continue;
// Only handle images
if (!file.type.includes('image')) continue;
images.push(file);
}
const nodes: ReturnType<typeof schema.nodes.image.createAndFill>[] = [];
for (const image of images) {
try {
// Use the upload handler if provided
if (onUploadImageRef.current) {
const url = await onUploadImageRef.current(image);
if (url) {
const node = schema.nodes.image.createAndFill({
src: url,
alt: image.name,
});
if (node) nodes.push(node);
}
} else {
// Fallback to base64 if no upload handler
const reader = new FileReader();
const dataUrl = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(image);
});
const node = schema.nodes.image.createAndFill({
src: dataUrl,
alt: image.name,
});
if (node) nodes.push(node);
}
} catch (error) {
console.error('Failed to upload image:', error);
}
}
return nodes.filter((node): node is NonNullable<typeof node> => node !== null);
},
enableHtmlFileUploader: true,
uploadWidgetFactory: (pos, spec) => {
// Create a placeholder widget while uploading
const widgetEl = document.createElement('span');
widgetEl.className = 'milkdown-upload-placeholder';
widgetEl.textContent = 'Uploading...';
return Decoration.widget(pos, widgetEl, spec);
},
});
})
.use(commonmark)
.use(gfm)
.use(history)
.use(clipboard)
.use(listener)
.use(upload);
return editor;
});
// Store editor reference
useEffect(() => {
editorRef.current = get() ?? null;
}, [get]);
// Toggle `has-content` class on blur so CSS can hide the placeholder
// when the editor has real content (including trailing whitespace that
// ProseMirror collapses out of the DOM).
useEffect(() => {
const editor = get();
if (!editor) return;
let dom: HTMLElement;
try {
dom = editor.ctx.get(editorViewCtx).dom;
} catch {
return;
}
const check = () => {
const hasContent = !!lastExternalValue.current.replace(/\n/g, '');
dom.classList.toggle('has-content', hasContent);
};
// Set initial state
check();
dom.addEventListener('blur', check);
return () => dom.removeEventListener('blur', check);
}, [get]);
// Handle external value changes (e.g., loading a draft).
// In source mode, just keep lastExternalValue in sync so the guard works
// correctly when switching back. When not in source mode, push the new
// value into the Milkdown editor via replaceAll.
useEffect(() => {
if (sourceMode) {
// Track textarea changes so we don't needlessly replaceAll on switch-back
lastExternalValue.current = value;
return;
}
const editor = get();
if (editor && value !== lastExternalValue.current) {
try {
editor.action(replaceAll(value));
} catch {
// editorView may not be ready yet (e.g. first render); ignore
return;
}
lastExternalValue.current = value;
}
}, [value, get, sourceMode]);
// Handle link dialog open
const handleLinkButtonClick = useCallback(() => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state } = view;
const { from, to } = state.selection;
const selectedText = state.doc.textBetween(from, to);
// Store selection for later use
selectionRef.current = { from, to };
setSelectedTextForLink(selectedText);
setLinkDialogOpen(true);
} catch (error) {
console.error('Failed to get selection:', error);
}
}, [get]);
// Handle link insertion from dialog
const handleLinkSubmit = useCallback((text: string, url: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
// Create a link mark
const linkMark = schema.marks.link.create({ href: url });
// Create text node with link mark
const linkNode = schema.text(text, [linkMark]);
const tr = state.tr;
if (selectionRef.current) {
const { from, to } = selectionRef.current;
// Replace selection with linked text
tr.replaceWith(from, to, linkNode);
} else {
// Insert at current position
const { from } = state.selection;
tr.insert(from, linkNode);
}
dispatch(tr);
view.focus();
} catch (error) {
console.error('Failed to insert link:', error);
}
}, [get]);
// Handle image upload via file picker + ProseMirror insertion
const handleImageButtonClick = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleImageFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !onUploadImageRef.current) return;
const url = await onUploadImageRef.current(file);
if (!url) return;
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
const { state, dispatch } = view;
const { schema } = state;
const node = schema.nodes.image.createAndFill({ src: url, alt: file.name });
if (node) {
const { from } = state.selection;
dispatch(state.tr.insert(from, node));
view.focus();
}
} catch (error) {
console.error('Failed to insert image:', error);
}
// Reset so the same file can be re-selected
e.target.value = '';
}, [get]);
// Handle toolbar commands
const handleCommand = useCallback((command: string) => {
const editor = get();
if (!editor) return;
try {
const view = editor.ctx.get(editorViewCtx);
switch (command) {
case 'toggleBold':
editor.action(callCommand(toggleStrongCommand.key));
break;
case 'toggleItalic':
editor.action(callCommand(toggleEmphasisCommand.key));
break;
case 'toggleStrikethrough':
editor.action(callCommand(toggleStrikethroughCommand.key));
break;
case 'toggleInlineCode':
editor.action(callCommand(toggleInlineCodeCommand.key));
break;
case 'heading1':
editor.action(callCommand(wrapInHeadingCommand.key, 1));
break;
case 'heading2':
editor.action(callCommand(wrapInHeadingCommand.key, 2));
break;
case 'heading3':
editor.action(callCommand(wrapInHeadingCommand.key, 3));
break;
case 'bulletList':
editor.action(callCommand(wrapInBulletListCommand.key));
break;
case 'orderedList':
editor.action(callCommand(wrapInOrderedListCommand.key));
break;
case 'blockquote':
editor.action(callCommand(wrapInBlockquoteCommand.key));
break;
case 'link':
handleLinkButtonClick();
return; // Don't refocus, dialog will handle it
case 'hr':
editor.action(callCommand(insertHrCommand.key));
break;
case 'paragraph':
editor.action(callCommand(turnIntoTextCommand.key));
break;
}
// Refocus the editor
view.focus();
} catch (error) {
console.error('Command failed:', error);
}
}, [get, handleLinkButtonClick]);
return (
<>
{showToolbar && (
<MilkdownToolbar
onCommand={handleCommand}
onImageUpload={onUploadImage ? handleImageButtonClick : undefined}
sourceMode={sourceMode}
onToggleSource={onToggleSource}
/>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageFileChange}
className="hidden"
/>
{sourceMode ? (
<textarea
dir="auto"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
className="w-full min-h-[250px] sm:min-h-[350px] p-3 bg-transparent font-mono text-sm resize-y outline-none"
placeholder={placeholder}
spellCheck={false}
/>
) : (
<div
dir="auto"
className="milkdown-content"
onBlur={onBlur}
style={placeholder ? { '--ph': `"${placeholder.replace(/"/g, '\\"')}"` } as React.CSSProperties : undefined}
>
<Milkdown />
</div>
)}
<LinkDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
selectedText={selectedTextForLink}
onSubmit={handleLinkSubmit}
/>
</>
);
}
interface MilkdownEditorProps {
value: string;
onChange: (markdown: string) => void;
onBlur?: () => void;
onUploadImage?: (file: File) => Promise<string | null>;
placeholder?: string;
className?: string;
showToolbar?: boolean;
}
export function MilkdownEditor({ value, onChange, onBlur, onUploadImage, placeholder, className, showToolbar = true }: MilkdownEditorProps) {
const [sourceMode, setSourceMode] = useState(false);
return (
<div className={`milkdown-editor ${className || ''}`}>
<MilkdownProvider>
<MilkdownEditorInner
value={value}
onChange={onChange}
onBlur={onBlur}
onUploadImage={onUploadImage}
placeholder={placeholder}
showToolbar={showToolbar}
sourceMode={sourceMode}
onToggleSource={() => setSourceMode((s) => !s)}
/>
</MilkdownProvider>
</div>
);
}
-233
View File
@@ -1,233 +0,0 @@
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Link,
Image,
Minus,
HelpCircle,
Eye,
EyeOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
function MarkdownHelpPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
>
<HelpCircle className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-2">
<h4 className="font-medium text-sm">Markdown Quick Reference</h4>
<div className="text-xs space-y-1.5 font-mono text-muted-foreground">
<div className="flex justify-between"><span>**bold**</span><span className="font-sans font-bold">bold</span></div>
<div className="flex justify-between"><span>*italic*</span><span className="font-sans italic">italic</span></div>
<div className="flex justify-between"><span># Heading 1</span><span className="font-sans">H1</span></div>
<div className="flex justify-between"><span>## Heading 2</span><span className="font-sans">H2</span></div>
<div className="flex justify-between"><span>- list item</span><span className="font-sans">* item</span></div>
<div className="flex justify-between"><span>1. numbered</span><span className="font-sans">1. item</span></div>
<div className="flex justify-between"><span>[text](url)</span><span className="font-sans text-primary">link</span></div>
<div className="flex justify-between"><span>![alt](url)</span><span className="font-sans">image</span></div>
<div className="flex justify-between"><span>&gt; quote</span><span className="font-sans border-l-2 pl-1">quote</span></div>
<div className="flex justify-between"><span>`code`</span><span className="font-sans bg-muted px-1 rounded">code</span></div>
</div>
<p className="text-xs text-muted-foreground pt-2 border-t">
Drag & drop or paste images to upload
</p>
</div>
</PopoverContent>
</Popover>
);
}
const hasPointerFine = typeof window !== 'undefined'
&& window.matchMedia('(pointer: fine)').matches;
interface ToolbarButtonProps {
icon: React.ReactNode;
label: string;
shortcut?: string;
onClick: () => void;
active?: boolean;
}
function ToolbarButton({ icon, label, shortcut, onClick, active }: ToolbarButtonProps) {
const button = (
<Button
variant="ghost"
size="icon"
onClick={onClick}
aria-label={label}
className={cn(
"h-8 w-8 text-muted-foreground hover:text-foreground",
active && "bg-muted text-foreground"
)}
>
{icon}
</Button>
);
if (!hasPointerFine) return button;
return (
<Tooltip>
<TooltipTrigger asChild>
{button}
</TooltipTrigger>
<TooltipContent>
<span>{label}</span>
{shortcut && <span className="ml-2 text-muted-foreground text-xs">{shortcut}</span>}
</TooltipContent>
</Tooltip>
);
}
interface MilkdownToolbarProps {
onCommand: (command: string) => void;
onImageUpload?: () => void;
sourceMode?: boolean;
onToggleSource?: () => void;
className?: string;
}
export function MilkdownToolbar({ onCommand, onImageUpload, sourceMode, onToggleSource, className }: MilkdownToolbarProps) {
return (
<div className={cn(
"flex items-center gap-0.5 p-1.5 border-b border-border bg-card/95 backdrop-blur-sm flex-wrap sticky top-0 z-10 rounded-t-xl",
className
)}>
{!sourceMode && (
<>
{/* Text formatting */}
<ToolbarButton
icon={<Bold className="h-4 w-4" />}
label="Bold"
shortcut="Ctrl+B"
onClick={() => onCommand('toggleBold')}
/>
<ToolbarButton
icon={<Italic className="h-4 w-4" />}
label="Italic"
shortcut="Ctrl+I"
onClick={() => onCommand('toggleItalic')}
/>
<ToolbarButton
icon={<Strikethrough className="h-4 w-4" />}
label="Strikethrough"
onClick={() => onCommand('toggleStrikethrough')}
/>
<ToolbarButton
icon={<Code className="h-4 w-4" />}
label="Inline Code"
onClick={() => onCommand('toggleInlineCode')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Headings */}
<ToolbarButton
icon={<Heading1 className="h-4 w-4" />}
label="Heading 1"
onClick={() => onCommand('heading1')}
/>
<ToolbarButton
icon={<Heading2 className="h-4 w-4" />}
label="Heading 2"
onClick={() => onCommand('heading2')}
/>
<ToolbarButton
icon={<Heading3 className="h-4 w-4" />}
label="Heading 3"
onClick={() => onCommand('heading3')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Lists */}
<ToolbarButton
icon={<List className="h-4 w-4" />}
label="Bullet List"
onClick={() => onCommand('bulletList')}
/>
<ToolbarButton
icon={<ListOrdered className="h-4 w-4" />}
label="Numbered List"
onClick={() => onCommand('orderedList')}
/>
<ToolbarButton
icon={<Quote className="h-4 w-4" />}
label="Blockquote"
onClick={() => onCommand('blockquote')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
{/* Links and media */}
<ToolbarButton
icon={<Link className="h-4 w-4" />}
label="Insert Link"
onClick={() => onCommand('link')}
/>
{onImageUpload && (
<ToolbarButton
icon={<Image className="h-4 w-4" />}
label="Insert Image"
onClick={onImageUpload}
/>
)}
<ToolbarButton
icon={<Minus className="h-4 w-4" />}
label="Horizontal Rule"
onClick={() => onCommand('hr')}
/>
<Separator orientation="vertical" className="mx-1 h-6" />
<MarkdownHelpPopover />
</>
)}
{sourceMode && (
<>
<span className="text-xs text-muted-foreground px-1.5">Markdown Source</span>
<span className="flex-1" />
</>
)}
{onToggleSource && (
<ToolbarButton
icon={sourceMode ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
label={sourceMode ? 'Rich text editor' : 'Markdown source'}
active={sourceMode}
onClick={onToggleSource}
/>
)}
</div>
);
}
@@ -1,53 +0,0 @@
import { useState } from 'react';
import { Music } from 'lucide-react';
import { KindInfoButton } from '@/components/KindInfoButton';
import type { ExtraKindDef } from '@/lib/extraKinds';
import { cn } from '@/lib/utils';
interface ContentCTACardProps {
/** The ExtraKindDef for this content type (used for the info dialog). */
kindDef: ExtraKindDef;
/** Title text (e.g. "Share Your Music on Nostr"). */
title: string;
/** Subtitle text. */
subtitle: string;
/** Icon to display above the title. Defaults to Music. */
icon?: React.ReactNode;
/** Extra classes on the outer container. */
className?: string;
}
/**
* Call-to-action card for content discovery pages.
* Displays a gradient card with icon, title, subtitle, and a "Learn More" button
* that opens the KindInfoButton dialog showing external apps for this content type.
*
* Used at the bottom of music, podcast, and other discovery tabs.
*/
export function ContentCTACard({ kindDef, title, subtitle, icon, className }: ContentCTACardProps) {
const [infoOpen, setInfoOpen] = useState(false);
return (
<div className={cn('mx-4 rounded-2xl overflow-hidden relative', className)}>
<div className="bg-gradient-to-br from-primary/20 via-primary/10 to-accent/10">
<div className="p-6 text-center">
<div className="flex justify-center text-primary/40">
{icon ?? <Music className="size-10" />}
</div>
<h3 className="text-lg font-bold mt-3">{title}</h3>
<p className="text-sm text-muted-foreground mt-2 max-w-xs mx-auto">{subtitle}</p>
<button
onClick={() => setInfoOpen(true)}
className="mt-4 px-6 py-2 rounded-full bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
Learn More
</button>
</div>
</div>
{/* Hidden KindInfoButton that we control programmatically */}
<div className="hidden">
<KindInfoButton kindDef={kindDef} open={infoOpen} onOpenChange={setInfoOpen} />
</div>
</div>
);
}
@@ -1,205 +0,0 @@
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
import { getCountryInfo, subdivisionFlag } from '@/lib/countries';
import { Skeleton } from '@/components/ui/skeleton';
import { CountryFlag } from '@/components/CountryFlag';
import { cn } from '@/lib/utils';
interface CountryPulseStripProps {
/** Maximum number of country chips to render. Default: 24. */
limit?: number;
className?: string;
}
interface CountryEntry {
/** Full ISO 3166 identifier — either `XX` (country) or `XX-YY` (subdivision). */
code: string;
/**
* Display name. For subdivisions this is the subdivision name
* (e.g. "California"); for countries it's the country name
* (e.g. "United States").
*/
name: string;
/**
* Best emoji flag for this entry. Subdivisions with an RGI tag
* sequence (currently England, Scotland, Wales) get their actual
* subnational flag; everything else falls back to the parent country
* flag and a subdivision-code badge.
*/
flag: string;
/**
* Subdivision token (e.g. `CA`, `TX`, `BY`) shown as a small badge
* overlay when the entry is a state/province *and* no native
* subdivision flag exists. `undefined` for country-level entries and
* for subdivisions that already have their own flag emoji.
*/
subdivisionToken?: string;
/** Activity count from kind 30385 stats. */
count: number;
}
/**
* Horizontal strip of country (and subdivision) flag chips, ordered by
* trailing-window activity from the trusted kind 30385 stats snapshots.
* Hovering a chip lifts its flag and brightens its warm gradient
* sleeve; clicking opens the entity's NIP-73 external-identifier feed
* at `/i/iso3166:XX` (or `/i/iso3166:XX-YY` for subdivisions).
*
* Sits below the hero on the Discover page as a low-friction entry into
* geo-scoped browsing — the "this is where the world is showing up
* today" rail.
*
* Renders a soft-pulse skeleton while activity data is loading so the
* page never collapses to zero height between the hero and the campaign
* shelf.
*/
/**
* ISO 3166-2 codes we display as country-level entries rather than
* subdivisions. The strip shows the subdivision's *own* name (no parent
* country fallback) and suppresses the small ISO-suffix badge — these
* are entities with their own widely-recognised flag and identity.
*
* Currently just Tibet: ISO 3166-2 lists `CN-XZ` as "Tibet Autonomous
* Region" under China, but the editorial position here is to surface it
* as a country in its own right with the Snow Lion flag.
*/
const COUNTRY_LEVEL_SUBDIVISIONS: Record<string, string> = {
'CN-XZ': 'Tibet',
};
export function CountryPulseStrip({ limit = 24, className }: CountryPulseStripProps) {
const { data: activityByCountry, isLoading } = useGlobalActivity();
const entries = useMemo<CountryEntry[]>(() => {
if (!activityByCountry) return [];
const out: CountryEntry[] = [];
for (const [code, count] of activityByCountry) {
const info = getCountryInfo(code);
if (!info) continue;
const upperCode = code.toUpperCase();
const countryLevelOverride = COUNTRY_LEVEL_SUBDIVISIONS[upperCode];
const isSubdivision = !!info.subdivision;
// Country-level override (Tibet etc.) wins: use the editorial
// name, drop the subdivision-token badge, and let CountryFlag pick
// up the bundled SVG asset on render.
if (countryLevelOverride) {
out.push({
code,
name: countryLevelOverride,
flag: info.flag,
subdivisionToken: undefined,
count,
});
continue;
}
// Prefer subdivision-level naming when available — "California" reads
// far more usefully than yet another "United States" tile.
const name = (isSubdivision ? info.subdivisionName : info.name) ?? info.name;
// Use the real subdivision flag (England/Scotland/Wales tag
// sequences) when one exists; otherwise fall back to the parent
// country flag and surface the ISO 3166-2 suffix as a badge.
const nativeSubFlag = isSubdivision && info.subdivision
? subdivisionFlag(info.subdivision)
: null;
const flag = nativeSubFlag ?? info.flag;
const subdivisionToken = isSubdivision && !nativeSubFlag
? info.subdivision?.split('-')[1]
: undefined;
out.push({
code,
name,
flag,
subdivisionToken,
count,
});
}
out.sort((a, b) => b.count - a.count);
return out.slice(0, limit);
}, [activityByCountry, limit]);
// Vertical padding (`py-2`) on the scroll track gives the chips room
// to lift on hover (`-translate-y-0.5` plus the slightly larger glyph)
// without `overflow-x-auto` cropping the top edge — `overflow-x-auto`
// implicitly clips on the Y axis too, and `overflow-y-visible` is
// unreliable across browsers, so we pad instead of fight the clip.
const scrollClass = 'flex gap-3 overflow-x-auto scrollbar-none px-4 py-2';
if (isLoading && entries.length === 0) {
return (
<div className={cn(scrollClass, className)}>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-28 shrink-0 rounded-2xl" />
))}
</div>
);
}
if (entries.length === 0) return null;
return (
<div className={cn(scrollClass, className)}>
{entries.map((entry) => (
<CountryChip key={entry.code} entry={entry} />
))}
</div>
);
}
function CountryChip({ entry }: { entry: CountryEntry }) {
const ariaLabel = entry.subdivisionToken
? `${entry.name} (${entry.code}): ${entry.count.toLocaleString()} recent comments`
: `${entry.name}: ${entry.count.toLocaleString()} recent comments`;
return (
<Link
to={`/i/iso3166:${entry.code}`}
className={cn(
'group relative flex w-28 shrink-0 flex-col items-center gap-1 rounded-2xl p-3',
'bg-gradient-to-br from-amber-100/30 via-rose-100/20 to-amber-50/20',
'dark:from-amber-900/20 dark:via-rose-900/15 dark:to-amber-950/15',
'border border-amber-200/40 dark:border-amber-900/40',
'shadow-sm motion-safe:transition-all motion-safe:duration-200',
'hover:shadow-md hover:-translate-y-0.5',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background',
)}
aria-label={ariaLabel}
>
{/* Flag + optional subdivision token. The token is a tiny pill
anchored bottom-right of the flag glyph — no emoji font has
reliable subnational flags, so we surface the ISO 3166-2
suffix (e.g. "CA", "TX", "BY") as a typographic badge.
`CountryFlag` swaps in a bundled SVG for codes that have a
recognised flag but no Unicode emoji (currently Tibet). */}
<span className="relative leading-none motion-safe:transition-transform group-hover:scale-110 inline-block">
<CountryFlag
code={entry.code}
emoji={entry.flag}
label={entry.name}
className="text-3xl"
/>
{entry.subdivisionToken && (
<span
className={cn(
'absolute -bottom-1 -right-2 px-1.5 py-0.5 rounded-md',
'text-[9px] font-bold tracking-wider leading-none',
'bg-background/95 text-foreground/85 border border-border/70 shadow-sm',
)}
aria-hidden="true"
>
{entry.subdivisionToken}
</span>
)}
</span>
<span className="text-[11px] font-semibold text-foreground/90 line-clamp-1 max-w-full">
{entry.name}
</span>
<span className="text-[10px] font-medium text-muted-foreground">
{entry.count.toLocaleString()}
</span>
</Link>
);
}
@@ -1,23 +0,0 @@
import { cn } from '@/lib/utils';
interface HorizontalScrollProps {
children: React.ReactNode;
/** Extra classes on the scroll container. */
className?: string;
}
/**
* Shared horizontal scroll container for discovery page sections.
* Provides a flex row with overflow-x-auto and hidden scrollbar,
* matching Ditto's existing scroll-snap-free pattern.
*
* Used by music track cards, playlist cards, artist cards, and
* other horizontally-scrolling content sections.
*/
export function HorizontalScroll({ children, className }: HorizontalScrollProps) {
return (
<div className={cn('flex gap-3 overflow-x-auto scrollbar-none px-4 pb-1', className)}>
{children}
</div>
);
}
-57
View File
@@ -1,57 +0,0 @@
import { cn } from '@/lib/utils';
interface TagChipsProps {
/** Available tags/genres to display as chips. */
tags: string[];
/** Currently selected tag (null/undefined = "All"). */
selected?: string | null;
/** Called when a tag chip is clicked. Called with `null` for the "All" chip. */
onSelect: (tag: string | null) => void;
/** Label for the "show all" chip (default: "All"). */
allLabel?: string;
/** Extra classes on the outer scroll container. */
className?: string;
}
/**
* Horizontal scrollable row of tag/genre pill buttons with an "All" default.
*
* Used by music genre filtering, podcast categories, and other
* tag-based discovery filters.
*/
export function TagChips({ tags, selected, onSelect, allLabel = 'All', className }: TagChipsProps) {
const isAllActive = !selected;
return (
<div className={cn('flex gap-2 overflow-x-auto scrollbar-none px-4 py-3', className)}>
<button
onClick={() => onSelect(null)}
className={cn(
'shrink-0 px-4 py-1.5 rounded-full text-sm font-medium transition-colors duration-200',
isAllActive
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:bg-secondary',
)}
>
{allLabel}
</button>
{tags.map((tag) => {
const isActive = selected === tag;
return (
<button
key={tag}
onClick={() => onSelect(isActive ? null : tag)}
className={cn(
'shrink-0 px-4 py-1.5 rounded-full text-sm font-medium transition-colors duration-200 capitalize',
isActive
? 'bg-primary text-primary-foreground'
: 'bg-secondary/50 text-muted-foreground hover:bg-secondary',
)}
>
{tag}
</button>
);
})}
</div>
);
}
@@ -1,24 +0,0 @@
import React from 'react';
/**
* Bars Staggered icon — three horizontal bars with staggered alignment.
* Based on Font Awesome Free v7.2.0 (https://fontawesome.com).
*/
export const BarsStaggeredIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 640 640"
fill="currentColor"
className={className}
{...props}
>
<path d="M64 160C64 142.3 78.3 128 96 128L480 128C497.7 128 512 142.3 512 160C512 177.7 497.7 192 480 192L96 192C78.3 192 64 177.7 64 160zM128 320C128 302.3 142.3 288 160 288L544 288C561.7 288 576 302.3 576 320C576 337.7 561.7 352 544 352L160 352C142.3 352 128 337.7 128 320zM512 480C512 497.7 497.7 512 480 512L96 512C78.3 512 64 497.7 64 480C64 462.3 78.3 448 96 448L480 448C497.7 448 512 462.3 512 480z" />
</svg>
),
);
BarsStaggeredIcon.displayName = 'BarsStaggeredIcon';
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
/**
* Mailbox icon from lief — a rounded post-box with a flag.
* Rendered as a standard lucide-style SVG component.
*/
export const MailboxIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, strokeWidth = 2, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<path d="M20 5.5A4 4 0 0 1 22 9v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a4 4 0 0 1 8 0v8a2 2 0 0 1-2 2M6 5h4" />
<path d="M14 9V5h2v1h-2" />
</svg>
),
);
MailboxIcon.displayName = 'MailboxIcon';
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
/**
* Planet icon from @lucide/lab — used for the Feed nav item.
* Rendered as a standard lucide-style SVG component.
*/
export const PlanetIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, strokeWidth = 2, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
{...props}
>
<circle cx="12" cy="12" r="8" />
<path d="M4.05 13c-1.7 1.8-2.5 3.5-1.8 4.5c1.1 1.9 6.4 1 11.8-2s8.9-7.1 7.7-9c-.6-1-2.4-1.2-4.7-.7" />
</svg>
),
);
PlanetIcon.displayName = 'PlanetIcon';
-35
View File
@@ -1,35 +0,0 @@
import React from 'react';
/**
* White Noise brand mark, drawn in `currentColor` so it adapts to theme.
*
* Source: https://www.whitenoise.chat/images/logomark.svg
*
* Used as the Messages sidebar icon and on the /messages "Install White Noise"
* placeholder card. The original logomark is 58×44; this component preserves
* that aspect ratio inside the SVG viewBox while accepting standard lucide-style
* width/height/className props.
*/
export const WhiteNoiseIcon = React.forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(
({ className, ...props }, ref) => (
<svg
ref={ref}
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 0 58 44"
fill="currentColor"
className={className}
aria-label="White Noise"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0 44V0H14.7304V13.4775L21.2348 0H35.9652V13.4775L42.4696 0H57.2V44H42.4696V30.5225L35.9652 44H21.2348V30.5225L14.7304 44H0ZM12.4348 2.29565H2.29565V39.2432L12.4348 18.2342V2.29565ZM44.7652 41.7043H54.9044V4.75676L44.7652 25.7658V41.7043ZM34.5241 41.7043L53.5431 2.29565H43.9107L24.8917 41.7043H34.5241ZM32.3083 2.29565H22.6759L3.65691 41.7043H13.2893L32.3083 2.29565ZM33.6696 4.75676L23.5304 25.7658V39.2432L33.6696 18.2342V4.75676Z"
/>
</svg>
),
);
WhiteNoiseIcon.displayName = 'WhiteNoiseIcon';
@@ -1,510 +0,0 @@
import { useState, useMemo, useRef, useCallback, useEffect, type ReactNode } from 'react';
import { ArrowLeft, Loader2, Pencil, Send, Sticker, X } from 'lucide-react';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { TabButton } from '@/components/TabButton';
import { FabButton } from '@/components/FabButton';
import { nip19 } from 'nostr-tools';
import { useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { backgroundTextColor } from '@/lib/colorUtils';
import {
LETTER_KIND,
FONT_OPTIONS,
LINE_HEIGHT_RATIO,
DEFAULT_STATIONERY_COLOR,
resolveStationery,
type Stationery,
type FrameStyle,
type LetterContent,
type LetterSticker,
} from '@/lib/letterTypes';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useThemeStationery } from '@/hooks/useThemeStationery';
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
import { LetterEditor } from './LetterEditor';
import { LetterStickers } from './LetterStickers';
import { StickerPicker } from './StickerPicker';
import { DrawingCanvas } from './DrawingCanvas';
import { ProfileSearchDropdown } from '@/components/ProfileSearchDropdown';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { StationeryBackground } from './StationeryBackground';
import { SendAnimation } from './SendAnimation';
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
/** Lightweight letter preview used inside the send animation */
function AnimationLetter({ content, width }: { content: LetterContent; width: number }) {
const { text: textColor, line: lineColor } = useStationeryColors(content.stationery);
const resolved = resolveStationery(content.stationery ?? { color: DEFAULT_STATIONERY_COLOR });
const fontFamily = resolved.fontFamily ?? FONT_OPTIONS[0].family;
const lh = Math.round(width * LINE_HEIGHT_RATIO);
return (
<div className="relative" style={{ containerType: 'inline-size', width }}>
<StationeryBackground
stationery={content.stationery}
frame={content.stationery?.frame}
frameTint={content.stationery?.frameTint}
className="rounded-2xl"
>
<div className="relative z-10 flex flex-col" style={{ aspectRatio: '5 / 4', padding: '5cqw' }}>
<p
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: `${lh}px`,
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily,
color: textColor,
backgroundImage: `linear-gradient(to bottom, transparent ${lh - 3}px, ${lineColor} ${lh - 3}px)`,
backgroundSize: `100% ${lh}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lh * 5}px`,
}}
>
{content.body}
</p>
{content.closing && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily }}>
<p style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
</div>
)}
</div>
</StationeryBackground>
{content.stickers && content.stickers.length > 0 && (
<div className="absolute inset-0 rounded-2xl overflow-hidden pointer-events-none" style={{ zIndex: 20 }}>
<div className="relative w-full h-full">
<LetterStickers stickers={content.stickers} />
</div>
</div>
)}
</div>
);
}
const BODY_MAX_LENGTH = 220;
/** Inline chip showing the selected recipient with avatar + name + optional clear. */
function SelectedRecipient({ pubkey, onClear }: { pubkey: string; onClear?: () => void }) {
const author = useAuthor(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-2xl bg-muted/60 min-w-0">
<Avatar className="size-6 shrink-0">
<AvatarImage src={metadata?.picture} alt={displayName} />
<AvatarFallback className="bg-primary/15 text-[10px] font-bold text-primary">
{displayName[0]?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="text-base font-medium truncate">{displayName}</span>
{onClear && (
<button
onClick={onClear}
className="text-muted-foreground hover:text-foreground shrink-0 transition-colors p-0.5"
>
<X className="size-4" strokeWidth={3} />
</button>
)}
</div>
);
}
type Overlay = 'none' | 'font' | 'stationery' | 'frame' | 'sticker' | 'draw';
interface ComposeLetterSheetProps {
onClose: () => void;
toPubkey?: string;
}
export function ComposeLetterSheet({ onClose, toPubkey }: ComposeLetterSheetProps) {
const { user } = useCurrentUser();
const { mutateAsync: createEvent } = useNostrPublish();
const queryClient = useQueryClient();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bodyAreaRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const timer = setTimeout(() => textareaRef.current?.focus(), 300);
return () => clearTimeout(timer);
}, []);
const initialRecipient = useMemo(() => {
if (!toPubkey) return undefined;
try {
if (toPubkey.startsWith('npub1')) {
const d = nip19.decode(toPubkey);
if (d.type === 'npub') return d.data;
}
if (/^[0-9a-f]{64}$/i.test(toPubkey)) return toPubkey;
} catch { /* ignore */ }
return undefined;
}, [toPubkey]);
const { prefs, isThemeDefault } = useLetterPreferences();
const themeStationery = useThemeStationery();
const [resolvedRecipient, setResolvedRecipient] = useState<string | undefined>(initialRecipient);
const [body, setBody] = useState('');
const [closing, setClosing] = useState(() => prefs.closing ?? 'Warmly,');
const [signature, setSignature] = useState(() => prefs.signature ?? '');
const [selectedFont, setSelectedFont] = useState(
() => FONT_OPTIONS.find((f) => f.value === prefs.font) ?? FONT_OPTIONS[0],
);
// Start from the live theme stationery immediately — don't wait for encrypted settings.
// If the user has saved a custom stationery preference, switch to it once prefs load.
// If no custom theme is active, fall back to the default parchment color so the letter
// doesn't just inherit the plain app background.
const [stationery, setStationery] = useState<Stationery>(
isThemeDefault ? { color: DEFAULT_STATIONERY_COLOR } : themeStationery,
);
const [frame, setFrame] = useState<FrameStyle>(() => prefs.frame ?? 'none');
const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false);
const [overlay, setOverlay] = useState<Overlay>('none');
const [stickers, setStickers] = useState<LetterSticker[]>([]);
const { emojis: customEmojis } = useCustomEmojis();
const [sealing, setSealing] = useState(false);
const [sendAnimationContent, setSendAnimationContent] = useState<LetterContent | null>(null);
const envDims = useEnvelopeDimensions();
const animLetterW = envDims.letterW;
// Once encrypted settings load, apply saved stationery preference (if any).
// isThemeDefault is false only when the user has an explicit saved stationery.
const prefsLoadedRef = useRef(false);
useEffect(() => {
if (prefsLoadedRef.current) return;
if (!isThemeDefault && prefs.stationery) {
setStationery(prefs.stationery as Stationery);
prefsLoadedRef.current = true;
} else if (isThemeDefault) {
// Settings loaded and confirmed no override — stay with theme stationery.
// Keep the live theme stationery in sync if the theme changes.
prefsLoadedRef.current = true;
}
}, [isThemeDefault, prefs.stationery]);
// Keep stationery in sync with the live theme when using the theme default.
// If the user has explicitly chosen a stationery in this session, don't override it.
const userPickedStationery = useRef(false);
const handleSetStationery = useCallback((s: Stationery) => {
userPickedStationery.current = true;
setStationery(s);
}, []);
useEffect(() => {
if (!userPickedStationery.current && !isThemeDefault) {
setStationery(themeStationery);
}
}, [themeStationery, isThemeDefault]);
const [textareaPadPx, setTextareaPadPx] = useState(0);
useEffect(() => {
const el = bodyAreaRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setTextareaPadPx(Math.ceil(w * 0.005));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const canSend = !!resolvedRecipient && (body.trim().length > 0 || stickers.length > 0) && !!user;
const handleAddSticker = useCallback((emoji: { shortcode: string; url: string }) => {
setStickers((prev) => [
...prev,
{
url: emoji.url,
shortcode: emoji.shortcode,
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
rotation: -15 + Math.random() * 30,
},
]);
setOverlay('none');
}, []);
const handleAddDrawing = useCallback((svg: string) => {
setStickers((prev) => [
...prev,
{
url: '',
shortcode: 'drawing',
svg,
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
rotation: -15 + Math.random() * 30,
},
]);
setOverlay('none');
}, []);
const handleUpdateSticker = useCallback((index: number, patch: Partial<LetterSticker>) => {
setStickers((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)));
}, []);
const handleRemoveSticker = useCallback((index: number) => {
setStickers((prev) => prev.filter((_, i) => i !== index));
}, []);
const buildLetterContent = useCallback((): LetterContent => {
const finalStationery: Stationery = {
...stationery,
...(selectedFont.family !== FONT_OPTIONS[0].family
? { fontFamily: selectedFont.family }
: {}),
...(frame && frame !== 'none' ? { frame } : {}),
...(frame && frame !== 'none' && frameTint ? { frameTint: true } : {}),
};
return {
body: body.trim(),
...(closing.trim() ? { closing: closing.trim() } : {}),
...(signature.trim() ? { signature: signature.trim() } : {}),
...(stickers.length > 0 ? { stickers } : {}),
stationery: finalStationery,
};
}, [body, closing, signature, stickers, stationery, selectedFont, frame, frameTint]);
const handleSend = async () => {
if (!canSend || !user || !resolvedRecipient) return;
if (!user.signer.nip44) {
toast({ title: "your signer doesn't support encryption yet", variant: 'destructive' });
return;
}
setSealing(true);
try {
const letterContent = buildLetterContent();
const encrypted = await user.signer.nip44.encrypt(
resolvedRecipient,
JSON.stringify(letterContent)
);
const tags: string[][] = [
['p', resolvedRecipient],
['alt', 'Encrypted letter'],
];
await createEvent({ kind: LETTER_KIND, content: encrypted, tags });
queryClient.invalidateQueries({ queryKey: ['letters-sent'] });
queryClient.invalidateQueries({ queryKey: ['letters-inbox'] });
setSendAnimationContent(letterContent);
} catch (err) {
console.error('Failed to send letter:', err);
setSealing(false);
toast({ title: "couldn't send that one", variant: 'destructive' });
}
};
const recipientAuthor = useAuthor(resolvedRecipient);
const recipientName = recipientAuthor.data?.metadata?.name
|| recipientAuthor.data?.metadata?.display_name
|| (resolvedRecipient ? genUserName(resolvedRecipient) : 'friend');
const resolvedSt = useMemo(() => resolveStationery(stationery ?? { color: DEFAULT_STATIONERY_COLOR }), [stationery]);
const bgColor = resolvedSt.color ?? DEFAULT_STATIONERY_COLOR;
const primaryColor = resolvedSt.primaryColor ?? '#7c52e0';
const textColor = resolvedSt.textColor ?? backgroundTextColor(bgColor);
// Memoize the animation letter element — only recompute when content or width changes.
// Uses sendAnimationContent directly (not a ref) so deps are exhaustive.
const animLetterElement = useMemo(
() => sendAnimationContent
? <AnimationLetter content={sendAnimationContent} width={animLetterW} />
: <AnimationLetter content={{ body: '' }} width={animLetterW} />,
[sendAnimationContent, animLetterW],
);
return (
<>
{/* Pre-render letter hidden so images/fonts are loaded before animation fires */}
<div aria-hidden className="absolute opacity-0 pointer-events-none" style={{ width: animLetterW, top: -9999 }}>
<AnimationLetter content={buildLetterContent()} width={animLetterW} />
</div>
{sendAnimationContent && (
<SendAnimation
letterElement={animLetterElement}
letterWidth={animLetterW}
recipientName={recipientName}
recipientPicture={recipientAuthor.data?.metadata?.picture}
bgColor={bgColor}
primaryColor={primaryColor}
textColor={textColor}
onComplete={onClose}
/>
)}
<div
ref={bodyAreaRef}
className="absolute inset-0 z-40 bg-background flex flex-col overflow-y-auto"
style={sendAnimationContent ? { visibility: 'hidden', pointerEvents: 'none' } : undefined}
>
<LetterEditor
state={{
selectedFont, setSelectedFont,
stationery, setStationery: handleSetStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
}}
overlay={overlay}
setOverlay={(o) => setOverlay(o as Overlay)}
renderToolbarButtons={(buttons: ReactNode, drawer: ReactNode) => (
<div className="sticky top-0 z-20">
{drawer}
<SubHeaderBar pinned className="relative !top-0">
<button
onClick={onClose}
className="pl-3 pr-1 py-1.5 text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="size-5" />
</button>
{buttons}
</SubHeaderBar>
</div>
)}
extraButtons={
<>
{customEmojis.length > 0 && (
<TabButton
label="Stickers"
active={overlay === 'sticker'}
onClick={() => setOverlay(overlay === 'sticker' ? 'none' : 'sticker')}
>
<Sticker className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
)}
<TabButton
label="Draw"
active={overlay === 'draw'}
onClick={() => setOverlay(overlay === 'draw' ? 'none' : 'draw')}
>
<Pencil className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
</>
}
extraDrawerContent={
<>
{overlay === 'sticker' && <StickerPicker onSelect={handleAddSticker} />}
{overlay === 'draw' && <DrawingCanvas onConfirm={handleAddDrawing} onCancel={() => setOverlay('none')} />}
</>
}
beforeCard={
<div className="max-w-xl mx-auto w-full px-5 pb-2 pt-4 max-sidebar:pt-[calc(20px+2.5rem)]">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-muted-foreground shrink-0 w-14">To</span>
{!initialRecipient && !resolvedRecipient ? (
<div className="flex-1 min-w-0">
<ProfileSearchDropdown
placeholder="search for a person..."
onSelect={(profile) => setResolvedRecipient(profile.pubkey)}
hideCountry
className="w-full"
inputClassName="rounded-2xl bg-muted/60 border-0 focus-visible:ring-2 focus-visible:ring-primary/20 text-base h-auto py-2"
/>
</div>
) : (
<SelectedRecipient
pubkey={resolvedRecipient ?? initialRecipient!}
onClear={initialRecipient ? undefined : () => setResolvedRecipient(undefined)}
/>
)}
</div>
</div>
}
bodyContent={({ lineHeightPx, stationeryTextColor: textColor, stationeryLineColor: lineColor, resolvedFontFamily: fontFamily }) => (
<textarea
ref={textareaRef}
dir="auto"
value={body}
onFocus={() => setOverlay('none')}
onChange={(e) => {
const el = e.target;
const next = e.target.value;
if (next.length > BODY_MAX_LENGTH) {
el.value = body;
return;
}
if (next.length > body.length && el.scrollHeight > el.clientHeight) {
el.value = body;
return;
}
setBody(next);
}}
maxLength={BODY_MAX_LENGTH}
placeholder="dear friend..."
className="w-full flex-1 min-h-0 border-none shadow-none resize-none overflow-hidden focus:outline-none font-semibold tracking-wide"
style={{
paddingTop: '0.5cqw',
paddingBottom: 0,
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
...(lineHeightPx > 0 ? { maxHeight: `${lineHeightPx * 5 + textareaPadPx}px` } : {}),
letterSpacing: '0.06em',
fontFamily,
color: textColor,
caretColor: textColor,
backgroundColor: 'transparent',
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
} : {}),
}}
/>
)}
cardRef={cardRef}
cardOverlay={
<LetterStickers
stickers={stickers}
editable
onUpdate={handleUpdateSticker}
onRemove={handleRemoveSticker}
containerRef={cardRef}
/>
}
/>
{/* Send FAB — fixed bottom right, matches app FAB style */}
<div className="fixed bottom-fab right-6 z-30 sidebar:hidden">
<FabButton
onClick={handleSend}
disabled={!canSend || sealing}
title="Send letter"
icon={sealing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
{/* Desktop FAB — sticky within column */}
<div className="hidden sidebar:block sticky bottom-6 z-30 pointer-events-none">
<div className="flex justify-end pr-4">
<div className="pointer-events-auto">
<FabButton
onClick={handleSend}
disabled={!canSend || sealing}
title="Send letter"
icon={sealing
? <Loader2 size={18} className="animate-spin" />
: <Send strokeWidth={3} size={18} />
}
/>
</div>
</div>
</div>
</div>
</>
);
}
-163
View File
@@ -1,163 +0,0 @@
/**
* DrawingCanvas
*
* Freehand SVG drawing tool for creating hand-drawn stickers.
* Produces a tightly-cropped, point-simplified SVG string on confirm.
*/
import { useRef, useState, useCallback } from 'react';
import { Undo2, Check, X, Eraser } from 'lucide-react';
import { type Stroke, pointsToPath, strokesToSvg } from '@/lib/svgDrawing';
const CANVAS_SIZE = 300;
const COLORS = [
'#1a1a1a', '#e53e3e', '#dd6b20', '#d69e2e',
'#38a169', '#3182ce', '#805ad5', '#d53f8c', '#f7f7f7',
];
const BRUSH_SIZES = [
{ value: 3, label: 'S' },
{ value: 6, label: 'M' },
{ value: 10, label: 'L' },
{ value: 16, label: 'XL' },
];
function pointerToSvg(e: React.PointerEvent<SVGSVGElement>, svg: SVGSVGElement): [number, number] {
const rect = svg.getBoundingClientRect();
return [
((e.clientX - rect.left) / rect.width) * CANVAS_SIZE,
((e.clientY - rect.top) / rect.height) * CANVAS_SIZE,
];
}
interface DrawingCanvasProps {
onConfirm: (svg: string) => void;
onCancel: () => void;
}
export function DrawingCanvas({ onConfirm, onCancel }: DrawingCanvasProps) {
const svgRef = useRef<SVGSVGElement>(null);
const [strokes, setStrokes] = useState<Stroke[]>([]);
const [activeStroke, setActiveStroke] = useState<Stroke | null>(null);
const [color, setColor] = useState(COLORS[0]);
const [brushSize, setBrushSize] = useState(BRUSH_SIZES[1].value);
const handlePointerDown = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
if (!svgRef.current) return;
e.preventDefault();
(e.target as Element).setPointerCapture?.(e.pointerId);
setActiveStroke({ points: [pointerToSvg(e, svgRef.current)], color, width: brushSize });
}, [color, brushSize]);
const handlePointerMove = useCallback((e: React.PointerEvent<SVGSVGElement>) => {
if (!activeStroke || !svgRef.current) return;
e.preventDefault();
const pt = pointerToSvg(e, svgRef.current);
setActiveStroke((prev) => prev ? { ...prev, points: [...prev.points, pt] } : prev);
}, [activeStroke]);
const handlePointerUp = useCallback(() => {
if (!activeStroke) return;
setStrokes((prev) => [...prev, activeStroke]);
setActiveStroke(null);
}, [activeStroke]);
const handleConfirm = useCallback(() => {
const svg = strokesToSvg(strokes);
if (svg) onConfirm(svg);
}, [strokes, onConfirm]);
const allStrokes = activeStroke ? [...strokes, activeStroke] : strokes;
const hasStrokes = strokes.length > 0;
const actionBtn = 'p-2.5 rounded-xl text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-30 disabled:cursor-not-allowed';
return (
<div className="flex flex-col gap-3">
<div className="relative mx-auto w-full max-w-[300px]">
<div className="relative rounded-2xl overflow-hidden border-2 border-dashed border-border bg-white" style={{ aspectRatio: '1' }}>
<svg
ref={svgRef}
viewBox={`0 0 ${CANVAS_SIZE} ${CANVAS_SIZE}`}
className="absolute inset-0 w-full h-full cursor-crosshair"
style={{ touchAction: 'none' }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<defs>
<pattern id="drawing-grid" width="30" height="30" patternUnits="userSpaceOnUse">
<circle cx="15" cy="15" r="0.5" fill="#d4d4d4" />
</pattern>
</defs>
<rect width={CANVAS_SIZE} height={CANVAS_SIZE} fill="url(#drawing-grid)" />
{allStrokes.map((s, i) => (
<path
key={i}
d={pointsToPath(s.points)}
fill="none"
stroke={s.color}
strokeWidth={s.width}
strokeLinecap="round"
strokeLinejoin="round"
opacity={s === activeStroke ? 0.8 : 1}
/>
))}
</svg>
</div>
</div>
{/* Colors */}
<div className="flex items-center justify-center gap-1.5">
{COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full transition-all border-2 ${color === c ? 'border-primary scale-110 shadow-md' : 'border-transparent hover:scale-105'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
{/* Brush sizes */}
<div className="flex items-center justify-center gap-2">
{BRUSH_SIZES.map((b) => (
<button
key={b.value}
type="button"
onClick={() => setBrushSize(b.value)}
className={`flex items-center justify-center rounded-xl px-3 py-1.5 text-xs font-semibold transition-all ${brushSize === b.value ? 'bg-primary text-primary-foreground shadow-sm' : 'bg-muted text-muted-foreground hover:text-foreground'}`}
>
<span className="rounded-full inline-block mr-1.5" style={{ width: Math.max(4, b.value), height: Math.max(4, b.value), backgroundColor: brushSize === b.value ? 'currentColor' : color }} />
{b.label}
</button>
))}
</div>
{/* Actions */}
<div className="flex items-center justify-center gap-2">
<button type="button" onClick={onCancel} className={actionBtn} title="Cancel">
<X className="w-5 h-5" strokeWidth={2.5} />
</button>
<button type="button" onClick={() => { setStrokes([]); setActiveStroke(null); }} disabled={!hasStrokes} className={actionBtn} title="Clear all">
<Eraser className="w-5 h-5" strokeWidth={2.5} />
</button>
<button type="button" onClick={() => setStrokes((p) => p.slice(0, -1))} disabled={!hasStrokes} className={actionBtn} title="Undo">
<Undo2 className="w-5 h-5" strokeWidth={2.5} />
</button>
<button
type="button"
onClick={handleConfirm}
disabled={!hasStrokes}
className="px-5 py-2.5 rounded-xl bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-30 disabled:cursor-not-allowed flex items-center gap-1.5"
>
<Check className="w-4 h-4" strokeWidth={3} />
done
</button>
</div>
</div>
);
}
-47
View File
@@ -1,47 +0,0 @@
import { FRAME_PRESETS, type FrameStyle } from '@/lib/letterTypes';
import { NoneFramePreview, EmojiFramePreview } from './FramePreviews';
import { Switch } from '@/components/ui/switch';
interface FramePickerProps {
frame: FrameStyle;
frameTint: boolean;
onFrameSelect: (frame: FrameStyle) => void;
onFrameTintChange: (tint: boolean) => void;
}
export function FramePicker({ frame, frameTint, onFrameSelect, onFrameTintChange }: FramePickerProps) {
const hasFrame = frame !== 'none';
return (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-2">
{FRAME_PRESETS.map((fp) => {
const isSelected = frame === fp.id;
return (
<button
key={fp.id}
onClick={() => onFrameSelect(fp.id)}
title={fp.name}
className={`aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95 ${
isSelected ? 'scale-105 shadow-md' : 'opacity-70 hover:opacity-100'
}`}
>
{fp.id === 'none' ? (
<NoneFramePreview />
) : (
<EmojiFramePreview frameId={fp.id} />
)}
</button>
);
})}
</div>
{hasFrame && (
<div className="flex items-center justify-between px-1 pt-1">
<span className="text-sm text-muted-foreground font-medium">match stationery color</span>
<Switch checked={frameTint} onCheckedChange={onFrameTintChange} />
</div>
)}
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
import { FRAME_PRESETS, type FrameStyle } from '@/lib/letterTypes';
const BG = '#f5e6d3';
export function NoneFramePreview() {
return (
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<rect x="10" y="10" width="44" height="44" fill={BG} rx="4" />
{[20,28,36,44].map(y => <line key={y} x1="14" y1={y} x2="50" y2={y} stroke="#c4a88240" strokeWidth="0.8" />)}
<line x1="22" y1="32" x2="42" y2="32" stroke="#c4a882" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
}
export function EmojiFramePreview({ frameId }: { frameId: FrameStyle }) {
const preset = FRAME_PRESETS.find(f => f.id === frameId);
if (!preset?.emojis || !preset.bgColor) return null;
const positions = [
{x:5,y:6},{x:15,y:4},{x:25,y:7},{x:35,y:4},{x:45,y:6},{x:55,y:5},
{x:5,y:58},{x:15,y:60},{x:25,y:57},{x:35,y:60},{x:45,y:58},{x:55,y:59},
{x:4,y:18},{x:6,y:30},{x:4,y:42},{x:6,y:52},
{x:58,y:18},{x:60,y:30},{x:58,y:42},{x:60,y:52},
{x:8,y:10},{x:56,y:10},{x:8,y:54},{x:56,y:54},
];
return (
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" className="w-full h-full">
<rect x="0" y="0" width="64" height="64" fill={preset.bgColor} rx="8" />
{positions.map((p, i) => (
<text key={i} x={p.x} y={p.y} fontSize="8" textAnchor="middle" dominantBaseline="central">
{preset.emojis![i % preset.emojis!.length]}
</text>
))}
<rect x="10" y="10" width="44" height="44" fill={BG} rx="4" />
{[20,28,36,44].map(y => <line key={y} x1="14" y1={y} x2="50" y2={y} stroke="#c4a88240" strokeWidth="0.8" />)}
</svg>
);
}
-339
View File
@@ -1,339 +0,0 @@
import { useState, useRef, useEffect, useLayoutEffect } from 'react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { Mail, MailOpen, Loader2, Lock, MoreHorizontal, Link2, Trash2, Braces } from 'lucide-react';
import { useAuthor } from '@/hooks/useAuthor';
import { useDecryptLetter } from '@/hooks/useLetters';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useShareOrigin } from '@/hooks/useShareOrigin';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from '@/hooks/useToast';
import { genUserName } from '@/lib/genUserName';
import { FONT_OPTIONS, LETTER_KIND, LINE_HEIGHT_RATIO, type Letter } from '@/lib/letterTypes';
import { ensureLetterFonts } from '@/lib/letterUtils';
import { sanitizeCssString } from '@/lib/fontLoader';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { LetterStickers } from './LetterStickers';
import { formatDistanceToNow } from 'date-fns';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface LetterCardProps {
letter: Letter;
mode: 'inbox' | 'sent';
}
export function LetterCard({ letter, mode }: LetterCardProps) {
const [isOpen, setIsOpen] = useState(false);
const [hasOpened, setHasOpened] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const letterRef = useRef<HTMLDivElement>(null);
const [lineHeightPx, setLineHeightPx] = useState(0);
const otherPubkey = mode === 'inbox' ? letter.sender : letter.recipient;
const author = useAuthor(otherPubkey);
const { data: decrypted, isLoading: isDecrypting } = useDecryptLetter(letter);
const content = decrypted?.content;
const { user } = useCurrentUser();
const { mutate: publishEvent } = useNostrPublish();
const queryClient = useQueryClient();
const shareOrigin = useShareOrigin();
const displayName = author.data?.metadata?.name || author.data?.metadata?.display_name || genUserName(otherPubkey);
const avatar = author.data?.metadata?.picture;
const npub = nip19.npubEncode(otherPubkey);
const noteId = nip19.noteEncode(letter.event.id);
const letterUrl = `${shareOrigin}/${noteId}`;
const isOwnLetter = user?.pubkey === letter.sender;
const handleCopyLink = (e: React.MouseEvent) => {
e.stopPropagation();
navigator.clipboard.writeText(letterUrl);
toast({ description: 'Link copied to clipboard.' });
};
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
const removeFn = (old: Letter[] | undefined) =>
old ? old.filter((l) => l.event.id !== letter.event.id) : [];
queryClient.setQueriesData<Letter[]>({ queryKey: ['letters-inbox'] }, removeFn);
queryClient.setQueriesData<Letter[]>({ queryKey: ['letters-sent'] }, removeFn);
publishEvent(
{
kind: 5,
content: '',
tags: [
['e', letter.event.id],
['k', String(LETTER_KIND)],
],
},
{
onError: () => {
queryClient.invalidateQueries({ queryKey: ['letters-inbox'] });
queryClient.invalidateQueries({ queryKey: ['letters-sent'] });
},
}
);
};
const effectiveStationery = decrypted?.stationery;
const effectiveFrame = effectiveStationery?.frame;
const effectiveFrameTint = effectiveStationery?.frameTint;
const timeAgo = formatDistanceToNow(new Date(letter.timestamp * 1000), { addSuffix: true });
const { text: textColor, faint: faintColor, line: lineColor } = useStationeryColors(effectiveStationery);
// Sanitize event-sourced font family before CSS interpolation (M-6).
const rawFont = effectiveStationery?.fontFamily
? sanitizeCssString(effectiveStationery.fontFamily)
: undefined;
const letterFontFamily = rawFont
? (rawFont.includes(',') ? rawFont : `${rawFont}, ${FONT_OPTIONS[0].family}`)
: FONT_OPTIONS[0].family;
// Lazy-load the letter's font when decrypted content is available
useLayoutEffect(() => { ensureLetterFonts(letterFontFamily); }, [letterFontFamily]);
useEffect(() => {
const el = letterRef.current;
if (!el) return;
let raf: number;
const ro = new ResizeObserver(([entry]) => {
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
});
ro.observe(el);
return () => { ro.disconnect(); cancelAnimationFrame(raf); };
}, [hasOpened]);
return (
<div>
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this letter?</AlertDialogTitle>
<AlertDialogDescription>
This publishes a deletion request. It may not be removed from all relays.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Envelope card */}
<div className="relative">
<button
onClick={() => { setIsOpen(o => !o); setHasOpened(true); }}
className={`
w-full text-left group
rounded-3xl overflow-hidden shadow-sm transition-all duration-200
${isOpen
? 'shadow-md ring-1 ring-primary/20'
: 'hover:shadow-md hover:ring-1 hover:ring-primary/10'
}
`}
>
<StationeryBackground
stationery={effectiveStationery}
className="h-16 w-full relative"
>
<div className="absolute inset-x-0 bottom-0 h-4">
<svg viewBox="0 0 100 12" preserveAspectRatio="none" className="w-full h-full">
<path d="M0 12 L50 1 L100 12 Z" fill="hsl(var(--card))" />
</svg>
</div>
</StationeryBackground>
<div className="bg-card px-4 pb-5 pt-1">
<div className="flex items-center gap-3">
{avatar ? (
<img src={avatar} alt="" className="w-9 h-9 rounded-full object-cover ring-2 ring-background shrink-0" />
) : (
<div className="w-9 h-9 rounded-full bg-secondary flex items-center justify-center text-sm font-semibold text-secondary-foreground shrink-0">
{displayName.charAt(0).toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0 leading-none pt-1">
<div className="truncate leading-none">
<span className="text-sm font-normal text-muted-foreground">{mode === 'inbox' ? 'from ' : 'to '}</span>
<Link
to={`/${npub}`}
onClick={(e) => e.stopPropagation()}
className="text-lg font-semibold text-foreground hover:text-primary transition-colors"
>{displayName}</Link>
</div>
<span className="text-xs text-muted-foreground leading-none mt-0.5 block">{timeAgo}</span>
</div>
{isOpen ? (
<MailOpen className="w-8 h-8 text-primary shrink-0 translate-y-0.5" strokeWidth={2} />
) : (
<Mail className="w-8 h-8 text-muted-foreground shrink-0 translate-y-0.5" strokeWidth={2} />
)}
</div>
</div>
</button>
{/* Menu — top right of card, only when open */}
{isOpen && (
<div className="absolute top-2 right-2 z-20">
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="p-1 rounded-full hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<MoreHorizontal className="w-5 h-5" strokeWidth={2.5} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleCopyLink}>
<Link2 className="w-4 h-4 mr-2" />
Copy link
</DropdownMenuItem>
{content && (
<DropdownMenuItem onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(JSON.stringify(content, null, 2));
toast({ description: 'Decrypted JSON copied.' });
}}>
<Braces className="w-4 h-4 mr-2" />
Copy decrypted JSON
</DropdownMenuItem>
)}
{isOwnLetter && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => { e.stopPropagation(); setConfirmDelete(true); }}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
{/* Letter — expands below the card */}
<div
style={{
overflow: 'hidden',
maxHeight: isOpen ? '800px' : '0',
transition: 'max-height 0.3s ease-in-out',
}}
aria-hidden={!isOpen}
>
<div style={{ padding: '8px 0 8px' }}>
{hasOpened && (
<div style={effectiveFrame && effectiveFrame !== 'none'
? { padding: '28px 28px 44px' }
: { padding: '0 0 0' }
}>
<div ref={letterRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={effectiveStationery}
frame={effectiveFrame}
frameTint={effectiveFrameTint}
className="rounded-3xl shadow-inner shadow-black/5"
>
<div
className="relative z-10 flex flex-col"
style={{ aspectRatio: '5 / 4', padding: '5cqw' }}
>
{isDecrypting ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2.5" style={{ color: faintColor }}>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-xs">unsealing...</span>
</div>
</div>
) : content ? (
<>
<p
dir="auto"
className="whitespace-pre-wrap font-semibold tracking-wide overflow-hidden flex-1 min-h-0"
style={{
fontSize: '4.8cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.06em',
paddingTop: '0.5cqw',
fontFamily: letterFontFamily,
color: textColor,
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${lineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
maxHeight: `${lineHeightPx * 5}px`,
} : {}),
backgroundPosition: '0 0',
}}
>
{content.body}
</p>
{(content.closing || content.signature) && (
<div className="flex flex-col items-end" style={{ paddingTop: '6cqw', gap: '3cqw', paddingRight: '4cqw', fontFamily: letterFontFamily }}>
{content.closing && (
<p dir="auto" style={{ fontSize: '4.8cqw', color: textColor }}>{content.closing}</p>
)}
{content.signature && (
<p dir="auto" className="font-semibold" style={{ fontSize: '5cqw', color: textColor }}>{content.signature}</p>
)}
</div>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-2" style={{ color: faintColor }}>
<Lock className="w-5 h-5" />
<p className="text-xs italic">couldn't unseal this one</p>
</div>
</div>
)}
</div>
</StationeryBackground>
{content?.stickers && content.stickers.length > 0 && (
<div className="absolute inset-0 rounded-3xl overflow-hidden pointer-events-none" style={{ zIndex: 20 }}>
<div className="relative w-full h-full">
<LetterStickers stickers={content.stickers} />
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
-322
View File
@@ -1,322 +0,0 @@
/**
* LetterEditor — drawer and preview card for composing/previewing letters.
*
* Callers own the sticky header. LetterEditor exposes its toolbar buttons
* via the `renderToolbarButtons` render prop so callers can place them
* inline in their own header row alongside back buttons, titles, etc.
*/
import { useState, useRef, useEffect, type ReactNode } from 'react';
import { Paintbrush } from 'lucide-react';
import { TabButton } from '@/components/TabButton';
import {
FONT_OPTIONS,
CLOSING_PRESETS,
LINE_HEIGHT_RATIO,
type Stationery,
type FrameStyle,
} from '@/lib/letterTypes';
import { loadBundledFont } from '@/lib/fonts';
import { StationeryBackground } from './StationeryBackground';
import { useStationeryColors } from '@/hooks/useStationeryColors';
import { StationeryPicker } from './StationeryPicker';
import { FramePicker } from './FramePicker';
import { resolveFont } from '@/lib/letterUtils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
// ---------------------------------------------------------------------------
// FrameIcon — shared SVG
// ---------------------------------------------------------------------------
export function FrameIcon({ className, strokeWidth = 2 }: { className?: string; strokeWidth?: number }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className}>
<rect x="2" y="3" width="20" height="18" rx="3" />
<rect x="5.5" y="6.5" width="13" height="11" rx="2" />
<path d="M4 5 C4.5 5.5 5 6 5.5 6.5" />
<path d="M20 5 C19.5 5.5 19 6 18.5 6.5" />
<path d="M4 19 C4.5 18.5 5 18 5.5 17.5" />
<path d="M20 19 C19.5 18.5 19 18 18.5 17.5" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Overlay types — base set shared by all consumers
// ---------------------------------------------------------------------------
export type BaseOverlay = 'none' | 'font' | 'stationery' | 'frame';
// ---------------------------------------------------------------------------
// LetterEditor types
// ---------------------------------------------------------------------------
export interface LetterEditorFont {
value: string;
label: string;
family: string;
}
export interface LetterEditorState {
selectedFont: LetterEditorFont;
setSelectedFont: (f: LetterEditorFont) => void;
stationery: Stationery;
setStationery: (s: Stationery) => void;
frame: FrameStyle;
setFrame: (f: FrameStyle) => void;
frameTint: boolean;
setFrameTint: (v: boolean) => void;
closing: string;
setClosing: (v: string) => void;
signature: string;
setSignature: (v: string) => void;
}
interface LetterEditorProps {
state: LetterEditorState;
/**
* Render prop — receives the toolbar button nodes and the sliding drawer so
* the caller can compose them into their own sticky header+drawer region.
* The drawer is passed separately so callers that want the arc-then-drawer
* pattern can render it outside the SubHeaderBar.
*/
renderToolbarButtons: (buttons: ReactNode, drawer: ReactNode) => ReactNode;
/** Extra buttons appended after the base Aa/paintbrush/frame buttons. */
extraButtons?: ReactNode;
/** Extra drawer panels for overlays beyond 'font'/'stationery'/'frame'. */
extraDrawerContent?: ReactNode;
/** Current overlay — managed externally so callers can add custom overlays. */
overlay: string;
setOverlay: (o: string) => void;
/** Body content rendered inside the card above the outro. */
bodyContent?: (ctx: { lineHeightPx: number; stationeryTextColor: string; stationeryLineColor: string; resolvedFontFamily: string }) => ReactNode;
/** Content rendered on top of the card (e.g. stickers layer). */
cardOverlay?: ReactNode;
/** Content rendered between the drawer and the card (e.g. recipient row). */
beforeCard?: ReactNode;
/** External ref for the card container — used for sticker positioning. */
cardRef?: React.RefObject<HTMLDivElement | null>;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function LetterEditor({
state,
renderToolbarButtons,
extraButtons,
extraDrawerContent,
overlay,
setOverlay,
bodyContent,
cardOverlay,
beforeCard,
cardRef: externalCardRef,
}: LetterEditorProps) {
const {
selectedFont, setSelectedFont,
stationery, setStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
} = state;
const internalCardRef = useRef<HTMLDivElement>(null);
const cardRef = externalCardRef ?? internalCardRef;
const [lineHeightPx, setLineHeightPx] = useState(0);
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
setLineHeightPx(Math.round(w * LINE_HEIGHT_RATIO));
});
ro.observe(el);
return () => ro.disconnect();
}, [cardRef]);
// Load the currently-selected font on mount/change so the card preview renders correctly.
useEffect(() => {
const primary = selectedFont.family.split(',')[0].trim();
loadBundledFont(primary);
}, [selectedFont.family]);
// Pre-load all letter fonts when the font picker opens so the buttons
// render in their actual typefaces rather than the system fallback.
useEffect(() => {
if (overlay !== 'font') return;
const primaryNames = [
'Fredoka', 'Nunito', 'Playfair Display', 'Caveat', 'Pacifico',
'Pirata One', 'Permanent Marker', 'Special Elite', 'Creepster', 'Silkscreen',
];
primaryNames.forEach((name) => loadBundledFont(name));
}, [overlay]);
const { text: stationeryTextColor, line: stationeryLineColor, fontFamily: themeFont } = useStationeryColors(stationery);
const resolvedFontFamily = resolveFont(selectedFont.family, themeFont);
const isBaseOverlay = (o: string): o is BaseOverlay => ['none', 'font', 'stationery', 'frame'].includes(o);
const drawerOpen = overlay !== 'none';
// The toolbar buttons — passed to renderToolbarButtons so the caller
// can embed them in their own sticky header row.
const toolbarButtons = (
<>
<TabButton
label="Font"
active={overlay === 'font'}
onClick={() => setOverlay(overlay === 'font' ? 'none' : 'font')}
>
<span className="text-base font-bold">Aa</span>
</TabButton>
<TabButton
label="Stationery"
active={overlay === 'stationery'}
onClick={() => setOverlay(overlay === 'stationery' ? 'none' : 'stationery')}
>
<Paintbrush className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
<TabButton
label="Frame"
active={overlay === 'frame'}
onClick={() => setOverlay(overlay === 'frame' ? 'none' : 'frame')}
>
<FrameIcon className="h-5 w-5" strokeWidth={2.5} />
</TabButton>
{extraButtons}
</>
);
const drawer = (
<div
className="bg-background/90 backdrop-blur-sm"
style={{
overflow: 'hidden',
maxHeight: drawerOpen ? (overlay === 'draw' ? '600px' : '400px') : '0',
transition: 'max-height 0.25s ease-in-out',
}}
>
<div className="max-w-xl mx-auto w-full px-4 pb-5 pt-3">
{overlay === 'font' && (
<div className="flex gap-2 flex-wrap">
{FONT_OPTIONS.map((font) => (
<button
key={font.value}
onClick={() => setSelectedFont(font)}
className={`px-4 py-2.5 rounded-2xl text-base font-medium transition-all ${
selectedFont.value === font.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`}
style={{ fontFamily: font.family }}
>
{font.label}
</button>
))}
</div>
)}
{overlay === 'stationery' && (
<StationeryPicker selected={stationery} onSelect={setStationery} />
)}
{overlay === 'frame' && (
<FramePicker
frame={frame}
frameTint={frameTint}
onFrameSelect={setFrame}
onFrameTintChange={setFrameTint}
/>
)}
{!isBaseOverlay(overlay) && extraDrawerContent}
</div>
</div>
);
return (
<>
{/* Caller composes the toolbar buttons and drawer into their own sticky region */}
{renderToolbarButtons(toolbarButtons, drawer)}
{beforeCard}
<div
className="max-w-xl mx-auto w-full"
style={frame !== 'none' ? { padding: '28px 44px 44px' } : { padding: '0 16px 16px' }}
>
<div ref={cardRef} className="relative" style={{ containerType: 'inline-size' }}>
<StationeryBackground
stationery={stationery}
frame={frame}
frameTint={frameTint}
className="rounded-3xl shadow-[0_2px_12px_rgba(0,0,0,0.06)]"
>
<div className="relative z-10 flex flex-col" style={{ aspectRatio: '5 / 4', padding: '5cqw' }}>
{bodyContent?.({ lineHeightPx, stationeryTextColor, stationeryLineColor, resolvedFontFamily })}
<div className="flex flex-col items-end" style={{ paddingTop: '4cqw', gap: '3cqw', paddingRight: '4cqw' }}>
<Select value={closing || '__none__'} onValueChange={(v) => setClosing(v === '__none__' ? '' : v)}>
<SelectTrigger
className="w-auto h-auto focus:ring-0 focus:ring-offset-0 ring-0 ring-offset-0 outline-none rounded-2xl border-0 shadow-none flex-row-reverse gap-3 [&>span]:text-right"
style={{
fontSize: '4cqw',
padding: '2.5cqw 4cqw',
marginRight: '-4cqw',
color: closing ? stationeryTextColor : `${stationeryTextColor}44`,
backgroundColor: parseInt(stationeryTextColor.slice(stationeryTextColor.indexOf('(') + 1), 10) < 128
? 'rgba(0,0,0,0.07)'
: 'rgba(255,255,255,0.18)',
fontFamily: resolvedFontFamily,
}}
>
<SelectValue placeholder="Closing..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
<span className="text-muted-foreground italic">None</span>
</SelectItem>
{CLOSING_PRESETS.map((preset) => (
<SelectItem key={preset} value={preset}>
{preset}
</SelectItem>
))}
</SelectContent>
</Select>
<input
type="text"
dir="auto"
value={signature}
onChange={(e) => setSignature(e.target.value)}
onFocus={() => setOverlay('none')}
maxLength={50}
placeholder="Your Name"
className="bg-transparent border-none font-semibold text-right focus:outline-none placeholder:opacity-60"
style={{
fontSize: '4.2cqw',
color: stationeryTextColor,
width: '60%',
fontFamily: resolvedFontFamily,
}}
/>
</div>
</div>
</StationeryBackground>
{cardOverlay && (
<div className="absolute inset-0 rounded-3xl overflow-hidden pointer-events-none" style={{ zIndex: 20 }}>
<div className="relative w-full h-full">
{cardOverlay}
</div>
</div>
)}
</div>
</div>
</>
);
}
@@ -1,202 +0,0 @@
import { useState, useEffect, useRef, type ReactNode } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ArrowLeft, Sparkles, RotateCcw } from 'lucide-react';
import { SubHeaderBar } from '@/components/SubHeaderBar';
import { useLetterPreferences } from '@/hooks/useLetterPreferences';
import { useThemeStationery } from '@/hooks/useThemeStationery';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import {
FONT_OPTIONS,
type Stationery,
type FrameStyle,
} from '@/lib/letterTypes';
import { LetterEditor, type BaseOverlay } from './LetterEditor';
/** Convert to serializable form for persisting. NostrEvent is plain JSON, so no stripping needed. */
function toSerializable(s: Stationery): Stationery {
return s;
}
export function LetterPreferencesSection() {
const { user } = useCurrentUser();
const navigate = useNavigate();
const { prefs, updatePrefs, resetStationery, isThemeDefault } = useLetterPreferences();
const themeStationery = useThemeStationery();
// Track whether any user-driven change has happened so we don't persist on mount
const mountedRef = useRef(false);
const [closing, setClosing] = useState(() => prefs.closing ?? 'Warmly,');
const [signature, setSignature] = useState(() => prefs.signature ?? '');
const [selectedFont, setSelectedFont] = useState(
() => FONT_OPTIONS.find((f) => f.value === prefs.font) ?? FONT_OPTIONS[0],
);
// When isThemeDefault, use the live theme stationery directly (not from prefs).
// When a custom stationery is saved, use that.
const [stationery, setStationery] = useState<Stationery>(
() => isThemeDefault ? themeStationery : (prefs.stationery as Stationery ?? themeStationery),
);
const [frame, setFrame] = useState<FrameStyle>(() => prefs.frame ?? 'none');
const [frameTint, setFrameTint] = useState(() => prefs.frameTint ?? false);
const [friendsOnlyInbox, setFriendsOnlyInbox] = useState(() => prefs.friendsOnlyInbox ?? false);
const [friendsOnlySearch, setFriendsOnlySearch] = useState(() => prefs.friendsOnlySearch ?? false);
const [overlay, setOverlay] = useState<BaseOverlay>('none');
// Keep preview in sync with the live theme when no custom stationery is saved
useEffect(() => {
if (isThemeDefault) {
setStationery(themeStationery);
}
}, [isThemeDefault, themeStationery]);
// Persist non-stationery prefs on change (skip mount).
// `updatePrefs` is intentionally omitted — its identity changes on every settings
// update (because it closes over `settings`), which would cause an infinite loop:
// effect → updatePrefs → settings change → new updatePrefs → effect.
// `user` is omitted because the guard (`!user`) short-circuits if absent and
// the component already renders a login prompt when user is null.
const updatePrefsRef = useRef(updatePrefs);
updatePrefsRef.current = updatePrefs;
useEffect(() => {
if (!mountedRef.current || !user) return;
updatePrefsRef.current({ font: selectedFont.value, frame, frameTint, closing, signature, friendsOnlyInbox, friendsOnlySearch });
}, [selectedFont, frame, frameTint, closing, signature, friendsOnlyInbox, friendsOnlySearch, user]);
// Mark as mounted
useEffect(() => { mountedRef.current = true; }, []);
// When the user picks stationery, persist it (not called on theme-sync updates
// because those go through setStationery directly, not this handler)
const handleSetStationery = (s: Stationery) => {
setStationery(s);
if (!user) return;
updatePrefs({ stationery: toSerializable(s) });
};
if (!user) {
return (
<div className="px-5 py-8 text-center text-sm text-muted-foreground">
Log in to set letter preferences.
</div>
);
}
return (
<div className="pb-8">
<LetterEditor
state={{
selectedFont, setSelectedFont,
stationery, setStationery: handleSetStationery,
frame, setFrame,
frameTint, setFrameTint,
closing, setClosing,
signature, setSignature,
}}
overlay={overlay}
setOverlay={(o) => setOverlay(o as BaseOverlay)}
renderToolbarButtons={(buttons: ReactNode, drawer: ReactNode) => (
<div className="sticky top-0 z-50">
<div className="flex items-center gap-4 px-4 mt-4 mb-1">
<button
onClick={() => navigate('/letters')}
className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-xl font-bold flex-1 truncate">Letter Preferences</h1>
</div>
{drawer}
<SubHeaderBar className="relative">
{buttons}
</SubHeaderBar>
</div>
)}
beforeCard={
<div className="pt-4 max-w-xl mx-auto w-full px-5">
{isThemeDefault ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-2xl bg-primary/8 border border-primary/20 text-sm mb-3">
<Sparkles className="w-4 h-4 text-primary shrink-0" />
<span className="text-muted-foreground flex-1">
Using your{' '}
<Link to="/settings" className="text-primary font-medium hover:underline">
Agora theme
</Link>
{' '}as stationery
</span>
</div>
) : (
<div className="flex items-center justify-between gap-2 px-3 py-2 rounded-2xl bg-muted/60 border border-border text-sm mb-3">
<span className="text-muted-foreground">Custom stationery saved</span>
<Button
variant="ghost"
size="sm"
onClick={resetStationery}
className="h-7 px-2 text-xs gap-1"
>
<RotateCcw className="w-3 h-3" />
Reset to theme
</Button>
</div>
)}
</div>
}
bodyContent={({ lineHeightPx, stationeryTextColor, stationeryLineColor, resolvedFontFamily }) => (
<div
className="flex-1 min-h-0"
style={{
...(lineHeightPx > 0 ? {
backgroundImage: `linear-gradient(to bottom, transparent ${lineHeightPx - 3}px, ${stationeryLineColor} ${lineHeightPx - 3}px)`,
backgroundSize: `100% ${lineHeightPx}px`,
backgroundRepeat: 'repeat-y',
} : {}),
}}
>
<p
className="font-semibold tracking-wide opacity-40 pointer-events-none select-none"
style={{
fontSize: '3.6cqw',
lineHeight: lineHeightPx > 0 ? `${lineHeightPx}px` : '8.4cqw',
letterSpacing: '0.04em',
fontFamily: resolvedFontFamily,
color: stationeryTextColor,
}}
>
Pick a font, stationery, and frame above. Choose a closing and sign your name below.
</p>
</div>
)}
/>
<div className="max-w-xl mx-auto w-full px-5 pt-4 space-y-8">
<p className="text-sm text-muted-foreground text-center">
These defaults apply when you start a new letter. You can always change them while composing.
</p>
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">inbox</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Friends only</p>
<p className="text-xs text-muted-foreground">Only show letters from people you follow</p>
</div>
<Switch checked={friendsOnlyInbox} onCheckedChange={setFriendsOnlyInbox} />
</div>
</div>
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">compose</h3>
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Friends only</p>
<p className="text-xs text-muted-foreground">Only suggest friends when choosing a recipient</p>
</div>
<Switch checked={friendsOnlySearch} onCheckedChange={setFriendsOnlySearch} />
</div>
</div>
</div>
</div>
);
}
-499
View File
@@ -1,499 +0,0 @@
/**
* SendAnimation
*
* Full-screen overlay: letter slides into envelope, flap closes, wax seal
* stamps with the Ditto logo, floats away, then shows "Sent a letter to <name>!"
*
* Envelope colors are derived from the letter's stationery (theme colors).
* The wax seal uses the stationery's primary color and the Ditto logo.
*/
import { useId, useRef, useEffect, useLayoutEffect, useCallback, useState, useMemo } from 'react';
import { hexToRgb, rgbToHex, darkenHex, blendHex } from '@/lib/colorUtils';
import { impactMedium } from '@/lib/haptics';
import { useEnvelopeDimensions } from '@/hooks/useEnvelopeDimensions';
// ---------------------------------------------------------------------------
// Easing + animation driver
// ---------------------------------------------------------------------------
const ease = {
inOutCubic: (t: number) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2,
outQuart: (t: number) => 1 - Math.pow(1 - t, 4),
outQuint: (t: number) => 1 - Math.pow(1 - t, 5),
inQuad: (t: number) => t * t,
};
function animateVal(
ms: number, fn: (t: number) => void, done: () => void, e: (t: number) => number,
): () => void {
let id = 0;
const s = performance.now();
const tick = (now: number) => {
const raw = Math.min(1, (now - s) / ms);
fn(e(raw));
if (raw < 1) id = requestAnimationFrame(tick); else done();
};
id = requestAnimationFrame(tick);
return () => cancelAnimationFrame(id);
}
// ---------------------------------------------------------------------------
// Envelope dimensions — responsive, using vw-based sizing
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Derive envelope palette from stationery background + primary colors
// ---------------------------------------------------------------------------
function mixHex(hex: string, lightAmount: number): string {
const [r, g, b] = hexToRgb(hex);
const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * lightAmount));
return rgbToHex(mix(r), mix(g), mix(b));
}
function envelopeColors(bgHex: string, primaryHex: string) {
// Tint the envelope body slightly toward the primary color so it
// contrasts with the raw background even on matching themes.
const body = blendHex(bgHex, primaryHex, 0.08);
const inner = blendHex(bgHex, primaryHex, 0.18);
return {
body,
inner,
stroke: darkenHex(body, 0.20),
corner: darkenHex(body, 0.12),
// Seal: use primary color
sealBase: primaryHex,
sealDark: darkenHex(primaryHex, 0.12),
sealDarker: darkenHex(primaryHex, 0.22),
sealEdge: darkenHex(primaryHex, 0.18),
};
}
// ---------------------------------------------------------------------------
// Confetti particles for the confirmation screen
// ---------------------------------------------------------------------------
interface ConfettiParticle {
delay: number;
duration: number;
size: number;
startRotate: number;
color: string;
}
function generateParticles(count: number, primaryHex: string): ConfettiParticle[] {
const [r, g, b] = hexToRgb(primaryHex);
const colors = [
primaryHex,
mixHex(primaryHex, 0.25),
mixHex(primaryHex, 0.45),
darkenHex(primaryHex, 0.15),
rgbToHex(r, g, Math.min(255, b + 40)),
];
return Array.from({ length: count }, () => ({
delay: Math.random() * 1.8,
duration: 2.5 + Math.random() * 2,
size: 18 + Math.random() * 16,
startRotate: Math.random() * 360,
color: colors[Math.floor(Math.random() * colors.length)],
}));
}
function haptic() {
impactMedium();
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface SendAnimationProps {
/** Pre-rendered letter element to animate */
letterElement: React.ReactNode;
/** Width of the letter element in px */
letterWidth: number;
recipientName: string;
recipientPicture?: string;
/** Background hex color of the stationery (used for envelope) */
bgColor: string;
/** Primary hex color of the stationery (used for wax seal) */
primaryColor: string;
/** Text/foreground color of the stationery (used for V-fold crease lines) */
textColor: string;
onComplete: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SendAnimation({
letterElement, letterWidth,
recipientName, recipientPicture,
bgColor, primaryColor, textColor,
onComplete,
}: SendAnimationProps) {
const d = useEnvelopeDimensions();
const splatId = useId();
const [t, setT] = useState(0);
const cancelRef = useRef<(() => void) | undefined>(undefined);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const C = useMemo(() => envelopeColors(bgColor, primaryColor), [bgColor, primaryColor]);
const particles = useMemo(() => generateParticles(12, primaryColor), [primaryColor]);
const sealHapticFired = useRef(false);
const cleanup = useCallback(() => { cancelRef.current?.(); cancelRef.current = undefined; }, []);
useEffect(() => () => cleanup(), [cleanup]);
useLayoutEffect(() => {
cancelRef.current = animateVal(9000, setT, () => {
onCompleteRef.current();
}, ease.inOutCubic);
return cleanup;
}, [cleanup]);
const sub = (lo: number, hi: number) => Math.max(0, Math.min(1, (t - lo) / (hi - lo)));
const envAppear = ease.outQuint(sub(0.0, 0.06));
const slideIn = ease.outQuart(sub(0.04, 0.16));
const flapClose = ease.outQuart(sub(0.15, 0.21));
const sealP = ease.inOutCubic(sub(0.18, 0.38));
const flyP = ease.inQuad(sub(0.48, 0.58));
const confirmP = ease.outQuart(sub(0.58, 0.65));
const fadeOutP = ease.inQuad(sub(0.92, 1.00));
const letterTop = -d.letterH + slideIn * (d.letterH + d.flapY);
const flapDeg = flapClose * 180;
// Seal
const sealVisible = sealP > 0;
const sealDropY = (1 - sealP) * -120;
const impactT = Math.max(0, (sealP - 0.75) / 0.25);
const sealScaleX = impactT === 0 ? 1 : impactT < 0.4 ? 1 + impactT / 0.4 * 0.08 : 1.08 - (impactT - 0.4) / 0.6 * 0.08;
const sealScaleY = impactT === 0 ? 1 : impactT < 0.4 ? 1 - impactT / 0.4 * 0.06 : 0.94 + (impactT - 0.4) / 0.6 * 0.06;
const splatScale = impactT < 0.3 ? impactT / 0.3 : 1;
const sealShadowBlur = (1 - sealP) * 24 + 4;
const sealShadowY = (1 - sealP) * 30 + 2;
if (impactT > 0 && !sealHapticFired.current) {
sealHapticFired.current = true;
haptic();
}
// Fly
const flyY = flyP * -250;
const flyOpacity = 1 - flyP;
const flyRotate = Math.sin(flyP * Math.PI) * 2;
const sealSize = Math.round(d.envW * 0.19);
const sealHalf = sealSize / 2;
const splatSize = Math.round(sealSize * 1.3);
const stageH = d.letterH + d.flapTriH + d.envH + 60;
return (
<div
className="absolute inset-0 z-50 bg-background flex items-center justify-center overflow-hidden"
style={{ opacity: 1 - fadeOutP }}
>
{/* Envelope animation */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ opacity: 1 - confirmP }}
>
<div className="relative" style={{ width: d.envW + 40, height: stageH, marginTop: -60 }}>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: flyP > 0 ? `translateY(${flyY}px) rotate(${flyRotate}deg)` : undefined,
opacity: flyP > 0 ? flyOpacity : 1,
}}
>
<div
className="relative"
style={{
width: d.envW, height: d.envH,
marginTop: d.flapTriH,
opacity: envAppear,
transform: `scale(${0.94 + envAppear * 0.06})`,
}}
>
{/* Flap */}
<div
style={{
position: 'absolute', bottom: '100%', left: 0,
width: d.envW, height: d.flapTriH,
transformOrigin: 'bottom center',
transform: `rotateX(${flapDeg}deg)`,
transformStyle: 'preserve-3d',
zIndex: flapDeg > 90 ? 4 : -1,
}}
>
{/* Front face: inner lining color */}
<svg
width={d.envW} height={d.flapTriH}
viewBox={`0 0 ${d.envW} ${d.flapTriH}`}
className="absolute inset-0"
style={{ backfaceVisibility: 'hidden' }}
>
<path
d={`M${d.r * 0.65},${d.flapTriH} L${d.envW / 2 - d.r},${d.r} Q${d.envW / 2},0 ${d.envW / 2 + d.r},${d.r} L${d.envW - d.r * 0.65},${d.flapTriH} Z`}
fill={C.inner}
/>
</svg>
{/* Back face: body color */}
<svg
width={d.envW} height={d.flapTriH}
viewBox={`0 0 ${d.envW} ${d.flapTriH}`}
className="absolute inset-0"
style={{ backfaceVisibility: 'hidden', transform: 'rotateX(180deg)' }}
>
<path
d={`M${d.r},0 Q0,0 0,${d.r} L0,${d.flapY} L${d.envW / 2 - d.r},${d.flapTriH - d.r} Q${d.envW / 2},${d.flapTriH} ${d.envW / 2 + d.r},${d.flapTriH - d.r} L${d.envW},${d.flapY} L${d.envW},${d.r} Q${d.envW},0 ${d.envW - d.r},0 Z`}
fill={C.body}
/>
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.flapTriH - d.r} Q${d.envW / 2},${d.flapTriH} ${d.envW / 2 + d.r},${d.flapTriH - d.r} L${d.envW},${d.flapY}`}
stroke={textColor} strokeWidth={d.strokeV} strokeLinecap="round" strokeLinejoin="round" fill="none"
/>
</svg>
</div>
{/* Back wall */}
<div
className="absolute inset-0"
style={{
backgroundColor: C.body,
borderRadius: d.r,
boxShadow: '0 8px 40px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.06)',
}}
/>
{/* Inner lining */}
<div
className="absolute overflow-hidden"
style={{
top: 0, left: 0, right: 0, height: d.vY,
borderRadius: `${d.r}px ${d.r}px 0 0`,
backgroundColor: C.inner,
zIndex: 0,
}}
/>
{/* Corner fold lines */}
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: d.r, zIndex: 1 }}>
<svg className="absolute inset-0" width={d.envW} height={d.envH} viewBox={`0 0 ${d.envW} ${d.envH}`}>
<path d={`M2,${d.envH - 2} L${d.envW * 0.35},${d.envH * 0.5}`} stroke={C.corner} strokeWidth={d.strokeCorner} strokeLinecap="round" fill="none" opacity="0.5" />
<path d={`M${d.envW - 2},${d.envH - 2} L${d.envW * 0.65},${d.envH * 0.5}`} stroke={C.corner} strokeWidth={d.strokeCorner} strokeLinecap="round" fill="none" opacity="0.5" />
</svg>
</div>
{/* Letter clip */}
<div
className="absolute overflow-hidden"
style={{
top: -(d.letterH + d.flapTriH), left: 0, right: 0, bottom: 0,
borderRadius: `0 0 ${d.r}px ${d.r}px`,
zIndex: 1,
}}
>
<div
className="absolute"
style={{
left: (d.envW - letterWidth) / 2,
width: letterWidth,
top: d.letterH + d.flapTriH + letterTop,
}}
>
{letterElement}
</div>
</div>
{/* Front V-fold pocket */}
<svg
className="absolute inset-0 pointer-events-none"
width={d.envW} height={d.envH}
viewBox={`0 0 ${d.envW} ${d.envH}`}
style={{ zIndex: 2 }}
>
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.vY - d.r} Q${d.envW / 2},${d.vY} ${d.envW / 2 + d.r},${d.vY - d.r} L${d.envW},${d.flapY} L${d.envW},${d.envH - d.r} Q${d.envW},${d.envH} ${d.envW - d.r},${d.envH} L${d.r},${d.envH} Q0,${d.envH} 0,${d.envH - d.r} Z`}
fill={C.body}
/>
{/* V crease lines in stationery text color */}
<path
d={`M0,${d.flapY} L${d.envW / 2 - d.r},${d.vY - d.r} Q${d.envW / 2},${d.vY} ${d.envW / 2 + d.r},${d.vY - d.r} L${d.envW},${d.flapY}`}
fill="none"
stroke={textColor}
strokeWidth={d.strokeV}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{/* Wax seal */}
{sealVisible && (
<div
className="absolute"
style={{
left: d.envW / 2 - sealHalf,
top: d.vY - sealHalf,
width: sealSize, height: sealSize,
zIndex: 5,
transform: `translateY(${sealDropY}px) scaleX(${sealScaleX}) scaleY(${sealScaleY})`,
transformOrigin: 'center center',
}}
>
{/* Splat blob on impact */}
{impactT > 0 && (
<svg
className="absolute"
width={splatSize} height={splatSize}
viewBox="0 0 84 84"
style={{
left: -(splatSize - sealSize) / 2,
top: -(splatSize - sealSize) / 2,
transform: `scale(${0.88 + splatScale * 0.12})`,
}}
>
<defs>
<radialGradient id={splatId}>
<stop offset="0%" stopColor={C.sealBase} />
<stop offset="50%" stopColor={C.sealDark} />
<stop offset="85%" stopColor={C.sealDarker} />
<stop offset="100%" stopColor={C.sealDarker} stopOpacity="0.6" />
</radialGradient>
</defs>
<path
d="M42 3 C50 2, 58 7, 64 13 C69 18, 76 24, 78 33 C80 41, 82 48, 77 56 C73 62, 66 70, 56 73 C48 76, 40 78, 32 74 C24 71, 14 66, 9 58 C5 50, 2 42, 4 34 C6 26, 12 18, 19 12 C26 6, 34 4, 42 3 Z"
fill={`url(#${splatId})`}
/>
<path
d="M42 3 C50 2, 58 7, 64 13 C69 18, 76 24, 78 33 C80 41, 82 48, 77 56 C73 62, 66 70, 56 73 C48 76, 40 78, 32 74 C24 71, 14 66, 9 58 C5 50, 2 42, 4 34 C6 26, 12 18, 19 12 C26 6, 34 4, 42 3 Z"
fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth="1"
/>
</svg>
)}
{/* Seal disc */}
<div
className="absolute rounded-full"
style={{
inset: 2,
background: `
radial-gradient(ellipse at 35% 30%, rgba(255,255,255,0.22) 0%, transparent 50%),
radial-gradient(ellipse at 65% 70%, rgba(0,0,0,0.14) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, ${C.sealBase} 0%, ${C.sealDark} 55%, ${C.sealDarker} 100%)
`,
boxShadow: `
0 ${sealShadowY}px ${sealShadowBlur}px rgba(0,0,0,0.28),
0 1px 3px rgba(0,0,0,0.14),
inset 0 1.5px 2px rgba(255,255,255,0.18),
inset 0 -1.5px 2px rgba(0,0,0,0.18)
`,
border: `2px solid ${C.sealEdge}`,
}}
/>
{/* Ditto logo */}
<div
className="absolute inset-0 flex items-center justify-center"
style={{ zIndex: 1 }}
>
<img
src="/logo.svg"
alt=""
style={{
width: sealSize * 0.58,
height: sealSize * 0.58,
filter: 'brightness(0) invert(1) opacity(0.85)',
}}
/>
</div>
</div>
)}
{/* Contact shadow */}
<div
className="absolute left-4 right-4"
style={{
bottom: -4, height: 8,
background: 'radial-gradient(ellipse at center, rgba(0,0,0,0.06) 0%, transparent 70%)',
zIndex: -1, borderRadius: '50%',
}}
/>
</div>
</div>
</div>
</div>
{/* Confirmation */}
{confirmP > 0 && (
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
style={{ opacity: confirmP }}
>
<div className="relative text-center space-y-5 px-6" style={{ transform: `translateY(${(1 - confirmP) * 12}px)` }}>
<div className="relative mx-auto" style={{ width: 96, height: 96 }}>
{/* Confetti burst */}
{particles.map((p, i) => {
const angle = (i / particles.length) * 360 + p.startRotate * 0.3;
const rad = (angle * Math.PI) / 180;
const dist = 60 + p.size * 3;
const tx = Math.cos(rad) * dist;
const ty = Math.sin(rad) * dist;
return (
<svg
key={i}
className="absolute pointer-events-none"
width={p.size} height={p.size}
viewBox="0 0 24 24"
fill="none"
style={{
left: '50%', top: '50%',
marginLeft: -p.size / 2, marginTop: -p.size / 2,
opacity: 0,
animation: `letter-send-burst ${p.duration}s ease-out ${p.delay * 0.4}s both`,
'--burst-tx': `${tx}px`,
'--burst-ty': `${ty}px`,
'--burst-rot': `${p.startRotate}deg`,
} as React.CSSProperties}
>
<circle cx="12" cy="12" r="5" fill={p.color} opacity={0.8} />
</svg>
);
})}
{/* Avatar */}
<div
className="w-24 h-24 rounded-full border-4 flex items-center justify-center overflow-hidden"
style={{
backgroundColor: bgColor,
borderColor: C.stroke,
animation: 'letter-send-avatar-in 0.7s cubic-bezier(0.34, 1.56, 0.64, 1) both',
}}
>
{recipientPicture ? (
<img src={recipientPicture} alt="" className="w-full h-full object-cover" />
) : (
<img src="/logo.svg" alt="" style={{ width: 44, height: 44, opacity: 0.5 }} />
)}
</div>
</div>
<p
className="text-2xl font-bold text-foreground"
style={{ opacity: 0, animation: 'letter-send-fade-up 0.5s ease-out 0.3s forwards' }}
>
Sent a letter to {recipientName}!
</p>
</div>
</div>
)}
</div>
);
}
-308
View File
@@ -1,308 +0,0 @@
import { useState, useMemo, useRef, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { NostrEvent } from '@nostrify/nostrify';
import {
STATIONERY_PRESETS,
COLOR_MOMENT_KIND,
type Stationery,
colorMomentToStationery,
presetToStationery,
resolveStationery,
} from '@/lib/letterTypes';
import { useColorMomentsPage } from '@/hooks/useStationery';
import { useFollowList } from '@/hooks/useFollowActions';
import { StationeryPreview } from './StationeryBackground';
const PAGE_SIZE = 24;
const PRESET_ENTRIES = Object.entries(STATIONERY_PRESETS);
// ---------------------------------------------------------------------------
// Paginated color moments grid with infinite scroll
// ---------------------------------------------------------------------------
function ColorMomentsGrid({
selectedStationery,
onSelect,
authors,
}: {
selectedStationery?: Stationery;
onSelect: (s: Stationery) => void;
authors?: string[];
}) {
const [pages, setPages] = useState<NostrEvent[][]>([]);
const [until, setUntil] = useState<number | undefined>(undefined);
const [hasMore, setHasMore] = useState(true);
const lastUntilRef = useRef<number | 'init' | undefined>('init');
const { data: page, isLoading } = useColorMomentsPage(PAGE_SIZE, until, authors);
useEffect(() => {
if (!page || isLoading) return;
if (lastUntilRef.current === until) return;
lastUntilRef.current = until;
if (page.length > 0) {
setPages((prev) => [...prev, page]);
if (page.length < PAGE_SIZE) setHasMore(false);
} else {
setHasMore(false);
}
}, [page, isLoading, until]);
const allItems = pages.flat();
const initialized = pages.length > 0 || (!isLoading && lastUntilRef.current !== 'init');
const loadMore = useCallback(() => {
if (!hasMore || isLoading || allItems.length === 0) return;
setUntil(allItems[allItems.length - 1].created_at - 1);
}, [hasMore, isLoading, allItems]);
const observerRef = useRef<IntersectionObserver | null>(null);
const sentinelCallback = useCallback(
(node: HTMLDivElement | null) => {
observerRef.current?.disconnect();
if (!node) return;
observerRef.current = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting) loadMore(); },
{ threshold: 0.1 }
);
observerRef.current.observe(node);
},
[loadMore]
);
if (!initialized && isLoading) {
return (
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="aspect-square rounded-2xl" />)}
</div>
);
}
if (initialized && allItems.length === 0) {
return <p className="py-6 text-center text-sm text-muted-foreground">none found try presets</p>;
}
return (
<div className="overflow-y-auto rounded-xl" style={{ height: 160 }}>
<div className="grid grid-cols-5 gap-2">
{allItems.map((event) => {
const stationery = colorMomentToStationery(event);
const isSelected = selectedStationery?.event?.id === event.id;
const name = event.tags.find(([n]) => n === 'name')?.[1];
return (
<button
key={event.id}
onClick={() => onSelect(stationery)}
title={name}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={isSelected}
className="w-full h-full"
/>
</button>
);
})}
{hasMore && (
<div ref={sentinelCallback} className="col-span-5 h-2">
{isLoading && Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="aspect-square rounded-2xl" />
))}
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main StationeryPicker
// ---------------------------------------------------------------------------
interface StationeryPickerProps {
selected?: Stationery;
onSelect: (stationery: Stationery) => void;
}
type Tab = 'presets' | 'colors';
export function StationeryPicker({ selected, onSelect }: StationeryPickerProps) {
const [tab, setTab] = useState<Tab>('presets');
const [scope, setScope] = useState<'everyone' | 'friends' | 'mine'>('everyone');
const [infoOpen, setInfoOpen] = useState(false);
const { user } = useCurrentUser();
const followListData = useFollowList();
const followPubkeyArray = followListData.data?.pubkeys;
const followList = useMemo(() => new Set(followPubkeyArray ?? []), [followPubkeyArray]);
const scopedAuthors = useMemo(() => {
if (scope === 'mine') return user ? [user.pubkey] : undefined;
if (scope === 'friends') return followList.size > 0 ? Array.from(followList) : undefined;
return undefined;
}, [scope, user, followList]);
const resolved = selected ? resolveStationery(selected) : undefined;
const emojiMode = selected?.emojiMode ?? 'tile';
const hasEmoji = !!resolved?.emoji;
const isColorMoment = selected?.event?.kind === COLOR_MOMENT_KIND;
// Remember the last color moment event so we can restore it when toggling off flat mode
const lastColorMomentRef = useRef<NostrEvent | undefined>(undefined);
if (isColorMoment && selected?.event) lastColorMomentRef.current = selected.event;
// Flat mode = color moment was selected but event has been stripped (only color remains)
const isFlatMode = !!(selected && !selected.event && lastColorMomentRef.current);
const toggleSingleColor = () => {
if (!selected) return;
if (isFlatMode && lastColorMomentRef.current) {
// Restore: put the event back
onSelect({ ...selected, event: lastColorMomentRef.current });
} else if (isColorMoment && selected.event) {
// Flatten: strip the event, keep only color
const { event: _, ...rest } = selected;
onSelect(rest as Stationery);
}
};
const toggleEmojiMode = () => {
if (!selected) return;
onSelect({ ...selected, emojiMode: emojiMode === 'tile' ? 'emblem' : 'tile' });
};
const tabs: { id: Tab; label: string }[] = [
{ id: 'presets', label: 'presets' },
{ id: 'colors', label: 'moments' },
];
const isMomentsTab = tab === 'colors';
const showInfoButton = isMomentsTab;
return (
<div className="space-y-3">
<div className="flex items-center gap-1.5">
{tabs.map((t) => (
<button
key={t.id}
onClick={() => { setTab(t.id); setScope('everyone'); }}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-all border ${
tab === t.id
? 'bg-foreground text-background border-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground border-secondary'
}`}
>
{t.label}
</button>
))}
{showInfoButton && (
<button
onClick={() => setInfoOpen(true)}
className="ml-auto opacity-70 hover:opacity-100 transition-opacity text-xs text-muted-foreground font-medium px-2 py-1 rounded-full bg-secondary"
>
{isMomentsTab ? 'about' : 'about'}
</button>
)}
</div>
{showInfoButton && (
<div className="flex gap-1">
{(['everyone', 'friends', ...(user ? ['mine' as const] : [])] as const).map((s) => (
<button
key={s}
onClick={() => setScope(s)}
className={`px-2.5 py-1 rounded-full text-xs font-medium transition-all border ${
scope === s
? 'bg-foreground text-background border-foreground'
: 'bg-secondary text-muted-foreground hover:text-foreground border-secondary'
}`}
>
{s}
</button>
))}
</div>
)}
<div>
{tab === 'presets' && (
<div className="rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PRESET_ENTRIES.map(([key, preset]) => {
const stationery: Stationery = { color: preset.color, emoji: preset.emoji };
return (
<button
key={key}
onClick={() => onSelect(presetToStationery(key) ?? stationery)}
title={preset.name}
className="relative aspect-square rounded-2xl overflow-hidden transition-all hover:scale-105 active:scale-95"
>
<StationeryPreview
stationery={stationery}
selected={selected?.color === preset.color && !selected?.event}
className="w-full h-full"
/>
</button>
);
})}
</div>
</div>
)}
{tab === 'colors' && <ColorMomentsGrid key={scope} selectedStationery={selected} onSelect={onSelect} authors={scopedAuthors} />}
</div>
{(hasEmoji || isColorMoment || isFlatMode) && (
<div className="flex items-center gap-4 px-1 pt-1">
{hasEmoji && (
<label className="flex items-center gap-1.5">
<Switch checked={emojiMode === 'emblem'} onCheckedChange={toggleEmojiMode} />
<span className="text-sm text-muted-foreground font-medium">emblem</span>
</label>
)}
{(isColorMoment || isFlatMode) && (
<label className="flex items-center gap-1.5">
<Switch checked={isFlatMode} onCheckedChange={toggleSingleColor} />
<span className="text-sm text-muted-foreground font-medium">flat</span>
</label>
)}
</div>
)}
<Dialog open={infoOpen} onOpenChange={setInfoOpen}>
<DialogContent className="max-w-xs rounded-2xl">
{isMomentsTab && (
<>
<DialogHeader>
<DialogTitle>Color moments</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
Color moments are beautiful color combinations created and shared by the community. Each one gives your letter a unique palette and mood.
</p>
<p>
<Link
to="/colors"
onClick={() => setInfoOpen(false)}
className="text-foreground font-medium underline underline-offset-2 hover:no-underline"
>
Discover and create color moments
</Link>
</p>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}
-85
View File
@@ -1,85 +0,0 @@
import { useMemo } from 'react';
import { useCuratedMusicArtists } from '@/hooks/useCuratedMusicArtists';
import { useMusicData } from '@/hooks/useMusicData';
import { ProfileCard, ProfileCardSkeleton } from '@/components/discovery/ProfileCard';
/**
* The "Artists" tab — grid of artist profile cards.
*
* Shows curated artists first (with track counts), then all other
* artists discovered from track events, sorted by track count.
*
* **States**:
* - Loading: Grid of skeleton cards
* - Empty: Centered message
* - Loaded: Grid of profile cards with track counts
*/
export function MusicArtistsTab() {
const { data: curatedPubkeys } = useCuratedMusicArtists();
const { artists, isLoading, isError } = useMusicData();
// Merge curated artists (first) with discovered artists
const allArtists = useMemo(() => {
const trackCountMap = new Map(artists.map((a) => [a.pubkey, a.trackCount]));
const seen = new Set<string>();
const result: { pubkey: string; trackCount: number }[] = [];
// Curated artists first
if (curatedPubkeys) {
for (const pk of curatedPubkeys) {
if (!seen.has(pk)) {
seen.add(pk);
result.push({ pubkey: pk, trackCount: trackCountMap.get(pk) ?? 0 });
}
}
}
// Then discovered artists sorted by track count
for (const a of artists) {
if (!seen.has(a.pubkey)) {
seen.add(a.pubkey);
result.push(a);
}
}
return result;
}, [curatedPubkeys, artists]);
if (isLoading) {
return (
<div className="grid grid-cols-3 gap-4 px-4 pt-4 pb-8">
{Array.from({ length: 9 }).map((_, i) => (
<ProfileCardSkeleton key={i} />
))}
</div>
);
}
if (isError) {
return (
<p className="px-4 py-12 text-sm text-muted-foreground text-center">
Failed to load artists. Check your relay connections and try again.
</p>
);
}
if (allArtists.length === 0) {
return (
<p className="px-4 py-12 text-sm text-muted-foreground text-center">
No music artists found yet. Check back soon!
</p>
);
}
return (
<div className="grid grid-cols-3 gap-4 px-4 pt-4 pb-8">
{allArtists.map((a) => (
<ProfileCard
key={a.pubkey}
pubkey={a.pubkey}
subtitle={a.trackCount > 0 ? `${a.trackCount} track${a.trackCount !== 1 ? 's' : ''}` : undefined}
/>
))}
</div>
);
}
-297
View File
@@ -1,297 +0,0 @@
import { useState, useMemo } from 'react';
import { Music } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { parseMusicTrack } from '@/lib/musicHelpers';
import { getExtraKindDef } from '@/lib/extraKinds';
import { DITTO_RELAYS } from '@/lib/appRelays';
import { useCuratedMusicArtists } from '@/hooks/useCuratedMusicArtists';
import { useFeaturedMusicTracks } from '@/hooks/useFeaturedMusicTracks';
import { useMusicCuratorFollows } from '@/hooks/useMusicCuratorFollows';
import { useMusicData } from '@/hooks/useMusicData';
import { useMusicPlaylists } from '@/hooks/useMusicPlaylists';
import { useFollowList } from '@/hooks/useFollowActions';
import { SectionHeader } from '@/components/discovery/SectionHeader';
import { HorizontalScroll } from '@/components/discovery/HorizontalScroll';
import { TagChips } from '@/components/discovery/TagChips';
import { ProfileCard, ProfileCardSkeleton } from '@/components/discovery/ProfileCard';
import { ContentCTACard } from '@/components/discovery/ContentCTACard';
import { MusicSortFilterBar, type MusicSort, type MusicScope } from './MusicSortFilterBar';
import { MusicHeroCard, MusicHeroCardSkeleton } from './MusicHeroCard';
import { MusicTrackCard, MusicTrackCardSkeleton } from './MusicTrackCard';
import { MusicTrackRow, MusicTrackRowSkeleton } from './MusicTrackRow';
import { MusicPlaylistCard, MusicPlaylistCardSkeleton } from './MusicPlaylistCard';
const musicDef = getExtraKindDef('music')!;
interface MusicDiscoverTabProps {
/** Switch to the Tracks tab. */
onSwitchToTracks: () => void;
/** Switch to the Playlists tab. */
onSwitchToPlaylists: () => void;
/** Switch to the Artists tab. */
onSwitchToArtists: () => void;
}
/**
* The "Discover" tab — the curator's storefront for music discovery.
*
* All content is gated through the curator's lists:
* - Hero + Featured: Hot tracks from curated artists, one per artist (sort:hot distinct:author)
* - New Tracks: Most recent tracks from curated artists, genre-filterable
* - Playlists: Playlists from people the curator follows
* - Artists: Curated artist profile cards
*
* Sections (top to bottom):
* 1. Hero card — #1 hot track from curated artists
* 2. Featured — Horizontal scroll of next-hottest tracks (one per artist)
* 3. Artists — Horizontal scroll of curated artist profile cards
* 4. Playlists — Horizontal scroll of playlists from curator's follows (sort:hot)
* 5. New Tracks header + sort/scope bar + genre chips
* 6. New Tracks — Compact track rows with Hot/Top/New sort and Global/Following scope
* 7. CTA — "Share Your Music on Nostr" card
*/
export function MusicDiscoverTab({ onSwitchToTracks, onSwitchToPlaylists, onSwitchToArtists }: MusicDiscoverTabProps) {
const { nostr } = useNostr();
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [newTracksSort, setNewTracksSort] = useState<MusicSort>('hot');
const [newTracksScope, setNewTracksScope] = useState<MusicScope>('global');
// Curated artist list (from curator's kind 30000 Listr list)
const { data: curatedPubkeys } = useCuratedMusicArtists();
// Featured tracks: hot tracks from curated artists, one per artist
// Index 0 = hero (#1 hot track), rest = Featured horizontal scroll
const { data: featuredTracks, isLoading: isFeaturedLoading } = useFeaturedMusicTracks(curatedPubkeys);
// Hero track: the #1 hot track from curated artists
const heroTrack = featuredTracks?.[0] ?? null;
// User's follow list (for Following scope)
const { data: followData } = useFollowList();
const followPubkeys = followData?.pubkeys;
// Determine which authors to query for the New Tracks section
const newTracksAuthors = newTracksScope === 'following' ? followPubkeys : curatedPubkeys;
// Base music data from curated artists only: genres, artist stats (always curated)
const {
tracks: curatedTracks,
genres,
artists,
isLoading: isTracksLoading,
} = useMusicData({ authors: curatedPubkeys });
// New Tracks: sorted query for hot/top via Ditto relay, or chronological for new
const { data: sortedNewTracks, isLoading: isSortedLoading, isError: isSortedError } = useQuery<NostrEvent[]>({
queryKey: ['discover-new-tracks', newTracksSort, newTracksScope, newTracksAuthors?.slice().sort().join(',') ?? '', selectedGenre ?? ''],
queryFn: async ({ signal }) => {
if (!newTracksAuthors || newTracksAuthors.length === 0) return [];
const filter: Record<string, unknown> = {
kinds: [36787],
authors: newTracksAuthors,
limit: 20,
};
if (selectedGenre) {
filter['#t'] = [selectedGenre];
}
const timeout = AbortSignal.any([signal, AbortSignal.timeout(10000)]);
let events: NostrEvent[];
if (newTracksSort === 'new') {
events = await nostr.query(
[filter as { kinds: number[]; authors: string[]; limit: number; '#t'?: string[] }],
{ signal: timeout },
);
} else {
filter.search = `sort:${newTracksSort}`;
const ditto = nostr.group(DITTO_RELAYS);
events = await ditto.query(
[filter as { kinds: number[]; authors: string[]; search: string; limit: number; '#t'?: string[] }],
{ signal: timeout },
);
// Fallback: if hot/top returned nothing, retry chronologically
if (events.length === 0) {
delete filter.search;
events = await nostr.query(
[filter as { kinds: number[]; authors: string[]; limit: number; '#t'?: string[] }],
{ signal: timeout },
);
}
}
return events.filter((ev) => parseMusicTrack(ev) !== null).slice(0, 8);
},
enabled: !!newTracksAuthors && newTracksAuthors.length > 0,
staleTime: 5 * 60 * 1000,
placeholderData: (prev) => prev,
});
const newTracks = sortedNewTracks ?? [];
const isNewTracksLoading = isSortedLoading && !sortedNewTracks;
// Curator's follow list (Heather's kind 3) — used to filter playlists
const { data: curatorFollows } = useMusicCuratorFollows();
// Playlists from people the curator follows, sorted by hot
const { data: playlists, isLoading: isPlaylistsLoading } = useMusicPlaylists({
authors: curatorFollows,
search: 'sort:hot',
limit: 10,
enabled: !!curatorFollows && curatorFollows.length > 0,
});
// Top genre names for chips (max 12)
const genreNames = useMemo(() => genres.slice(0, 12).map((g) => g.genre), [genres]);
// Featured artists: curated pubkeys with their track counts
const featuredArtists = useMemo(() => {
if (curatedPubkeys && curatedPubkeys.length > 0) {
const trackCounts = new Map<string, number>();
for (const ev of curatedTracks) {
trackCounts.set(ev.pubkey, (trackCounts.get(ev.pubkey) ?? 0) + 1);
}
return curatedPubkeys.slice(0, 10).map((pk) => ({
pubkey: pk,
trackCount: trackCounts.get(pk) ?? 0,
}));
}
return artists.slice(0, 10);
}, [curatedPubkeys, artists, curatedTracks]);
return (
<div className="pb-8 space-y-1">
{/* Hero — #1 hot track from curated artists */}
{isFeaturedLoading ? (
<div className="pt-3">
<MusicHeroCardSkeleton />
</div>
) : heroTrack ? (
<div className="pt-3">
<MusicHeroCard event={heroTrack} />
</div>
) : null}
{/* Featured tracks horizontal scroll (hot, one per artist) */}
{isFeaturedLoading ? (
<>
<SectionHeader title="Featured" />
<HorizontalScroll>
{Array.from({ length: 4 }).map((_, i) => (
<MusicTrackCardSkeleton key={i} />
))}
</HorizontalScroll>
</>
) : featuredTracks && featuredTracks.length > 1 ? (
<>
<SectionHeader title="Featured" onSeeAll={onSwitchToTracks} />
<HorizontalScroll>
{featuredTracks.slice(1, 8).map((ev) => (
<MusicTrackCard key={ev.id} event={ev} />
))}
</HorizontalScroll>
</>
) : null}
{/* Artists horizontal scroll */}
{(isTracksLoading || featuredArtists.length > 0) && (
<>
<SectionHeader title="Artists" onSeeAll={onSwitchToArtists} />
{isTracksLoading ? (
<HorizontalScroll>
{Array.from({ length: 5 }).map((_, i) => (
<ProfileCardSkeleton key={i} />
))}
</HorizontalScroll>
) : (
<HorizontalScroll>
{featuredArtists.map((a) => (
<ProfileCard
key={a.pubkey}
pubkey={a.pubkey}
subtitle={a.trackCount > 0 ? `${a.trackCount} track${a.trackCount !== 1 ? 's' : ''}` : undefined}
/>
))}
</HorizontalScroll>
)}
</>
)}
{/* Playlists — from people the curator follows, sorted by hot */}
{(isPlaylistsLoading || (playlists && playlists.length > 0)) && (
<>
<SectionHeader title="Playlists" onSeeAll={onSwitchToPlaylists} />
{isPlaylistsLoading ? (
<HorizontalScroll>
{Array.from({ length: 3 }).map((_, i) => (
<MusicPlaylistCardSkeleton key={i} />
))}
</HorizontalScroll>
) : (
<HorizontalScroll>
{playlists!.slice(0, 6).map((ev) => (
<MusicPlaylistCard key={ev.id} event={ev} />
))}
</HorizontalScroll>
)}
</>
)}
{/* New Tracks — sort/scope filterable */}
<SectionHeader title="New Tracks" onSeeAll={onSwitchToTracks} />
<MusicSortFilterBar
sort={newTracksSort}
scope={newTracksScope}
onSortChange={setNewTracksSort}
onScopeChange={setNewTracksScope}
/>
{/* Genre chips */}
{genreNames.length > 0 && (
<TagChips
tags={genreNames}
selected={selectedGenre}
onSelect={setSelectedGenre}
/>
)}
{isSortedError ? (
<p className="px-4 py-6 text-sm text-muted-foreground text-center">
Failed to load tracks. Check your relay connections and try again.
</p>
) : isNewTracksLoading ? (
<div>
{Array.from({ length: 5 }).map((_, i) => (
<MusicTrackRowSkeleton key={i} />
))}
</div>
) : newTracks.length > 0 ? (
<div>
{newTracks.map((ev, i) => (
<MusicTrackRow key={ev.id} event={ev} index={i} />
))}
</div>
) : (
<p className="px-4 py-6 text-sm text-muted-foreground text-center">
{newTracksScope === 'following'
? 'No tracks from people you follow yet.'
: selectedGenre ? `No ${selectedGenre} tracks found.` : 'No music yet. Check back soon!'}
</p>
)}
{/* CTA */}
<div className="pt-4">
<ContentCTACard
kindDef={musicDef}
title="Share Your Music on Nostr"
subtitle="Upload tracks and reach a global audience. Earn sats directly from fans."
icon={<Music className="size-10" />}
/>
</div>
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
import { useMemo, useState } from 'react';
import { Play, Pause, Music } from 'lucide-react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { useAuthor } from '@/hooks/useAuthor';
import { parseMusicTrack, toAudioTrack } from '@/lib/musicHelpers';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { formatTime } from '@/lib/formatTime';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface MusicHeroCardProps {
/** The featured track event. */
event: NostrEvent;
}
/**
* Full-width featured track hero card with large artwork and gradient overlay.
*
* Displays the featured track with a prominent play button, title, artist,
* and "Featured" badge. The entire card is playable.
*
* **States**:
* - Default: Gradient overlay with track info
* - Now playing: Primary border, pause icon on play button
* - No artwork: Gradient placeholder with Music icon
*/
export function MusicHeroCard({ event }: MusicHeroCardProps) {
const player = useAudioPlayer();
const parsed = useMemo(() => parseMusicTrack(event), [event]);
const author = useAuthor(event.pubkey);
const [imgError, setImgError] = useState(false);
if (!parsed) return null;
const isNowPlaying = player.currentTrack?.id === event.id;
const dur = parsed.duration ? formatTime(parsed.duration) : undefined;
const handlePlay = (e: React.MouseEvent) => {
e.stopPropagation();
if (isNowPlaying && player.isPlaying) {
player.pause();
} else if (isNowPlaying) {
player.resume();
} else {
const track = toAudioTrack(event, parsed);
track.artwork ??= sanitizeUrl(author.data?.metadata?.picture);
player.playTrack(track);
}
};
return (
<div
className={cn(
'mx-4 rounded-2xl overflow-hidden relative cursor-pointer',
isNowPlaying && 'ring-2 ring-primary',
)}
onClick={handlePlay}
>
{/* Artwork */}
{parsed.artwork && !imgError ? (
<img
src={parsed.artwork}
alt={parsed.title}
className="w-full aspect-[16/10] object-cover"
loading="eager"
onError={() => setImgError(true)}
/>
) : (
<div className="w-full aspect-[16/10] bg-gradient-to-br from-primary/20 via-primary/10 to-accent/10 flex items-center justify-center">
<Music className="size-16 text-primary/20" />
</div>
)}
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent" />
{/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-5">
<span className="inline-block px-2.5 py-0.5 rounded-full bg-primary/80 text-primary-foreground text-xs font-medium mb-2">
Featured
</span>
<h3 className="text-2xl font-bold text-white leading-tight truncate">{parsed.title}</h3>
{parsed.artist && (
<p className="text-base text-white/80 truncate mt-0.5">{parsed.artist}</p>
)}
<div className="flex items-center gap-3 mt-3">
<button
onClick={handlePlay}
className={cn(
'size-12 rounded-full flex items-center justify-center transition-all hover:scale-105',
isNowPlaying && player.isPlaying
? 'bg-primary text-primary-foreground'
: 'bg-white/90 text-black hover:bg-white',
)}
aria-label={isNowPlaying && player.isPlaying ? 'Pause' : 'Play'}
>
{isNowPlaying && player.isPlaying
? <Pause className="size-5" fill="currentColor" />
: <Play className="size-5 ml-0.5" fill="currentColor" />}
</button>
{dur && (
<span className="text-sm text-white/60">{dur}</span>
)}
</div>
</div>
</div>
);
}
/** Loading skeleton matching MusicHeroCard dimensions. */
export function MusicHeroCardSkeleton() {
return (
<div className="mx-4 rounded-2xl overflow-hidden">
<Skeleton className="w-full aspect-[16/10]" />
</div>
);
}
@@ -1,89 +0,0 @@
import { useMemo, useState } from 'react';
import { Disc3, ListMusic } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { parseMusicPlaylist } from '@/lib/musicHelpers';
import { usePlaylistCoverArt } from '@/hooks/usePlaylistCoverArt';
import { Skeleton } from '@/components/ui/skeleton';
interface MusicPlaylistCardProps {
/** The music playlist event. */
event: NostrEvent;
}
/**
* Playlist card for horizontal scroll sections and the Playlists tab grid.
*
* Layout: [square artwork] + [title] + [track count]
*
* **States**:
* - Default: Artwork with title and track count below
* - No artwork: Gradient fallback with ListMusic icon
*/
export function MusicPlaylistCard({ event }: MusicPlaylistCardProps) {
const parsed = useMemo(() => parseMusicPlaylist(event), [event]);
const naddrPath = useMemo(() => {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return '/' + nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: d });
}, [event]);
const [failedUrls, setFailedUrls] = useState<Set<string>>(() => new Set());
// Fall back to first track's artwork when playlist has none or it failed to load
const playlistArt = parsed?.artwork && !failedUrls.has(parsed.artwork) ? parsed.artwork : undefined;
const coverArt = usePlaylistCoverArt(playlistArt, parsed?.trackRefs ?? []);
const displayArt = coverArt && !failedUrls.has(coverArt) ? coverArt : undefined;
if (!parsed) return null;
const trackCount = parsed.trackRefs.length;
return (
<Link to={naddrPath} className="w-[160px] shrink-0 cursor-pointer group">
{/* Artwork */}
<div className="w-full aspect-square rounded-xl overflow-hidden">
{displayArt ? (
<img
src={displayArt}
alt={parsed.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
onError={() => setFailedUrls((prev) => new Set(prev).add(displayArt))}
/>
) : (
<div className="w-full h-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<ListMusic className="size-10 text-primary/20" />
</div>
)}
</div>
{/* Info */}
<p className="text-sm font-medium truncate mt-2 group-hover:text-primary transition-colors">
{parsed.title}
</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{parsed.isAlbum && (
<span className="inline-flex items-center gap-0.5 font-medium text-primary/70">
<Disc3 className="size-3" />Album
</span>
)}
{parsed.isAlbum && trackCount > 0 && <span>·</span>}
{trackCount > 0 && (
<span>{trackCount} track{trackCount !== 1 ? 's' : ''}</span>
)}
</div>
</Link>
);
}
/** Loading skeleton matching MusicPlaylistCard dimensions. */
export function MusicPlaylistCardSkeleton() {
return (
<div className="w-[160px] shrink-0">
<Skeleton className="w-full aspect-square rounded-xl" />
<Skeleton className="h-4 w-3/4 mt-2" />
<Skeleton className="h-3 w-12 mt-1" />
</div>
);
}
-155
View File
@@ -1,155 +0,0 @@
import { useMemo, useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useMusicFeed } from '@/hooks/useMusicFeed';
import { MusicPlaylistCard, MusicPlaylistCardSkeleton } from './MusicPlaylistCard';
import { MusicSortFilterBar, type MusicSort, type MusicScope } from './MusicSortFilterBar';
import { parseMusicPlaylist } from '@/lib/musicHelpers';
import { cn } from '@/lib/utils';
type FilterMode = 'all' | 'playlists' | 'albums';
/**
* The "Playlists" tab — 2-column grid of playlist cards with sort, scope, and
* album/playlist type filters.
*
* **Sort**: Hot (engagement + decay), Top (total engagement), New (chronological)
* **Scope**: Global (all authors) or Following (user's follow list)
* **Type filter**: All / Playlists / Albums
*/
export function MusicPlaylistsTab() {
const [sort, setSort] = useState<MusicSort>('new');
const [scope, setScope] = useState<MusicScope>('global');
const [filter, setFilter] = useState<FilterMode>('all');
// Infinite-scroll feed with sort + scope
const feedQuery = useMusicFeed({ kind: 34139, sort, scope });
const {
data: rawData,
isPending,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = feedQuery;
// Auto-fetch page 2
useEffect(() => {
if (hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
threshold: 0,
rootMargin: '400px',
});
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten, deduplicate, validate
const allPlaylists = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return rawData.pages.flat().filter((event) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
return parseMusicPlaylist(event) !== null;
});
}, [rawData?.pages]);
// Album/playlist type filter
const filtered = useMemo(() => {
if (filter === 'all') return allPlaylists;
return allPlaylists.filter((ev) => {
const parsed = parseMusicPlaylist(ev);
if (!parsed) return false;
return filter === 'albums' ? parsed.isAlbum : !parsed.isAlbum;
});
}, [allPlaylists, filter]);
// Only show type toggle if there are both albums and non-albums
const hasAlbums = useMemo(() => {
return allPlaylists.some((ev) => parseMusicPlaylist(ev)?.isAlbum);
}, [allPlaylists]);
const showSkeleton = isPending || (isLoading && !rawData);
return (
<div className="pb-8">
{/* Sort + scope filter bar */}
<MusicSortFilterBar
sort={sort}
scope={scope}
onSortChange={setSort}
onScopeChange={setScope}
/>
{/* Album/playlist type toggle */}
{hasAlbums && (
<div className="px-4 pb-2">
<div className="flex gap-1 p-1 rounded-lg bg-secondary/40 w-fit">
{(['all', 'playlists', 'albums'] as const).map((mode) => (
<button
key={mode}
onClick={() => setFilter(mode)}
className={cn(
'px-3 py-1.5 text-xs font-medium rounded-md transition-colors capitalize',
filter === mode
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{mode}
</button>
))}
</div>
</div>
)}
{/* Playlist grid */}
{isError ? (
<p className="px-4 py-12 text-sm text-muted-foreground text-center">
Failed to load playlists. Check your relay connections and try again.
</p>
) : showSkeleton ? (
<div className="px-4 pt-2">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<MusicPlaylistCardSkeleton key={i} />
))}
</div>
</div>
) : filtered.length > 0 ? (
<div className="px-4 pt-2">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{filtered.map((ev) => (
<MusicPlaylistCard key={ev.id} event={ev} />
))}
</div>
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
) : (
<p className="px-4 py-12 text-sm text-muted-foreground text-center">
{scope === 'following'
? `No ${filter === 'albums' ? 'albums' : 'playlists'} from people you follow yet.`
: `No ${filter === 'albums' ? 'albums' : 'playlists'} yet. Check back soon!`}
</p>
)}
</div>
);
}
@@ -1,88 +0,0 @@
import { Flame, TrendingUp, Clock, Globe, Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCurrentUser } from '@/hooks/useCurrentUser';
export type MusicSort = 'hot' | 'top' | 'new';
export type MusicScope = 'global' | 'following';
interface MusicSortFilterBarProps {
sort: MusicSort;
scope: MusicScope;
onSortChange: (sort: MusicSort) => void;
onScopeChange: (scope: MusicScope) => void;
className?: string;
}
const SORT_OPTIONS: { value: MusicSort; label: string; icon: typeof Flame }[] = [
{ value: 'hot', label: 'Hot', icon: Flame },
{ value: 'top', label: 'Top', icon: TrendingUp },
{ value: 'new', label: 'New', icon: Clock },
];
const SCOPE_OPTIONS: { value: MusicScope; label: string; icon: typeof Globe }[] = [
{ value: 'global', label: 'Global', icon: Globe },
{ value: 'following', label: 'Following', icon: Users },
];
/**
* Shared sort + scope filter bar for Music pages.
*
* - **Sort**: Hot (engagement + decay), Top (total engagement), New (chronological)
* - **Scope**: Global (all artists) or Following (user's follow list)
*
* The "Following" option is only shown when the user is logged in.
*/
export function MusicSortFilterBar({
sort,
scope,
onSortChange,
onScopeChange,
className,
}: MusicSortFilterBarProps) {
const { user } = useCurrentUser();
return (
<div className={cn('flex items-center gap-2 px-4 py-2', className)}>
{/* Sort pills */}
<div className="flex gap-1 p-1 rounded-lg bg-secondary/40">
{SORT_OPTIONS.map(({ value, label, icon: Icon }) => (
<button
key={value}
onClick={() => onSortChange(value)}
className={cn(
'flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md transition-colors',
sort === value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-3" />
{label}
</button>
))}
</div>
{/* Scope pills — only show Following when logged in */}
<div className="flex gap-1 p-1 rounded-lg bg-secondary/40">
{SCOPE_OPTIONS.map(({ value, label, icon: Icon }) => {
if (value === 'following' && !user) return null;
return (
<button
key={value}
onClick={() => onScopeChange(value)}
className={cn(
'flex items-center gap-1 px-2.5 py-1 text-xs font-medium rounded-md transition-colors',
scope === value
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-3" />
{label}
</button>
);
})}
</div>
</div>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { useMemo, useState } from 'react';
import { Play, Pause, Music } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '@nostrify/nostrify';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
import { useAuthor } from '@/hooks/useAuthor';
import { parseMusicTrack, toAudioTrack } from '@/lib/musicHelpers';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface MusicTrackCardProps {
/** The music track event. */
event: NostrEvent;
}
/**
* Square card for horizontal scroll sections (Featured, genre-filtered).
*
* Layout: [square artwork with hover play overlay] + [title] + [artist]
*
* **States**:
* - Default: Artwork with title and artist below
* - Hover: Semi-transparent overlay with centered play button
* - Now playing: Primary ring around artwork, pause icon on overlay
* - No artwork: Gradient placeholder with Music icon
*/
export function MusicTrackCard({ event }: MusicTrackCardProps) {
const player = useAudioPlayer();
const parsed = useMemo(() => parseMusicTrack(event), [event]);
const author = useAuthor(event.pubkey);
const naddrPath = useMemo(() => {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
return '/' + nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: d });
}, [event]);
const [imgError, setImgError] = useState(false);
if (!parsed) return null;
const isNowPlaying = player.currentTrack?.id === event.id;
const handlePlay = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isNowPlaying && player.isPlaying) {
player.pause();
} else if (isNowPlaying) {
player.resume();
} else {
const track = toAudioTrack(event, parsed);
track.artwork ??= sanitizeUrl(author.data?.metadata?.picture);
player.playTrack(track);
}
};
return (
<Link to={naddrPath} className="w-[140px] shrink-0 cursor-pointer group">
{/* Artwork */}
<div
className={cn(
'w-full aspect-square rounded-xl overflow-hidden relative',
isNowPlaying && 'ring-2 ring-primary',
)}
>
{parsed.artwork && !imgError ? (
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" onError={() => setImgError(true)} />
) : (
<div className="w-full h-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
<Music className="size-8 text-primary/20" />
</div>
)}
{/* Play overlay on hover */}
<div
className="absolute inset-0 flex items-center justify-center bg-black/0 group-hover:bg-black/20 transition-colors"
onClick={handlePlay}
>
<div className={cn(
'size-10 rounded-full flex items-center justify-center transition-all',
'opacity-0 group-hover:opacity-100 scale-90 group-hover:scale-100',
isNowPlaying && player.isPlaying
? 'bg-primary text-primary-foreground opacity-100 scale-100'
: 'bg-white/90 text-black',
)}>
{isNowPlaying && player.isPlaying
? <Pause className="size-4" fill="currentColor" />
: <Play className="size-4 ml-0.5" fill="currentColor" />}
</div>
</div>
</div>
{/* Info */}
<p className="text-sm font-medium truncate mt-2">{parsed.title}</p>
<p className="text-xs text-muted-foreground truncate">{parsed.artist}</p>
</Link>
);
}
/** Loading skeleton matching MusicTrackCard dimensions. */
export function MusicTrackCardSkeleton() {
return (
<div className="w-[140px] shrink-0">
<Skeleton className="w-full aspect-square rounded-xl" />
<Skeleton className="h-4 w-3/4 mt-2" />
<Skeleton className="h-3 w-1/2 mt-1" />
</div>
);
}
-138
View File
@@ -1,138 +0,0 @@
import { useState, useMemo, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import type { NostrEvent } from '@nostrify/nostrify';
import { useMusicFeed } from '@/hooks/useMusicFeed';
import { useMusicData } from '@/hooks/useMusicData';
import { useMuteList } from '@/hooks/useMuteList';
import { parseMusicTrack } from '@/lib/musicHelpers';
import { isEventMuted } from '@/lib/muteHelpers';
import { TagChips } from '@/components/discovery/TagChips';
import { MusicSortFilterBar, type MusicSort, type MusicScope } from './MusicSortFilterBar';
import { MusicTrackRow, MusicTrackRowSkeleton } from './MusicTrackRow';
/**
* The "Tracks" tab — infinite scroll list of music tracks.
*
* Features:
* - **Sort**: Hot (engagement + decay), Top (total engagement), New (chronological)
* - **Scope**: Global (all artists) or Following (user's follow list)
* - **Genre filter**: Relay-level `#t` tag filtering via TagChips
*/
export function MusicTracksTab() {
const [selectedGenre, setSelectedGenre] = useState<string | null>(null);
const [sort, setSort] = useState<MusicSort>('new');
const [scope, setScope] = useState<MusicScope>('global');
const { muteItems } = useMuteList();
// Base query for genre names only (reuses cached data from Discover tab)
const { genres } = useMusicData();
const genreNames = useMemo(() => genres.slice(0, 12).map((g) => g.genre), [genres]);
// Infinite-scroll feed with sort + scope + genre
const feedQuery = useMusicFeed({ kind: 36787, sort, scope, genre: selectedGenre });
const {
data: rawData,
isPending,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = feedQuery;
// Auto-fetch page 2
useEffect(() => {
if (hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
// Intersection observer for infinite scroll
const { ref: scrollRef, inView } = useInView({
threshold: 0,
rootMargin: '400px',
});
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
// Flatten, deduplicate, filter
const trackEvents = useMemo(() => {
if (!rawData?.pages) return [];
const seen = new Set<string>();
return rawData.pages
.flat()
.filter((event: NostrEvent) => {
if (seen.has(event.id)) return false;
seen.add(event.id);
if (event.kind !== 36787) return false;
if (parseMusicTrack(event) === null) return false;
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
return true;
});
}, [rawData?.pages, muteItems]);
const showSkeleton = isPending || (isLoading && !rawData);
return (
<div className="pb-8">
{/* Sort + scope filter bar */}
<MusicSortFilterBar
sort={sort}
scope={scope}
onSortChange={setSort}
onScopeChange={setScope}
/>
{/* Genre chips */}
{genreNames.length > 0 && (
<TagChips
tags={genreNames}
selected={selectedGenre}
onSelect={setSelectedGenre}
/>
)}
{/* Track list */}
{isError ? (
<p className="px-4 py-12 text-sm text-muted-foreground text-center">
Failed to load tracks. Check your relay connections and try again.
</p>
) : showSkeleton ? (
<div>
{Array.from({ length: 10 }).map((_, i) => (
<MusicTrackRowSkeleton key={i} />
))}
</div>
) : trackEvents.length > 0 ? (
<div>
{trackEvents.map((ev, i) => (
<MusicTrackRow key={ev.id} event={ev} index={i} />
))}
{hasNextPage && (
<div ref={scrollRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
) : (
<p className="px-4 py-12 text-sm text-muted-foreground text-center">
{selectedGenre
? `No ${selectedGenre} tracks found. Try a different genre.`
: scope === 'following'
? 'No tracks from people you follow yet.'
: 'No music tracks yet. Check back soon!'}
</p>
)}
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }
-115
View File
@@ -1,115 +0,0 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
-70
View File
@@ -1,70 +0,0 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button-variants";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
month_caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
button_previous: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1"
),
button_next: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1"
),
month_grid: "w-full border-collapse space-y-1",
weekdays: "flex",
weekday:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
week: "flex w-full mt-2",
day: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].rdp-range_end)]:rounded-r-md [&:has([aria-selected].rdp-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day_button: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
range_end: "rdp-range_end",
selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
today: "bg-accent text-accent-foreground",
outside:
"rdp-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
disabled: "text-muted-foreground opacity-50",
range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
hidden: "invisible",
...classNames,
}}
components={{
Chevron: ({ orientation, ...chevronProps }) =>
orientation === "left" ? (
<ChevronLeft className="h-4 w-4" {...chevronProps} />
) : (
<ChevronRight className="h-4 w-4" {...chevronProps} />
),
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
-260
View File
@@ -1,260 +0,0 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
-321
View File
@@ -1,321 +0,0 @@
import * as React from 'react';
import { Pencil } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
interface ColorPickerProps {
/** Current color in hex format (#RRGGBB) */
value: string;
/** Called with new hex color */
onChange: (hex: string) => void;
/** Optional label */
label?: string;
/** Optional className for the trigger */
className?: string;
/** Disable the picker */
disabled?: boolean;
}
const HEX_REGEX = /^#[0-9a-fA-F]{6}$/;
/**
* Color picker with a swatch trigger and popover containing a gradient area,
* hue slider, and hex input.
*/
export function ColorPicker({ value, onChange, label, className, disabled }: ColorPickerProps) {
const [localHex, setLocalHex] = React.useState(value);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const hueRef = React.useRef<HTMLCanvasElement>(null);
const [hue, setHue] = React.useState(() => hexToHue(value));
const [isDraggingSL, setIsDraggingSL] = React.useState(false);
const [isDraggingHue, setIsDraggingHue] = React.useState(false);
const [popoverOpen, setPopoverOpen] = React.useState(false);
// Sync external value changes
React.useEffect(() => {
setLocalHex(value);
setHue(hexToHue(value));
}, [value]);
// Draw the saturation/lightness gradient.
// Uses requestAnimationFrame to ensure the canvas is in the DOM after popover opens.
React.useEffect(() => {
if (!popoverOpen) return;
const draw = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
// White to hue (horizontal)
const gradH = ctx.createLinearGradient(0, 0, w, 0);
gradH.addColorStop(0, '#ffffff');
gradH.addColorStop(1, `hsl(${hue}, 100%, 50%)`);
ctx.fillStyle = gradH;
ctx.fillRect(0, 0, w, h);
// Transparent to black (vertical)
const gradV = ctx.createLinearGradient(0, 0, 0, h);
gradV.addColorStop(0, 'rgba(0,0,0,0)');
gradV.addColorStop(1, '#000000');
ctx.fillStyle = gradV;
ctx.fillRect(0, 0, w, h);
};
// Defer drawing to next frame so Radix has time to mount the portal content
const raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [hue, popoverOpen]);
// Draw the hue bar when popover opens
React.useEffect(() => {
if (!popoverOpen) return;
const draw = () => {
const canvas = hueRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const w = canvas.width;
const h = canvas.height;
const grad = ctx.createLinearGradient(0, 0, w, 0);
for (let i = 0; i <= 360; i += 30) {
grad.addColorStop(i / 360, `hsl(${i}, 100%, 50%)`);
}
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
};
const raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [popoverOpen]);
/** Extract clientX/clientY from either a mouse or touch event. */
const getPointer = (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
if ('touches' in e) {
const touch = e.touches[0] ?? (e as TouchEvent).changedTouches[0];
return { clientX: touch.clientX, clientY: touch.clientY };
}
return { clientX: (e as MouseEvent).clientX, clientY: (e as MouseEvent).clientY };
};
const handleSLInteraction = React.useCallback(
(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const { clientX, clientY } = getPointer(e);
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
const s = x * 100;
const v = (1 - y) * 100;
// HSV to HSL conversion
const l = v * (1 - s / 200);
const sl = l === 0 || l === 100 ? 0 : ((v - l) / Math.min(l, 100 - l)) * 100;
const hex = hslToHex(hue, sl, l);
setLocalHex(hex);
onChange(hex);
},
[hue, onChange],
);
const handleHueInteraction = React.useCallback(
(e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => {
const canvas = hueRef.current;
if (!canvas) return;
const { clientX } = getPointer(e);
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
const newHue = Math.round(x * 360);
setHue(newHue);
// Re-derive color with new hue but keep sat/light from current value
const { s, l } = hexToHSL(localHex);
const hex = hslToHex(newHue, s, l);
setLocalHex(hex);
onChange(hex);
},
[localHex, onChange],
);
// Global mouse + touch handlers for dragging
React.useEffect(() => {
if (!isDraggingSL && !isDraggingHue) return;
const handleMove = (e: MouseEvent | TouchEvent) => {
if (isDraggingSL) handleSLInteraction(e);
if (isDraggingHue) handleHueInteraction(e);
};
const handleUp = () => {
setIsDraggingSL(false);
setIsDraggingHue(false);
};
window.addEventListener('mousemove', handleMove);
window.addEventListener('mouseup', handleUp);
window.addEventListener('touchmove', handleMove, { passive: false });
window.addEventListener('touchend', handleUp);
return () => {
window.removeEventListener('mousemove', handleMove);
window.removeEventListener('mouseup', handleUp);
window.removeEventListener('touchmove', handleMove);
window.removeEventListener('touchend', handleUp);
};
}, [isDraggingSL, isDraggingHue, handleSLInteraction, handleHueInteraction]);
const handleHexInput = (e: React.ChangeEvent<HTMLInputElement>) => {
let val = e.target.value;
if (!val.startsWith('#')) val = '#' + val;
setLocalHex(val);
if (HEX_REGEX.test(val)) {
onChange(val);
setHue(hexToHue(val));
}
};
// Compute the SL picker indicator position
const { s, l } = hexToHSL(value);
// HSL to HSV for positioning
const v = l + s * Math.min(l, 100 - l) / 100;
const sv = v === 0 ? 0 : 2 * (1 - l / v);
const indicatorX = sv * 100;
const indicatorY = (1 - v / 100) * 100;
const hueX = (hue / 360) * 100;
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild disabled={disabled}>
<button
type="button"
className={cn(
'group transition-colors',
'flex flex-col items-center gap-1.5',
'sidebar:flex-row sidebar:items-center sidebar:gap-2.5',
disabled && 'opacity-50 cursor-not-allowed',
className,
)}
>
{/* Color circle swatch */}
<div
className="relative size-12 rounded-full border-2 border-border shadow-sm cursor-pointer transition-all group-hover:scale-105 group-hover:shadow-md group-hover:border-foreground/20 shrink-0"
style={{ backgroundColor: value }}
>
{/* Edit overlay */}
<div className="absolute inset-0 rounded-full flex items-center justify-center transition-colors">
<Pencil className="size-3.5 text-white drop-shadow-sm" />
</div>
</div>
{label && (
<div className="flex flex-col items-center gap-0.5">
<span className="text-xs font-medium text-foreground">{label}</span>
<span className="text-[10px] text-muted-foreground font-mono uppercase">{value}</span>
</div>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3 space-y-3" align="center" sideOffset={8} onOpenAutoFocus={(e) => e.preventDefault()}>
{/* Saturation/Lightness area */}
<div className="relative w-full aspect-square rounded-lg overflow-hidden cursor-crosshair">
<canvas
ref={canvasRef}
width={256}
height={256}
className="w-full h-full touch-none"
onMouseDown={(e) => {
setIsDraggingSL(true);
handleSLInteraction(e);
}}
onTouchStart={(e) => {
setIsDraggingSL(true);
handleSLInteraction(e);
}}
/>
{/* Indicator */}
<div
className="absolute size-4 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)] pointer-events-none -translate-x-1/2 -translate-y-1/2"
style={{ left: `${indicatorX}%`, top: `${indicatorY}%` }}
/>
</div>
{/* Hue slider */}
<div className="relative w-full h-3 rounded-full overflow-hidden cursor-pointer">
<canvas
ref={hueRef}
width={256}
height={12}
className="w-full h-full touch-none"
onMouseDown={(e) => {
setIsDraggingHue(true);
handleHueInteraction(e);
}}
onTouchStart={(e) => {
setIsDraggingHue(true);
handleHueInteraction(e);
}}
/>
<div
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 size-4 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.3)] pointer-events-none"
style={{ left: `${hueX}%` }}
/>
</div>
{/* Hex input */}
<div className="flex items-center gap-2">
<div
className="size-8 rounded-md border border-border shrink-0"
style={{ backgroundColor: localHex }}
/>
<Input
value={localHex}
onChange={handleHexInput}
className="h-8 font-mono text-base uppercase"
maxLength={7}
spellCheck={false}
/>
</div>
</PopoverContent>
</Popover>
);
}
// ─── Color Helpers (local, lightweight) ────────────────────────────────
function hexToHue(hex: string): number {
return hexToHSL(hex).h;
}
function hexToHSL(hex: string): { h: number; s: number; l: number } {
hex = hex.replace('#', '');
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
const r = parseInt(hex.slice(0, 2), 16) / 255;
const g = parseInt(hex.slice(2, 4), 16) / 255;
const b = parseInt(hex.slice(4, 6), 16) / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: l * 100 };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const k = (n: number) => (n + h / 30) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const toHex = (v: number) => Math.round(v * 255).toString(16).padStart(2, '0');
return `#${toHex(f(0))}${toHex(f(8))}${toHex(f(4))}`;
}
-198
View File
@@ -1,198 +0,0 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const ContextMenu = ContextMenuPrimitive.Root
const ContextMenuTrigger = ContextMenuPrimitive.Trigger
const ContextMenuGroup = ContextMenuPrimitive.Group
const ContextMenuPortal = ContextMenuPrimitive.Portal
const ContextMenuSub = ContextMenuPrimitive.Sub
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</ContextMenuPrimitive.SubTrigger>
))
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
))
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
))
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
))
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-foreground",
inset && "pl-8",
className
)}
{...props}
/>
))
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
ContextMenuShortcut.displayName = "ContextMenuShortcut"
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
-120
View File
@@ -1,120 +0,0 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, onClick, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
onClick={(e) => {
e.stopPropagation();
onClick?.(e);
}}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
-69
View File
@@ -1,69 +0,0 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"
import { cn } from "@/lib/utils"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
))
InputOTP.displayName = "InputOTP"
const InputOTPGroup = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-base transition-all first:rounded-l-md first:border-l last:rounded-r-md md:text-sm",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
</div>
)}
</div>
)
})
InputOTPSlot.displayName = "InputOTPSlot"
const InputOTPSeparator = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
-234
View File
@@ -1,234 +0,0 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const MenubarMenu = MenubarPrimitive.Menu
const MenubarGroup = MenubarPrimitive.Group
const MenubarPortal = MenubarPrimitive.Portal
const MenubarSub = MenubarPrimitive.Sub
const MenubarRadioGroup = MenubarPrimitive.RadioGroup
const Menubar = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Root
ref={ref}
className={cn(
"flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
className
)}
{...props}
/>
))
Menubar.displayName = MenubarPrimitive.Root.displayName
const MenubarTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Trigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
/>
))
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
const MenubarSubTrigger = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<MenubarPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
))
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
const MenubarSubContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
const MenubarContent = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
>(
(
{ className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
ref
) => (
<MenubarPrimitive.Portal>
<MenubarPrimitive.Content
ref={ref}
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</MenubarPrimitive.Portal>
)
)
MenubarContent.displayName = MenubarPrimitive.Content.displayName
const MenubarItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarItem.displayName = MenubarPrimitive.Item.displayName
const MenubarCheckboxItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<MenubarPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
))
MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
const MenubarRadioItem = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<MenubarPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
))
MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
const MenubarLabel = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<MenubarPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
MenubarLabel.displayName = MenubarPrimitive.Label.displayName
const MenubarSeparator = React.forwardRef<
React.ElementRef<typeof MenubarPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
>(({ className, ...props }, ref) => (
<MenubarPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
const MenubarShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
MenubarShortcut.displayname = "MenubarShortcut"
export {
Menubar,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarItem,
MenubarSeparator,
MenubarLabel,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarPortal,
MenubarSubContent,
MenubarSubTrigger,
MenubarGroup,
MenubarSub,
MenubarShortcut,
}

Some files were not shown because too many files have changed in this diff Show More