feat: display Mastodon post media inline (images, video, gif)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -89,7 +89,7 @@ export function HeroSection({ title, tagline }: HeroSectionProps) {
|
||||
}, [reducedMotion]);
|
||||
|
||||
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 */}
|
||||
{!reducedMotion && (
|
||||
<div
|
||||
|
||||
@@ -134,10 +134,83 @@ export function MastodonFeed() {
|
||||
{truncated}
|
||||
</p>
|
||||
|
||||
{/* Media indicator */}
|
||||
{/* Media attachments - render images/videos from remote URLs */}
|
||||
{hasMedia && (
|
||||
<div className="type-small text-slate-400 dark:text-slate-500">
|
||||
📎 包含 {displayStatus.media_attachments.length} 個媒體
|
||||
<div
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
onClick={close}
|
||||
>
|
||||
<Icon className="h-4 w-4 text-slate-400" />
|
||||
<span>{item.label}</span>
|
||||
<Icon className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</Link>
|
||||
) : 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"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon className="h-5 w-5 text-slate-400" />
|
||||
<span>{item.label}</span>
|
||||
<Icon className="h-5 w-5 shrink-0 text-slate-400" />
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</div>
|
||||
<FiChevronRight
|
||||
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"
|
||||
onClick={close}
|
||||
>
|
||||
<Icon className="h-5 w-5 text-slate-400" />
|
||||
<span>{item.label}</span>
|
||||
<Icon className="h-5 w-5 shrink-0 text-slate-400" />
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</Link>
|
||||
) : null;
|
||||
};
|
||||
@@ -261,13 +261,13 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
>
|
||||
<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-expanded={isOpen}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
|
||||
<span>{item.label}</span>
|
||||
<FiChevronDown className="h-3 w-3 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 className="whitespace-nowrap">{item.label}</span>
|
||||
<FiChevronDown className="h-3 w-3 shrink-0 text-slate-400 transition group-hover:text-accent" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
@@ -290,11 +290,11 @@ export function NavMenu({ items }: NavMenuProps) {
|
||||
<Link
|
||||
key={item.key}
|
||||
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}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
|
||||
<span>{item.label}</span>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-slate-400 transition group-hover:text-accent" />
|
||||
<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" />
|
||||
</Link>
|
||||
) : null;
|
||||
|
||||
@@ -263,11 +263,11 @@ export function SearchButton({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
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)"
|
||||
>
|
||||
<FiSearch className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">搜尋</span>
|
||||
<FiSearch className="h-3.5 w-3.5 shrink-0" />
|
||||
<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">
|
||||
⌘K
|
||||
</kbd>
|
||||
|
||||
@@ -27,7 +27,9 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
|
||||
|
||||
const findPage = (title: string) => pages.find((page) => page.title === title);
|
||||
|
||||
const aboutChildren = [
|
||||
const aboutChildren: NavLinkItem[] = [
|
||||
...(
|
||||
[
|
||||
{ title: '關於作者', label: '作者' },
|
||||
{ title: '關於本站', label: '本站' }
|
||||
]
|
||||
@@ -41,7 +43,10 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
|
||||
iconKey: getIconForPage(page.title, page.slug)
|
||||
} satisfies NavLinkItem;
|
||||
})
|
||||
.filter(Boolean) as NavLinkItem[];
|
||||
.filter(Boolean) as NavLinkItem[]
|
||||
),
|
||||
{ key: 'projects', href: '/projects', label: '作品', iconKey: 'pen' }
|
||||
];
|
||||
|
||||
const deviceChildren = [
|
||||
{ title: '開發工作環境', label: '開發環境' },
|
||||
@@ -61,7 +66,6 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
|
||||
|
||||
const navItems: NavLinkItem[] = [
|
||||
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
|
||||
{ key: 'projects', href: '/projects', label: '作品', iconKey: 'pen' },
|
||||
{
|
||||
key: 'about',
|
||||
href: aboutChildren[0]?.href,
|
||||
@@ -84,7 +88,7 @@ export function SiteHeader({ recentPosts = [] }: SiteHeaderProps) {
|
||||
<Link
|
||||
href="/"
|
||||
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" />
|
||||
{siteConfig.title}
|
||||
|
||||
@@ -14,9 +14,12 @@ export interface MastodonStatus {
|
||||
avatar: string;
|
||||
};
|
||||
media_attachments: Array<{
|
||||
type: string;
|
||||
url: string;
|
||||
preview_url: string;
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'gifv' | 'audio' | 'unknown';
|
||||
url: string | null;
|
||||
preview_url: string | null;
|
||||
description: string | null;
|
||||
blurhash?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user