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]);
|
}, [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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user