Merge branch 'main' of gitlab.com:soapbox-pub/ditto-mew
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.mew.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 20260220
|
||||
versionName "2026.02.20"
|
||||
versionCode 20260221
|
||||
versionName "2026.02.21"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -62,6 +62,8 @@ const defaultConfig: AppConfig = {
|
||||
feedIncludeColors: false,
|
||||
feedIncludePacks: false,
|
||||
feedIncludeStreams: false,
|
||||
showDecks: false,
|
||||
feedIncludeDecks: false,
|
||||
},
|
||||
nip85StatsPubkey: "5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea",
|
||||
nip85OnlyMode: true,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy } from "react";
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { Clapperboard, BarChart3, Palette, PartyPopper, FileText } from "lucide-react";
|
||||
import { CardsIcon } from "./components/icons/CardsIcon";
|
||||
import { ScrollToTop } from "./components/ScrollToTop";
|
||||
|
||||
// Eager: critical path (home page + 404)
|
||||
@@ -40,6 +41,7 @@ export function AppRouter() {
|
||||
<Route path="/packs" element={<KindFeedPage kind={39089} title="Follow Packs" icon={<PartyPopper className="size-5" />} />} />
|
||||
<Route path="/streams" element={<StreamsFeedPage />} />
|
||||
<Route path="/articles" element={<KindFeedPage kind={30023} title="Articles" icon={<FileText className="size-5" />} />} />
|
||||
<Route path="/decks" element={<KindFeedPage kind={37381} title="Magic Decks" icon={<CardsIcon className="size-5" />} />} />
|
||||
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ const FeedSettingsSchema = z.object({
|
||||
feedIncludeColors: z.boolean().optional(),
|
||||
feedIncludePacks: z.boolean().optional(),
|
||||
feedIncludeStreams: z.boolean().optional(),
|
||||
showDecks: z.boolean().optional(),
|
||||
feedIncludeDecks: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
// Zod schema for AppConfig validation
|
||||
|
||||
@@ -203,6 +203,7 @@ export function ContentSettings() {
|
||||
// Import the internals from FeedSettingsForm (we'll need to export them)
|
||||
import { Clapperboard, BarChart3, Palette, PartyPopper, Radio, MessageSquare, Repeat2, FileText } from 'lucide-react';
|
||||
import { ChestIcon } from '@/components/icons/ChestIcon';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -219,6 +220,7 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
packs: <PartyPopper className="size-5" />,
|
||||
streams: <Radio className="size-5" />,
|
||||
articles: <FileText className="size-5" />,
|
||||
decks: <CardsIcon className="size-5" />,
|
||||
// Feed-only items (keyed by kind number)
|
||||
'1': <MessageSquare className="size-5" />,
|
||||
'6': <Repeat2 className="size-5" />,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Clapperboard, BarChart3, Palette, PartyPopper, Radio, MessageSquare, Repeat2, FileText } from 'lucide-react';
|
||||
import { ChestIcon } from '@/components/icons/ChestIcon';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
||||
@@ -17,6 +18,7 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
packs: <PartyPopper className="size-5" />,
|
||||
streams: <Radio className="size-5" />,
|
||||
articles: <FileText className="size-5" />,
|
||||
decks: <CardsIcon className="size-5" />,
|
||||
// Feed-only items (keyed by kind number)
|
||||
'1': <MessageSquare className="size-5" />,
|
||||
'6': <Repeat2 className="size-5" />,
|
||||
|
||||
@@ -374,6 +374,8 @@ function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: {
|
||||
feedIncludeColors: selectedContent.has('colors'),
|
||||
feedIncludePacks: selectedContent.has('packs'),
|
||||
feedIncludeStreams: selectedContent.has('streams'),
|
||||
showDecks: selectedContent.has('decks'),
|
||||
feedIncludeDecks: selectedContent.has('decks'),
|
||||
};
|
||||
|
||||
updateConfig((current) => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Home, Bell, Search, Clapperboard, BarChart3, Palette, PartyPopper, Radio, FileText, User, Settings, Bookmark, UserPlus, LogOut, Check, Moon, Sun, Cat, Heart, ChevronDown } from 'lucide-react';
|
||||
import { ChestIcon } from '@/components/icons/ChestIcon';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
@@ -75,6 +76,7 @@ export function LeftSidebar() {
|
||||
packs: <PartyPopper className="size-6" />,
|
||||
streams: <Radio className="size-6" />,
|
||||
articles: <FileText className="size-6" />,
|
||||
decks: <CardsIcon className="size-6" />,
|
||||
};
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
|
||||
@@ -93,6 +93,25 @@ export function LiveStreamPlayer({ src, poster, className }: LiveStreamPlayerPro
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
// Pause video when scrolled out of view
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!video || !container) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (!entry.isIntersecting && !video.paused) {
|
||||
video.pause();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.25 },
|
||||
);
|
||||
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Track playback & buffering state
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import { useMemo, useState, useCallback, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Shield, Sparkles, Swords, Palette, List, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
function getAllTagValues(tags: string[][], name: string): string[] {
|
||||
return tags.filter(([n]) => n === name).map(([, v]) => v);
|
||||
}
|
||||
|
||||
/** A parsed card entry from `c` or `b` tags. */
|
||||
interface CardEntry {
|
||||
name: string;
|
||||
quantity: number;
|
||||
setId: string;
|
||||
artId: string;
|
||||
lang: string;
|
||||
foil: boolean;
|
||||
}
|
||||
|
||||
/** Parse a card tag (`c` or `b`) into a CardEntry. */
|
||||
function parseCardTag(tag: string[]): CardEntry | null {
|
||||
if (tag.length < 3) return null;
|
||||
const [, name, qty, setId, artId, lang, foil] = tag;
|
||||
const quantity = parseInt(qty, 10);
|
||||
if (!name || isNaN(quantity) || quantity < 1) return null;
|
||||
return {
|
||||
name,
|
||||
quantity,
|
||||
setId: setId ?? '',
|
||||
artId: artId ?? '',
|
||||
lang: lang ?? '',
|
||||
foil: foil === 'foil' || foil === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Scryfall image URL for a card.
|
||||
* Uses set/collector_number when available for exact printing,
|
||||
* otherwise falls back to exact name lookup.
|
||||
*/
|
||||
function scryfallImageUrl(card: CardEntry, version: 'small' | 'normal' | 'large' = 'small'): string {
|
||||
if (card.setId && card.artId) {
|
||||
return `https://api.scryfall.com/cards/${encodeURIComponent(card.setId.toLowerCase())}/${encodeURIComponent(card.artId)}?format=image&version=${version}`;
|
||||
}
|
||||
return `https://api.scryfall.com/cards/named?exact=${encodeURIComponent(card.name)}&format=image&version=${version}`;
|
||||
}
|
||||
|
||||
/** Format labels for MTG formats. */
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
standard: 'Standard',
|
||||
modern: 'Modern',
|
||||
commander: 'Commander',
|
||||
legacy: 'Legacy',
|
||||
vintage: 'Vintage',
|
||||
pioneer: 'Pioneer',
|
||||
pauper: 'Pauper',
|
||||
cedh: 'cEDH',
|
||||
limited: 'Limited',
|
||||
draft: 'Draft',
|
||||
sealed: 'Sealed',
|
||||
brawl: 'Brawl',
|
||||
historic: 'Historic',
|
||||
explorer: 'Explorer',
|
||||
alchemy: 'Alchemy',
|
||||
timeless: 'Timeless',
|
||||
};
|
||||
|
||||
/** Non-format archetype labels. */
|
||||
const ARCHETYPE_LABELS: Record<string, string> = {
|
||||
aggro: 'Aggro',
|
||||
midrange: 'Midrange',
|
||||
control: 'Control',
|
||||
combo: 'Combo',
|
||||
tempo: 'Tempo',
|
||||
ramp: 'Ramp',
|
||||
tribal: 'Tribal',
|
||||
burn: 'Burn',
|
||||
mill: 'Mill',
|
||||
stax: 'Stax',
|
||||
tokens: 'Tokens',
|
||||
reanimator: 'Reanimator',
|
||||
voltron: 'Voltron',
|
||||
aristocrats: 'Aristocrats',
|
||||
};
|
||||
|
||||
/** Render a single card row in list view. */
|
||||
function CardRow({ card, onClick }: { card: CardEntry; onClick?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-1 text-[13px] hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-muted-foreground tabular-nums text-xs w-5 text-right shrink-0">
|
||||
{card.quantity}x
|
||||
</span>
|
||||
<span className={cn('truncate', card.foil && 'bg-gradient-to-r from-foreground via-primary to-foreground bg-clip-text text-transparent')}>
|
||||
{card.name}
|
||||
</span>
|
||||
{card.foil && (
|
||||
<Sparkles className="size-3 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{card.setId && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider shrink-0 ml-2">
|
||||
{card.setId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Render a card as a visual image tile. */
|
||||
function CardTile({ card, onClick }: { card: CardEntry; onClick?: () => void }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className="relative aspect-[5/7] rounded-lg bg-secondary/60 border border-border flex items-center justify-center p-1 cursor-pointer"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-[9px] text-center text-muted-foreground leading-tight line-clamp-3">
|
||||
{card.name}
|
||||
</span>
|
||||
{card.quantity > 1 && <QuantityBadge quantity={card.quantity} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative aspect-[5/7] rounded-lg overflow-hidden group cursor-pointer" onClick={onClick}>
|
||||
<img
|
||||
src={scryfallImageUrl(card, 'normal')}
|
||||
alt={card.name}
|
||||
className="w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
{card.foil && (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-transparent via-white/10 to-transparent pointer-events-none" />
|
||||
)}
|
||||
{card.quantity > 1 && <QuantityBadge quantity={card.quantity} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuantityBadge({ quantity }: { quantity: number }) {
|
||||
return (
|
||||
<span className="absolute top-1 right-1 bg-black/70 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full leading-none backdrop-blur-sm">
|
||||
x{quantity}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Full-screen card lightbox with prev/next navigation. */
|
||||
function CardLightbox({ cards, currentIndex, onClose, onNext, onPrev }: {
|
||||
cards: CardEntry[];
|
||||
currentIndex: number;
|
||||
onClose: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [touchStart, setTouchStart] = useState<number | null>(null);
|
||||
const [touchDelta, setTouchDelta] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const card = cards[currentIndex];
|
||||
const hasMultiple = cards.length > 1;
|
||||
const imageUrl = scryfallImageUrl(card, 'large');
|
||||
|
||||
useEffect(() => { setIsLoaded(false); }, [currentIndex]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
else if (e.key === 'ArrowRight' && hasMultiple) onNext();
|
||||
else if (e.key === 'ArrowLeft' && hasMultiple) onPrev();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose, onNext, onPrev, hasMultiple]);
|
||||
|
||||
// Lock body scroll
|
||||
useEffect(() => {
|
||||
const original = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = original; };
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => { setTouchStart(e.touches[0].clientX); setIsDragging(true); };
|
||||
const handleTouchMove = (e: React.TouchEvent) => { if (touchStart !== null) setTouchDelta(e.touches[0].clientX - touchStart); };
|
||||
const handleTouchEnd = () => {
|
||||
if (Math.abs(touchDelta) > 60 && hasMultiple) { touchDelta > 0 ? onPrev() : onNext(); }
|
||||
setTouchStart(null); setTouchDelta(0); setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'IMG' || target.closest('button') || target.closest('[data-gallery-topbar]')) return;
|
||||
e.stopPropagation(); e.preventDefault(); onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center animate-in fade-in duration-200"
|
||||
onClick={handleBackdropClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
|
||||
|
||||
{/* Top bar */}
|
||||
<div data-gallery-topbar className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{hasMultiple && (
|
||||
<span className="text-white/80 text-sm font-medium tabular-nums">
|
||||
{currentIndex + 1} / {cards.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-white text-sm font-medium truncate max-w-[200px]">
|
||||
{card.name}
|
||||
</span>
|
||||
{card.foil && <Sparkles className="size-3.5 text-amber-400 shrink-0" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }}
|
||||
className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prev/Next buttons */}
|
||||
{hasMultiple && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPrev(); }}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex"
|
||||
>
|
||||
<ChevronLeft className="size-6" />
|
||||
</button>
|
||||
)}
|
||||
{hasMultiple && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNext(); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex"
|
||||
>
|
||||
<ChevronRight className="size-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Card image */}
|
||||
<div
|
||||
className="relative z-[1] flex items-center justify-center w-full h-full px-4 py-16 sm:px-16"
|
||||
style={{
|
||||
transform: isDragging ? `translateX(${touchDelta}px)` : undefined,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="size-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
key={imageUrl}
|
||||
src={imageUrl}
|
||||
alt={card.name}
|
||||
className={cn(
|
||||
'max-w-full max-h-full object-contain rounded-xl select-none transition-opacity duration-300',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dot indicators (mobile, small decks) */}
|
||||
{hasMultiple && cards.length <= 20 && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 sm:hidden">
|
||||
{cards.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'rounded-full transition-all duration-200',
|
||||
i === currentIndex ? 'size-2 bg-white' : 'size-1.5 bg-white/40',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MagicDeckContent({ event }: { event: NostrEvent }) {
|
||||
const title = getTag(event.tags, 'title');
|
||||
const banner = getTag(event.tags, 'banner');
|
||||
const commanders = getAllTagValues(event.tags, 'C');
|
||||
const companion = getTag(event.tags, 'S');
|
||||
const tTags = getAllTagValues(event.tags, 't');
|
||||
const [visualView, setVisualView] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// Parse main deck and sideboard
|
||||
const mainDeck = useMemo(() => {
|
||||
return event.tags
|
||||
.filter(([n]) => n === 'c')
|
||||
.map(parseCardTag)
|
||||
.filter((c): c is CardEntry => c !== null);
|
||||
}, [event.tags]);
|
||||
|
||||
const sideboard = useMemo(() => {
|
||||
return event.tags
|
||||
.filter(([n]) => n === 'b')
|
||||
.map(parseCardTag)
|
||||
.filter((c): c is CardEntry => c !== null);
|
||||
}, [event.tags]);
|
||||
|
||||
// All cards in one flat list for lightbox navigation
|
||||
const allCards = useMemo(() => [...mainDeck, ...sideboard], [mainDeck, sideboard]);
|
||||
|
||||
// Separate format tags from archetype/other tags
|
||||
const formatTags = useMemo(() => tTags.filter((t) => t in FORMAT_LABELS), [tTags]);
|
||||
const archetypeTags = useMemo(() => tTags.filter((t) => t in ARCHETYPE_LABELS), [tTags]);
|
||||
const otherTags = useMemo(
|
||||
() => tTags.filter((t) => !(t in FORMAT_LABELS) && !(t in ARCHETYPE_LABELS)),
|
||||
[tTags],
|
||||
);
|
||||
|
||||
const totalCards = useMemo(() => mainDeck.reduce((sum, c) => sum + c.quantity, 0), [mainDeck]);
|
||||
const totalSideboard = useMemo(() => sideboard.reduce((sum, c) => sum + c.quantity, 0), [sideboard]);
|
||||
|
||||
const openLightbox = useCallback((index: number) => { setLightboxIndex(index); }, []);
|
||||
const closeLightbox = useCallback(() => { setLightboxIndex(null); }, []);
|
||||
const goNext = useCallback(() => { setLightboxIndex((prev) => (prev !== null ? (prev + 1) % allCards.length : null)); }, [allCards.length]);
|
||||
const goPrev = useCallback(() => { setLightboxIndex((prev) => (prev !== null ? (prev - 1 + allCards.length) % allCards.length : null)); }, [allCards.length]);
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{/* Banner image */}
|
||||
{banner && (
|
||||
<div className="rounded-2xl overflow-hidden mb-3">
|
||||
<img
|
||||
src={banner}
|
||||
alt={title ?? 'Magic deck'}
|
||||
className="w-full max-h-[200px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<CardsIcon className="size-4 text-primary mt-0.5 shrink-0" />
|
||||
<span className="text-[15px] font-semibold leading-snug">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commander(s) */}
|
||||
{commanders.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Shield className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Commander{commanders.length > 1 ? 's' : ''}:
|
||||
</span>
|
||||
<span className="text-xs font-medium">
|
||||
{commanders.join(' & ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Companion */}
|
||||
{companion && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles className="size-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs text-muted-foreground">Companion:</span>
|
||||
<span className="text-xs font-medium">{companion}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Format badges + card count + sideboard — all on one line */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-2">
|
||||
{formatTags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Badge variant="secondary" className="text-[11px] gap-1 font-medium hover:bg-secondary/80 transition-colors">
|
||||
<Swords className="size-3" />
|
||||
{FORMAT_LABELS[tag] ?? tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
{archetypeTags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Badge variant="outline" className="text-[11px] font-medium hover:bg-secondary/80 transition-colors">
|
||||
{ARCHETYPE_LABELS[tag] ?? tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
{otherTags.map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/t/${encodeURIComponent(tag)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Badge variant="outline" className="text-[11px] font-medium hover:bg-secondary/80 transition-colors">
|
||||
{tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
<Badge variant="secondary" className="text-[11px] gap-1 font-medium">
|
||||
<CardsIcon className="size-3" />
|
||||
{totalCards} cards
|
||||
</Badge>
|
||||
{totalSideboard > 0 && (
|
||||
<Badge variant="secondary" className="text-[11px] gap-1 font-medium">
|
||||
{totalSideboard} sideboard
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card list / visual spoiler */}
|
||||
{mainDeck.length > 0 && (
|
||||
<div className="rounded-xl border border-border overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
{/* View toggle header */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-secondary/30 border-b border-border/50">
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
{visualView ? 'Visual Spoiler' : 'Decklist'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setVisualView(!visualView)}
|
||||
className="flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{visualView ? <List className="size-3.5" /> : <Palette className="size-3.5" />}
|
||||
{visualView ? 'List' : 'Visual'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{visualView ? (
|
||||
/* Visual spoiler grid */
|
||||
<div className="max-h-[400px] overflow-y-auto p-2">
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{mainDeck.map((card, i) => (
|
||||
<CardTile key={`${card.name}-${i}`} card={card} onClick={() => openLightbox(i)} />
|
||||
))}
|
||||
</div>
|
||||
{sideboard.length > 0 && (
|
||||
<>
|
||||
<div className="px-1 py-2 mt-1">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Sideboard
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{sideboard.map((card, i) => (
|
||||
<CardTile
|
||||
key={`sb-${card.name}-${i}`}
|
||||
card={card}
|
||||
onClick={() => openLightbox(mainDeck.length + i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Text decklist */
|
||||
<div className="max-h-[240px] overflow-y-auto">
|
||||
{mainDeck.map((card, i) => (
|
||||
<CardRow key={`${card.name}-${i}`} card={card} onClick={() => openLightbox(i)} />
|
||||
))}
|
||||
|
||||
{/* Sideboard inline */}
|
||||
{sideboard.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 py-1.5 bg-secondary/40 border-y border-border/50">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Sideboard
|
||||
</span>
|
||||
</div>
|
||||
{sideboard.map((card, i) => (
|
||||
<CardRow
|
||||
key={`sb-${card.name}-${i}`}
|
||||
card={card}
|
||||
onClick={() => openLightbox(mainDeck.length + i)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Card lightbox */}
|
||||
{lightboxIndex !== null && allCards.length > 0 && (
|
||||
<CardLightbox
|
||||
cards={allCards}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={closeLightbox}
|
||||
onNext={goNext}
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Sheet, SheetContent, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { ChestIcon } from '@/components/icons/ChestIcon';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useLoginActions } from '@/hooks/useLoginActions';
|
||||
import { useLoggedInAccounts } from '@/hooks/useLoggedInAccounts';
|
||||
@@ -24,6 +25,7 @@ const ROUTE_ICONS: Record<string, React.ReactNode> = {
|
||||
packs: <PartyPopper className="size-5" />,
|
||||
streams: <Radio className="size-5" />,
|
||||
articles: <FileText className="size-5" />,
|
||||
decks: <CardsIcon className="size-5" />,
|
||||
};
|
||||
|
||||
|
||||
|
||||
+207
-2
@@ -1,7 +1,8 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { MessageCircle, Zap, MoreHorizontal, Play } from 'lucide-react';
|
||||
import { MessageCircle, Zap, MoreHorizontal, Play, Radio, Users } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { VideoPlayer } from '@/components/VideoPlayer';
|
||||
@@ -15,7 +16,10 @@ import { ColorMomentContent } from '@/components/ColorMomentContent';
|
||||
import { FollowPackContent } from '@/components/FollowPackContent';
|
||||
import { ArticleContent } from '@/components/ArticleContent';
|
||||
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||
import { MagicDeckContent } from '@/components/MagicDeckContent';
|
||||
import { LiveStreamPlayer } from '@/components/LiveStreamPlayer';
|
||||
import { ChestIcon } from '@/components/icons/ChestIcon';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { ReplyContext } from '@/components/ReplyContext';
|
||||
import { Nip05Badge } from '@/components/Nip05Badge';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
@@ -185,7 +189,9 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
|
||||
const isColor = event.kind === 3367;
|
||||
const isFollowPack = event.kind === 39089 || event.kind === 30000;
|
||||
const isArticle = event.kind === 30023;
|
||||
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle;
|
||||
const isMagicDeck = event.kind === 37381;
|
||||
const isStream = event.kind === 30311;
|
||||
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck && !isStream;
|
||||
|
||||
// Kind 1 specific
|
||||
const images = useMemo(() => isTextNote ? extractImages(event.content) : [], [event.content, isTextNote]);
|
||||
@@ -228,6 +234,14 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hide magic decks tagged t:unlisted and geocaches tagged t:hidden
|
||||
if (isMagicDeck && event.tags.some(([n, v]) => n === 't' && v === 'unlisted')) {
|
||||
return null;
|
||||
}
|
||||
if (isGeocache && event.tags.some(([n, v]) => n === 't' && v === 'hidden')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cn(
|
||||
@@ -246,6 +260,16 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
|
||||
<TreasureHeader pubkey={event.pubkey} variant={isGeocache ? 'hid' : 'found'} />
|
||||
)}
|
||||
|
||||
{/* Deck header — "<cards> <name> shared a deck" */}
|
||||
{isMagicDeck && !repostedBy && (
|
||||
<DeckHeader pubkey={event.pubkey} />
|
||||
)}
|
||||
|
||||
{/* Stream header — "<radio> <name> is streaming / streamed" */}
|
||||
{isStream && (
|
||||
<StreamHeader pubkey={event.pubkey} isLive={getTag(event.tags, 'status') === 'live'} />
|
||||
)}
|
||||
|
||||
{/* Header: avatar + name/handle stacked */}
|
||||
<div className="flex items-center gap-3">
|
||||
{author.isLoading ? (
|
||||
@@ -322,6 +346,10 @@ export function NoteCard({ event, className, repostedBy, compact }: NoteCardProp
|
||||
<FollowPackContent event={event} />
|
||||
) : isArticle ? (
|
||||
<ArticleContent event={event} preview className="mt-2" />
|
||||
) : isMagicDeck ? (
|
||||
<MagicDeckContent event={event} />
|
||||
) : isStream ? (
|
||||
<StreamContent event={event} />
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 break-words overflow-hidden">
|
||||
@@ -503,6 +531,125 @@ function VineMedia({ imeta, hashtags }: { imeta?: { url?: string; thumbnail?: st
|
||||
);
|
||||
}
|
||||
|
||||
/** Stream status badge config. */
|
||||
function getStreamStatusConfig(status: string | undefined) {
|
||||
switch (status) {
|
||||
case 'live':
|
||||
return { label: 'LIVE', className: 'bg-red-600 hover:bg-red-600 text-white border-red-600' };
|
||||
case 'ended':
|
||||
return { label: 'ENDED', className: 'bg-muted text-muted-foreground border-border' };
|
||||
case 'planned':
|
||||
return { label: 'PLANNED', className: 'bg-blue-600/90 hover:bg-blue-600/90 text-white border-blue-600' };
|
||||
default:
|
||||
return { label: status?.toUpperCase() || 'UNKNOWN', className: 'bg-muted text-muted-foreground border-border' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline content for kind 30311 live stream events. */
|
||||
function StreamContent({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const title = getTag(event.tags, 'title') || 'Untitled Stream';
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
const imageUrl = getTag(event.tags, 'image');
|
||||
const streamingUrl = getTag(event.tags, 'streaming');
|
||||
const status = getTag(event.tags, 'status');
|
||||
const currentParticipants = getTag(event.tags, 'current_participants');
|
||||
const statusConfig = getStreamStatusConfig(status);
|
||||
|
||||
const isLive = status === 'live' && !!streamingUrl;
|
||||
|
||||
const encodedId = useMemo(() => {
|
||||
const dTag = getTag(event.tags, 'd') || '';
|
||||
return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag });
|
||||
}, [event]);
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Stream player / thumbnail */}
|
||||
<div className="rounded-xl overflow-hidden border border-border">
|
||||
{isLive ? (
|
||||
// Inline live player — clicks on the player are intercepted so they don't navigate away
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div className="relative" onClick={(e) => e.stopPropagation()}>
|
||||
<LiveStreamPlayer src={streamingUrl} poster={imageUrl} />
|
||||
{/* Status + viewer overlay on top of the player */}
|
||||
<div className="absolute top-2 left-2 z-10 flex items-center gap-2 pointer-events-none">
|
||||
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
|
||||
<div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
{currentParticipants && (
|
||||
<span className="flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
|
||||
<Users className="size-3" />
|
||||
{currentParticipants}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : imageUrl ? (
|
||||
<div className="relative w-full aspect-video overflow-hidden bg-muted">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute top-2 left-2">
|
||||
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
</div>
|
||||
{currentParticipants && (
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 bg-black/60 text-white text-xs px-2 py-0.5 rounded">
|
||||
<Users className="size-3" />
|
||||
{currentParticipants}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// No image, no live stream — show a minimal placeholder with status
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 bg-muted/40">
|
||||
<Radio className="size-4 text-primary shrink-0" />
|
||||
<Badge variant="outline" className={cn('text-[10px]', statusConfig.className)}>
|
||||
{status === 'live' && <div className="size-1.5 bg-white rounded-full animate-pulse mr-1" />}
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
{currentParticipants && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Users className="size-3" />
|
||||
{currentParticipants}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + summary — clickable to open stream details */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-start gap-2 text-left w-full group"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/${encodedId}`);
|
||||
}}
|
||||
>
|
||||
<Radio className="size-4 text-primary shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-semibold text-sm leading-snug line-clamp-2 group-hover:underline">
|
||||
{title}
|
||||
</h3>
|
||||
{summary && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mt-0.5">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepostHeader({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name || genUserName(pubkey);
|
||||
@@ -531,6 +678,64 @@ function RepostHeader({ pubkey }: { pubkey: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StreamHeader({ pubkey, isLive }: { pubkey: string; isLive: boolean }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name || genUserName(pubkey);
|
||||
const url = useMemo(() => getProfileUrl(pubkey, author.data?.metadata), [pubkey, author.data?.metadata]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
|
||||
<div className="w-11 shrink-0 flex justify-end">
|
||||
<Radio className={cn("size-4 translate-y-px", isLive ? "text-primary" : "text-muted-foreground")} />
|
||||
</div>
|
||||
<div className="flex items-center min-w-0">
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3 w-20 inline-block" />
|
||||
) : (
|
||||
<Link
|
||||
to={url}
|
||||
className="font-medium hover:underline mr-1 truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{name}</EmojifiedText> : name}
|
||||
</Link>
|
||||
)}
|
||||
<span className={cn("shrink-0", author.isLoading && 'ml-1')}>
|
||||
{isLive ? 'is streaming' : 'streamed'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeckHeader({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name || genUserName(pubkey);
|
||||
const url = useMemo(() => getProfileUrl(pubkey, author.data?.metadata), [pubkey, author.data?.metadata]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3 min-w-0">
|
||||
<div className="w-11 shrink-0 flex justify-end">
|
||||
<CardsIcon className="size-4 text-primary translate-y-px" />
|
||||
</div>
|
||||
<div className="flex items-center min-w-0">
|
||||
{author.isLoading ? (
|
||||
<Skeleton className="h-3 w-20 inline-block" />
|
||||
) : (
|
||||
<Link
|
||||
to={url}
|
||||
className="font-medium hover:underline mr-1 truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? <EmojifiedText tags={author.data.event.tags}>{name}</EmojifiedText> : name}
|
||||
</Link>
|
||||
)}
|
||||
<span className={cn("shrink-0", author.isLoading && 'ml-1')}>shared a deck</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreasureHeader({ pubkey, variant }: { pubkey: string; variant: 'hid' | 'found' }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const name = author.data?.metadata?.name || genUserName(pubkey);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import {
|
||||
@@ -11,12 +12,23 @@ import {
|
||||
Pin,
|
||||
FileJson,
|
||||
FileDigit,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -27,6 +39,7 @@ import { usePinnedNotes } from '@/hooks/usePinnedNotes';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { toast } from '@/hooks/useToast';
|
||||
@@ -73,6 +86,8 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
const displayName = metadata?.name || genUserName(event.pubkey);
|
||||
const { addMute, removeMute, isMuted } = useMuteList();
|
||||
const userMuted = isMuted('pubkey', event.pubkey);
|
||||
const { mutate: deleteEvent, isPending: isDeleting } = useDeleteEvent();
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
const neventId = nip19.neventEncode({ id: event.id, author: event.pubkey });
|
||||
|
||||
@@ -161,6 +176,21 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
close();
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteEvent(
|
||||
{ eventId: event.id, eventKind: event.kind },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Post deleted' });
|
||||
close();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to delete post', variant: 'destructive' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md p-0 gap-0 rounded-2xl overflow-hidden [&>button]:hidden">
|
||||
@@ -239,6 +269,14 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
onClick={handleTogglePin}
|
||||
/>
|
||||
)}
|
||||
{isOwnPost && (
|
||||
<MenuItem
|
||||
icon={<Trash2 className="size-5" />}
|
||||
label="Delete post"
|
||||
onClick={() => setDeleteConfirmOpen(true)}
|
||||
destructive
|
||||
/>
|
||||
)}
|
||||
{!isOwnPost && (
|
||||
<MenuItem
|
||||
icon={<AtSign className="size-5" />}
|
||||
@@ -280,6 +318,27 @@ export function NoteMoreMenu({ event, open, onOpenChange }: NoteMoreMenuProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete post?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will request deletion from relays. Some relays may still keep a copy of the original event. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Stacked cards icon — used for Magic: The Gathering decks.
|
||||
* Rendered as a filled SVG component styled with currentColor.
|
||||
*/
|
||||
export const CardsIcon = 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 24 24"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<path d="M11.19,2.25C10.93,2.25 10.67,2.31 10.42,2.4L3.06,5.45C2.04,5.87 1.55,7.04 1.97,8.05L6.93,20C7.24,20.77 7.97,21.23 8.74,21.25C9,21.25 9.27,21.22 9.53,21.1L16.9,18.05C17.65,17.74 18.11,17 18.13,16.25C18.14,16 18.09,15.71 18,15.45L13,3.5C12.71,2.73 11.97,2.26 11.19,2.25M14.67,2.25L18.12,10.6V4.25A2,2 0 0,0 16.12,2.25M20.13,3.79V12.82L22.56,6.96C22.97,5.94 22.5,4.78 21.47,4.36M11.19,4.22L16.17,16.24L8.78,19.3L3.8,7.29" />
|
||||
</svg>
|
||||
),
|
||||
);
|
||||
|
||||
CardsIcon.displayName = 'CardsIcon';
|
||||
@@ -60,6 +60,10 @@ export interface FeedSettings {
|
||||
feedIncludePacks: boolean;
|
||||
/** Include Streams in the follows/global feed */
|
||||
feedIncludeStreams: boolean;
|
||||
/** Show Magic Decks (kind 37381) link in sidebar */
|
||||
showDecks: boolean;
|
||||
/** Include Magic Decks in the follows/global feed */
|
||||
feedIncludeDecks: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from './useCurrentUser';
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
|
||||
/**
|
||||
* Hook to publish a kind 5 deletion request event (NIP-09).
|
||||
* After publishing, invalidates feed caches so relays are re-queried
|
||||
* and the deleted event is no longer returned.
|
||||
*/
|
||||
export function useDeleteEvent() {
|
||||
const { user } = useCurrentUser();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ eventId, eventKind }: { eventId: string; eventKind: number }) => {
|
||||
if (!user) throw new Error('User is not logged in');
|
||||
|
||||
await publishEvent({
|
||||
kind: 5,
|
||||
content: '',
|
||||
tags: [
|
||||
['e', eventId],
|
||||
['k', String(eventKind)],
|
||||
],
|
||||
});
|
||||
|
||||
return eventId;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate feed queries so relays are re-queried.
|
||||
// The relay should no longer return the deleted event.
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profileFeed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profileLikes'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['replies'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -136,6 +136,16 @@ export const EXTRA_KINDS: ExtraKindDef[] = [
|
||||
addressable: false,
|
||||
section: 'whimsy',
|
||||
},
|
||||
{
|
||||
kind: 37381,
|
||||
showKey: 'showDecks',
|
||||
feedKey: 'feedIncludeDecks',
|
||||
label: 'Magic Decks',
|
||||
description: 'Magic: The Gathering deck lists',
|
||||
route: 'decks',
|
||||
addressable: true,
|
||||
section: 'whimsy',
|
||||
},
|
||||
{
|
||||
kind: 37516,
|
||||
showKey: 'showTreasures',
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ColorMomentContent } from '@/components/ColorMomentContent';
|
||||
import { FollowPackContent } from '@/components/FollowPackContent';
|
||||
import { FollowPackDetailContent } from '@/components/FollowPackDetailContent';
|
||||
import { ArticleContent } from '@/components/ArticleContent';
|
||||
import { MagicDeckContent } from '@/components/MagicDeckContent';
|
||||
import { LiveStreamPage } from '@/components/LiveStreamPage';
|
||||
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
||||
import { useEvent, useAddrEvent, type AddrCoords } from '@/hooks/useEvent';
|
||||
@@ -642,7 +643,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
const isColor = event.kind === 3367;
|
||||
const isFollowPack = event.kind === 39089 || event.kind === 30000;
|
||||
const isArticle = event.kind === 30023;
|
||||
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle;
|
||||
const isMagicDeck = event.kind === 37381;
|
||||
const isTextNote = !isVine && !isPoll && !isGeocache && !isFoundLog && !isColor && !isFollowPack && !isArticle && !isMagicDeck;
|
||||
|
||||
const images = useMemo(() => isTextNote ? extractImages(event.content) : [], [event.content, isTextNote]);
|
||||
const videos = useMemo(() => isTextNote ? extractVideos(event.content) : [], [event.content, isTextNote]);
|
||||
@@ -740,6 +742,8 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
<ContentWarningGuard event={event}>
|
||||
{isArticle ? (
|
||||
<ArticleContent event={event} className="mt-3" />
|
||||
) : isMagicDeck ? (
|
||||
<MagicDeckContent event={event} />
|
||||
) : isVine || isPoll || isGeocache || isFoundLog || isColor || isFollowPack ? (
|
||||
<>
|
||||
{isVine && <VineDetailContent event={event} />}
|
||||
|
||||
@@ -570,7 +570,6 @@ export function ProfilePage() {
|
||||
const { toast } = useToast();
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('posts');
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [followingModalOpen, setFollowingModalOpen] = useState(false);
|
||||
|
||||
@@ -50,6 +50,8 @@ export function TestApp({ children }: TestAppProps) {
|
||||
feedIncludeColors: false,
|
||||
feedIncludePacks: false,
|
||||
feedIncludeStreams: false,
|
||||
showDecks: false,
|
||||
feedIncludeDecks: false,
|
||||
},
|
||||
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
|
||||
nip85OnlyMode: false,
|
||||
|
||||
Reference in New Issue
Block a user