feat: display Mastodon post media inline (images, video, gif)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-14 09:57:00 +08:00
parent f7f2451357
commit bdd42b9d26
6 changed files with 120 additions and 40 deletions

View File

@@ -89,7 +89,7 @@ export function HeroSection({ title, tagline }: HeroSectionProps) {
}, [reducedMotion]); }, [reducedMotion]);
return ( return (
<div className="relative min-h-[280px] w-full overflow-hidden rounded-2xl sm:min-h-[320px] lg:min-h-[360px] xl:min-h-[400px]"> <div className="relative h-[360px] w-full overflow-hidden rounded-2xl sm:h-[400px] lg:h-[440px] xl:h-[480px]">
{/* Matrix rain - full area, fades out */} {/* Matrix rain - full area, fades out */}
{!reducedMotion && ( {!reducedMotion && (
<div <div

View File

@@ -134,10 +134,83 @@ export function MastodonFeed() {
{truncated} {truncated}
</p> </p>
{/* Media indicator */} {/* Media attachments - render images/videos from remote URLs */}
{hasMedia && ( {hasMedia && (
<div className="type-small text-slate-400 dark:text-slate-500"> <div
📎 {displayStatus.media_attachments.length} className={`mt-1.5 grid gap-1 ${
displayStatus.media_attachments.length === 1
? 'grid-cols-1'
: 'grid-cols-2'
}`}
>
{displayStatus.media_attachments.map((att) => {
const src = att.preview_url ?? att.url;
if (!src) return null;
if (att.type === 'image') {
return (
<img
key={att.id}
src={src}
alt={att.description ?? ''}
loading="lazy"
className="aspect-video w-full rounded-md object-cover"
/>
);
}
if (att.type === 'gifv' && att.url) {
return (
<div
key={att.id}
className="overflow-hidden rounded-md"
onClick={(e) => e.stopPropagation()}
>
<video
src={att.url}
poster={att.preview_url ?? undefined}
autoPlay
loop
muted
playsInline
className="aspect-video w-full object-cover"
/>
</div>
);
}
if (att.type === 'video' && att.url) {
return (
<div
key={att.id}
className="overflow-hidden rounded-md"
onClick={(e) => e.stopPropagation()}
>
<video
src={att.url}
poster={att.preview_url ?? undefined}
controls
playsInline
className="aspect-video w-full object-cover"
/>
</div>
);
}
if (att.type === 'audio' && att.preview_url) {
return (
<div
key={att.id}
className="flex aspect-video w-full items-center justify-center rounded-md bg-slate-200 dark:bg-slate-700"
>
<img
src={att.preview_url}
alt={att.description ?? '音訊'}
loading="lazy"
className="h-full w-full object-cover opacity-80"
/>
</div>
);
}
return null;
})}
</div> </div>
)} )}

View File

@@ -131,8 +131,8 @@ export function NavMenu({ items }: NavMenuProps) {
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800" className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={close} onClick={close}
> >
<Icon className="h-4 w-4 text-slate-400" /> <Icon className="h-4 w-4 shrink-0 text-slate-400" />
<span>{item.label}</span> <span className="whitespace-nowrap">{item.label}</span>
</Link> </Link>
) : null; ) : null;
}; };
@@ -150,8 +150,8 @@ export function NavMenu({ items }: NavMenuProps) {
className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800" className="flex w-full items-center justify-between rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Icon className="h-5 w-5 text-slate-400" /> <Icon className="h-5 w-5 shrink-0 text-slate-400" />
<span>{item.label}</span> <span className="whitespace-nowrap">{item.label}</span>
</div> </div>
<FiChevronRight <FiChevronRight
className={`h-5 w-5 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`} className={`h-5 w-5 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
@@ -178,8 +178,8 @@ export function NavMenu({ items }: NavMenuProps) {
className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800" className="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-base font-medium text-slate-700 transition-colors active:bg-slate-100 dark:text-slate-200 dark:active:bg-slate-800"
onClick={close} onClick={close}
> >
<Icon className="h-5 w-5 text-slate-400" /> <Icon className="h-5 w-5 shrink-0 text-slate-400" />
<span>{item.label}</span> <span className="whitespace-nowrap">{item.label}</span>
</Link> </Link>
) : null; ) : null;
}; };
@@ -261,13 +261,13 @@ export function NavMenu({ items }: NavMenuProps) {
> >
<button <button
type="button" type="button"
className="motion-link type-nav inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200" className="motion-link type-nav inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={isOpen} aria-expanded={isOpen}
> >
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" /> <Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent" />
<span>{item.label}</span> <span className="whitespace-nowrap">{item.label}</span>
<FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-accent" /> <FiChevronDown className="h-3 w-3 shrink-0 text-slate-400 transition group-hover:text-accent" />
</button> </button>
<div <div
@@ -290,11 +290,11 @@ export function NavMenu({ items }: NavMenuProps) {
<Link <Link
key={item.key} key={item.key}
href={item.href} href={item.href}
className="motion-link type-nav group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200" className="motion-link type-nav group relative inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
onClick={close} onClick={close}
> >
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" /> <Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent" />
<span>{item.label}</span> <span className="whitespace-nowrap">{item.label}</span>
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" /> <span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
</Link> </Link>
) : null; ) : null;

View File

@@ -263,11 +263,11 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="motion-link inline-flex h-9 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700" className="motion-link inline-flex h-9 shrink-0 items-center gap-2 rounded-full bg-slate-100 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
aria-label="搜尋 (Cmd+K)" aria-label="搜尋 (Cmd+K)"
> >
<FiSearch className="h-3.5 w-3.5" /> <FiSearch className="h-3.5 w-3.5 shrink-0" />
<span className="hidden sm:inline"></span> <span className="hidden shrink-0 whitespace-nowrap sm:inline"></span>
<kbd className="hidden rounded bg-white px-1.5 py-0.5 text-xs font-semibold text-slate-500 shadow-sm dark:bg-slate-900 dark:text-slate-400 sm:inline-block"> <kbd className="hidden rounded bg-white px-1.5 py-0.5 text-xs font-semibold text-slate-500 shadow-sm dark:bg-slate-900 dark:text-slate-400 sm:inline-block">
K K
</kbd> </kbd>

View File

@@ -27,7 +27,9 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
const findPage = (title: string) => pages.find((page) => page.title === title); const findPage = (title: string) => pages.find((page) => page.title === title);
const aboutChildren = [ const aboutChildren: NavLinkItem[] = [
...(
[
{ title: '關於作者', label: '作者' }, { title: '關於作者', label: '作者' },
{ title: '關於本站', label: '本站' } { title: '關於本站', label: '本站' }
] ]
@@ -41,7 +43,10 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
iconKey: getIconForPage(page.title, page.slug) iconKey: getIconForPage(page.title, page.slug)
} satisfies NavLinkItem; } satisfies NavLinkItem;
}) })
.filter(Boolean) as NavLinkItem[]; .filter(Boolean) as NavLinkItem[]
),
{ key: 'projects', href: '/projects', label: '作品', iconKey: 'pen' }
];
const deviceChildren = [ const deviceChildren = [
{ title: '開發工作環境', label: '開發環境' }, { title: '開發工作環境', label: '開發環境' },
@@ -61,7 +66,6 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
const navItems: NavLinkItem[] = [ const navItems: NavLinkItem[] = [
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' }, { key: 'home', href: '/', label: '首頁', iconKey: 'home' },
{ key: 'projects', href: '/projects', label: '作品', iconKey: 'pen' },
{ {
key: 'about', key: 'about',
href: aboutChildren[0]?.href, href: aboutChildren[0]?.href,
@@ -84,7 +88,7 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
<Link <Link
href="/" href="/"
prefetch={true} prefetch={true}
className="motion-link group relative type-title text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100" className="motion-link group relative type-title whitespace-nowrap text-slate-900 hover:text-accent focus-visible:outline-none focus-visible:text-accent dark:text-slate-100"
> >
<span className="absolute -bottom-0.5 left-0 h-[2px] w-0 bg-accent transition-all duration-180 ease-snappy group-hover:w-full" aria-hidden="true" /> <span className="absolute -bottom-0.5 left-0 h-[2px] w-0 bg-accent transition-all duration-180 ease-snappy group-hover:w-full" aria-hidden="true" />
{siteConfig.title} {siteConfig.title}

View File

@@ -14,9 +14,12 @@ export interface MastodonStatus {
avatar: string; avatar: string;
}; };
media_attachments: Array<{ media_attachments: Array<{
type: string; id: string;
url: string; type: 'image' | 'video' | 'gifv' | 'audio' | 'unknown';
preview_url: string; url: string | null;
preview_url: string | null;
description: string | null;
blurhash?: string | null;
}>; }>;
} }