diff --git a/.unipi/docs/fix/2026-06-03-category-page-layout-fix.md b/.unipi/docs/fix/2026-06-03-category-page-layout-fix.md new file mode 100644 index 0000000..d2a912e --- /dev/null +++ b/.unipi/docs/fix/2026-06-03-category-page-layout-fix.md @@ -0,0 +1,35 @@ +--- +title: "Category page stream layout mismatch — Quick Fix" +type: quick-fix +date: 2026-06-03 +--- + +# Category page stream layout mismatch — Quick Fix + +## Bug +After clicking a card on 资料分类 (`/categories`) and landing on `/category/:slug`, the resource bubbles render with narrower bubbles / different waterfall spacing than the 全部资料 page (`/browse`). Both pages use the same `MessageStream` component, but the page-level wrapper applies extra horizontal padding only to the category route. + +## Root Cause +`src/layouts/PublicLayout.tsx` chooses the `
` padding using a flag named `footerInContentFlow`, defined as: + +```ts +const footerInContentFlow = stripLangPrefix(pathname) === "/browse"; +``` + +That flag selects the `px-0 ... md:px-9 xl:px-0` zero-mobile-padding branch — which is the layout `MessageStream` is designed for (it manages its own inner `max-w` and centers bubbles). All other routes fall through to the default `px-4 min-[440px]:px-5 sm:px-6 md:px-9 ...`, which on mobile inset the stream by 16–24 px and shrunk each bubble's `max-w` proportionally. Because `/category/:slug` rendered the same `MessageStream`, that extra inset is exactly what made the category waterfall look "off" vs `/browse`. + +## Fix +Extend the same zero-padding branch to also match `/category/`, so both routes share the wrapper that `MessageStream` was designed to live in. + +### Files Modified +- `src/layouts/PublicLayout.tsx` — renamed the flag's derivation to also include `/category/*`. Kept the existing `BackToTop` and footer-in-content checks (`stripLangPrefix(pathname) === "/browse"`) untouched, since those are separate features the user did not ask to share with category pages. + +## Verification +- `npx tsc --noEmit` — clean. +- `npm run format:check` — clean. +- `npm test` — 49/49 passing. +- Visual: opening `/categories` → tapping a category card now lands on a `/category/:slug` view whose `
` matches `/browse` (no extra mobile horizontal inset), so bubbles render with the same `max-w-[358px]` width. + +## Notes +- The flag is still called `footerInContentFlow` for now even though it only controls padding, matching prior code; renaming would expand the change footprint beyond this fix. +- BackToTop and the `footerInContentFlow` footer slot remain `/browse`-only — those are independent of layout width and the user didn't ask to enable them on category pages. diff --git a/.unipi/docs/fix/2026-06-03-filter-chips-scroll-survive-remount-fix.md b/.unipi/docs/fix/2026-06-03-filter-chips-scroll-survive-remount-fix.md new file mode 100644 index 0000000..93bff60 --- /dev/null +++ b/.unipi/docs/fix/2026-06-03-filter-chips-scroll-survive-remount-fix.md @@ -0,0 +1,55 @@ +--- +title: "FilterChips scroll position lost on filter click (remount) — Quick Fix" +type: quick-fix +date: 2026-06-03 +--- + +# FilterChips scroll position lost on filter click (remount) — Quick Fix + +## Bug +On mobile (and in general), scrolling the `FilterChips` horizontal bar to the right and then clicking a chip caused the bar to snap all the way back to the leftmost position. Earlier attempts to fix this with `useRef` + `useLayoutEffect` save/restore inside `FilterChips` did not work — the bar kept resetting. + +## Root Cause +`PublicLayout` wraps the routed page in: + +```tsx + + + {outlet} + + +``` + +(`src/layouts/PublicLayout.tsx:761`) + +When the user clicks a filter chip, `MessageStream`'s `updateParam("type", v)` mutates the `search` query (e.g. `?type=archive`). That changes the `m.div`'s `key`, so `AnimatePresence` **fully unmounts** the current page and **mounts a fresh one**. `FilterChips` is part of that page tree, so it is destroyed and re-created — every internal `useRef` resets to its initial value, which is why the previous in-component save/restore approach silently failed. + +A secondary symptom (already present before this fix) was an iOS Safari quirk where the sibling `ScrollToTop`'s `window.scrollTo({top:0, left:0})` triggers a relayout of the sticky bar that asynchronously sets the inner `overflow-x` `scrollLeft` back to 0. + +## Fix +Move the saved `scrollLeft` to a **module-level** variable so it survives the unmount/remount cycle, and restore it on first paint after every fresh mount. Combine with the existing user-input-only save logic and an iOS post-mount watch window to defeat the asynchronous reset. + +Key points in the new `FilterChips`: + +1. `let lastScrollLeft = 0;` declared at module scope. +2. `useLayoutEffect(() => { … }, [])` on mount: if `lastScrollLeft > 0`, restore `scrollLeft` synchronously before paint. +3. `useEffect(() => { … }, [])` saves `lastScrollLeft` only on `touchend` / `pointerup` / `wheel` (deferred by one rAF so iOS momentum has settled). We deliberately do **not** save on raw `scroll` events because iOS Safari's quirky 0-reset fires one of those too. +4. A second `useEffect(() => { … }, [])` runs a ~1.5 s post-mount watch: rAF tick plus a `scroll` listener that re-applies the saved value **only when `scrollLeft` snaps to 0**, and only when the user is not actively touching the bar (so a fresh scroll gesture in that window isn't yanked back). + +### Files Modified +- `src/components/messageStream/FilterChips.tsx` + - Added module-level `lastScrollLeft`. + - Added `useLayoutEffect` to restore on mount. + - Kept the user-input-only save effect. + - Kept the ~1.5 s iOS-quirk watch (now keyed to mount instead of `[type]` since the component remounts anyway). + +## Verification +- `npx tsc --noEmit` — clean. +- `npm run format:check` — clean. +- `npm test` — 49/49 passing. +- Expected behavior on device: scroll the filter bar to the right, tap any chip (e.g. 压缩包) — bar should stay exactly where the user left it, with the new active chip already highlighted in gold. + +## Notes +- Module-level state is intentional and acceptable here: there is only ever one `FilterChips` mounted at a time, and the value semantically belongs to the user's session, not the component instance. +- If this `AnimatePresence` `key` strategy changes (or `FilterChips` is reused outside `MessageStream` in a context with multiple instances), revisit this — the module value would then need to be scoped per surface (e.g. via a Map keyed by surface id). +- Previous attempts using `useRef` inside `FilterChips` failed because they ignored the remount caused by `AnimatePresence`. That is now documented inline in the component. diff --git a/.unipi/docs/fix/2026-06-03-filter-chips-simplify-fix.md b/.unipi/docs/fix/2026-06-03-filter-chips-simplify-fix.md new file mode 100644 index 0000000..3cccc3d --- /dev/null +++ b/.unipi/docs/fix/2026-06-03-filter-chips-simplify-fix.md @@ -0,0 +1,35 @@ +--- +title: "FilterChips simplify — strip comments and remove 1.5s watch window" +type: quick-fix +date: 2026-06-03 +--- + +# FilterChips simplify — strip comments and remove 1.5s watch window + +## Bug +The previous `FilterChips` carried too much defensive machinery (long inline comments, a 1.5s post-mount `requestAnimationFrame` + `scroll`-event watch window that re-applied `lastScrollLeft` whenever the bar snapped to 0). The watch window could interfere with a fresh slide gesture right after a filter tap, and the surrounding prose made the component hard to read. + +## Root Cause +Over-engineering: the iOS-quirk watch loop and its `touching` guard were added defensively but were not strictly needed once `lastScrollLeft` was lifted out to a module-level slot and restored synchronously in `useLayoutEffect`. The extra event listeners were also a source of friction when the user immediately slid the bar after tapping a chip. + +## Fix +Reduce `FilterChips` to just the essentials: + +- Module-level `lastScrollLeft` (kept) — survives the `AnimatePresence` remount that happens when `?type=…` changes. +- `useLayoutEffect` on mount (kept) — restores `scrollLeft = lastScrollLeft` before paint so the new instance starts where the user left off. +- Desktop wheel-to-horizontal handler (kept) — necessary for mice without horizontal wheels. +- Save effect on `touchend` / `pointerup` / `wheel` (kept) — captures the user's final scroll position, deferred by one rAF so iOS momentum settles before recording. +- Removed: the ~1.5s rAF + `scroll` watch loop and its `touching` flag. +- Removed: all explanatory inline comments — the code is short enough to be self-evident now. + +### Files Modified +- `src/components/messageStream/FilterChips.tsx` — stripped the 1.5s watch effect and every prose comment; kept the mount-restore and user-input save flow. + +## Verification +- `npx tsc --noEmit` — clean. +- `npm run format:check` — clean. +- `npm test` — 49/49 passing. +- Behavior expected on device: tapping a filter highlights it in gold; sliding the bar after the tap is no longer pulled back to the previous position. + +## Notes +- If the iOS sticky/scroll-to-top quirk actually re-surfaces in production, the fallback would be to move the bar out of the `AnimatePresence`-keyed subtree (so it never unmounts), rather than re-introducing the watch loop. diff --git a/.unipi/docs/quick-work/2026-06-03-official-assets-filename-overlay.md b/.unipi/docs/quick-work/2026-06-03-official-assets-filename-overlay.md new file mode 100644 index 0000000..21724c3 --- /dev/null +++ b/.unipi/docs/quick-work/2026-06-03-official-assets-filename-overlay.md @@ -0,0 +1,36 @@ +--- +title: "官方物料 cards — bottom-left filename overlay" +type: quick-work +date: 2026-06-03 +--- + +# 官方物料 cards — bottom-left filename overlay + +## Task +In every content card classified as 官方物料 (`categorySlug === "official-assets"`), display the source filename at the bottom-left of the card — image filename for image bubbles, video filename for video bubbles. + +## Data Source — Confirmed +- Endpoint: `/api/posts` (list, called by `src/components/messageStream/hooks/usePostStream.ts:89`) and `/api/posts/:id` (single, used by `MessageStream` deep-links). +- Field: `Post.attachments[*].filename` (`src/types/post.ts`). +- Category gate: `Post.categorySlug === "official-assets"` (also referenced in `src/lib/categorySvgSlug.ts:13`, `src/pages/Categories/index.tsx:23`, `src/pages/Home/index.tsx:33`). +- No additional API call is needed — `filename` already ships with the post payload. + +## Changes +- `src/components/messageStream/AttachmentFilenameLabel.tsx` (new) — small dark pill overlay using `filenameWithExtension(att.filename, att.mime)`, positioned `absolute bottom-2 left-2`, with `pointer-events-none`, `max-w-[calc(100%-1rem)]`, `truncate`, and a `title` attribute so the full filename is available on hover. Style mirrors the `AttachmentDownloadPill` (`bg-black/80` + `ring-white/20` + `backdrop-blur-md`) for visual consistency. +- `src/components/messageStream/bubbles/SingleImageFrame.tsx` — accepts new `showFilename?: boolean` prop; when true, renders `AttachmentFilenameLabel` alongside the existing top-left download pill. +- `src/components/messageStream/bubbles/ImageBubble.tsx` — passes `showFilename={post.categorySlug === "official-assets"}`. +- `src/components/messageStream/bubbles/ImageWithTextBubble.tsx` — same gate, threaded through `SingleImageFrame`. +- `src/components/messageStream/bubbles/AlbumBubble.tsx` — when category matches, renders the filename label inside each visible tile (skipping the `+N` overflow tile so its overlay stays clean). +- `src/components/messageStream/bubbles/VideoBubble.tsx` — `VideoAttachmentCard` accepts `showFilename`, threaded into both the multi-video grid layout and the single-video layout. Skipped on the `+N` overflow tile. + +## Verification +- `npx tsc --noEmit` — clean. +- `npm run format` then `npm run format:check` — clean. +- `npm test` — 49/49 passing. +- Visual confirmation pending on a posts feed that contains `official-assets` content. + +## Notes +- Reuses existing `filenameWithExtension` util so attachments without an extension still get a sensible label from their MIME type. +- The label is `pointer-events-none` so it never blocks the bubble's own tap target (open lightbox / play video). +- Only single/album/video bubbles surface this. `FileDocBubble` already renders the filename inline, so no overlay is needed there. +- If a future requirement adds more "show filename" categories, switch `showFilename` from a boolean gate to a derived util (e.g. `shouldShowAttachmentFilename(post)`). diff --git a/src/components/messageStream/AttachmentFilenameLabel.tsx b/src/components/messageStream/AttachmentFilenameLabel.tsx new file mode 100644 index 0000000..9010f85 --- /dev/null +++ b/src/components/messageStream/AttachmentFilenameLabel.tsx @@ -0,0 +1,27 @@ +import type { Attachment } from "../../types/post"; +import { filenameWithExtension } from "./utils/filenameDisplay"; + +/** + * Bottom-left overlay that surfaces an attachment's filename on top of an + * image/video card. Used for posts in the 官方物料 (`official-assets`) + * category, where editors rely on the filename to identify the original asset + * at a glance. + */ +export function AttachmentFilenameLabel({ + attachment, + className = "absolute bottom-2 left-2", +}: { + attachment: Attachment; + className?: string; +}) { + const display = filenameWithExtension(attachment.filename, attachment.mime); + if (!display) return null; + return ( +
+ {display} +
+ ); +} diff --git a/src/components/messageStream/FilterChips.tsx b/src/components/messageStream/FilterChips.tsx index a82519a..59cc48c 100644 --- a/src/components/messageStream/FilterChips.tsx +++ b/src/components/messageStream/FilterChips.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import { useI18n } from "../../i18n"; import { typeFilterLabel } from "../../resourceTypeLabels"; @@ -19,13 +19,20 @@ export type FilterChipsProps = { onTypeChange: (next: string) => void; }; +let lastScrollLeft = 0; + export function FilterChips({ type, onTypeChange }: FilterChipsProps) { const { t } = useI18n(); const scrollRef = useRef(null); - // Let a mouse wheel scroll the row horizontally when it overflows — desktop - // mice have no horizontal wheel and the scrollbar is hidden, so otherwise the - // last filters are unreachable. Touch/trackpad scroll natively. + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + if (lastScrollLeft > 0 && el.scrollLeft !== lastScrollLeft) { + el.scrollLeft = lastScrollLeft; + } + }, []); + useEffect(() => { const el = scrollRef.current; if (!el) return; @@ -38,13 +45,32 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) { return () => el.removeEventListener("wheel", onWheel); }, []); + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const save = () => { + lastScrollLeft = el.scrollLeft; + }; + const saveDeferred = () => { + window.requestAnimationFrame(save); + }; + el.addEventListener("touchend", saveDeferred, { passive: true }); + el.addEventListener("pointerup", saveDeferred, { passive: true }); + el.addEventListener("wheel", saveDeferred, { passive: true }); + return () => { + el.removeEventListener("touchend", saveDeferred); + el.removeEventListener("pointerup", saveDeferred); + el.removeEventListener("wheel", saveDeferred); + }; + }, []); + const tabClass = (active: boolean) => [ "relative flex h-[52px] shrink-0 items-center whitespace-nowrap px-3 pb-4 pt-3 text-[15px] leading-6 outline-none transition-colors md:h-auto md:px-1 md:py-3 md:leading-none", "border-b-0 md:border-b-2", active ? "border-ark-gold font-medium text-ark-gold" - : "border-transparent text-[#97989A] hover:text-ark-gold/80 md:text-neutral-400", + : "border-transparent text-[#97989A] [@media(hover:hover)]:hover:text-ark-gold/80 md:text-neutral-400", ].join(" "); return ( diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 192ef5f..dac45ae 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -1,8 +1,9 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; -import { postJSON } from "../../api"; -import { useI18n } from "../../i18n"; -import type { PostScope } from "../../types/post"; +import { LoaderCircle } from "lucide-react"; +import { getJSON, postJSON } from "../../api"; +import { langQuery, useI18n } from "../../i18n"; +import type { Post, PostScope } from "../../types/post"; import { Reveal } from "../../motion"; import { Skeleton } from "../Skeleton"; import { FilterChips } from "./FilterChips"; @@ -32,7 +33,6 @@ export function MessageStream({ scope }: MessageStreamProps) { const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); const { ensureFavoriteIds } = useFavorites(); - const groups = useGroupedByDay(items, lang); const retryLabel = t("retry"); useEffect(() => { @@ -85,6 +85,27 @@ export function MessageStream({ scope }: MessageStreamProps) { ? hash.slice("#post-".length) : ""; const targetPostId = queryTargetPostId || hashTargetPostId; + const [resolvedTargetPost, setResolvedTargetPost] = useState( + null, + ); + const [isFetchingTargetPost, setIsFetchingTargetPost] = useState(false); + const [targetPostFetchFailed, setTargetPostFetchFailed] = useState(false); + const targetAlreadyInBaseItems = useMemo( + () => + !!queryTargetPostId && + items.some((post) => post.id === queryTargetPostId), + [items, queryTargetPostId], + ); + const streamItems = useMemo(() => { + if ( + resolvedTargetPost && + !items.some((post) => post.id === resolvedTargetPost.id) + ) { + return [resolvedTargetPost, ...items]; + } + return items; + }, [items, resolvedTargetPost]); + const groups = useGroupedByDay(streamItems, lang); // Lock only engages while we are actively running the smooth-scroll animation // — not during the wait/pagination phase — so the page never feels frozen // before the bubble exists. @@ -119,10 +140,14 @@ export function MessageStream({ scope }: MessageStreamProps) { // Mark when first real content becomes visible (skeletons gone, items in). // Captured per-target via the reset above so a later navigation re-measures. useEffect(() => { - if (items.length > 0 && !isLoading && firstContentAtRef.current === null) { + if ( + streamItems.length > 0 && + !isLoading && + firstContentAtRef.current === null + ) { firstContentAtRef.current = performance.now(); } - }, [items.length, isLoading]); + }, [streamItems.length, isLoading]); // Banner / deep-link arrivals (`?post=`) should always begin the // smooth-scroll positioning from the top of the stream, so the user sees a @@ -134,6 +159,51 @@ export function MessageStream({ scope }: MessageStreamProps) { window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, [queryTargetPostId]); + // Search result clicks can target very old posts that are nowhere near the + // first paginated /browse page. Do not make the user wait while the stream + // loads page after page; fetch the target post directly and inject it at the + // top so it can render and be highlighted immediately. The normal stream + // still loads underneath for context / scrolling. + useEffect(() => { + if (!queryTargetPostId) { + setResolvedTargetPost(null); + setIsFetchingTargetPost(false); + setTargetPostFetchFailed(false); + return; + } + if (targetAlreadyInBaseItems) { + setResolvedTargetPost(null); + setIsFetchingTargetPost(false); + setTargetPostFetchFailed(false); + return; + } + + let cancelled = false; + setIsFetchingTargetPost(true); + setTargetPostFetchFailed(false); + getJSON( + `/api/posts/${encodeURIComponent(queryTargetPostId)}?lang=${encodeURIComponent( + langQuery(lang), + )}`, + ) + .then((post) => { + if (cancelled) return; + setResolvedTargetPost(post); + }) + .catch(() => { + if (cancelled) return; + setResolvedTargetPost(null); + setTargetPostFetchFailed(true); + }) + .finally(() => { + if (!cancelled) setIsFetchingTargetPost(false); + }); + + return () => { + cancelled = true; + }; + }, [lang, queryTargetPostId, targetAlreadyInBaseItems]); + useEffect(() => clearTargetScrollTimers, []); useEffect(() => { @@ -267,7 +337,7 @@ export function MessageStream({ scope }: MessageStreamProps) { } if (hasMore && !isLoading) loadMore(); else if (!hasMore && !isLoading) setIsAligningQueryTarget(false); - }, [targetPostId, items, hasMore, isLoading, error, loadMore]); + }, [targetPostId, streamItems, hasMore, isLoading, error, loadMore]); const updateParam = (key: string, value: string) => { const n = new URLSearchParams(sp); @@ -276,7 +346,28 @@ export function MessageStream({ scope }: MessageStreamProps) { setSp(n, { replace: true }); }; - const isInitialLoad = isLoading && items.length === 0; + const isInitialLoad = isLoading && streamItems.length === 0; + + // When the user arrives via /browse?post= (typically from search or a + // banner) and the target post lives deep in the stream, pagination has to + // keep loading older pages until it surfaces. Show an explicit "finding + // your post" indicator so the user knows we're actively searching for + // their specific post, not just lazily loading the feed. + const targetInLoadedItems = + !!queryTargetPostId && streamItems.some((p) => p.id === queryTargetPostId); + const isSearchingDeepTarget = + !!queryTargetPostId && + !targetInLoadedItems && + !error && + (isFetchingTargetPost || hasMore || isLoading); + const targetNotFoundInStream = + !!queryTargetPostId && + !targetInLoadedItems && + !error && + targetPostFetchFailed && + !hasMore && + !isLoading && + streamItems.length > 0; return (
@@ -290,6 +381,24 @@ export function MessageStream({ scope }: MessageStreamProps) {
+ {isSearchingDeepTarget ? ( +
+ + {t("searchingForPost")} +
+ ) : null} + {targetNotFoundInStream ? ( +
+ {t("postNotFound")} +
+ ) : null} {isInitialLoad ? ( <> {Array.from({ length: 10 }).map((_, i) => ( @@ -317,7 +426,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
))} - {!isLoading && !error && items.length === 0 ? ( + {!isLoading && !error && streamItems.length === 0 ? (

{t("noResults")}

@@ -331,7 +440,9 @@ export function MessageStream({ scope }: MessageStreamProps) { {t("loadMoreFailed")}