diff --git a/.unipi/docs/specs/2026-05-28-browse-figma-redesign-design.md b/.unipi/docs/specs/2026-05-28-browse-figma-redesign-design.md new file mode 100644 index 0000000..9ccebc8 --- /dev/null +++ b/.unipi/docs/specs/2026-05-28-browse-figma-redesign-design.md @@ -0,0 +1,157 @@ +--- +title: "Browse 页(全部资料)Figma 1:1 视觉对齐" +type: brainstorm +date: 2026-05-28 +--- + +# Browse 页(全部资料)Figma 1:1 视觉对齐 + +## Problem Statement + +`/browse` 路由(「全部资料」页)的视觉与 Figma 设计稿(node `4206-6051`)有两处显著落差,需要对齐: + +1. **筛选条 (FilterChips)** 当前是椭圆 pill chip + 边框 + 溢出折叠按钮,Figma 是下划线 tab 风格(文字 + 橘色底线,无边框、无背景)。 +2. **mobile 底部导航第三格**目前是「官方推荐」(连到 `/browse?sort=recommended`),Figma 是「我的收藏」。 + +页面标题、Header、讯息流 bubble 已对齐,不需要再动。 + +## Context + +- 当前 FilterChips 实现:`src/components/messageStream/FilterChips.tsx`,含 9 种 type(all/image/video/music/ppt/pdf/text/link/archive),有 `SlidersHorizontal` overflow expand 按钮。 +- Figma 只展示 mobile viewport,且只露 6 种 type(全部/图片/视频/音乐/PPT/PDF)。 +- 现有 mobile 底部 nav:`src/layouts/PublicLayout.tsx` 内的 `BottomNavIcon`,4 格:home / document / heart / update。 +- 资产已经存在 `heart-active.svg` / `heart-inactive.svg`,不需要新增图示档。 +- 「我的收藏」功能本身**不实作**,只做 stub「即将推出」页。等用户系统建立后再做真功能。 +- text / link / archive 三种 type 现在确实有资料可筛,不能直接砍掉。 + +## Chosen Approach + +**Approach A + 全 viewport responsive**: + +- 筛选条改成下划线 tab 风格,**所有 viewport** 统一使用(mobile / tablet / desktop)。 +- 9 种 type 全部保留,溢出改成「水平横向滚动」(无 expand 按钮,自由滑),符合 Telegram 风格 + Figma 风格。 +- mobile 底部 nav 第 3 格:图示沿用 heart,label 改 `t("favorites")`,连结改 `/favorites`。 +- 新增 `/favorites` route,stub 页内容为「我的收藏 — 即将推出」+ 返回首页 CTA。 +- 桌机顶部 nav **不动**(「官方推荐」入口保留在 desktop top nav)。 + +## Why This Approach + +- **为什么所有 viewport 统一筛选条样式**:Terry 明确要求 responsive across screen sizes;同一组件维护两套样式是不必要的复杂度;下划线 tab 在桌机宽度下也清爽(更胜 pill chip)。 +- **为什么水平滚动而非「⋯」展开**:Figma 没画出展开行为;标准的「Telegram filter bar」模式就是横滑;少一个状态机;本专案的 `MessageStream` 已经是无限滚动列表,再加一个 expand 反而割裂体验。 +- **为什么不砍掉 text/link/archive**:这些是真有资料的 type,砍掉会让桌机用户失去筛选能力;横滑设计已经能容纳全部 9 种。 +- **为什么 favorites 做 stub 不做完整功能**:Terry 选 Q2 选项 3(等用户系统再做),明确不留 localStorage 技术债。 +- **为什么沿用 heart 图示**:资产已经存在;Figma 视觉风格与现有 heart 相容;不增加设计审查负担。 + +### 已拒绝的替代方案 + +- **B(桌机也加「我的收藏」顶部 nav 入口)**:会让桌机 nav 多一个空壳连结,不必要。 +- **C(移除 text/link/archive type 入口)**:损失既有功能,不符合「视觉对齐 ≠ 砍功能」原则。 +- **localStorage 收藏功能**:Terry 选 Q2 选项 3,明确不做。 +- **「⋯」expand 按钮(Figma-faithful)**:Figma 没明确画出展开行为,是我推测的;横滑更直接。 + +## Design + +### 1. FilterChips 重做 + +**文件**:`src/components/messageStream/FilterChips.tsx`(改写,非新增) + +**新视觉规范**: +- 容器:水平横向滚动 (`overflow-x-auto`),隐藏 scrollbar,sticky top 保留 +- 每个 tab:纯文字按钮,无 border、无 background、无 rounded +- inactive:`text-neutral-400`,hover `text-ark-gold/80` +- active:`text-ark-gold` + 底部 `2px` 橘色下划线(`border-b-2 border-ark-gold`),inactive 底部 `2px` 透明 border(占位防跳动) +- 间距:tab 之间 `gap-5`(或 `gap-6`,实作时微调) +- padding:每个 tab 上下 `py-3`,左右无(让 text 自然贴齐 underline) +- 字号:`text-sm` 或 `text-[15px]`,依照 Figma 比例 +- 移除 `SlidersHorizontal` expand 按钮与 `expanded` state +- 移除 measure 隐藏元素 + `ResizeObserver`(不再需要侦测溢出) + +**type 顺序保持不变**:all / image / video / music / ppt / pdf / text / link / archive + +**响应性**: +- 因为是水平滚动,所有宽度都能容纳;mobile 极窄屏自然横滑,桌机宽屏会自然撑开置左 + +### 2. 新增 `/favorites` Stub 页 + +**文件**:`src/pages/Favorites/index.tsx`(新增) + +**结构**: +- 居中容器(`flex items-center justify-center min-h-[60vh]`) +- 心形 icon(用 lucide `Heart`,size 48px,`text-ark-gold/70`) +- 标题:`t("favorites")` → 「我的收藏」 +- 副标:`t("favoritesComingSoon")` → 「功能即将推出」 +- 描述:`t("favoritesComingSoonDesc")` → 简短说明(一行) +- CTA:「返回首页」按钮 → `Link to="/"`,使用既有 `ark-gold` 风格 + +**响应性**: +- 单栏居中,所有 viewport 都用同一份 layout +- 文字 `text-base` 起跳,md 以上放大 + +### 3. App.tsx 加 route + +**文件**:`src/App.tsx` + +- 在公开 routes 区段加入 `} />` +- 透过 `PublicLayout` Outlet 渲染(继承 Header + 底部 nav) + +### 4. PublicLayout 底部 nav 第 3 格改写 + +**文件**:`src/layouts/PublicLayout.tsx` + +- 第 3 个 `BottomNavIcon`: + - `to="/browse?sort=recommended"` → `to="/favorites"` + - `label={t("official")}` → `label={t("favorites")}` + - `active={...recommended}` → `active={pathname === "/favorites"}` + - icon 保持 `heart`(资产已存在) + +### 5. i18n 增加 key + +**文件**:`src/i18n.tsx` + +- 三语言(zh-TW / zh-CN / en)各加: + - `favorites`:「我的收藏」/「My Favorites」 + - `favoritesComingSoon`:「即将推出」/「Coming Soon」 + - `favoritesComingSoonDesc`:一行说明 + - `backToHome`:「返回首页」/「Back to Home」(如不存在) + +### Data Flow + +- FilterChips 的 prop API(`type` / `onTypeChange`)**不变**,纯视觉重做,使用方(`MessageStream` 等)无需调整。 +- `/favorites` stub 无任何资料请求,纯静态页。 + +### Error Handling + +- `/favorites` 是纯静态,无错误状态。 +- FilterChips 横滑容器:测试在 `overflow-x: hidden` 的 ancestor 中是否仍可滚动;既有 `global_overflow_x_hidden_mobile_2026_05_27` 的全域规则需要确认不会卡住此处的 horizontal scroll(可能需要 `overflow-x-auto` 强制覆盖)。 + +### Testing + +- 既有 `npm test` 应该全过(API 没变) +- 视觉测试需要:mobile (375px) / tablet (768px) / desktop (1280px) 三档手测 +- TypeScript: `npx tsc --noEmit` +- Format: `npm run format:check` + +## Implementation Checklist + +- [ ] FilterChips.tsx 重写:移除 pill 样式与 expand 按钮,改为下划线 tab + 水平滚动 +- [ ] 新增 `src/pages/Favorites/index.tsx` stub 页(含 Heart icon、标题、副标、返回首页 CTA) +- [ ] `src/App.tsx` 加入 `/favorites` route 与 import +- [ ] `src/layouts/PublicLayout.tsx` 底部 nav 第 3 格改 label / route / active 判断 +- [ ] `src/i18n.tsx` 三语加入 `favorites` / `favoritesComingSoon` / `favoritesComingSoonDesc` / `backToHome` +- [ ] 验证 FilterChips 横滑在 `global overflow-x-hidden` mobile 规则下仍正常 +- [ ] 跑 `npx tsc --noEmit` + `npm run format:check` + `npm test` +- [ ] mobile / tablet / desktop 三档视觉手测 + +## Open Questions + +- 「我的收藏」stub 页的副标描述文字具体怎么写?(建议:「登入功能开发中,敬请期待」之类,可在实作时定) +- FilterChips 的字号要 `text-sm` 还是 `text-[15px]`?需要量一下 Figma 才能 1:1(实作时对图调整) +- 桌机顶部 nav 的「官方推荐」是否要在未来 phase 一起处理?(本 spec 暂不动) + +## Out of Scope + +- 真正的「我的收藏」功能(依赖未来用户系统,独立 spec) +- 桌机顶部 nav 调整(包括「官方推荐」入口存废) +- 讯息流 bubble 本身的视觉调整(既有已对齐) +- 移除 text / link / archive 三种 type +- 任何后端改动 diff --git a/public/assets/ark-library/navbar/bookmark-active.svg b/public/assets/ark-library/navbar/bookmark-active.svg new file mode 100644 index 0000000..db89b93 --- /dev/null +++ b/public/assets/ark-library/navbar/bookmark-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ark-library/navbar/bookmark-inactive.svg b/public/assets/ark-library/navbar/bookmark-inactive.svg new file mode 100644 index 0000000..3a216c5 --- /dev/null +++ b/public/assets/ark-library/navbar/bookmark-inactive.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/ark-library/navbar/document-active.svg b/public/assets/ark-library/navbar/document-active.svg index 847b4a7..eb40a6e 100644 --- a/public/assets/ark-library/navbar/document-active.svg +++ b/public/assets/ark-library/navbar/document-active.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/assets/ark-library/navbar/document-inactive.svg b/public/assets/ark-library/navbar/document-inactive.svg index d5c1656..fbe4c34 100644 --- a/public/assets/ark-library/navbar/document-inactive.svg +++ b/public/assets/ark-library/navbar/document-inactive.svg @@ -1,7 +1,3 @@ - - - - - - + + diff --git a/public/assets/ark-library/navbar/heart-active.svg b/public/assets/ark-library/navbar/heart-active.svg deleted file mode 100644 index 3cc2284..0000000 --- a/public/assets/ark-library/navbar/heart-active.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/assets/ark-library/navbar/heart-inactive.svg b/public/assets/ark-library/navbar/heart-inactive.svg deleted file mode 100644 index 97e7dc2..0000000 --- a/public/assets/ark-library/navbar/heart-inactive.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/assets/ark-library/navbar/home-active.svg b/public/assets/ark-library/navbar/home-active.svg index d95bce0..b465b04 100644 --- a/public/assets/ark-library/navbar/home-active.svg +++ b/public/assets/ark-library/navbar/home-active.svg @@ -1,10 +1,3 @@ - - - - - - - - - + + diff --git a/public/assets/ark-library/navbar/home-inactive.svg b/public/assets/ark-library/navbar/home-inactive.svg index ccfe202..13ed113 100644 --- a/public/assets/ark-library/navbar/home-inactive.svg +++ b/public/assets/ark-library/navbar/home-inactive.svg @@ -1,10 +1,3 @@ - - - - - - - - - + + diff --git a/public/assets/ark-library/navbar/update-active.svg b/public/assets/ark-library/navbar/update-active.svg index c7ff273..25d25b3 100644 --- a/public/assets/ark-library/navbar/update-active.svg +++ b/public/assets/ark-library/navbar/update-active.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/assets/ark-library/navbar/update-inactive.svg b/public/assets/ark-library/navbar/update-inactive.svg index 4c00d8c..51c1c84 100644 --- a/public/assets/ark-library/navbar/update-inactive.svg +++ b/public/assets/ark-library/navbar/update-inactive.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/src/App.tsx b/src/App.tsx index bd5333e..6d2ad9f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { CategoryPage } from "./pages/Category"; import { SearchPage } from "./pages/Search"; import { PostRedirect } from "./pages/PostRedirect"; import { AboutPage } from "./pages/About"; +import Favorites from "./pages/Favorites"; import { adminUiPrefix } from "./adminPaths"; import { AdminRouteTree } from "./adminRouteTree"; import { AdminRouterModeProvider } from "./adminRouterMode"; @@ -30,6 +31,7 @@ export default function App() { } /> } /> } /> + } /> {adminEnabled ? ( diff --git a/src/components/icons/DownloadCloudIcon.tsx b/src/components/icons/DownloadCloudIcon.tsx new file mode 100644 index 0000000..a0fa835 --- /dev/null +++ b/src/components/icons/DownloadCloudIcon.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from "react"; + +export function DownloadCloudIcon(props: SVGProps) { + return ( + + + + ); +} diff --git a/src/components/messageStream/AttachmentDownloadPill.tsx b/src/components/messageStream/AttachmentDownloadPill.tsx index 702a8fe..017d1c7 100644 --- a/src/components/messageStream/AttachmentDownloadPill.tsx +++ b/src/components/messageStream/AttachmentDownloadPill.tsx @@ -1,4 +1,5 @@ -import { ArrowDownToLine, LoaderCircle } from "lucide-react"; +import { LoaderCircle } from "lucide-react"; +import { DownloadCloudIcon } from "../icons/DownloadCloudIcon"; import { useState, type MouseEvent } from "react"; import { useI18n } from "../../i18n"; import type { Attachment } from "../../types/post"; @@ -35,20 +36,23 @@ export function AttachmentDownloadPill({ type="button" onClick={handleDownload} disabled={isDownloading} - className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-[10px] text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60 disabled:cursor-wait ${className}`} + className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 text-[11px] text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`} aria-label={ isDownloading ? t("downloading") : `Download ${attachment.filename}` } aria-busy={isDownloading} > - + {isDownloading ? ( - + ) : ( - + )} - + {isDownloading ? ( t("downloading") ) : ( diff --git a/src/components/messageStream/FilterChips.tsx b/src/components/messageStream/FilterChips.tsx index e1e254d..5083101 100644 --- a/src/components/messageStream/FilterChips.tsx +++ b/src/components/messageStream/FilterChips.tsx @@ -1,5 +1,3 @@ -import { SlidersHorizontal } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; import { useI18n } from "../../i18n"; import { typeFilterLabel } from "../../resourceTypeLabels"; @@ -22,95 +20,37 @@ export type FilterChipsProps = { export function FilterChips({ type, onTypeChange }: FilterChipsProps) { const { t } = useI18n(); - const containerRef = useRef(null); - const measureRef = useRef(null); - const [expanded, setExpanded] = useState(false); - const [hasOverflow, setHasOverflow] = useState(false); - const labelsKey = useMemo( - () => TYPE_FILTERS.map((tp) => typeFilterLabel(t, tp)).join("|"), - [t], - ); - - useEffect(() => { - const checkOverflow = () => { - const container = containerRef.current; - const measure = measureRef.current; - if (!container || !measure) return; - - const nextHasOverflow = measure.scrollWidth > container.clientWidth + 1; - setHasOverflow(nextHasOverflow); - if (!nextHasOverflow) setExpanded(false); - }; - - checkOverflow(); - const resizeObserver = new ResizeObserver(checkOverflow); - if (containerRef.current) resizeObserver.observe(containerRef.current); - if (measureRef.current) resizeObserver.observe(measureRef.current); - return () => resizeObserver.disconnect(); - }, [labelsKey]); - - const chipClass = (active: boolean) => - `inline-flex h-8 min-w-[72px] shrink-0 items-center justify-center rounded-full border px-3 text-xs leading-none transition ${ + const tabClass = (active: boolean) => + [ + "relative shrink-0 whitespace-nowrap px-1 py-3 text-[15px] leading-none outline-none transition-colors", + "border-b-2", active - ? "border-ark-gold bg-ark-gold/10 text-ark-gold2" - : "border-ark-line text-neutral-300 hover:border-ark-gold/50" - }`; + ? "border-ark-gold text-ark-gold font-medium" + : "border-transparent text-neutral-400 hover:text-ark-gold/80", + ].join(" "); return ( -
-
-
- {TYPE_FILTERS.map((tp) => { - const active = type === tp; - return ( - - ); - })} -
- - {hasOverflow ? ( - - ) : null} -
- +
); diff --git a/src/components/messageStream/MessageBubble.tsx b/src/components/messageStream/MessageBubble.tsx index 5e1fb73..077ad1f 100644 --- a/src/components/messageStream/MessageBubble.tsx +++ b/src/components/messageStream/MessageBubble.tsx @@ -26,29 +26,31 @@ export function pickBubble(post: Post): BubbleComponent { export function MessageBubble({ post }: { post: Post }) { const { lang } = useI18n(); const Bubble = pickBubble(post); - const isTextOnly = post.attachments.length === 0; - const isVisual = post.attachments.some( - (a) => a.kind === "image" || a.kind === "video", - ); + const isVisual = + Bubble === AlbumBubble || + Bubble === VideoBubble || + Bubble === ImageBubble || + Bubble === ImageWithTextBubble; return (
-
); diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index b09a32b..586e3cc 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -72,12 +72,12 @@ export function MessageStream({ scope }: MessageStreamProps) { }; return ( -
+
updateParam("type", v)} /> -
+
{groups.map((group) => ( -
+
{group.items.map((post) => ( ))} diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index df06503..ee04115 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -1,4 +1,5 @@ -import { ArrowDownToLine, LoaderCircle, X } from "lucide-react"; +import { LoaderCircle, X } from "lucide-react"; +import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useI18n } from "../../../i18n"; @@ -12,8 +13,15 @@ import { postDisplayText } from "../utils/postText"; const MAX_VISIBLE = 4; -function imageRatio(att: Attachment) { - return att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3"; +function albumGridClass(count: number) { + const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]"; + if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`; + return `${height} grid grid-cols-2 grid-rows-2`; +} + +function albumItemClass(index: number, count: number) { + if (count === 3 && index === 0) return "row-span-2"; + return ""; } function ImageListDownloadButton({ @@ -51,7 +59,7 @@ function ImageListDownloadButton({ {isDownloading ? ( ) : ( - + )} ); @@ -142,54 +150,22 @@ export function AlbumBubble({ post }: { post: Post }) { const [listOpen, setListOpen] = useState(false); const images = post.attachments; const text = postDisplayText(post, lang); - const shouldMerge = images.length > MAX_VISIBLE; - - if (!shouldMerge) { - return ( -
- {images.map((att, i) => ( -
- - -
- ))} - {text ? ( -
- {autolink(text)} -
- ) : null} -
- ); - } - const visible = images.slice(0, MAX_VISIBLE); const extra = images.length - MAX_VISIBLE; + const layoutCount = Math.min(images.length, MAX_VISIBLE); return ( -
-
+
+
{visible.map((att, i) => { const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0; return (
diff --git a/src/components/messageStream/bubbles/ImageBubble.tsx b/src/components/messageStream/bubbles/ImageBubble.tsx index 314a891..5c7c391 100644 --- a/src/components/messageStream/bubbles/ImageBubble.tsx +++ b/src/components/messageStream/bubbles/ImageBubble.tsx @@ -6,15 +6,12 @@ export function ImageBubble({ post }: { post: Post }) { const { openLightbox } = useLightbox(); const att = post.attachments[0]; if (!att) return null; - const ratio = - att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3"; - return ( -
+
diff --git a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx index 966679a..5c51dcb 100644 --- a/src/components/messageStream/bubbles/ImageWithTextBubble.tsx +++ b/src/components/messageStream/bubbles/ImageWithTextBubble.tsx @@ -11,31 +11,27 @@ export function ImageWithTextBubble({ post }: { post: Post }) { const att = post.attachments[0]; const text = postDisplayText(post, lang); if (!att) return null; - const ratio = - att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3"; - return ( -
- - +
+
+ + +
{text ? ( -
-
- {autolink(text)} -
+
+ {autolink(text)}
) : null}
diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 34952b6..c553eee 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -1,4 +1,5 @@ -import { ArrowDownToLine, LoaderCircle, Play, X } from "lucide-react"; +import { LoaderCircle, Play, X } from "lucide-react"; +import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useI18n } from "../../../i18n"; @@ -12,6 +13,17 @@ import { postDisplayText } from "../utils/postText"; const MAX_VISIBLE = 4; +function videoGridClass(count: number) { + const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]"; + if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`; + return `${height} grid grid-cols-2 grid-rows-2`; +} + +function videoItemClass(index: number, count: number) { + if (count === 3 && index === 0) return "row-span-2"; + return ""; +} + function formatDuration(sec: number | undefined): string { if (!sec || sec <= 0) return ""; const m = Math.floor(sec / 60); @@ -54,7 +66,7 @@ function VideoAttachmentCard({ className={`relative w-full overflow-hidden bg-black ${ compact ? "h-full" - : "max-h-[220px] rounded-xl min-[440px]:max-h-[250px] md:max-h-[300px] lg:max-h-[340px]" + : "h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]" }`} style={compact ? undefined : { aspectRatio: videoRatio(attachment) }} onClick={() => { @@ -180,7 +192,7 @@ function AttachmentListDownloadButton({ {isDownloading ? ( ) : ( - + )} ); @@ -297,32 +309,38 @@ export function VideoBubble({ post }: { post: Post }) { const [listOpen, setListOpen] = useState(false); const videos = post.attachments.filter(isVideoAttachment); const text = postDisplayText(post, lang); - const shouldMerge = videos.length > MAX_VISIBLE; if (!videos.length) return null; - if (shouldMerge) { + if (videos.length >= 2) { const visible = videos.slice(0, MAX_VISIBLE); const extra = videos.length - MAX_VISIBLE; + const layoutCount = Math.min(videos.length, MAX_VISIBLE); return ( -
-
+
+
{visible.map((att, i) => { const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0; return ( - setListOpen(true)} - /> + className={`h-full w-full ${videoItemClass(i, layoutCount)}`} + > + setListOpen(true)} + /> +
); })}
{text ? ( -
+
{autolink(text)}
) : null} @@ -342,12 +360,10 @@ export function VideoBubble({ post }: { post: Post }) { } return ( -
- {videos.map((att) => ( - - ))} +
+ {text ? ( -
+
{autolink(text)}
) : null} diff --git a/src/i18n.tsx b/src/i18n.tsx index e115d64..7a95d31 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -118,6 +118,10 @@ const zhDict: Dict = { adminSearchQuery: "查询词", adminSearchTime: "时间", adminSearchId: "编号", + favorites: "我的收藏", + favoritesComingSoon: "功能即将推出", + favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。", + backToHome: "返回首页", }; const enDict: Dict = { @@ -228,6 +232,11 @@ const enDict: Dict = { adminSearchQuery: "Query", adminSearchTime: "Time", adminSearchId: "ID", + favorites: "My Favorites", + favoritesComingSoon: "Coming Soon", + favoritesComingSoonDesc: + "Sign-in and favorites are in development. Stay tuned.", + backToHome: "Back to Home", }; const languageNames: Record = { diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index ccd90a6..c4dc84b 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -266,6 +266,8 @@ export function PublicLayout() { const na = (which: PublicNavWhich) => navIsActive(pathname, search, hash, which); + const footerInContentFlow = + pathname === "/browse" || pathname.startsWith("/category/"); const goSearch = () => { const s = q.trim(); @@ -276,7 +278,7 @@ export function PublicLayout() { }; return ( -
+
-
+
-