feat: add mobile sidebar access via FAB and slide-over drawer
- Extract RightSidebarContent for reuse in desktop and mobile - Add floating action button (FAB) on narrow screens to open sidebar - Slide-over drawer from right with author card, Mastodon feed, tags - Lazy load Mastodon feed when drawer opens (forceLoadFeed prop) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -15,12 +15,16 @@ const MastodonFeed = dynamic(() => import('./mastodon-feed').then(mod => ({ defa
|
|||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function RightSidebar() {
|
/** Shared sidebar content for desktop aside and mobile drawer */
|
||||||
const [shouldLoadFeed, setShouldLoadFeed] = useState(false);
|
export function RightSidebarContent({ forceLoadFeed = false }: { forceLoadFeed?: boolean }) {
|
||||||
|
const [shouldLoadFeed, setShouldLoadFeed] = useState(forceLoadFeed);
|
||||||
const feedRef = useRef<HTMLDivElement>(null);
|
const feedRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use Intersection Observer to lazy load MastodonFeed when sidebar is visible
|
if (forceLoadFeed) {
|
||||||
|
setShouldLoadFeed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!feedRef.current) return;
|
if (!feedRef.current) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
@@ -30,13 +34,12 @@ export function RightSidebar() {
|
|||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: '100px' } // Start loading 100px before it's visible
|
{ rootMargin: '100px' }
|
||||||
);
|
);
|
||||||
|
|
||||||
observer.observe(feedRef.current);
|
observer.observe(feedRef.current);
|
||||||
|
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, []);
|
}, [forceLoadFeed]);
|
||||||
|
|
||||||
const tags = getAllTagsWithCount().slice(0, 5);
|
const tags = getAllTagsWithCount().slice(0, 5);
|
||||||
|
|
||||||
@@ -68,8 +71,7 @@ export function RightSidebar() {
|
|||||||
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
].filter(Boolean) as { key: string; href: string; icon: any; label: string }[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden lg:block">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="sticky top-20 flex flex-col gap-4">
|
|
||||||
<section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800/80">
|
<section className="motion-card group relative overflow-hidden rounded-xl border bg-white px-4 py-4 shadow-sm hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800/80">
|
||||||
<div className="pointer-events-none absolute -left-10 -top-10 h-24 w-24 rounded-full bg-sky-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
|
<div className="pointer-events-none absolute -left-10 -top-10 h-24 w-24 rounded-full bg-sky-300/35 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-sky-500/25" />
|
||||||
<div className="pointer-events-none absolute -bottom-12 right-[-2.5rem] h-28 w-28 rounded-full bg-indigo-300/30 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/20" />
|
<div className="pointer-events-none absolute -bottom-12 right-[-2.5rem] h-28 w-28 rounded-full bg-indigo-300/30 blur-3xl mix-blend-soft-light motion-safe:animate-float-soft dark:bg-indigo-500/20" />
|
||||||
@@ -164,6 +166,15 @@ export function RightSidebar() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RightSidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-20">
|
||||||
|
<RightSidebarContent />
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,100 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
import { FiLayout, FiX } from 'react-icons/fi';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
// Lazy load RightSidebar since it's only visible on lg+ screens
|
// Lazy load RightSidebar since it's only visible on lg+ screens
|
||||||
const RightSidebar = dynamic(() => import('./right-sidebar').then(mod => ({ default: mod.RightSidebar })), {
|
const RightSidebar = dynamic(() => import('./right-sidebar').then(mod => ({ default: mod.RightSidebar })), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const RightSidebarContent = dynamic(() => import('./right-sidebar').then(mod => ({ default: mod.RightSidebarContent })), {
|
||||||
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
export function SidebarLayout({ children }: { children: React.ReactNode }) {
|
export function SidebarLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileSidebarOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, [mobileSidebarOpen]);
|
||||||
|
|
||||||
|
const mobileDrawer = mounted && createPortal(
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'fixed inset-0 z-[1100] bg-black/40 backdrop-blur-sm transition-opacity duration-300 lg:hidden',
|
||||||
|
mobileSidebarOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
)}
|
||||||
|
onClick={() => setMobileSidebarOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slide-over panel from right */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
'fixed top-0 right-0 bottom-0 z-[1110] w-full max-w-sm flex flex-col rounded-l-2xl border-l border-white/20 bg-white/95 shadow-2xl backdrop-blur-xl transition-transform duration-300 ease-snappy dark:border-white/10 dark:bg-slate-900/95 lg:hidden',
|
||||||
|
mobileSidebarOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200/50 px-6 py-4 dark:border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-2 font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
<FiLayout className="h-5 w-5 text-slate-500" />
|
||||||
|
<span>側邊欄</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileSidebarOpen(false)}
|
||||||
|
className="rounded-full p-1 text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
aria-label="關閉側邊欄"
|
||||||
|
>
|
||||||
|
<FiX className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||||
|
<RightSidebarContent forceLoadFeed={mobileSidebarOpen} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
|
||||||
|
const mobileFab = mounted && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
className={clsx(
|
||||||
|
'fixed bottom-6 right-6 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-slate-200 bg-white/90 text-slate-600 shadow-md backdrop-blur-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-300 dark:hover:bg-slate-800 lg:hidden',
|
||||||
|
mobileSidebarOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'
|
||||||
|
)}
|
||||||
|
aria-label="開啟側邊欄"
|
||||||
|
>
|
||||||
|
<FiLayout className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,1.4fr)]">
|
<div className="grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,1.4fr)]">
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
<RightSidebar />
|
<RightSidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{mobileDrawer}
|
||||||
|
{mobileFab}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user