feat(stream): surface source filename on official-assets cards
Image, album, and video bubbles in the official-assets category now render the attachment filename as a bottom-left overlay so editors can identify the source asset at a glance. Shared AttachmentFilenameLabel component mirrors the AttachmentDownloadPill style, uses filenameWithExtension so MIME-only attachments still get a sensible label, and is pointer-events-none so it never blocks the bubble's tap target.
This commit is contained in:
@@ -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)`).
|
||||||
27
src/components/messageStream/AttachmentFilenameLabel.tsx
Normal file
27
src/components/messageStream/AttachmentFilenameLabel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useI18n } from "../../../i18n";
|
|||||||
import type { Post } from "../../../types/post";
|
import type { Post } from "../../../types/post";
|
||||||
import { ALBUM_GAP, ALBUM_MAX_HEIGHT } from "../../../constants/media";
|
import { ALBUM_GAP, ALBUM_MAX_HEIGHT } from "../../../constants/media";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
|
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
|
||||||
import { BubbleImage } from "../BubbleImage";
|
import { BubbleImage } from "../BubbleImage";
|
||||||
import { useImageRatios } from "../hooks/useImageRatios";
|
import { useImageRatios } from "../hooks/useImageRatios";
|
||||||
import { useLightbox } from "../overlays/ImageLightbox";
|
import { useLightbox } from "../overlays/ImageLightbox";
|
||||||
@@ -19,6 +20,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
const text = postDisplayText(post, lang);
|
const text = postDisplayText(post, lang);
|
||||||
const visible = images.slice(0, MAX_VISIBLE);
|
const visible = images.slice(0, MAX_VISIBLE);
|
||||||
const extra = images.length - MAX_VISIBLE;
|
const extra = images.length - MAX_VISIBLE;
|
||||||
|
const showFilename = post.categorySlug === "official-assets";
|
||||||
|
|
||||||
const sources = visible.map(
|
const sources = visible.map(
|
||||||
(att) => att.thumbnailUrl ?? att.thumbUrl ?? att.url,
|
(att) => att.thumbnailUrl ?? att.thumbUrl ?? att.url,
|
||||||
@@ -90,6 +92,9 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
adaptive
|
adaptive
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!isLastSlot && showFilename ? (
|
||||||
|
<AttachmentFilenameLabel attachment={att} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,5 +4,11 @@ import { SingleImageFrame } from "./SingleImageFrame";
|
|||||||
export function ImageBubble({ post }: { post: Post }) {
|
export function ImageBubble({ post }: { post: Post }) {
|
||||||
const att = post.attachments[0];
|
const att = post.attachments[0];
|
||||||
if (!att) return null;
|
if (!att) return null;
|
||||||
return <SingleImageFrame postId={post.id} attachment={att} />;
|
return (
|
||||||
|
<SingleImageFrame
|
||||||
|
postId={post.id}
|
||||||
|
attachment={att}
|
||||||
|
showFilename={post.categorySlug === "official-assets"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
|
|||||||
if (!att) return null;
|
if (!att) return null;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<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 ? (
|
{text ? (
|
||||||
<CollapsibleText
|
<CollapsibleText
|
||||||
wrapperClassName="px-4 pt-3"
|
wrapperClassName="px-4 pt-3"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Attachment } from "../../../types/post";
|
import type { Attachment } from "../../../types/post";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
|
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
|
||||||
import { useLightbox } from "../overlays/ImageLightbox";
|
import { useLightbox } from "../overlays/ImageLightbox";
|
||||||
import { AdaptiveImageFrame } from "./AdaptiveImageFrame";
|
import { AdaptiveImageFrame } from "./AdaptiveImageFrame";
|
||||||
|
|
||||||
@@ -11,10 +12,13 @@ export function SingleImageFrame({
|
|||||||
postId,
|
postId,
|
||||||
attachment,
|
attachment,
|
||||||
text,
|
text,
|
||||||
|
showFilename = false,
|
||||||
}: {
|
}: {
|
||||||
postId: string;
|
postId: string;
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/** Show the source filename pinned bottom-left (used for 官方物料 cards). */
|
||||||
|
showFilename?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { openLightbox } = useLightbox();
|
const { openLightbox } = useLightbox();
|
||||||
return (
|
return (
|
||||||
@@ -29,6 +33,9 @@ export function SingleImageFrame({
|
|||||||
ariaLabel="View image"
|
ariaLabel="View image"
|
||||||
>
|
>
|
||||||
<AttachmentDownloadPill postId={postId} attachment={attachment} />
|
<AttachmentDownloadPill postId={postId} attachment={attachment} />
|
||||||
|
{showFilename ? (
|
||||||
|
<AttachmentFilenameLabel attachment={attachment} />
|
||||||
|
) : null}
|
||||||
</AdaptiveImageFrame>
|
</AdaptiveImageFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
|
import { AttachmentFilenameLabel } from "../AttachmentFilenameLabel";
|
||||||
import { MessageInlineVideo } from "../MessageInlineVideo";
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||||
import {
|
import {
|
||||||
useShouldUseMobilePreview,
|
useShouldUseMobilePreview,
|
||||||
@@ -57,12 +58,15 @@ function VideoAttachmentCard({
|
|||||||
compact = false,
|
compact = false,
|
||||||
overlayCount,
|
overlayCount,
|
||||||
onMoreClick,
|
onMoreClick,
|
||||||
|
showFilename = false,
|
||||||
}: {
|
}: {
|
||||||
postId: string;
|
postId: string;
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
overlayCount?: number;
|
overlayCount?: number;
|
||||||
onMoreClick?: () => void;
|
onMoreClick?: () => void;
|
||||||
|
/** Show the source filename pinned bottom-left (used for 官方物料 cards). */
|
||||||
|
showFilename?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { openVideo } = useVideoPlayer();
|
const { openVideo } = useVideoPlayer();
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
@@ -117,6 +121,9 @@ function VideoAttachmentCard({
|
|||||||
leadingLabel={duration}
|
leadingLabel={duration}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!overlayCount && showFilename ? (
|
||||||
|
<AttachmentFilenameLabel attachment={attachment} />
|
||||||
|
) : null}
|
||||||
{overlayCount ? (
|
{overlayCount ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -324,6 +331,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
const [listOpen, setListOpen] = useState(false);
|
const [listOpen, setListOpen] = useState(false);
|
||||||
const videos = post.attachments.filter(isVideoAttachment);
|
const videos = post.attachments.filter(isVideoAttachment);
|
||||||
const text = postDisplayText(post, lang);
|
const text = postDisplayText(post, lang);
|
||||||
|
const showFilename = post.categorySlug === "official-assets";
|
||||||
if (!videos.length) return null;
|
if (!videos.length) return null;
|
||||||
|
|
||||||
if (videos.length >= 2) {
|
if (videos.length >= 2) {
|
||||||
@@ -349,6 +357,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
compact
|
compact
|
||||||
overlayCount={isLastSlot ? extra : undefined}
|
overlayCount={isLastSlot ? extra : undefined}
|
||||||
onMoreClick={() => setListOpen(true)}
|
onMoreClick={() => setListOpen(true)}
|
||||||
|
showFilename={showFilename}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -379,7 +388,11 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<VideoAttachmentCard postId={post.id} attachment={videos[0]} />
|
<VideoAttachmentCard
|
||||||
|
postId={post.id}
|
||||||
|
attachment={videos[0]}
|
||||||
|
showFilename={showFilename}
|
||||||
|
/>
|
||||||
{text ? (
|
{text ? (
|
||||||
<CollapsibleText
|
<CollapsibleText
|
||||||
wrapperClassName="px-4 pt-3"
|
wrapperClassName="px-4 pt-3"
|
||||||
|
|||||||
Reference in New Issue
Block a user