e9def50a85
Replace the plain launch card with a Game Boy-style cartridge (using public/cartridge.png) whose label region centers the app icon. The whole cartridge scales as one image and reacts to the pointer with the existing useCardTilt 3D effect. The cartridge is tinted by the icon's dominant color: a new useDominantColor hook samples the icon in an off-screen canvas, picks the heaviest hue bucket, and exposes it as HSL. A mask-image layer masked to the cartridge silhouette blends the color over the grayscale PNG with mix-blend-mode: color, preserving the shading. Grayscale or CORS-blocked icons fall back to the original gray cartridge. The app name moves out from under the cartridge and into the description card in FileMetadataContent — rendered larger and bolder above the note's content — while WebxdcEmbed still renders its own name card when a parent isn't providing one (e.g. the kind 1 imeta path).
178 lines
6.6 KiB
TypeScript
178 lines
6.6 KiB
TypeScript
import { Download, FileIcon } from 'lucide-react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { ImageGallery } from '@/components/ImageGallery';
|
|
import { VideoPlayer } from '@/components/VideoPlayer';
|
|
import { WebxdcEmbed } from '@/components/WebxdcEmbed';
|
|
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { getDisplayName } from '@/lib/getDisplayName';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { getAvatarShape } from '@/lib/avatarShape';
|
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/** Extract the first value of a tag by name. */
|
|
function getTag(tags: string[][], name: string): string | undefined {
|
|
return tags.find(([n]) => n === name)?.[1];
|
|
}
|
|
|
|
/** Format bytes into a human-readable string. */
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
/** YouTube-style description card rendered below media. */
|
|
function DescriptionCard({ title, text }: { title?: string; text?: string }) {
|
|
if (!title && !text) return null;
|
|
return (
|
|
<div className="mt-2.5 rounded-xl bg-secondary/50 px-3.5 py-2.5">
|
|
{title && (
|
|
<p className="text-base font-semibold text-foreground break-words">{title}</p>
|
|
)}
|
|
{text && (
|
|
<p className={cn('text-sm leading-relaxed text-muted-foreground break-words', title && 'mt-1')}>
|
|
{text}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Inner component for audio events — needs author data for avatar. */
|
|
function AudioFileContent({
|
|
event,
|
|
url,
|
|
mime,
|
|
description,
|
|
}: {
|
|
event: NostrEvent;
|
|
url: string;
|
|
mime: string;
|
|
description: string | undefined;
|
|
}) {
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
|
|
|
|
return (
|
|
<div className="mt-3">
|
|
<AudioVisualizer
|
|
src={url}
|
|
mime={mime}
|
|
avatarUrl={metadata?.picture}
|
|
avatarFallback={displayName[0]?.toUpperCase() ?? '?'}
|
|
avatarShape={getAvatarShape(metadata)}
|
|
/>
|
|
{description && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface FileMetadataContentProps {
|
|
event: NostrEvent;
|
|
/** If true, render a more compact version for feed cards. */
|
|
compact?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Renders the content of a NIP-94 (kind 1063) file metadata event.
|
|
*
|
|
* Media renders directly, and the description appears in a separate
|
|
* rounded card below it (similar to YouTube's description box).
|
|
*/
|
|
export function FileMetadataContent({ event, compact }: FileMetadataContentProps) {
|
|
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
|
const mime = getTag(event.tags, 'm') ?? '';
|
|
const alt = getTag(event.tags, 'alt');
|
|
const webxdcId = getTag(event.tags, 'webxdc');
|
|
const dim = getTag(event.tags, 'dim');
|
|
const blurhash = getTag(event.tags, 'blurhash');
|
|
const thumb = getTag(event.tags, 'thumb') ?? getTag(event.tags, 'image');
|
|
const summary = getTag(event.tags, 'summary');
|
|
const size = getTag(event.tags, 'size');
|
|
|
|
if (!url) return null;
|
|
|
|
const description = event.content || undefined;
|
|
const altText = alt ?? undefined;
|
|
const fileName = url.split('/').pop() ?? 'file';
|
|
const sizeStr = size ? formatBytes(Number(size)) : undefined;
|
|
|
|
// ── Webxdc app ──────────────────────────────────────────────────────
|
|
if (mime === 'application/x-webxdc') {
|
|
const appName = altText?.replace(/^Webxdc app:\s*/i, '') ?? summary ?? fileName.replace('.xdc', '');
|
|
return (
|
|
<div className="mt-3">
|
|
<WebxdcEmbed
|
|
url={url}
|
|
uuid={webxdcId}
|
|
icon={thumb}
|
|
showNameCard={false}
|
|
/>
|
|
<DescriptionCard title={appName} text={description} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Image ───────────────────────────────────────────────────────────
|
|
if (mime.startsWith('image/')) {
|
|
const imetaMap = (dim || blurhash)
|
|
? new Map([[url, { dim, blurhash }]])
|
|
: undefined;
|
|
return (
|
|
<div className="mt-3">
|
|
<ImageGallery images={[url]} imetaMap={imetaMap} />
|
|
{description && !compact && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Video ───────────────────────────────────────────────────────────
|
|
if (mime.startsWith('video/')) {
|
|
return (
|
|
<div className="mt-3">
|
|
<VideoPlayer src={url} poster={thumb} dim={dim} blurhash={blurhash} title={altText} />
|
|
{description && !compact && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Audio ───────────────────────────────────────────────────────────
|
|
if (mime.startsWith('audio/')) {
|
|
return <AudioFileContent event={event} url={url} mime={mime} description={description} />;
|
|
}
|
|
|
|
// ── Fallback: generic file ──────────────────────────────────────────
|
|
const displayName = altText ?? fileName;
|
|
const mimeLabel = mime ? mime.split('/').pop()?.toUpperCase() : undefined;
|
|
|
|
return (
|
|
<div className="mt-3">
|
|
<div className="rounded-2xl border border-border overflow-hidden">
|
|
<div className="flex items-center gap-3.5 p-4">
|
|
<div className="flex items-center justify-center size-12 rounded-xl bg-muted shrink-0">
|
|
<FileIcon className="size-6 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold truncate">{displayName}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{[mimeLabel, sizeStr].filter(Boolean).join(' · ') || 'File'}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" className="shrink-0 rounded-full gap-1.5" asChild>
|
|
<a href={url} download>
|
|
<Download className="size-3.5" />
|
|
Download
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{description && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|