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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>© {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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 Photos–style) 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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></span><span className="font-sans">image</span></div>
|
||||
<div className="flex justify-between"><span>> 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
const AspectRatio = AspectRatioPrimitive.Root
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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))}`;
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
Reference in New Issue
Block a user