terry-wallet-login #15

Merged
terry merged 95 commits from terry-wallet-login into terry-staging 2026-06-05 16:32:43 +00:00
20 changed files with 410 additions and 19 deletions
Showing only changes of commit b4ef5ddb61 - Show all commits

View File

@@ -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 `<main>` 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 1624 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/<slug>`, 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 `<main>` 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.

View File

@@ -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
<AnimatePresence mode="wait" initial={false}>
<m.div key={`${pathname}${search}`} variants={pageTransition} >
{outlet}
</m.div>
</AnimatePresence>
```
(`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.

View File

@@ -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.

View File

@@ -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)`).

View File

@@ -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 (
<div
className={`pointer-events-none z-10 max-w-[calc(100%-1rem)] truncate rounded-full bg-black/80 px-2.5 py-1 text-[12px] font-medium text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md ${className}`}
title={display}
>
{display}
</div>
);
}

View File

@@ -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<HTMLDivElement>(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 (

View File

@@ -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<Post | null>(
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=<id>`) 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<Post>(
`/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=<id> (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 (
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
@@ -290,6 +381,24 @@ export function MessageStream({ scope }: MessageStreamProps) {
</div>
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{isSearchingDeepTarget ? (
<div
role="status"
aria-live="polite"
className="mx-auto flex w-fit max-w-full items-center gap-2 rounded-full border border-ark-gold/40 bg-ark-gold/10 px-4 py-2 text-sm text-ark-gold shadow-sm"
>
<LoaderCircle className="h-4 w-4 animate-spin" aria-hidden />
<span>{t("searchingForPost")}</span>
</div>
) : null}
{targetNotFoundInStream ? (
<div
role="status"
className="mx-auto w-fit max-w-full rounded-full border border-yellow-700/40 bg-yellow-950/30 px-4 py-2 text-center text-sm text-yellow-200"
>
{t("postNotFound")}
</div>
) : null}
{isInitialLoad ? (
<>
{Array.from({ length: 10 }).map((_, i) => (
@@ -317,7 +426,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
</div>
))}
{!isLoading && !error && items.length === 0 ? (
{!isLoading && !error && streamItems.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
@@ -331,7 +440,9 @@ export function MessageStream({ scope }: MessageStreamProps) {
<span className="break-words">{t("loadMoreFailed")}</span>
<button
type="button"
onClick={() => (items.length === 0 ? reset() : loadMore())}
onClick={() =>
streamItems.length === 0 ? reset() : loadMore()
}
className="shrink-0 self-start rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500 sm:self-auto"
>
{retryLabel}

View File

@@ -2,6 +2,7 @@ import { useI18n } from "../../../i18n";
import type { Post } from "../../../types/post";
import { ALBUM_GAP, ALBUM_MAX_HEIGHT } from "../../../constants/media";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
import { BubbleImage } from "../BubbleImage";
import { useImageRatios } from "../hooks/useImageRatios";
import { useLightbox } from "../overlays/ImageLightbox";
@@ -19,6 +20,7 @@ export function AlbumBubble({ post }: { post: Post }) {
const text = postDisplayText(post, lang);
const visible = images.slice(0, MAX_VISIBLE);
const extra = images.length - MAX_VISIBLE;
const showFilename = post.categorySlug === "official-assets";
const sources = visible.map(
(att) => att.thumbnailUrl ?? att.thumbUrl ?? att.url,
@@ -90,6 +92,9 @@ export function AlbumBubble({ post }: { post: Post }) {
adaptive
/>
) : null}
{!isLastSlot && showFilename ? (
<AttachmentFilenameLabel attachment={att} />
) : null}
</div>
);
})}

View File

@@ -4,5 +4,11 @@ import { SingleImageFrame } from "./SingleImageFrame";
export function ImageBubble({ post }: { post: Post }) {
const att = post.attachments[0];
if (!att) return null;
return <SingleImageFrame postId={post.id} attachment={att} />;
return (
<SingleImageFrame
postId={post.id}
attachment={att}
showFilename={post.categorySlug === "official-assets"}
/>
);
}

View File

@@ -12,7 +12,12 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
if (!att) return null;
return (
<div className="flex flex-col">
<SingleImageFrame postId={post.id} attachment={att} text={text} />
<SingleImageFrame
postId={post.id}
attachment={att}
text={text}
showFilename={post.categorySlug === "official-assets"}
/>
{text ? (
<CollapsibleText
wrapperClassName="px-4 pt-3"

View File

@@ -1,5 +1,6 @@
import type { Attachment } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
import { useLightbox } from "../overlays/ImageLightbox";
import { AdaptiveImageFrame } from "./AdaptiveImageFrame";
@@ -11,10 +12,13 @@ export function SingleImageFrame({
postId,
attachment,
text,
showFilename = false,
}: {
postId: string;
attachment: Attachment;
text?: string;
/** Show the source filename pinned bottom-left (used for 官方物料 cards). */
showFilename?: boolean;
}) {
const { openLightbox } = useLightbox();
return (
@@ -29,6 +33,9 @@ export function SingleImageFrame({
ariaLabel="View image"
>
<AttachmentDownloadPill postId={postId} attachment={attachment} />
{showFilename ? (
<AttachmentFilenameLabel attachment={attachment} />
) : null}
</AdaptiveImageFrame>
);
}

View File

@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
import { MessageInlineVideo } from "../MessageInlineVideo";
import {
useShouldUseMobilePreview,
@@ -57,12 +58,15 @@ function VideoAttachmentCard({
compact = false,
overlayCount,
onMoreClick,
showFilename = false,
}: {
postId: string;
attachment: Attachment;
compact?: boolean;
overlayCount?: number;
onMoreClick?: () => void;
/** Show the source filename pinned bottom-left (used for 官方物料 cards). */
showFilename?: boolean;
}) {
const { openVideo } = useVideoPlayer();
const [playing, setPlaying] = useState(false);
@@ -117,6 +121,9 @@ function VideoAttachmentCard({
leadingLabel={duration}
/>
) : null}
{!overlayCount && showFilename ? (
<AttachmentFilenameLabel attachment={attachment} />
) : null}
{overlayCount ? (
<button
type="button"
@@ -324,6 +331,7 @@ export function VideoBubble({ post }: { post: Post }) {
const [listOpen, setListOpen] = useState(false);
const videos = post.attachments.filter(isVideoAttachment);
const text = postDisplayText(post, lang);
const showFilename = post.categorySlug === "official-assets";
if (!videos.length) return null;
if (videos.length >= 2) {
@@ -349,6 +357,7 @@ export function VideoBubble({ post }: { post: Post }) {
compact
overlayCount={isLastSlot ? extra : undefined}
onMoreClick={() => setListOpen(true)}
showFilename={showFilename}
/>
</div>
);
@@ -379,7 +388,11 @@ export function VideoBubble({ post }: { post: Post }) {
return (
<div className="flex flex-col">
<VideoAttachmentCard postId={post.id} attachment={videos[0]} />
<VideoAttachmentCard
postId={post.id}
attachment={videos[0]}
showFilename={showFilename}
/>
{text ? (
<CollapsibleText
wrapperClassName="px-4 pt-3"

View File

@@ -336,7 +336,14 @@ export function PublicLayout() {
});
}
};
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
// Routes that render a full-bleed asset stream and manage their own inner
// width / padding via `MessageStream`. Both 全部资料 (/browse) and the
// per-category view (/category/<slug>) reuse the same component, so they
// need the same zero outer padding here — otherwise the category page's
// bubbles render narrower than the all-resources page.
const strippedPath = stripLangPrefix(pathname);
const footerInContentFlow =
strippedPath === "/browse" || strippedPath.startsWith("/category/");
// Current page name shown in the header brand slot (falls back to the brand).
const pageTitle = usePageTitle();

View File

@@ -129,6 +129,9 @@ export const enDict: Dict = {
loadMoreFailed:
"Couldn't load more posts. Check your connection and try again.",
retry: "Retry",
searchingForPost: "Finding your post… loading older entries, please wait.",
postNotFound:
"Couldnt find this post in the current view. It may have been removed.",
paginationPrev: "Previous",
paginationNext: "Next",
listRange: "Showing {{from}}{{to}} of {{total}}",

View File

@@ -129,6 +129,10 @@ export const idDict: Dict = {
loadMoreFailed:
"Gagal memuat lebih banyak. Periksa koneksi Anda dan coba lagi.",
retry: "Coba lagi",
searchingForPost:
"Mencari postingan Anda… memuat postingan lama, mohon tunggu.",
postNotFound:
"Postingan ini tidak ditemukan di daftar saat ini. Mungkin sudah dihapus.",
paginationPrev: "Sebelumnya",
paginationNext: "Berikutnya",
listRange: "Menampilkan {{from}}{{to}} dari {{total}}",

View File

@@ -130,6 +130,10 @@ export const jaDict: Dict = {
loadMoreFailed:
"追加の読み込みに失敗しました。接続を確認してやり直してください。",
retry: "再試行",
searchingForPost:
"投稿を検索中…古い投稿を読み込んでいます。しばらくお待ちください。",
postNotFound:
"現在のリストでこの投稿が見つかりません。削除された可能性があります。",
paginationPrev: "前へ",
paginationNext: "次へ",
listRange: "{{from}}{{to}} / 全 {{total}} 件",

View File

@@ -128,6 +128,10 @@ export const koDict: Dict = {
loading: "로딩 중…",
loadMoreFailed: "더 불러오지 못했습니다. 연결을 확인하고 다시 시도하세요.",
retry: "다시 시도",
searchingForPost:
"게시물을 찾는 중… 이전 게시물을 불러오고 있습니다. 잠시만 기다려주세요.",
postNotFound:
"현재 목록에서 이 게시물을 찾을 수 없습니다. 삭제되었을 수 있습니다.",
paginationPrev: "이전",
paginationNext: "다음",
listRange: "{{from}}{{to}} / 총 {{total}}건",

View File

@@ -128,6 +128,9 @@ export const msDict: Dict = {
loading: "Memuatkan…",
loadMoreFailed: "Gagal memuatkan lagi. Sila semak sambungan dan cuba lagi.",
retry: "Cuba lagi",
searchingForPost: "Mencari pos anda… memuat pos lama, sila tunggu.",
postNotFound:
"Pos ini tidak ditemui dalam senarai semasa. Mungkin telah dipadam.",
paginationPrev: "Sebelum",
paginationNext: "Seterusnya",
listRange: "Menunjukkan {{from}}{{to}} daripada {{total}}",

View File

@@ -128,6 +128,9 @@ export const viDict: Dict = {
loading: "Đang tải…",
loadMoreFailed: "Không thể tải thêm bài. Hãy kiểm tra kết nối và thử lại.",
retry: "Thử lại",
searchingForPost: "Đang tìm bài viết… tải thêm bài cũ, vui lòng đợi.",
postNotFound:
"Không tìm thấy bài này trong danh sách hiện tại. Bài có thể đã bị xóa.",
paginationPrev: "Trước",
paginationNext: "Sau",
listRange: "Hiển thị {{from}}{{to}} trên {{total}}",

View File

@@ -126,6 +126,8 @@ export const zhDict: Dict = {
loading: "加载中…",
loadMoreFailed: "加载更多资料失败,请检查网络后重试。",
retry: "重试",
searchingForPost: "正在查找您的帖子,请稍等…",
postNotFound: "在当前列表中找不到这个帖子,可能已被移除。",
paginationPrev: "上一页",
paginationNext: "下一页",
listRange: "显示 {{from}}{{to}},共 {{total}} 条",