Improve Zapstore and app handler card display

- Rename Zapstore kind labels to include 'Zapstore' prefix across all
  label registries (NoteCard, PostDetailPage, CommentContext,
  ExternalContentHeader, NotificationsPage, extraKinds)
- Wrap Zapstore (32267, 30063, 3063) compact and detail content in
  rounded bordered cards with hover effects; remove redundant mt-2/mt-3
  margins from component roots
- Replace useLinkPreview thumbnail with metadata banner/picture in kind
  31990 app handler cards (compact and full views)
- Add pt-4 to Zapstore detail card wrappers in PostDetailPage
- Fix sticky tab bar (SubHeaderBar z-10) being painted over by card
  content: remove z-10 from AppHandlerContent inner div and add z-0 to
  the main content column in MainLayout
This commit is contained in:
Alex Gleason
2026-04-03 16:20:40 -05:00
parent bc83d08961
commit 8e3f778f5b
10 changed files with 91 additions and 80 deletions
+24 -40
View File
@@ -1,12 +1,11 @@
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { ExternalLink, GitFork, Package } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { Skeleton } from '@/components/ui/skeleton';
import { useLinkPreview } from '@/hooks/useLinkPreview';
import { NostrURI } from '@/lib/NostrURI';
import { cn } from '@/lib/utils';
@@ -79,42 +78,32 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App';
const about = metadata.about;
const picture = metadata.picture;
const banner = metadata.banner;
const websiteUrl = getWebsiteUrl(event.tags, metadata);
const hashtags = getAllTags(event.tags, 't');
const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]);
const { data: preview, isLoading: previewLoading } = useLinkPreview(websiteUrl ?? null);
const thumbnailUrl = preview?.thumbnail_url;
const [imgError, setImgError] = useState(false);
const showThumbnail = thumbnailUrl && !imgError;
if (compact) {
return (
<div className="mt-2">
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
{/* Banner hero */}
{banner && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
<img
src={banner}
alt=""
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</div>
)}
{/* Content */}
<div className="relative z-10 px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className={showThumbnail || previewLoading ? '-mt-7' : 'pt-3.5'}>
<div className="relative px-3.5 pb-3.5 space-y-2">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={banner ? '-mt-7' : 'pt-3.5'}>
{picture ? (
<img
src={picture}
@@ -193,29 +182,24 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
return (
<div className="mt-3">
<div className="rounded-xl border border-border overflow-hidden">
{/* Screenshot hero — only shown while loading or when a thumbnail exists */}
{(previewLoading || showThumbnail) && (
{/* Banner hero */}
{banner && (
<div className="relative aspect-[2/1] bg-gradient-to-br from-muted/50 to-muted overflow-hidden">
{previewLoading ? (
<Skeleton className="absolute inset-0" />
) : (
<img
src={thumbnailUrl}
alt={name}
className="size-full object-cover"
loading="lazy"
onError={() => setImgError(true)}
/>
)}
<img
src={banner}
alt=""
className="size-full object-cover"
loading="lazy"
/>
</div>
)}
{/* Content */}
<div className="relative z-10 px-4 pb-4 space-y-3">
{/* App icon — overlaps the screenshot hero like a profile avatar */}
<div className="relative px-4 pb-4 space-y-3">
{/* App icon — overlaps the banner hero like a profile avatar */}
<div className={cn(
'flex items-end justify-between',
showThumbnail || previewLoading ? '-mt-10' : 'pt-4',
banner ? '-mt-10' : 'pt-4',
)}>
{picture ? (
<img
+6 -6
View File
@@ -108,8 +108,8 @@ const KIND_LABELS: Record<number, string> = {
30030: 'an emoji pack',
30054: 'a podcast episode',
30055: 'a podcast trailer',
3063: 'an asset',
30063: 'a release',
3063: 'a Zapstore asset',
30063: 'a Zapstore release',
30311: 'a stream',
30315: 'a status',
30617: 'a repository',
@@ -117,7 +117,7 @@ const KIND_LABELS: Record<number, string> = {
31922: 'a calendar event',
31923: 'a calendar event',
31990: 'an app',
32267: 'an app',
32267: 'a Zapstore app',
34139: 'a playlist',
34236: 'a divine',
34550: 'a community',
@@ -214,11 +214,11 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
34139: 'playlist',
};
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto app"). */
/** Postfix that replaces the default pattern (e.g. "Ditto on Zapstore" instead of "Ditto Zapstore app"). */
const KIND_POSTFIXES: Partial<Record<number, string>> = {
32267: 'on Zapstore',
30063: 'release',
3063: 'asset',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/** Get a display name for an event based on its kind and tags. */
+3 -3
View File
@@ -1083,9 +1083,9 @@ function hasVideo(tags: string[][]): boolean {
/** Fallback labels for well-known kinds not in EXTRA_KINDS. */
const WELL_KNOWN_KIND_LABELS: Record<number, string> = {
31990: 'App',
32267: 'App',
30063: 'Release',
3063: 'Asset',
32267: 'Zapstore App',
30063: 'Zapstore Release',
3063: 'Zapstore Asset',
15128: 'Nsite',
35128: 'Nsite',
31124: 'Blobbi',
+1 -1
View File
@@ -135,7 +135,7 @@ function MainLayoutInner() {
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 className={cn("relative 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")}>
<div 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
+18 -6
View File
@@ -539,11 +539,23 @@ export const NoteCard = memo(function NoteCard({
) : isNsite ? (
<NsiteCard event={event} />
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} compact />
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAppContent event={event} compact />
</div>
</div>
) : isZapstoreRelease ? (
<ZapstoreReleaseContent event={event} compact />
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreReleaseContent event={event} compact />
</div>
</div>
) : isZapstoreAsset ? (
<ZapstoreAssetContent event={event} compact />
<div className="mt-2 rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
<div className="px-3.5 pb-3.5 pt-3">
<ZapstoreAssetContent event={event} compact />
</div>
</div>
) : isAppHandler ? (
<AppHandlerContent event={event} compact />
) : isEncryptedDM ? (
@@ -2014,15 +2026,15 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
},
32267: {
icon: Package,
action: "published an app",
action: "published a Zapstore app",
},
30063: {
icon: Package,
action: "published a release",
action: "published a Zapstore release",
},
3063: {
icon: Package,
action: "published an asset",
action: "published a Zapstore asset",
},
31990: {
icon: Package,
+2 -2
View File
@@ -255,7 +255,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
if (compact) {
return (
<div className="mt-2 space-y-2.5">
<div className="space-y-2.5">
{/* Header: icon + name + summary */}
<div className="flex items-start gap-3">
{icon ? (
@@ -326,7 +326,7 @@ export function ZapstoreAppContent({ event, compact }: ZapstoreAppContentProps)
// Full detail view
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
{/* Header: icon + name + summary */}
<div className="flex items-start gap-4">
{icon ? (
+6 -6
View File
@@ -308,7 +308,7 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
if (compact) {
return (
<div className="mt-2 space-y-2.5">
<div className="space-y-2.5">
{/* Header: icon + app name + version */}
<div className="flex items-start gap-3">
{appIcon ? (
@@ -367,7 +367,7 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
// Full detail view
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-4">
{appIcon ? (
@@ -488,7 +488,7 @@ export function ZapstoreReleaseContent({ event, compact }: ZapstoreReleaseConten
/** Skeleton loading state for ZapstoreReleaseContent. */
export function ZapstoreReleaseSkeleton() {
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-14 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
@@ -554,7 +554,7 @@ export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentPro
if (compact) {
return (
<div className="mt-2 space-y-2">
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<PlatformIcon mime={mime} className="size-5 text-primary" />
@@ -581,7 +581,7 @@ export function ZapstoreAssetContent({ event, compact }: ZapstoreAssetContentPro
}
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
{/* Header */}
<div className="flex items-start gap-4">
<div className="size-14 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
@@ -694,7 +694,7 @@ function MetaRow({ label, value }: { label: string; value: React.ReactNode }) {
/** Skeleton for ZapstoreAssetContent. */
export function ZapstoreAssetSkeleton() {
return (
<div className="mt-3 space-y-4">
<div className="space-y-4">
<div className="flex items-start gap-4">
<Skeleton className="size-14 rounded-2xl shrink-0" />
<div className="flex-1 space-y-2">
+3 -3
View File
@@ -546,10 +546,10 @@ const KIND_SPECIFIC_LABELS: Record<number, string> = {
35128: 'nsite',
30008: 'profile badges',
30817: 'repository issue',
32267: 'app',
32267: 'Zapstore app',
31990: 'app',
30063: 'release',
3063: 'asset',
30063: 'Zapstore release',
3063: 'Zapstore asset',
};
/**
+3 -3
View File
@@ -78,15 +78,15 @@ const NOTIFICATION_KIND_NOUNS: Record<number, string> = {
30030: 'emoji pack',
30054: 'podcast episode',
30055: 'podcast trailer',
3063: 'asset',
30063: 'release',
3063: 'Zapstore asset',
30063: 'Zapstore release',
30311: 'stream',
30315: 'status',
30617: 'repository',
30817: 'custom NIP',
31922: 'calendar event',
31923: 'calendar event',
32267: 'app',
32267: 'Zapstore app',
34139: 'playlist',
34236: 'divine',
34550: 'community',
+25 -10
View File
@@ -136,9 +136,9 @@ function shellTitleForKind(kind?: number): string {
if (kind === BADGE_DEFINITION_KIND) return "Badge Details";
if (kind === BADGE_PROFILE_KIND_NEW || kind === BADGE_PROFILE_KIND_LEGACY) return "Badge Collection";
if (kind === BOOK_REVIEW_KIND) return "Book Review";
if (kind === 32267) return "App Details";
if (kind === 30063) return "Release";
if (kind === 3063) return "Asset";
if (kind === 32267) return "Zapstore App";
if (kind === 30063) return "Zapstore Release";
if (kind === 3063) return "Zapstore Asset";
if (kind === 31990) return "App";
if (kind === 15128 || kind === 35128) return "Nsite";
if (kind === VANISH_KIND) return "Request to Vanish";
@@ -1950,6 +1950,15 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
{isAppHandler && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published an app" />
)}
{isZapstoreApp && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published a Zapstore app" />
)}
{isZapstoreRelease && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published a Zapstore release" />
)}
{isZapstoreAsset && (
<EventActionHeader pubkey={event.pubkey} icon={Package} action="published a Zapstore asset" />
)}
{isNsite && (
<EventActionHeader pubkey={event.pubkey} icon={Rocket} action="deployed an" noun="nsite" nounRoute="/development" />
)}
@@ -2064,15 +2073,21 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
<NsiteCard event={event} />
</div>
) : isZapstoreApp ? (
<ZapstoreAppContent event={event} />
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
<ZapstoreAppContent event={event} />
</div>
) : isZapstoreRelease ? (
<Suspense fallback={<ZapstoreReleaseSkeleton />}>
<ZapstoreReleaseContent event={event} />
</Suspense>
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
<Suspense fallback={<ZapstoreReleaseSkeleton />}>
<ZapstoreReleaseContent event={event} />
</Suspense>
</div>
) : isZapstoreAsset ? (
<Suspense fallback={<ZapstoreAssetSkeleton />}>
<ZapstoreAssetContent event={event} />
</Suspense>
<div className="mt-3 rounded-xl border border-border overflow-hidden px-4 pt-4 pb-4">
<Suspense fallback={<ZapstoreAssetSkeleton />}>
<ZapstoreAssetContent event={event} />
</Suspense>
</div>
) : isAppHandler ? (
<AppHandlerContent event={event} />
) : isEncryptedDM ? (