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/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index d50074e..bc5979a 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -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 ? ( + + ) : null} ); })} diff --git a/src/components/messageStream/bubbles/ImageBubble.tsx b/src/components/messageStream/bubbles/ImageBubble.tsx index 0ef21b8..22c3be0 100644 --- a/src/components/messageStream/bubbles/ImageBubble.tsx +++ b/src/components/messageStream/bubbles/ImageBubble.tsx @@ -4,5 +4,11 @@ import { SingleImageFrame } from "./SingleImageFrame"; export function ImageBubble({ post }: { post: Post }) { const att = post.attachments[0]; if (!att) return null; - return ; + return ( + + ); } diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index 809362c..342799d 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -12,7 +12,12 @@ export function ImageWithTextBubble({ post }: { post: Post }) { if (!att) return null; return (
- + {text ? ( + {showFilename ? ( + + ) : null} ); } diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 34144b0..6b096be 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -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 ? ( + + ) : null} {overlayCount ? (
); @@ -379,7 +388,11 @@ export function VideoBubble({ post }: { post: Post }) { return (
- + {text ? (