Merge pull request 'terry-staging' (#14) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 35s

Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
2026-05-31 10:36:12 +00:00
4 changed files with 62 additions and 13 deletions

View File

@@ -6,8 +6,10 @@ import {
type MouseEvent as ReactMouseEvent, type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from "react"; } from "react";
import { m } from "framer-motion";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api"; import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
import { EASE_OUT } from "../motion";
import { langQuery, useI18n, type Lang } from "../i18n"; import { langQuery, useI18n, type Lang } from "../i18n";
const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
@@ -123,7 +125,11 @@ export function FigmaBanner() {
if (!scroller) return; if (!scroller) return;
const target = scroller.children[index] as HTMLElement | undefined; const target = scroller.children[index] as HTMLElement | undefined;
if (!target) return; if (!target) return;
scroller.scrollTo({ left: target.offsetLeft, behavior }); // Center the slide in the viewport. On mobile (full-width slides) the
// offset is 0 so this matches the previous snap-start behavior.
const centerOffset = (scroller.clientWidth - target.offsetWidth) / 2;
const left = Math.max(0, target.offsetLeft - centerOffset);
scroller.scrollTo({ left, behavior });
}, []); }, []);
const pauseAutoplay = useCallback(() => { const pauseAutoplay = useCallback(() => {
@@ -168,9 +174,10 @@ export function FigmaBanner() {
if (!scroller) return; if (!scroller) return;
const handleScroll = () => { const handleScroll = () => {
const width = scroller.clientWidth; const firstChild = scroller.children[0] as HTMLElement | undefined;
if (width === 0) return; const slideWidth = firstChild?.offsetWidth ?? scroller.clientWidth;
const next = Math.round(scroller.scrollLeft / width); if (slideWidth === 0) return;
const next = Math.round(scroller.scrollLeft / slideWidth);
setActiveIndex((prev) => (prev === next ? prev : next)); setActiveIndex((prev) => (prev === next ? prev : next));
}; };
@@ -238,8 +245,9 @@ export function FigmaBanner() {
window.setTimeout(() => { window.setTimeout(() => {
suppressClickRef.current = false; suppressClickRef.current = false;
}, 0); }, 0);
const width = scroller.clientWidth || 1; const firstChild = scroller.children[0] as HTMLElement | undefined;
const nearest = Math.round(scroller.scrollLeft / width); const slideWidth = firstChild?.offsetWidth || scroller.clientWidth || 1;
const nearest = Math.round(scroller.scrollLeft / slideWidth);
const clamped = Math.max(0, Math.min(slides.length - 1, nearest)); const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
scroller.style.scrollSnapType = ""; scroller.style.scrollSnapType = "";
goTo(clamped, "smooth"); goTo(clamped, "smooth");
@@ -315,6 +323,7 @@ export function FigmaBanner() {
aria-label="ARK Library banner" aria-label="ARK Library banner"
> >
{slides.map((slide, index) => { {slides.map((slide, index) => {
const isActive = index === activeIndex;
const image = ( const image = (
<picture className="block aspect-video w-full overflow-hidden bg-black md:rounded-xl"> <picture className="block aspect-video w-full overflow-hidden bg-black md:rounded-xl">
<source media="(max-width: 767px)" srcSet={slide.mobile} /> <source media="(max-width: 767px)" srcSet={slide.mobile} />
@@ -330,11 +339,27 @@ export function FigmaBanner() {
/> />
</picture> </picture>
); );
const animatedImage = (
<m.div
className="h-full w-full origin-center will-change-transform"
animate={{
filter: isActive ? "blur(0px)" : "blur(6px)",
opacity: isActive ? 1 : 0.55,
scale: isActive ? 1 : 0.93,
}}
transition={{
duration: 0.45,
ease: EASE_OUT as unknown as number[],
}}
>
{image}
</m.div>
);
return ( return (
<div <div
key={slide.id} key={slide.id}
className="relative w-full shrink-0 snap-start" className="relative w-full shrink-0 snap-center md:w-[78%] md:first:ml-[11%] md:last:mr-[11%] lg:w-[72%] lg:first:ml-[14%] lg:last:mr-[14%] xl:w-[60%] xl:first:ml-[20%] xl:last:mr-[20%]"
role="group" role="group"
aria-roledescription="slide" aria-roledescription="slide"
aria-label={`${index + 1} / ${slides.length}`} aria-label={`${index + 1} / ${slides.length}`}
@@ -346,10 +371,10 @@ export function FigmaBanner() {
rel="noreferrer" rel="noreferrer"
onClick={(event) => handleSlideClick(event, slide.linkUrl!)} onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
> >
{image} {animatedImage}
</a> </a>
) : ( ) : (
image animatedImage
)} )}
</div> </div>
); );

View File

@@ -23,7 +23,15 @@ export function pickBubble(post: Post): BubbleComponent {
return FileDocBubble; return FileDocBubble;
} }
export function MessageBubble({ post }: { post: Post }) { export function MessageBubble({
post,
fluid = false,
}: {
post: Post;
/** When true, fill the parent container instead of applying the standalone
* feed max-widths. Used by the desktop 3-column masonry on the home page. */
fluid?: boolean;
}) {
const Bubble = pickBubble(post); const Bubble = pickBubble(post);
const isVisual = const isVisual =
Bubble === AlbumBubble || Bubble === AlbumBubble ||
@@ -34,7 +42,11 @@ export function MessageBubble({ post }: { post: Post }) {
return ( return (
<div <div
id={`post-${post.id}`} id={`post-${post.id}`}
className="mx-auto w-full max-w-[358px] scroll-mt-[82px] md:max-w-[680px] md:scroll-mt-[98px] lg:max-w-[900px] xl:max-w-[1120px]" className={
fluid
? "w-full scroll-mt-[82px] md:scroll-mt-[98px]"
: "mx-auto w-full max-w-[358px] scroll-mt-[82px] md:max-w-[680px] md:scroll-mt-[98px] lg:max-w-[900px] xl:max-w-[1120px]"
}
> >
<article <article
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${ className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${

View File

@@ -535,7 +535,7 @@ export function PublicLayout() {
</div> </div>
</div> </div>
<div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-0"> <div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-6">
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */} {/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
<div className="flex h-10 items-center gap-2 min-[1000px]:gap-0 lg:gap-4"> <div className="flex h-10 items-center gap-2 min-[1000px]:gap-0 lg:gap-4">
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold"> <div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold">

View File

@@ -464,13 +464,25 @@ export function Home() {
viewAllLabel={t("viewAll")} viewAllLabel={t("viewAll")}
/> />
</div> </div>
<div className="mt-2.5 flex flex-col gap-3 md:mt-7"> <div className="mt-2.5 flex flex-col gap-3 md:hidden">
{latestPosts.slice(0, 5).map((post, index) => ( {latestPosts.slice(0, 5).map((post, index) => (
<Reveal key={post.id} delay={Math.min(index, 8) * 0.05}> <Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
<MessageBubble post={post} /> <MessageBubble post={post} />
</Reveal> </Reveal>
))} ))}
</div> </div>
{/* Desktop: 3-column masonry that matches the Figma layout. */}
<div className="mt-7 hidden gap-4 md:block md:columns-3">
{latestPosts.map((post, index) => (
<Reveal
key={post.id}
delay={Math.min(index, 8) * 0.05}
className="mb-4 break-inside-avoid"
>
<MessageBubble post={post} fluid />
</Reveal>
))}
</div>
</div> </div>
</section> </section>
</Reveal> </Reveal>