diff --git a/docs/link-preview.md b/docs/link-preview.md new file mode 100644 index 0000000..1b2876b --- /dev/null +++ b/docs/link-preview.md @@ -0,0 +1,258 @@ +# Link preview (`/api/link-preview`) + +Telegram-style rich card for the **first URL** found in a post's text. +Front-end renders a single clickable card showing site name, title, +description, and a thumbnail; the data is fetched from a back-end proxy +that scrapes Open Graph / oEmbed / Twitter Card metadata once and caches +it. + +> **Scope**: only the first link in the post text gets a preview, matching +> Telegram's behaviour. Any additional URLs in the same post still render +> as inline autolinks but do not get their own card. + +## Why a back-end proxy + +Browsers cannot fetch arbitrary cross-origin pages, so OG metadata must be +fetched server-side. A single proxy endpoint keeps secrets / outbound IPs on +the server and lets us cache so each URL is only scraped once for the whole +audience. + +--- + +## Endpoint contract + +``` +GET /api/link-preview?url= +``` + +| Query | Required | Notes | +| ----- | -------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `url` | yes | Absolute `http://` or `https://` URL. Must be `URI` encoded so query strings inside the target URL survive the round trip. | + +### Success — `200 OK` + +```json +{ + "url": "https://app.safe.global/welcome", + "canonicalUrl": "https://app.safe.global/welcome", + "siteName": "app.safe.global", + "title": "Safe{Wallet}", + "description": "Safe{Wallet} is the most trusted smart account wallet on Ethereum with over $100B secured.", + "imageUrl": "https://app.safe.global/og.png", + "imageWidth": 1200, + "imageHeight": 630, + "favicon": "https://app.safe.global/favicon.ico", + "themeColor": "#12FF80", + "fetchedAt": "2026-05-29T10:00:00Z", + "cacheTtlSeconds": 86400 +} +``` + +- All string fields except `url` may be empty. The front-end gracefully hides + rows that are missing (e.g. no `imageUrl` → image area is omitted). +- `url` echoes the original input so the client can match the response + against the URL it asked about, even if the request was racy. +- `canonicalUrl` is the URL the client should open when the card is tapped. + Defaults to `url` if no `` was found. + +### Already cached / freshly cached — same shape + +The endpoint is idempotent and the response shape is identical whether +the metadata is hot, warm, or freshly scraped. + +### Errors + +| Status | When | Body shape | +| ------ | --------------------------------------------------- | --------------------------------------------------------------------------- | +| `400` | Missing / invalid / non-http(s) `url` | `{ "error": "invalid_url" }` | +| `422` | URL passed validation but resolves to a private/internal address (SSRF guard) | `{ "error": "blocked_target" }` | +| `404` | Target returned 404 or fetch produced no metadata | `{ "error": "not_found" }` | +| `408` | Target took longer than the timeout to respond | `{ "error": "timeout" }` | +| `502` | Target returned 5xx | `{ "error": "upstream_error" }` | +| `429` | Rate limit on this client / IP | `{ "error": "rate_limited", "retryAfter": 60 }` | + +The front-end treats every non-`200` as “no preview available” and +silently renders nothing. No toasts. URLs already render as inline +clickable text via `autolink`, so the user is never blocked. + +--- + +## Caching strategy + +Store one row per `canonicalUrl` (or normalized `url` if `canonicalUrl` is +absent). Suggested TTLs: + +- Successful preview: **24 hours** (`cacheTtlSeconds: 86400`). +- 404 / timeout / blocked: **6 hours** negative cache. Otherwise transient + failures on the target site will hammer the proxy. +- Send `Cache-Control: public, max-age=86400` so CDN / browser also cache. + +Cache key normalization: +- Lowercase scheme + host. +- Strip the trailing slash on the path when it's the only character. +- Strip `utm_*`, `ref`, `referrer`, `fbclid`, `gclid` query params. +- Keep the rest of the query and fragment as-is. + +--- + +## SSRF and abuse guard (must-have) + +The proxy will fetch any URL the front-end asks about, which is dangerous. +Before issuing the outbound request: + +1. Resolve the host to all of its A/AAAA records. +2. Reject if any resolved IP is in: loopback, link-local, private + (RFC1918), `0.0.0.0/8`, multicast, broadcast, or the internal cluster + CIDR. +3. Reject schemes other than `http` and `https`. +4. Cap response body at **5 MB**; abort on overflow. +5. Cap request total time at **5 s**; abort on timeout. +6. Cap redirect chain at **3 hops**; re-validate target IP at each hop. +7. Do not forward client cookies, auth headers, or `Referer` to the target. +8. Use a clear `User-Agent` such as `ArkLibraryLinkBot/1.0 (+https://ark-library.com/bot)`. +9. Per-client (IP or session) rate limit, e.g. 60 req / min. + +--- + +## Metadata extraction precedence + +For each field, pick the first present: + +| Field | Sources (in order) | +| ------------- | -------------------------------------------------------------------------------------------------------- | +| `title` | `og:title` → `twitter:title` → `` → empty | +| `description` | `og:description` → `twitter:description` → `<meta name="description">` → empty | +| `imageUrl` | `og:image:secure_url` → `og:image` → `twitter:image` → first prominent `<img>` (skip if <200×200) → empty | +| `siteName` | `og:site_name` → `application-name` → hostname (sans `www.`) | +| `canonicalUrl`| `<link rel="canonical">` → request URL | +| `favicon` | `<link rel="icon">` → `<link rel="shortcut icon">` → `/favicon.ico` | +| `themeColor` | `<meta name="theme-color">` | + +Resolve any relative URLs (`og:image`, `favicon`, `canonical`) against the +final response URL (after redirects). + +--- + +## Provider quirks worth handling + +These quirks save a lot of "why doesn't this site preview?" debugging later. + +- **Twitter / X**: `x.com` and `twitter.com` strip OG when not signed in. Use + the public oEmbed endpoint + `https://publish.twitter.com/oembed?url=...&omit_script=1` for + Twitter/X URLs and map: `title = author_name`, `description = html` stripped + to text, `imageUrl = thumbnail_url` if available. +- **YouTube**: prefer `https://noembed.com/embed?url=...` or + `https://www.youtube.com/oembed?url=...&format=json` (no key). +- **Reddit / Mastodon**: standard OG works fine. +- **Sites behind Cloudflare bot challenge**: surface 502 to the client. + Don't retry hot — let the negative-cache TTL absorb it. +- **AMP pages**: prefer `og:url` when present so the cached entry points to + the canonical page, not the AMP variant. + +--- + +## Front-end integration + +### Type addition (`src/types/post.ts`) + +```ts +export type LinkPreview = { + url: string; + canonicalUrl: string; + siteName: string; + title: string; + description: string; + imageUrl?: string; + imageWidth?: number; + imageHeight?: number; + favicon?: string; + themeColor?: string; +}; + +export type Post = { + // ...existing fields + /** Preview for the first URL in `text`. At most one per post. */ + linkPreview?: LinkPreview; +}; +``` + +### Which URL gets previewed + +The back-end picks the **first** URL it finds in `text` using the same +regex as the front-end's `autolink` (`/(https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]}'])/i`). +Only that URL is fetched, stored, and returned as `post.linkPreview`. Any +later URLs in the same post are ignored for preview purposes (still +clickable inline via `autolink`). + +### Where data comes from + +Two viable paths — pick one when wiring the back-end. + +1. **Inline on `Post`** (preferred): the post API enriches each post with + `linkPreview`. The first URL in `text` is resolved once at write time + (or lazily on first read with a background job). The client renders + without making any extra request. +2. **Client-side lookup**: the client extracts the first URL via the + existing `autolink` regex, calls `/api/link-preview?url=...` once per + post (with in-memory dedupe across posts that share the same URL), and + renders the card when the response comes back. Slower first paint but + keeps the posts endpoint cheap. + +Recommend (1) for the public feed and keep `/api/link-preview` available for +(2) only on admin previews. + +### Rendering + +- New component: `src/components/messageStream/LinkPreviewCard.tsx` +- Renders a card with a left vertical 3px accent bar (`themeColor` → + fallback `bg-ark-gold`). +- Layout: + + ``` + ┌──────────────────────────────────────────────────┐ + │ ▍ siteName (12px, neutral-400) │ + │ ▍ Title (15px, bold, neutral-100) │ + │ ▍ Description (13px, neutral-300, 3-line clamp) │ + │ ▍ ┌────────────────────────────────────────────┐ │ + │ ▍ │ imageUrl (lazy, aspect-video, rounded) │ │ + │ ▍ └────────────────────────────────────────────┘ │ + └──────────────────────────────────────────────────┘ + ``` + +- Whole card is `<a href={canonicalUrl} target="_blank" rel="noopener noreferrer">`. +- Reuse the bubble background (`bg-[#272632]` is OK, slightly lift with + `bg-white/[0.03]` overlay so the card reads as inset within the bubble). +- Mount points (text-bearing bubbles only): `TextBubble`, + `ImageWithTextBubble`, `AlbumBubble`, `VideoBubble`, `FileDocBubble`. + Render below the existing `CollapsibleText` so cards stay visible even + when long text is collapsed. + +### Picking the URL to preview + +If `post.linkPreview` is present, render that single card. Otherwise the +bubble renders nothing extra (URLs still autolink inline). The front-end +never picks the URL itself — that decision lives on the back-end so the +client and server agree on which URL was chosen. + +### Falling back gracefully + +- No `imageUrl` → omit the image area, keep the text block. +- Title shorter than 8 characters → hide the description below (treat as + a low-confidence preview). +- Title empty and description empty → render nothing. + +--- + +## Open questions for the back-end + +- Where in the stack will OG extraction live? Existing post pipeline, a + worker queue, or inline on read? +- Storage: a new `link_previews` table keyed by `canonicalUrl`, with a + `post_link_previews` join table preserving original URL order, or just a + JSON column on `posts`? +- How aggressive should re-scrape be? E.g. re-scrape every 30 days for + successful previews, every 24 hours for `themeColor` updates. +- Should admin be able to override / hide a preview per post? Telegram has + a "no preview" toggle and editors often want it. +- Do we want a manual "refresh preview" button in the admin UI? diff --git a/docs/posts-title-api.md b/docs/posts-title-api.md new file mode 100644 index 0000000..14a30fc --- /dev/null +++ b/docs/posts-title-api.md @@ -0,0 +1,89 @@ +# Posts title fields for list/card surfaces + +Back-end should provide short title fields on `Post` responses so front-end can render concise list/card titles without using the full Telegram/body text. + +## Product requirement + +Any list/card surface that is meant for quick browsing should display a short `title`, not the full `text` body. Full Telegram-style body copy is too noisy for compact surfaces. + +This includes: + +- Home **官方推荐** carousel/cards +- Home **最新资料** +- Home **热门资料** ranking list +- `/official-recommendations` +- `/browse` (**全部资料**) +- `/category/:slug` +- Search result previews/lists + +The full body text should remain available as `text` for message/detail rendering, expansion, and search indexing. + +## Current front-end implementation status + +Already consuming `title` through `postToResource` / `Resource.title`: + +- Home **官方推荐** carousel/cards +- `/official-recommendations` +- Home **热门资料** ranking list + +Pending front-end follow-up after the back-end fields are available: + +- Home **最新资料** +- `/browse` (**全部资料**) +- `/category/:slug` +- Search results + +Those pending surfaces currently render `text` via `MessageBubble`. Once the back-end consistently provides `title`, front-end can decide the final UI treatment, but the desired display content for compact browsing is the short `title`. + +## Affected endpoints + +Any endpoint returning `Post` items should include a short title when available: + +- `GET /api/posts` +- `GET /api/posts/search` +- `GET /api/posts/recommended` +- `GET /api/posts/:id` + +## Recommended response shape + +```jsonc +{ + "id": "string", + "title": "ARK 2026 共识加速计划", // optional global fallback title + "text": "完整正文 / Telegram-style body text...", + "localizations": { + "zh": { + "title": "ARK 2026 共识加速计划", + "text": "完整中文正文..." + }, + "en": { + "title": "ARK 2026 Consensus Acceleration Plan", + "text": "Full English body..." + } + } +} +``` + +## Front-end fallback order + +For resource card/list `Resource.title`, front-end reads: + +1. `localizations[currentLang].title` +2. `post.title` +3. first non-empty line of localized/full `text` +4. first attachment filename +5. `post.id` + +So backend can roll this out gradually: old posts without `title` still render, but resource card/list surfaces reduce long body text to its first non-empty line instead of displaying the full paragraph. + +## Requirement + +Do **not** put an entire body paragraph into `title`. `title` should be concise enough for a two-line card/list title. + +Examples: + +| Good title | Bad title | +| --- | --- | +| `ARK 2026「共识加速计划」邀请王霸榜` | Full event body with links, schedule, rules, and hashtags | +| `ARK 主网核心合约地址(BSC链)` | Full contract explainer paragraph | +| `ARK灵魂五问完整视频` | Full video caption text | diff --git a/docs/search-and-tags-api.md b/docs/search-and-tags-api.md new file mode 100644 index 0000000..2a21797 --- /dev/null +++ b/docs/search-and-tags-api.md @@ -0,0 +1,87 @@ +# 搜索与标签接口说明(给后端) + +前端搜索体验依赖后端两件事:①`/api/posts/search` 做**模糊搜索**;②新增 `/api/tags` +返回完整标签列表。下面是前端目前的调用方式与期望。 + +--- + +## 1. 模糊搜索:`GET /api/posts/search` + +### 现状 +- 前端在用户输入关键字或点击标签时,调用此接口,只负责把 `q` 传过去。 +- **匹配方式(模糊 / 精确)完全由后端决定**,前端无法控制。 +- 页面提示写明「支持搜索:标题 · 分类 · 标签 · 简介 · 文件类型 · 正文」,因此期望是 + **跨这些字段的模糊匹配**。请确认当前实现;若为精确匹配(`= q`),需改为模糊。 + +### 查询参数(前端实际会带的) +| 参数 | 必填 | 说明 | 示例 | +|---|---|---|---| +| `q` | 是 | 搜索关键字(已 trim) | `海报` | +| `lang` | 是 | 界面语言 | `zh-CN` / `en` | +| `limit` | 是 | 每页数量 | `20`(标签预览用 `12`) | +| `cursor` | 否 | 分页游标(上一页返回的 `nextCursor`) | | +| `category` | 否 | 分类 slug(在分类页内搜索时) | `tutorial` | +| `type` | 否 | 资源类型过滤 | `image`/`video`/`music`/`pdf`/`ppt`/`text`/`link`/`archive` | +| `sort` | 否 | 排序 | `latest`/`popular`/`recommended` | +| `language` | 否 | 资料源语言过滤 | | + +### 期望的匹配规则(模糊) +- 对 `q` 做**部分匹配**(`LIKE %q%` 或全文索引),**大小写不敏感**。 +- 匹配字段:**标题、分类名、标签、简介、文件类型、正文**(与页面提示一致)。 +- 中文建议用全文索引 / 分词(如 MySQL FULLTEXT、PostgreSQL `pg_trgm`/`tsvector`、或 ES), + 避免仅按整词精确匹配。 +- 建议按**相关度排序**(命中标题 > 标签 > 正文…);无 `sort` 时默认相关度,有 `sort` + 时按指定排序。 +- (可选增强)错别字容错、拼音匹配。 + +### 返回结构(与 `/api/posts` 一致) +```jsonc +{ + "items": [ /* Post[] */ ], + "nextCursor": "..." // 还有下一页时返回;没有则省略/为空 +} +``` +`Post` 关键字段:`id, categoryId, categorySlug, language, text?, attachments[], +isRecommended, publishedAt, updatedAt?, tags?: string[], postType?`。 + +--- + +## 2. 标签列表:新增 `GET /api/tags` + +### 现状(痛点) +- 「现有标签」目前是前端从**最新 80 条**帖子里现算出来的(取前 12 个高频标签), + **不完整、也不稳定**——更早的帖子里的标签不会出现。 + +### 期望 +新增接口直接返回**全部标签 + 计数**,前端不再现算。 + +``` +GET /api/tags?lang=zh-CN +``` +| 参数 | 必填 | 说明 | +|---|---|---| +| `lang` | 是 | 界面语言(用于本地化标签名,若有) | + +返回: +```jsonc +{ + "tags": [ + { "name": "图片", "count": 128 }, + { "name": "教程", "count": 96 } + // 按 count 降序 + ] +} +``` +- 按 `count` 降序;前端会自行截取展示数量。 +- 只统计**已发布 / 公开**的帖子。 + +--- + +## 验收要点 +- [ ] `/api/posts/search?q=部分词` 能返回包含该词的结果(标题/标签/正文等任一命中), + 大小写不敏感。 +- [ ] 同一关键字在「搜索框」和「分类内搜索」表现一致。 +- [ ] `/api/tags` 返回全量标签(不止最新 80 条里的)。 + +> 前端已就绪:搜索框/标签都走上面的参数;标签支持再次点击取消。后端按本文件落地后, +> 前端只需把「现有标签」数据源从现算切换到 `/api/tags`(小改动,待接口可用后进行)。 diff --git a/index.html b/index.html index 42c9af9..4751cc1 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,10 @@ <html lang="zh-CN"> <head> <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta + name="viewport" + content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" + /> <meta name="theme-color" content="#08070c" /> <meta name="color-scheme" content="dark" /> <meta name="application-name" content="ARK 资料库" /> diff --git a/src/App.tsx b/src/App.tsx index dc69c41..076f0ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations"; import { SearchPage } from "./pages/Search"; import { PostRedirect } from "./pages/PostRedirect"; import { ScrollToTop } from "./components/ScrollToTop"; -import { AboutPage } from "./pages/About"; +import { PageTitleProvider } from "./components/PageTitleContext"; import Favorites from "./pages/Favorites"; import { adminUiPrefix } from "./adminPaths"; import { AdminRouteTree } from "./adminRouteTree"; @@ -29,39 +29,46 @@ export default function App() { <AdminRouterModeProvider value="absolute"> <ImageLightboxProvider> <VideoPlayerProvider> - <BrowserRouter> - <ScrollToTop /> - <Routes> - <Route element={<PublicLayout />}> - <Route path="/" element={<Home />} /> - <Route path="/browse" element={<Browse />} /> - <Route path="/categories" element={<CategoriesPage />} /> - <Route - path="/official-recommendations" - element={<OfficialRecommendationsPage />} - /> - <Route - path="/category/:slug" - element={<CategoryPage />} - /> - <Route path="/search" element={<SearchPage />} /> - <Route path="/resource/:id" element={<PostRedirect />} /> - <Route path="/about" element={<AboutPage />} /> - <Route path="/favorites" element={<Favorites />} /> - </Route> + <PageTitleProvider> + <BrowserRouter> + <ScrollToTop /> + <Routes> + <Route element={<PublicLayout />}> + <Route path="/" element={<Home />} /> + <Route path="/browse" element={<Browse />} /> + <Route + path="/categories" + element={<CategoriesPage />} + /> + <Route + path="/official-recommendations" + element={<OfficialRecommendationsPage />} + /> + <Route + path="/category/:slug" + element={<CategoryPage />} + /> + <Route path="/search" element={<SearchPage />} /> + <Route + path="/resource/:id" + element={<PostRedirect />} + /> + <Route path="/favorites" element={<Favorites />} /> + </Route> - {adminEnabled ? ( - AdminRouteTree() - ) : ( - <Route - path={`${adminUiPrefix}/*`} - element={<Navigate to="/" replace />} - /> - )} + {adminEnabled ? ( + AdminRouteTree() + ) : ( + <Route + path={`${adminUiPrefix}/*`} + element={<Navigate to="/" replace />} + /> + )} - <Route path="*" element={<Navigate to="/" replace />} /> - </Routes> - </BrowserRouter> + <Route path="*" element={<Navigate to="/" replace />} /> + </Routes> + </BrowserRouter> + </PageTitleProvider> </VideoPlayerProvider> </ImageLightboxProvider> </AdminRouterModeProvider> diff --git a/src/components/AssetStreamPage.tsx b/src/components/AssetStreamPage.tsx index 9402fd6..26acb71 100644 --- a/src/components/AssetStreamPage.tsx +++ b/src/components/AssetStreamPage.tsx @@ -1,6 +1,6 @@ import type { PostScope } from "../types/post"; import { MessageStream } from "./messageStream/MessageStream"; -import { SectionHeader } from "./SectionHeader"; +import { useSetPageTitle } from "./PageTitleContext"; type AssetStreamPageProps = { title: string; @@ -8,11 +8,11 @@ type AssetStreamPageProps = { }; export function AssetStreamPage({ title, scope }: AssetStreamPageProps) { + // Show the page name in the global header instead of a separate title row, + // saving vertical space. + useSetPageTitle(title); return ( <section> - <div className="mx-auto max-w-full px-4 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> - <SectionHeader title={title} /> - </div> <MessageStream scope={scope} /> </section> ); diff --git a/src/components/DocumentMeta.tsx b/src/components/DocumentMeta.tsx index bf2c859..fc459ae 100644 --- a/src/components/DocumentMeta.tsx +++ b/src/components/DocumentMeta.tsx @@ -15,7 +15,6 @@ const descriptions: Record<Lang, Record<string, string>> = { "按分类探索 ARK 官方资料,快速定位所需教材、公告、视频、图片与文件。", official: "查看 ARK 官方推荐资料,获取优先整理的重点内容与可信资源。", favorites: "收藏功能开发中,未来可在这里集中管理常用 ARK 资料。", - about: "了解 ARK 资料库的用途、资料范围与本站索引说明。", search: "在 ARK 资料库中搜索标题、分类、标签、简介、文件类型与正文内容。", }, en: { @@ -28,8 +27,6 @@ const descriptions: Record<Lang, Record<string, string>> = { "View official ARK recommendations and prioritized trusted resources.", favorites: "Favorites are in development and will help you manage commonly used ARK resources.", - about: - "Learn about the ARK Library purpose, resource scope, and indexing notes.", search: "Search ARK Library titles, categories, tags, summaries, file types, and body text.", }, @@ -136,13 +133,6 @@ function routeMeta( }; } - if (pathname === "/about") { - return { - title: t("footerAbout"), - description: metaDescription(lang, "about"), - }; - } - return { title: t("brand"), description: metaDescription(lang, "home") }; } diff --git a/src/components/PageTitleContext.tsx b/src/components/PageTitleContext.tsx new file mode 100644 index 0000000..53e15ab --- /dev/null +++ b/src/components/PageTitleContext.tsx @@ -0,0 +1,41 @@ +import { + createContext, + useContext, + useEffect, + useState, + type PropsWithChildren, +} from "react"; + +type PageTitleCtx = { + title: string | null; + setTitle: (title: string | null) => void; +}; + +const PageTitleContext = createContext<PageTitleCtx | null>(null); + +/** + * Lets a page publish its title to the global header so the header can show the + * current page name (e.g. "全部资料" / "热门资料") in place of the brand, avoiding + * a separate on-page title row. Pages that don't set one fall back to the brand. + */ +export function PageTitleProvider({ children }: PropsWithChildren) { + const [title, setTitle] = useState<string | null>(null); + return ( + <PageTitleContext.Provider value={{ title, setTitle }}> + {children} + </PageTitleContext.Provider> + ); +} + +export function usePageTitle(): string | null { + return useContext(PageTitleContext)?.title ?? null; +} + +/** Publish the current page's title; clears it again when the page unmounts. */ +export function useSetPageTitle(title: string | null): void { + const setTitle = useContext(PageTitleContext)?.setTitle; + useEffect(() => { + setTitle?.(title); + return () => setTitle?.(null); + }, [setTitle, title]); +} diff --git a/src/components/PopularRankList.tsx b/src/components/PopularRankList.tsx index 6d0f503..0e9e938 100644 --- a/src/components/PopularRankList.tsx +++ b/src/components/PopularRankList.tsx @@ -108,7 +108,6 @@ function PopularRankRow({ r.downloadAttachmentId, r.title, ); - showToast(t("downloadOk")); } catch { showToast(t("downloadFail"), "error"); } finally { @@ -137,7 +136,8 @@ function PopularRankRow({ src={cover} alt="" loading="lazy" - className="h-full w-full object-cover" + decoding="async" + className="h-full w-full object-fill" onError={() => setCoverFailed(true)} /> ) : ( diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index fb84c6f..ce8a857 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -85,7 +85,6 @@ export function RecommendedCard({ } else { await downloadFile(dl, displayTitle); } - showToast(t("downloadOk")); } catch { showToast(t("downloadFail"), "error"); } finally { @@ -117,6 +116,7 @@ export function RecommendedCard({ alt="" className="ark-img-fade h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]" loading="lazy" + decoding="async" onLoad={(e) => e.currentTarget.classList.add("is-loaded")} /> ) : ( @@ -224,6 +224,7 @@ export function ComingSoonRecommendedCard({ alt="" className="h-full w-full object-cover opacity-75 grayscale-[15%]" loading="lazy" + decoding="async" /> <span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black"> 即将到来 diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 41b9c49..dba8775 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -70,7 +70,11 @@ export function SearchPanel({ const langParam = useMemo(() => langQuery(lang), [lang]); useEffect(() => { - inputRef.current?.focus(); + // Avoid scroll-into-view: browsers default-scroll the focused element + // into the viewport, which moves the underlying page when the search + // overlay opens from a scrolled position. `preventScroll` keeps the page + // exactly where it was. + inputRef.current?.focus({ preventScroll: true }); }, []); useEffect(() => { @@ -102,6 +106,13 @@ export function SearchPanel({ }, [langParam]); const showTagPosts = (tag: string) => { + // Tapping the active tag again clears it (toggle) instead of staying stuck. + if (selectedTag === tag) { + setSelectedTag(""); + setTagPosts([]); + onQueryChange(""); + return; + } setSelectedTag(tag); onQueryChange(tag); const searchUrl = buildSearchUrl({ @@ -133,10 +144,10 @@ export function SearchPanel({ }; return ( - <div className="ark-header-menu-enter fixed inset-x-0 bottom-0 top-[64px] z-50 overflow-y-auto bg-[#0f0f13] md:hidden"> + <div className="ark-header-menu-enter fixed inset-x-0 bottom-0 top-[64px] z-50 overflow-y-auto overscroll-contain bg-[#0f0f13] md:hidden"> <div className="border-t border-white/10 px-5 pb-6 pt-3 max-[360px]:px-3"> <div className="flex h-12 items-center gap-2"> - <div className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full border border-ark-gold bg-[#191921] px-3 shadow-[0_0_0_2px_rgba(245,180,53,0.12)]"> + <div className="flex h-11 min-w-0 flex-1 items-center gap-2 rounded-full bg-[#191921] px-3 shadow-[0_0_0_2px_rgba(245,180,53,0.12)] ring-1 ring-inset ring-ark-gold"> <SearchIcon size={18} className="shrink-0 text-ark-gold" /> <input ref={inputRef} @@ -144,7 +155,7 @@ export function SearchPanel({ onChange={(e) => onQueryChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onSearch()} placeholder={t("searchPanelPlaceholder")} - className="min-w-0 flex-1 bg-transparent text-sm text-neutral-100 outline-none placeholder:text-[#777985]" + className="min-w-0 flex-1 bg-transparent text-base text-neutral-100 outline-none placeholder:text-[#777985]" /> <button type="button" @@ -164,8 +175,8 @@ export function SearchPanel({ </button> </div> - <div className="mt-1 flex items-start gap-1.5 text-[11px] leading-4 text-[#777985]"> - <Info size={15} className="mt-0.5 shrink-0" /> + <div className="mt-1 flex items-center gap-1.5 pl-3 text-[12px] leading-4 text-[#777985]"> + <Info size={14} className="shrink-0" /> <span>{t("searchPanelHint")}</span> </div> diff --git a/src/components/messageStream/AttachmentDownloadPill.tsx b/src/components/messageStream/AttachmentDownloadPill.tsx index 6b8ea14..cf8481c 100644 --- a/src/components/messageStream/AttachmentDownloadPill.tsx +++ b/src/components/messageStream/AttachmentDownloadPill.tsx @@ -12,6 +12,22 @@ type AttachmentDownloadPillProps = { attachment: Attachment; leadingLabel?: string; className?: string; + /** + * When true, the pill scales with its host container's width via container- + * query units (clamped 22-30px). The host element must establish a query + * container with `style={{ containerType: "inline-size" }}`. Use this on + * album tiles so the pill shrinks on small thumbnails in mixed layouts. + * Defaults to false (fixed 30px) for standalone images/videos. + */ + adaptive?: boolean; + /** + * Visual size. `sm` (default, 30 px tall) for inline tiles where space is + * tight. `lg` (44 px tall with a 24 px icon and 14 px text) for overlay + * surfaces like the image lightbox or fullscreen video player where the + * affordance can breathe and should feel touch-friendly. + * Ignored when `adaptive` is set. + */ + size?: "sm" | "lg"; }; export function AttachmentDownloadPill({ @@ -19,6 +35,8 @@ export function AttachmentDownloadPill({ attachment, leadingLabel, className = "absolute left-2 top-2", + adaptive = false, + size = "sm", }: AttachmentDownloadPillProps) { const { t } = useI18n(); const { showToast } = useToast(); @@ -30,7 +48,6 @@ export function AttachmentDownloadPill({ setIsDownloading(true); try { await downloadAttachment(postId, attachment.id, attachment.filename); - showToast(t("downloadOk")); } catch { showToast(t("downloadFail"), "error"); } finally { @@ -38,28 +55,56 @@ export function AttachmentDownloadPill({ } }; + const isLg = !adaptive && size === "lg"; + + const fontCls = adaptive + ? "text-[clamp(10px,7cqw,12px)]" + : isLg + ? "text-[14px] font-medium" + : "text-[12px]"; + + const squareCls = adaptive + ? "h-[clamp(22px,18cqw,30px)] w-[clamp(22px,18cqw,30px)]" + : isLg + ? "h-[44px] w-[44px]" + : "h-[30px] w-[30px]"; + + const iconCls = adaptive + ? "h-[clamp(13px,11cqw,18px)] w-[clamp(13px,11cqw,18px)]" + : isLg + ? "h-[22px] w-[22px]" + : "h-[18px] w-[18px]"; + + const textBoxCls = adaptive + ? "h-[clamp(22px,18cqw,30px)] px-[clamp(6px,6cqw,10px)]" + : isLg + ? "h-[44px] px-4" + : "h-[30px] px-2.5"; + return ( <button type="button" onClick={handleDownload} disabled={isDownloading} - 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}`} + className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 ${fontCls} 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} > - <span className="flex h-6 w-6 items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70"> + <span + className={`flex items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70 ${squareCls}`} + > {isDownloading ? ( <LoaderCircle - className="h-3.5 w-3.5 animate-spin" + className={`${iconCls} animate-spin`} strokeWidth={2.3} /> ) : ( - <DownloadCloudIcon className="h-3.5 w-3.5" /> + <DownloadCloudIcon className={iconCls} /> )} </span> - <span className="flex h-6 items-center gap-0.5 px-2"> + <span className={`flex items-center gap-1 ${textBoxCls}`}> {isDownloading ? ( t("downloading") ) : ( diff --git a/src/components/messageStream/BubbleImage.tsx b/src/components/messageStream/BubbleImage.tsx index 342bc28..a109259 100644 --- a/src/components/messageStream/BubbleImage.tsx +++ b/src/components/messageStream/BubbleImage.tsx @@ -68,9 +68,13 @@ export function BubbleImage({ src={current} alt="" loading={loading} - className={className} + decoding="async" + className={`ark-img-fade ${className ?? ""}`} onLoad={(e) => { const img = e.currentTarget; + // Fade each image in as soon as it loads, so they appear progressively + // instead of the page seeming to wait for everything. + img.classList.add("is-loaded"); if (img.naturalWidth && img.naturalHeight) onNaturalSize?.(img.naturalWidth, img.naturalHeight); }} diff --git a/src/components/messageStream/CollapsibleText.tsx b/src/components/messageStream/CollapsibleText.tsx index b0320a4..7bc743b 100644 --- a/src/components/messageStream/CollapsibleText.tsx +++ b/src/components/messageStream/CollapsibleText.tsx @@ -28,7 +28,7 @@ export function CollapsibleText({ children, className = "", wrapperClassName = "", - collapsedLines = 8, + collapsedLines = 25, }: { children: ReactNode; /** Typography classes applied to the text container. */ @@ -110,7 +110,7 @@ export function CollapsibleText({ type="button" onClick={() => setExpanded((v) => !v)} aria-expanded={expanded} - className="group mt-1.5 inline-flex h-8 items-center gap-1 text-[13px] font-medium text-ark-gold transition-colors duration-200 hover:text-ark-gold2" + className="group mt-1 inline-flex items-center gap-1 text-[13px] font-medium leading-5 text-ark-gold transition-colors duration-200 hover:text-ark-gold2" > <span>{expanded ? t("showLess") : t("showMore")}</span> <m.span diff --git a/src/components/messageStream/FilterChips.tsx b/src/components/messageStream/FilterChips.tsx index fb8e9fe..a649951 100644 --- a/src/components/messageStream/FilterChips.tsx +++ b/src/components/messageStream/FilterChips.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { useI18n } from "../../i18n"; import { typeFilterLabel } from "../../resourceTypeLabels"; @@ -20,19 +21,36 @@ export type FilterChipsProps = { 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. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + if (e.deltaY === 0 || el.scrollWidth <= el.clientWidth) return; + e.preventDefault(); + el.scrollLeft += e.deltaY; + }; + el.addEventListener("wheel", onWheel, { passive: false }); + return () => el.removeEventListener("wheel", onWheel); + }, []); 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-white md:text-ark-gold" + ? "border-ark-gold font-medium text-ark-gold" : "border-transparent text-[#97989A] hover:text-ark-gold/80 md:text-neutral-400", ].join(" "); return ( - <div className="sticky top-0 z-10 bg-ark-bg/95 backdrop-blur md:rounded-t-xl md:border-b md:border-ark-line"> + <div className="bg-ark-bg/95 backdrop-blur md:rounded-t-xl"> <div + ref={scrollRef} className="flex items-end gap-2 overflow-x-auto overflow-y-hidden px-4 pr-10 [-ms-overflow-style:none] [scrollbar-width:none] md:gap-5 md:px-1 md:pr-1 [&::-webkit-scrollbar]:hidden" role="tablist" > diff --git a/src/components/messageStream/LinkPreviewCard.tsx b/src/components/messageStream/LinkPreviewCard.tsx new file mode 100644 index 0000000..3858464 --- /dev/null +++ b/src/components/messageStream/LinkPreviewCard.tsx @@ -0,0 +1,65 @@ +import type { LinkPreview } from "../../types/post"; + +/** + * Telegram-style rich preview card for a single URL embedded in a post. + * + * Renders an accent bar on the left, then site name → title → description, + * with an optional thumbnail at the bottom. The whole card is one anchor + * that opens `canonicalUrl` in a new tab. + */ +export function LinkPreviewCard({ preview }: { preview: LinkPreview }) { + const accent = preview.themeColor || "#EEB726"; + const hasUsefulText = + preview.title.length > 0 || preview.description.length > 0; + if (!hasUsefulText && !preview.imageUrl) return null; + + return ( + <a + href={preview.canonicalUrl || preview.url} + target="_blank" + rel="noopener noreferrer" + className="group block overflow-hidden rounded-lg bg-white/[0.04] transition hover:bg-white/[0.07]" + > + <div className="flex"> + <div + aria-hidden + className="w-[3px] shrink-0 rounded-l-lg" + style={{ backgroundColor: accent }} + /> + <div className="min-w-0 flex-1 px-3 py-2.5"> + {preview.siteName ? ( + <div + className="truncate text-[12px] leading-4" + style={{ color: accent }} + > + {preview.siteName} + </div> + ) : null} + {preview.title ? ( + <div className="mt-0.5 line-clamp-2 break-words text-[14px] font-semibold leading-5 text-neutral-100"> + {preview.title} + </div> + ) : null} + {preview.description ? ( + <div className="mt-1 line-clamp-3 break-words text-[13px] leading-[18px] text-neutral-300"> + {preview.description} + </div> + ) : null} + {preview.imageUrl ? ( + <div className="mt-2 overflow-hidden rounded-md bg-black/30"> + <img + src={preview.imageUrl} + alt="" + loading="lazy" + decoding="async" + width={preview.imageWidth} + height={preview.imageHeight} + className="block aspect-[1.91/1] w-full object-cover transition duration-300 group-hover:scale-[1.02]" + /> + </div> + ) : null} + </div> + </div> + </a> + ); +} diff --git a/src/components/messageStream/MessageBubble.tsx b/src/components/messageStream/MessageBubble.tsx index 5dbfc93..5312977 100644 --- a/src/components/messageStream/MessageBubble.tsx +++ b/src/components/messageStream/MessageBubble.tsx @@ -1,12 +1,12 @@ import type { ComponentType } from "react"; import type { Post } from "../../types/post"; -import { useI18n } from "../../i18n"; import { TextBubble } from "./bubbles/TextBubble"; import { FileDocBubble } from "./bubbles/FileDocBubble"; import { ImageBubble } from "./bubbles/ImageBubble"; import { ImageWithTextBubble } from "./bubbles/ImageWithTextBubble"; import { AlbumBubble } from "./bubbles/AlbumBubble"; import { VideoBubble } from "./bubbles/VideoBubble"; +import { LinkPreviewCard } from "./LinkPreviewCard"; import { formatDateTime } from "./utils/formatTime"; type BubbleComponent = ComponentType<{ post: Post }>; @@ -24,7 +24,6 @@ export function pickBubble(post: Post): BubbleComponent { } export function MessageBubble({ post }: { post: Post }) { - const { lang } = useI18n(); const Bubble = pickBubble(post); const isVisual = Bubble === AlbumBubble || @@ -43,13 +42,18 @@ export function MessageBubble({ post }: { post: Post }) { }`} > <Bubble post={post} /> + {post.linkPreview ? ( + <div className={isVisual ? "px-4 pt-3" : "mt-3"}> + <LinkPreviewCard preview={post.linkPreview} /> + </div> + ) : null} <time dateTime={post.publishedAt} className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${ - isVisual ? "px-4 pb-3 pt-3" : "mt-3" + isVisual ? "px-4 pb-3 pt-0.5" : "mt-3" }`} > - {formatDateTime(post.publishedAt, lang)} + {formatDateTime(post.publishedAt)} </time> </article> </div> diff --git a/src/components/messageStream/MessageInlineVideo.tsx b/src/components/messageStream/MessageInlineVideo.tsx new file mode 100644 index 0000000..c4ae03b --- /dev/null +++ b/src/components/messageStream/MessageInlineVideo.tsx @@ -0,0 +1,368 @@ +import { Maximize2, Pause, Play } from "lucide-react"; +import { + useCallback, + useEffect, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; +import type { Attachment } from "../../types/post"; +import { AttachmentDownloadPill } from "./AttachmentDownloadPill"; +import { useVideoPlayer } from "./overlays/VideoPlayer"; + +function pad2(n: number): string { + return String(Math.floor(n)).padStart(2, "0"); +} + +function formatClock(sec: number): string { + if (!Number.isFinite(sec) || sec < 0) return "0:00"; + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${pad2(s)}`; +} + +type Size = "sm" | "lg"; + +type SizeTokens = { + /** Padding / gap of the bottom controls bar. */ + bar: string; + /** Square hit area of the play/pause and fullscreen buttons. */ + btn: string; + /** Lucide icon inside the bar buttons. */ + btnIcon: string; + /** Center play affordance shown while paused. */ + centerBox: string; + /** Lucide icon inside the centered play affordance. */ + centerIcon: string; + /** Hit area of the scrub bar wrapper. */ + scrubRow: string; + /** Visible fill rail. */ + scrubRail: string; + /** Drag handle dot. */ + scrubHandle: string; + /** Time text. */ + timeText: string; +}; + +const TOKENS: Record<Size, SizeTokens> = { + sm: { + bar: "gap-2 px-3 pb-2 pt-6 text-[12px]", + btn: "h-7 w-7", + btnIcon: "h-4 w-4", + centerBox: "h-14 w-14 md:h-16 md:w-16", + centerIcon: "h-6 w-6", + scrubRow: "h-5", + scrubRail: "h-1", + scrubHandle: "h-3 w-3", + timeText: "", + }, + lg: { + bar: "gap-3 px-5 pb-4 pt-10 text-[14px]", + btn: "h-10 w-10", + btnIcon: "h-5 w-5", + centerBox: "h-20 w-20 md:h-24 md:w-24", + centerIcon: "h-8 w-8", + scrubRow: "h-7", + scrubRail: "h-1.5", + scrubHandle: "h-4 w-4", + timeText: "text-[14px]", + }, +}; + +/** + * Cross-platform inline video player with custom controls. Disables every + * native control overlay (iOS Safari's `playsInline` UI is otherwise + * impossible to fully tame via CSS) and reimplements the essentials: + * + * - Centered play affordance while paused. + * - Bottom bar with play/pause, current time, scrub bar, total time, and + * a fullscreen button. + * - Tap on the video toggles play/pause. + * - Click or drag on the scrub bar seeks live to that point (mouse + + * touch, via pointer events with pointer capture). + * + * The download pill is preserved at the top-left so it lives alongside the + * existing visual language of the message bubbles. + */ +export function MessageInlineVideo({ + postId, + attachment, + initialTime = 0, + autoPlay = true, + leadingLabel, + hideDownload = false, + hideFullscreen = false, + onTimeUpdate, + size = "sm", +}: { + postId: string; + attachment: Attachment; + initialTime?: number; + autoPlay?: boolean; + leadingLabel?: string; + /** Suppress the top-left download pill (overlay supplies its own). */ + hideDownload?: boolean; + /** Suppress the fullscreen button (when we are already in fullscreen). */ + hideFullscreen?: boolean; + /** Reports the playhead to the parent on every native `timeupdate`. */ + onTimeUpdate?: (currentTime: number) => void; + /** + * `sm` (default) for bubble-sized inline players. `lg` for surfaces like + * the fullscreen overlay where controls should feel touch-friendly. + */ + size?: Size; +}) { + const { openVideo } = useVideoPlayer(); + const videoRef = useRef<HTMLVideoElement>(null); + const scrubRef = useRef<HTMLDivElement>(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(initialTime); + const [duration, setDuration] = useState(attachment.durationSec ?? 0); + const [isScrubbing, setIsScrubbing] = useState(false); + + const t = TOKENS[size]; + + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const onPlay = () => setIsPlaying(true); + const onPause = () => setIsPlaying(false); + const onTime = () => { + setCurrentTime(v.currentTime); + onTimeUpdate?.(v.currentTime); + }; + // `seeked` fires even when the video is paused, so an external + // `currentTime = ...` (e.g. syncing back from fullscreen) reaches state + // immediately instead of waiting for the next `timeupdate`. + const onSeeked = () => { + setCurrentTime(v.currentTime); + onTimeUpdate?.(v.currentTime); + }; + const onMeta = () => { + if (Number.isFinite(v.duration)) setDuration(v.duration); + if (initialTime > 0) { + try { + v.currentTime = initialTime; + } catch { + // Ignore out-of-range seeks. + } + } + }; + v.addEventListener("play", onPlay); + v.addEventListener("pause", onPause); + v.addEventListener("timeupdate", onTime); + v.addEventListener("seeked", onSeeked); + v.addEventListener("loadedmetadata", onMeta); + return () => { + v.removeEventListener("play", onPlay); + v.removeEventListener("pause", onPause); + v.removeEventListener("timeupdate", onTime); + v.removeEventListener("seeked", onSeeked); + v.removeEventListener("loadedmetadata", onMeta); + }; + }, [initialTime, onTimeUpdate]); + + const togglePlay = useCallback(() => { + const v = videoRef.current; + if (!v) return; + if (v.paused) v.play().catch(() => {}); + else v.pause(); + }, []); + + const seekToClientX = useCallback((clientX: number) => { + const el = scrubRef.current; + const v = videoRef.current; + if (!el || !v || !Number.isFinite(v.duration)) return; + const rect = el.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const next = ratio * v.duration; + try { + v.currentTime = next; + } catch { + // Ignore out-of-range seeks. + } + setCurrentTime(next); + }, []); + + const onScrubPointerDown = useCallback( + (e: ReactPointerEvent<HTMLDivElement>) => { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + setIsScrubbing(true); + seekToClientX(e.clientX); + }, + [seekToClientX], + ); + + const onScrubPointerMove = useCallback( + (e: ReactPointerEvent<HTMLDivElement>) => { + if (!isScrubbing) return; + seekToClientX(e.clientX); + }, + [isScrubbing, seekToClientX], + ); + + const onScrubPointerEnd = useCallback( + (e: ReactPointerEvent<HTMLDivElement>) => { + if (!isScrubbing) return; + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch { + // Pointer may already have been released. + } + setIsScrubbing(false); + }, + [isScrubbing], + ); + + const goFullscreen = useCallback( + (e: ReactMouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); + const v = videoRef.current; + if (!v) return; + const resumeAt = Number.isFinite(v.currentTime) ? v.currentTime : 0; + v.pause(); + openVideo( + attachment, + resumeAt, + (finalTime) => { + const inline = videoRef.current; + if (!inline || !Number.isFinite(finalTime)) return; + // Update React state synchronously so the progress bar paints the + // new playhead in the next frame, before the <video> seek round- + // trip emits its own events (paused videos don't fire timeupdate + // and `seeked` can lag ~hundreds of ms). + setCurrentTime(finalTime); + onTimeUpdate?.(finalTime); + const apply = () => { + try { + inline.currentTime = finalTime; + } catch { + // Ignore out-of-range seeks. + } + }; + if (inline.readyState >= 1) apply(); + else inline.addEventListener("loadedmetadata", apply, { once: true }); + }, + postId, + ); + }, + [attachment, openVideo, postId], + ); + + const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0; + const remaining = Math.max(0, duration - currentTime); + const handleOffset = size === "lg" ? 8 : 6; + + return ( + <> + <video + ref={videoRef} + src={attachment.url} + poster={attachment.posterUrl} + playsInline + autoPlay={autoPlay} + onClick={togglePlay} + className="absolute inset-0 h-full w-full object-contain" + /> + + {hideDownload ? null : ( + <AttachmentDownloadPill + postId={postId} + attachment={attachment} + leadingLabel={leadingLabel} + className="absolute left-2 top-2 z-20" + /> + )} + + {!isPlaying ? ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + togglePlay(); + }} + className="absolute inset-0 z-10 flex items-center justify-center" + aria-label="Play" + > + <span + className={`flex items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition group-hover:bg-black/70 ${t.centerBox}`} + > + <Play className={`translate-x-0.5 fill-white ${t.centerIcon}`} /> + </span> + </button> + ) : null} + + <div + className={`absolute inset-x-0 bottom-0 z-20 flex items-center bg-gradient-to-t from-black/85 via-black/45 to-transparent leading-none text-white ${t.bar}`} + onClick={(e) => e.stopPropagation()} + > + <button + type="button" + onClick={togglePlay} + className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`} + aria-label={isPlaying ? "Pause" : "Play"} + > + {isPlaying ? ( + <Pause className={`fill-white ${t.btnIcon}`} /> + ) : ( + <Play className={`translate-x-0.5 fill-white ${t.btnIcon}`} /> + )} + </button> + + <span className={`shrink-0 tabular-nums text-white ${t.timeText}`}> + {formatClock(currentTime)} + </span> + + <div + ref={scrubRef} + role="slider" + aria-label="Seek" + aria-valuemin={0} + aria-valuemax={duration || 0} + aria-valuenow={currentTime} + tabIndex={0} + onPointerDown={onScrubPointerDown} + onPointerMove={onScrubPointerMove} + onPointerUp={onScrubPointerEnd} + onPointerCancel={onScrubPointerEnd} + className={`group relative min-w-0 flex-1 cursor-pointer touch-none select-none ${t.scrubRow}`} + > + <div + className={`absolute inset-x-0 top-1/2 -translate-y-1/2 overflow-hidden rounded-full bg-white/25 ${t.scrubRail}`} + > + <div + className={`h-full bg-white ${ + isScrubbing ? "" : "transition-[width] duration-150 ease-out" + }`} + style={{ width: `${progressPct}%` }} + /> + </div> + <div + aria-hidden + className={`pointer-events-none absolute top-1/2 -translate-y-1/2 rounded-full bg-white shadow-lg transition-transform duration-150 ease-out ${ + isScrubbing ? "scale-125" : "scale-100" + } ${t.scrubHandle}`} + style={{ left: `calc(${progressPct}% - ${handleOffset}px)` }} + /> + </div> + + <span className={`shrink-0 tabular-nums text-white/80 ${t.timeText}`}> + -{formatClock(remaining)} + </span> + + {hideFullscreen ? null : ( + <button + type="button" + onClick={goFullscreen} + className={`flex shrink-0 items-center justify-center text-white transition hover:scale-105 ${t.btn}`} + aria-label="Fullscreen" + > + <Maximize2 className={t.btnIcon} strokeWidth={2.2} /> + </button> + )} + </div> + </> + ); +} diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index e8f60b9..89f5c4d 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -34,6 +34,7 @@ export function MessageStream({ scope }: MessageStreamProps) { const retryLabel = lang === "zh-CN" ? "重试" : "Retry"; const sentinelRef = useRef<HTMLDivElement>(null); + const filterBarRef = useRef<HTMLDivElement>(null); const hasMoreRef = useRef(hasMore); const isLoadingRef = useRef(isLoading); useEffect(() => { @@ -70,33 +71,77 @@ export function MessageStream({ scope }: MessageStreamProps) { return () => io.disconnect(); }, [loadMore]); - // When arriving with a `#post-<id>` hash (e.g. from a recommended card), + // When arriving with a `?post=<id>` query (or legacy `#post-<id>` hash), // scroll to that bubble — loading more pages until it shows up — then give // it a brief highlight so the user can see where they landed. - const targetPostId = hash.startsWith("#post-") + const queryTargetPostId = sp.get("post") || ""; + const hashTargetPostId = hash.startsWith("#post-") ? hash.slice("#post-".length) : ""; + const targetPostId = queryTargetPostId || hashTargetPostId; const handledTargetRef = useRef<string>(""); + const targetScrollTimersRef = useRef<number[]>([]); + + const clearTargetScrollTimers = () => { + for (const timer of targetScrollTimersRef.current) { + window.clearTimeout(timer); + } + targetScrollTimersRef.current = []; + }; useEffect(() => { handledTargetRef.current = ""; + clearTargetScrollTimers(); }, [targetPostId]); + useEffect(() => clearTargetScrollTimers, []); + useEffect(() => { if (!targetPostId || handledTargetRef.current === targetPostId) return; const el = document.getElementById(`post-${targetPostId}`); if (el) { handledTargetRef.current = targetPostId; - const frame = window.requestAnimationFrame(() => { - el.scrollIntoView({ block: "start", behavior: "smooth" }); - el.classList.add("ark-bubble-highlight"); - window.setTimeout( - () => el.classList.remove("ark-bubble-highlight"), - 2000, - ); - }); - return () => window.cancelAnimationFrame(frame); + clearTargetScrollTimers(); + + const scrollToTarget = (behavior: ScrollBehavior = "auto") => { + const target = document.getElementById(`post-${targetPostId}`); + if (!target) return; + const filterBottom = + filterBarRef.current?.getBoundingClientRect().bottom ?? 0; + const targetTop = target.getBoundingClientRect().top + window.scrollY; + window.scrollTo({ + top: Math.max(0, targetTop - filterBottom - 12), + left: 0, + behavior, + }); + }; + + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + + // Show a deliberate "from top to target" transition when opening a card + // from Home. The later auto re-alignments are intentionally delayed so + // they don't interrupt the visible smooth scroll animation. + window.scrollTo({ top: 0, left: 0, behavior: "auto" }); + window.requestAnimationFrame(() => + scrollToTarget(prefersReducedMotion ? "auto" : "smooth"), + ); + + // Media above the target can finish loading after the first scroll and + // shift the target downward. Re-align after the smooth animation while + // stream image/video heights settle, so the final resting point is exact. + targetScrollTimersRef.current = [900, 1400, 2000].map((ms) => + window.setTimeout(() => scrollToTarget("auto"), ms), + ); + + el.classList.add("ark-bubble-highlight"); + window.setTimeout( + () => el.classList.remove("ark-bubble-highlight"), + 2000, + ); + return; } // Not loaded yet — keep paging until it appears or the stream is exhausted. @@ -114,7 +159,14 @@ export function MessageStream({ scope }: MessageStreamProps) { return ( <div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> - <FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} /> + {/* Filters stay pinned below the global header (which shows the page + name) so users can switch filters while scrolling. */} + <div + ref={filterBarRef} + className="sticky top-[64px] z-30 bg-ark-bg md:top-[70px]" + > + <FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} /> + </div> <div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2"> {isInitialLoad ? ( diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index fe1c68d..d50074e 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -26,6 +26,12 @@ export function AlbumBubble({ post }: { post: Post }) { const ratios = useImageRatios(visible, sources); const layout = computeAlbumLayout(ratios); + // Two-image albums scale each image to fit its cell (object-contain) so a + // tall poster is never cropped — the cell already matches the image ratio, so + // there are no bars except on very wide screens where the height is capped. + // 3+ images keep object-cover to stay a tidy mosaic. + const imgFit = visible.length === 2 ? "object-contain" : "object-cover"; + return ( <div className="flex flex-col"> {/* aspect-ratio sets a definite box height; tiles are absolutely @@ -51,6 +57,8 @@ export function AlbumBubble({ post }: { post: Post }) { top: `${tile.top * 100}%`, width: `calc(${tile.width * 100}% - ${ALBUM_GAP}px)`, height: `calc(${tile.height * 100}% - ${ALBUM_GAP}px)`, + // Query container so the download pill scales with this tile. + containerType: "inline-size", }} > <button @@ -65,7 +73,7 @@ export function AlbumBubble({ post }: { post: Post }) { src={sources[i]} fallbackSrc={[att.thumbUrl, att.url]} loading="lazy" - className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" + className={`h-full w-full ${imgFit} transition duration-300 group-hover:scale-[1.03]`} /> {isLastSlot ? ( <div className="absolute inset-0 flex flex-col items-center justify-center gap-0.5 bg-black/60 text-white backdrop-blur-[1px] transition group-hover:bg-black/50"> @@ -76,7 +84,11 @@ export function AlbumBubble({ post }: { post: Post }) { ) : null} </button> {!isLastSlot ? ( - <AttachmentDownloadPill postId={post.id} attachment={att} /> + <AttachmentDownloadPill + postId={post.id} + attachment={att} + adaptive + /> ) : null} </div> ); diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index ada2c61..42837ca 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -27,7 +27,6 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { setIsDownloading(true); try { await downloadAttachment(postId, att.id, displayFilename); - showToast(t("downloadOk")); } catch { showToast(t("downloadFail"), "error"); } finally { @@ -40,22 +39,23 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined); return ( - <div className="group flex h-[52px] items-center gap-3"> + <div className="group flex min-h-[64px] items-center gap-3"> {previewUrl && !previewFailed ? ( <img src={previewUrl} alt="" loading="lazy" + decoding="async" onError={() => setPreviewFailed(true)} - className="h-[52px] w-[52px] shrink-0 rounded-full object-cover" + className="h-16 w-16 shrink-0 rounded-lg object-fill" /> ) : ( <div - className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-full" + className="flex h-16 w-16 shrink-0 items-center justify-center rounded-lg" style={{ backgroundColor: color }} aria-hidden="true" > - <Icon className="h-8 w-8 text-white" strokeWidth={2.1} /> + <Icon className="h-9 w-9 text-white" strokeWidth={2.1} /> </div> )} <div className="min-w-0 flex-1"> diff --git a/src/components/messageStream/bubbles/SingleImageFrame.tsx b/src/components/messageStream/bubbles/SingleImageFrame.tsx index be6a779..17d79e1 100644 --- a/src/components/messageStream/bubbles/SingleImageFrame.tsx +++ b/src/components/messageStream/bubbles/SingleImageFrame.tsx @@ -20,6 +20,11 @@ export function SingleImageFrame({ return ( <AdaptiveImageFrame attachment={attachment} + // Show the lightweight thumbnail in-stream for fast, progressive loading; + // the full image is loaded on tap in the lightbox. Falls back to the full + // asset if no thumbnail exists. + src={attachment.thumbnailUrl ?? attachment.thumbUrl ?? attachment.url} + fallbackSrc={[attachment.thumbUrl, attachment.url]} onOpen={() => openLightbox([attachment], 0, text, postId)} ariaLabel="View image" > diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index 440b384..e33844a 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -1,10 +1,11 @@ import { LoaderCircle, Play, X } from "lucide-react"; import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useI18n } from "../../../i18n"; import type { Attachment, Post } from "../../../types/post"; import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; +import { MessageInlineVideo } from "../MessageInlineVideo"; import { useVideoPlayer } from "../overlays/VideoPlayer"; import { autolink } from "../utils/autolink"; import { CollapsibleText } from "../CollapsibleText"; @@ -56,7 +57,6 @@ function VideoAttachmentCard({ }) { const { openVideo } = useVideoPlayer(); const [playing, setPlaying] = useState(false); - const videoRef = useRef<HTMLVideoElement>(null); const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl; const duration = formatDuration(attachment.durationSec); const previewVideoUrl = attachment.url.includes("#") @@ -71,37 +71,21 @@ function VideoAttachmentCard({ : "h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]" }`} style={compact ? undefined : { aspectRatio: videoRatio(attachment) }} - onClick={() => { - if (playing) { - const v = videoRef.current; - openVideo(attachment, v?.currentTime ?? 0); - } - }} > {playing && !compact ? ( - <> - <video - ref={videoRef} - src={attachment.url} - poster={attachment.posterUrl} - controls - playsInline - autoPlay - className="absolute inset-0 h-full w-full" - /> - <AttachmentDownloadPill - postId={postId} - attachment={attachment} - leadingLabel={duration} - className="absolute left-2 top-2 z-20" - /> - </> + <MessageInlineVideo + postId={postId} + attachment={attachment} + leadingLabel={duration} + /> ) : ( <> {posterUrl ? ( <img src={posterUrl} alt="" + loading="lazy" + decoding="async" className={`absolute inset-0 h-full w-full object-cover ${ overlayCount ? "blur-sm scale-105" : "" }`} @@ -142,7 +126,7 @@ function VideoAttachmentCard({ type="button" onClick={(e) => { e.stopPropagation(); - if (compact) openVideo(attachment, 0); + if (compact) openVideo(attachment, 0, undefined, postId); else setPlaying(true); }} className="absolute inset-0 flex items-center justify-center" @@ -183,7 +167,6 @@ function AttachmentListDownloadButton({ setIsDownloading(true); try { await downloadAttachment(postId, attachment.id, attachment.filename); - showToast(t("downloadOk")); } catch { showToast(t("downloadFail"), "error"); } finally { @@ -278,6 +261,8 @@ function VideoListDialog({ <img src={thumb} alt="" + loading="lazy" + decoding="async" className="h-full w-full object-cover" /> ) : ( @@ -370,7 +355,7 @@ export function VideoBubble({ post }: { post: Post }) { onClose={() => setListOpen(false)} onPick={(att) => { setListOpen(false); - openVideo(att, 0); + openVideo(att, 0, undefined, post.id); }} /> ) : null} diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index fc7643b..5a12617 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -94,6 +94,36 @@ function buildRealUrl(params: PostStreamParams, cursor?: string): string { return `${q ? "/api/posts/search" : "/api/posts"}?${sp.toString()}`; } +type CachedStream = { + items: Post[]; + cursor: string | undefined; + hasMore: boolean; +}; + +/** + * Session cache of loaded stream state, keyed by the request params. Lets a + * view that's been seen before (e.g. switching Home ⇄ All) restore instantly — + * all loaded pages, no skeleton, no refetch — instead of reloading from page 1. + * In-memory only: a full page reload starts fresh. + */ +const streamCache = new Map<string, CachedStream>(); + +function streamKey(params: PostStreamParams): string { + return buildRealUrl(params); +} + +/** + * Warm the cache for a stream view before the user navigates to it, so opening + * the page shows content immediately instead of starting to load on arrival. + * No-op for the mock backend or when the first page is already cached. + */ +export function prefetchPostStream(params: PostStreamParams): void { + if (USE_MOCK) return; + const url = buildRealUrl(params); + if (readJSONCache<PostListResponse>(url)) return; + getJSON<PostListResponse>(url).catch(() => {}); +} + export function usePostStream(params: PostStreamParams): PostStreamResult { const [items, setItems] = useState<Post[]>([]); const [hasMore, setHasMore] = useState(true); @@ -172,6 +202,17 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { ); useEffect(() => { + // Restore a previously-loaded view instantly (no reset, no refetch). + const cached = streamCache.get(streamKey(params)); + if (cached) { + setItems(cached.items); + cursorRef.current = cached.cursor; + setHasMore(cached.hasMore); + hasMoreRef.current = cached.hasMore; + setIsLoading(false); + setError(null); + return; + } setItems([]); cursorRef.current = undefined; setHasMore(true); @@ -188,6 +229,17 @@ export function usePostStream(params: PostStreamParams): PostStreamResult { params.lang, ]); + // Persist loaded state so returning to this view restores it from cache. + useEffect(() => { + if (items.length === 0) return; + streamCache.set(streamKey(params), { + items, + cursor: cursorRef.current, + hasMore, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [items, hasMore]); + const loadMore = useCallback(() => { fetchPage(false); }, [fetchPage]); diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index c41657d..fc5f32a 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -8,14 +8,11 @@ import { type PropsWithChildren, } from "react"; import { createPortal } from "react-dom"; -import { ChevronLeft, ChevronRight, LoaderCircle, X } from "lucide-react"; +import { ChevronLeft, ChevronRight, X } from "lucide-react"; import type { Attachment } from "../../../types/post"; -import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon"; -import { useI18n } from "../../../i18n"; -import { useToast } from "../../Toast"; +import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; import { BubbleImage } from "../BubbleImage"; import { autolink } from "../utils/autolink"; -import { downloadAttachment } from "../utils/downloadFile"; type LightboxState = { images: Attachment[]; @@ -78,51 +75,6 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) { ); } -function LightboxDownloadButton({ - postId, - attachment, -}: { - postId: string; - attachment: Attachment; -}) { - const { t } = useI18n(); - const { showToast } = useToast(); - const [isDownloading, setIsDownloading] = useState(false); - - const handleDownload = async () => { - if (isDownloading) return; - setIsDownloading(true); - try { - await downloadAttachment(postId, attachment.id, attachment.filename); - showToast(t("downloadOk")); - } catch { - showToast(t("downloadFail"), "error"); - } finally { - setIsDownloading(false); - } - }; - - return ( - <button - type="button" - onClick={(e) => { - e.stopPropagation(); - handleDownload(); - }} - disabled={isDownloading} - className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 disabled:cursor-wait" - aria-label={isDownloading ? t("downloading") : t("download")} - aria-busy={isDownloading} - > - {isDownloading ? ( - <LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.3} /> - ) : ( - <DownloadCloudIcon className="h-5 w-5" /> - )} - </button> - ); -} - function Filmstrip({ images, index, @@ -199,12 +151,11 @@ function LightboxView({ const [isCaptionVisible, setIsCaptionVisible] = useState(true); const touchStartX = useRef<number | null>(null); - const goPrev = useCallback( - () => setIndex((i) => (i - 1 + images.length) % images.length), - [images.length], - ); + // Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow + // keys should all behave like a linear gallery, not a carousel. + const goPrev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []); const goNext = useCallback( - () => setIndex((i) => (i + 1) % images.length), + () => setIndex((i) => Math.min(images.length - 1, i + 1)), [images.length], ); @@ -262,46 +213,51 @@ function LightboxView({ </div> <div className="flex items-center gap-2"> {postId ? ( - <LightboxDownloadButton postId={postId} attachment={current} /> + <AttachmentDownloadPill + postId={postId} + attachment={current} + size="lg" + className="" + /> ) : null} <button type="button" onClick={onClose} - className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20" + className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20" aria-label="Close" > - <X className="h-5 w-5" /> + <X className="h-6 w-6" /> </button> </div> </div> {/* Image stage */} <div className="relative flex min-h-0 w-full flex-1 items-center justify-center"> - {hasMany ? ( - <> - <button - type="button" - onClick={(e) => { - e.stopPropagation(); - goPrev(); - }} - className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:left-6" - aria-label="Previous" - > - <ChevronLeft className="h-6 w-6" /> - </button> - <button - type="button" - onClick={(e) => { - e.stopPropagation(); - goNext(); - }} - className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:right-6" - aria-label="Next" - > - <ChevronRight className="h-6 w-6" /> - </button> - </> + {hasMany && index > 0 ? ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + goPrev(); + }} + className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/25 transition hover:bg-black/80 md:left-6" + aria-label="Previous" + > + <ChevronLeft className="h-6 w-6" /> + </button> + ) : null} + {hasMany && index < images.length - 1 ? ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + goNext(); + }} + className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-black/60 text-white shadow-lg ring-1 ring-white/25 transition hover:bg-black/80 md:right-6" + aria-label="Next" + > + <ChevronRight className="h-6 w-6" /> + </button> ) : null} <div diff --git a/src/components/messageStream/overlays/VideoPlayer.tsx b/src/components/messageStream/overlays/VideoPlayer.tsx index 48722ae..310a64b 100644 --- a/src/components/messageStream/overlays/VideoPlayer.tsx +++ b/src/components/messageStream/overlays/VideoPlayer.tsx @@ -10,14 +10,32 @@ import { import { createPortal } from "react-dom"; import { X } from "lucide-react"; import type { Attachment } from "../../../types/post"; +import { AttachmentDownloadPill } from "../AttachmentDownloadPill"; +import { MessageInlineVideo } from "../MessageInlineVideo"; + +type OnClose = (finalTime: number) => void; type PlayerState = { attachment: Attachment; currentTime: number; + onClose?: OnClose; + /** Post the video belongs to, needed for the download pill. */ + postId?: string; } | null; type Ctx = { - openVideo: (attachment: Attachment, currentTime?: number) => void; + /** + * Open the fullscreen player. `onClose` (optional) is invoked with the + * playhead at the moment the user dismisses the overlay, so callers can + * sync the original inline `<video>` back to the time the user actually + * watched until. + */ + openVideo: ( + attachment: Attachment, + currentTime?: number, + onClose?: OnClose, + postId?: string, + ) => void; closeVideo: () => void; }; @@ -34,8 +52,12 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) { const [state, setState] = useState<PlayerState>(null); const openVideo = useCallback( - (attachment: Attachment, currentTime = 0) => - setState({ attachment, currentTime }), + ( + attachment: Attachment, + currentTime = 0, + onClose?: OnClose, + postId?: string, + ) => setState({ attachment, currentTime, onClose, postId }), [], ); const closeVideo = useCallback(() => setState(null), []); @@ -47,7 +69,11 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) { <PlayerView attachment={state.attachment} startAt={state.currentTime} - onClose={closeVideo} + postId={state.postId} + onClose={(finalTime) => { + state.onClose?.(finalTime); + setState(null); + }} /> ) : null} </VideoPlayerContext.Provider> @@ -57,61 +83,106 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) { function PlayerView({ attachment, startAt, + postId, onClose, }: { attachment: Attachment; startAt: number; - onClose: () => void; + postId?: string; + onClose: (finalTime: number) => void; }) { - const videoRef = useRef<HTMLVideoElement>(null); + // Track the playhead reported by the embedded `MessageInlineVideo` so we + // can hand it back to the caller when the user dismisses the overlay. + const lastTimeRef = useRef<number>(startAt); + + const close = useCallback(() => { + const finalTime = Number.isFinite(lastTimeRef.current) + ? lastTimeRef.current + : startAt; + onClose(finalTime); + }, [onClose, startAt]); useEffect(() => { const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") close(); }; window.addEventListener("keydown", onKey); - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; + + // iOS-compatible scroll lock: pin the body in place at the current scroll + // offset, then restore both styles and scroll position on cleanup. Plain + // `overflow: hidden` doesn't work on iOS Safari and can reset scroll to 0. + const scrollY = window.scrollY; + const body = document.body; + const prev = { + position: body.style.position, + top: body.style.top, + left: body.style.left, + right: body.style.right, + width: body.style.width, + overflow: body.style.overflow, + }; + body.style.position = "fixed"; + body.style.top = `-${scrollY}px`; + body.style.left = "0"; + body.style.right = "0"; + body.style.width = "100%"; + body.style.overflow = "hidden"; + return () => { window.removeEventListener("keydown", onKey); - document.body.style.overflow = prevOverflow; + body.style.position = prev.position; + body.style.top = prev.top; + body.style.left = prev.left; + body.style.right = prev.right; + body.style.width = prev.width; + body.style.overflow = prev.overflow; + window.scrollTo(0, scrollY); }; - }, [onClose]); - - useEffect(() => { - const v = videoRef.current; - if (!v) return; - if (startAt > 0) v.currentTime = startAt; - v.play().catch(() => {}); - }, [startAt]); + }, [close]); return createPortal( <div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95" - onClick={onClose} + onClick={close} role="dialog" aria-modal="true" > + {postId ? ( + <AttachmentDownloadPill + postId={postId} + attachment={attachment} + size="lg" + className="absolute left-4 top-4 z-10" + /> + ) : null} <button type="button" onClick={(e) => { e.stopPropagation(); - onClose(); + close(); }} - className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20" + className="absolute right-4 top-4 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-white/20" aria-label="Close" > - <X className="h-5 w-5" /> + <X className="h-6 w-6" /> </button> - <video - ref={videoRef} - src={attachment.url} - poster={attachment.posterUrl} - controls - playsInline - className="max-h-[92vh] max-w-[96vw] outline-none" + <div + className="relative h-full w-full" onClick={(e) => e.stopPropagation()} - /> + > + <MessageInlineVideo + postId={postId ?? ""} + attachment={attachment} + initialTime={startAt} + autoPlay + hideDownload + hideFullscreen + size="lg" + onTimeUpdate={(t) => { + lastTimeRef.current = t; + }} + /> + </div> </div>, document.body, ); diff --git a/src/components/messageStream/utils/albumLayout.ts b/src/components/messageStream/utils/albumLayout.ts index 2ea0488..6d61849 100644 --- a/src/components/messageStream/utils/albumLayout.ts +++ b/src/components/messageStream/utils/albumLayout.ts @@ -34,8 +34,13 @@ export type AlbumLayout = { * Keep ratios within a sane range so one extreme image can't make the whole * mosaic absurdly tall or flat. Beyond this the cell crops (object-cover). */ -function clampRatio(ratio: number | undefined): number { +function safeRatio(ratio: number | undefined): number { if (!ratio || !Number.isFinite(ratio) || ratio <= 0) return 1; + return ratio; +} + +/** Bound a ratio so one extreme image can't make a multi-image mosaic ugly. */ +function clampRatio(ratio: number): number { return Math.min(2, Math.max(0.55, ratio)); } @@ -116,8 +121,11 @@ function layoutPrimaryPlusLine(ratios: number[]): AlbumLayout { export function computeAlbumLayout( rawRatios: (number | undefined)[], ): AlbumLayout | null { - const ratios = rawRatios.map(clampRatio); + const ratios = rawRatios.map(safeRatio); if (ratios.length < 2) return null; + // Two images keep their true ratios so each cell matches its image exactly + // (object-cover then fits with no cropping). 3+ are clamped to keep the + // mosaic tidy. if (ratios.length === 2) return layoutTwo(ratios); - return layoutPrimaryPlusLine(ratios); + return layoutPrimaryPlusLine(ratios.map(clampRatio)); } diff --git a/src/components/messageStream/utils/formatTime.ts b/src/components/messageStream/utils/formatTime.ts index 4e07164..3dacecc 100644 --- a/src/components/messageStream/utils/formatTime.ts +++ b/src/components/messageStream/utils/formatTime.ts @@ -1,34 +1,17 @@ -function localeFor(lang: string): string { - const locales: Record<string, string> = { - zh: "zh-CN", - en: "en-US", - ja: "ja-JP", - ko: "ko-KR", - vi: "vi-VN", - id: "id-ID", - ms: "ms-MY", - }; - return locales[lang] ?? "en-US"; +function pad2(n: number): string { + return String(n).padStart(2, "0"); } -function formatDate(iso: string, lang: string): string { +function formatDate(iso: string): string { const d = new Date(iso); - return new Intl.DateTimeFormat(localeFor(lang), { - year: "numeric", - month: lang === "en" ? "short" : "numeric", - day: "numeric", - }).format(d); + return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; } -export function formatTime(iso: string, lang: string): string { +export function formatTime(iso: string): string { const d = new Date(iso); - return new Intl.DateTimeFormat(localeFor(lang), { - hour: "numeric", - minute: "2-digit", - hour12: lang === "en", - }).format(d); + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } -export function formatDateTime(iso: string, lang: string): string { - return `${formatDate(iso, lang)} ${formatTime(iso, lang)}`; +export function formatDateTime(iso: string): string { + return `${formatDate(iso)} ${formatTime(iso)}`; } diff --git a/src/components/messageStream/utils/postText.ts b/src/components/messageStream/utils/postText.ts index 2ba5bd2..b469613 100644 --- a/src/components/messageStream/utils/postText.ts +++ b/src/components/messageStream/utils/postText.ts @@ -11,3 +11,19 @@ export function postDisplayText(post: Post, lang: string): string { "" ); } + +export function postTitleText(post: Post, lang: string): string { + const key = localizationKey(lang); + const localized = + post.localizations?.[key as keyof typeof post.localizations]; + const explicitTitle = localized?.title?.trim() || post.title?.trim(); + if (explicitTitle) return explicitTitle; + + const text = postDisplayText(post, lang); + return ( + text + .split(/\n{1,}/) + .map((line) => line.trim()) + .find(Boolean) || "" + ); +} diff --git a/src/i18n.tsx b/src/i18n.tsx index 72f1887..ca3b7d7 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -122,10 +122,6 @@ const zhDict: Dict = { resourceLangFilter: "资料语言", filterTagClear: "清除标签", filterLanguageAll: "全部语言", - aboutTitle: "关于本站", - aboutIntro: - "ARK 数据库汇总官方教材、公告、视频与常用文件,帮助社区快速获取一致版本的可信内容。\n\n本站仅供展示与索引;权利归属以官方公告为准。", - footerAbout: "关于本站", footerAdminLogin: "管理员登录", adminSearchLogs: "搜索记录", adminMetricShares: "分享", @@ -254,10 +250,6 @@ const enDict: Dict = { resourceLangFilter: "Resource language", filterTagClear: "Clear tag", filterLanguageAll: "All languages", - aboutTitle: "About this site", - aboutIntro: - "The ARK library brings together official decks, announcements, videos, and common files so the community can find consistent, trustworthy versions quickly.\n\nThis site is for discovery and indexing only; rights remain with official notices.", - footerAbout: "About", footerAdminLogin: "Admin sign-in", adminSearchLogs: "Search logs", adminMetricShares: "Shares", diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 6b78a19..7d70149 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; import { pageTransition } from "../motion"; import { ArkLogoMark } from "../components/ArkLogoMark"; +import { usePageTitle } from "../components/PageTitleContext"; +import { prefetchPostStream } from "../components/messageStream/hooks/usePostStream"; import { BackToTop } from "../components/BackToTop"; import { DocumentMeta } from "../components/DocumentMeta"; import { SearchPanel } from "../components/SearchPanel"; @@ -17,8 +19,7 @@ type PublicNavWhich = | "browseLatest" | "browseRecommended" | "browsePopular" - | "favorites" - | "about"; + | "favorites"; function navIsActive( pathname: string, @@ -47,8 +48,6 @@ function navIsActive( return ( pathname === "/favorites" || (pathname === "/" && hash === "#favorites") ); - case "about": - return pathname === "/about"; default: return false; } @@ -292,6 +291,45 @@ export function PublicLayout() { navIsActive(pathname, search, hash, which); const isHome = pathname === "/"; const footerInContentFlow = pathname === "/browse"; + // Current page name shown in the header brand slot (falls back to the brand). + const pageTitle = usePageTitle(); + + // Warm the common stream views (全部资料 / 热门资料 / 最新) in the background so + // tapping them shows content immediately. Run one at a time, spaced out and + // only while idle, so prefetching never competes with the current page or + // janks low-end phones. Prefetch is JSON-only (no images). + useEffect(() => { + const base = { scope: { kind: "all" as const }, type: "all", q: "", lang }; + const jobs = [ + () => prefetchPostStream({ ...base, sort: "" }), + () => prefetchPostStream({ ...base, sort: "popular" }), + () => prefetchPostStream({ ...base, sort: "latest" }), + ]; + const ric = window as typeof window & { + requestIdleCallback?: (cb: () => void) => number; + cancelIdleCallback?: (id: number) => void; + }; + + let i = 0; + let stepTimer = 0; + let idleId = 0; + const runNext = () => { + if (i >= jobs.length) return; + jobs[i++](); + stepTimer = window.setTimeout(schedule, 400); // space requests apart + }; + const schedule = () => { + if (ric.requestIdleCallback) idleId = ric.requestIdleCallback(runNext); + else stepTimer = window.setTimeout(runNext, 200); + }; + + const startTimer = window.setTimeout(schedule, 600); + return () => { + window.clearTimeout(startTimer); + window.clearTimeout(stepTimer); + if (idleId) ric.cancelIdleCallback?.(idleId); + }; + }, [lang]); const popularHref = "/browse?sort=popular"; const goSearch = () => { @@ -311,6 +349,12 @@ export function PublicLayout() { useEffect(() => { if (!open) return; + // Opening the menu from the burger also closes the search overlay, whose + // scroll-lock cleanup fires a programmatic scroll. Ignore scroll-to-close + // for a brief window so that restore scroll doesn't shut the menu we just + // opened; genuine user scrolls afterwards still close it. + const openedAt = Date.now(); + const closeOnOutside = (event: MouseEvent | TouchEvent) => { const target = event.target as Node; if ( @@ -322,7 +366,10 @@ export function PublicLayout() { } setOpen(false); }; - const closeOnScroll = () => setOpen(false); + const closeOnScroll = () => { + if (Date.now() - openedAt < 250) return; + setOpen(false); + }; document.addEventListener("mousedown", closeOnOutside); document.addEventListener("touchstart", closeOnOutside); @@ -334,25 +381,63 @@ export function PublicLayout() { }; }, [open]); + // Lock background scroll while the mobile search overlay is open. + // Uses the iOS-compatible position-fixed pattern so the underlying page + // doesn't move at all (overflow:hidden alone is not enough on iOS Safari). + useEffect(() => { + if (!mobileSearchOpen) return; + const scrollY = window.scrollY; + const body = document.body; + const prev = { + position: body.style.position, + top: body.style.top, + left: body.style.left, + right: body.style.right, + width: body.style.width, + }; + body.style.position = "fixed"; + body.style.top = `-${scrollY}px`; + body.style.left = "0"; + body.style.right = "0"; + body.style.width = "100%"; + return () => { + body.style.position = prev.position; + body.style.top = prev.top; + body.style.left = prev.left; + body.style.right = prev.right; + body.style.width = prev.width; + window.scrollTo(0, scrollY); + }; + }, [mobileSearchOpen]); + return ( <div className="min-h-full flex flex-col"> <DocumentMeta /> <header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98"> <div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden"> - <Link - to="/" - className="flex h-8 shrink-0 items-center gap-2 rounded-sm text-[20px] font-black leading-5 tracking-tight text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]" - aria-label={t("brand")} - onClick={(e) => { - if (isHome) { - e.preventDefault(); - window.scrollTo({ top: 0, behavior: "smooth" }); - } - }} - > - <ArkLogoMark className="h-8 w-8 shrink-0" /> - <span className="truncate text-ark-gold">{t("brand")}</span> - </Link> + <div className="flex h-8 min-w-0 shrink items-center gap-2 text-[20px] font-black leading-5 tracking-tight text-ark-gold"> + {/* Logo → home; page-name text → scroll to top of the current page. */} + <Link + to="/" + aria-label={t("brand")} + onClick={(e) => { + if (isHome) { + e.preventDefault(); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }} + className="shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]" + > + <ArkLogoMark className="h-8 w-8 shrink-0" /> + </Link> + <button + type="button" + onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} + className="truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]" + > + {pageTitle || t("brand")} + </button> + </div> <div className="flex h-[40px] w-[136px] shrink-0 items-center gap-[8px]"> <button @@ -415,21 +500,29 @@ export function PublicLayout() { <div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-0"> {/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */} <div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4"> - <Link - to="/" - className="flex min-w-0 shrink-0 items-center gap-2.5 rounded-sm text-xl font-bold tracking-wide text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" - onClick={(e) => { - if (isHome) { - e.preventDefault(); - window.scrollTo({ top: 0, behavior: "smooth" }); - } - }} - > - <ArkLogoMark className="h-10 w-10 shrink-0" /> - <span className="max-w-[8rem] truncate text-ark-gold sm:inline"> - {t("brand")} - </span> - </Link> + <div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold"> + {/* Logo → home; page-name text → scroll to top of the current page. */} + <Link + to="/" + aria-label={t("brand")} + onClick={(e) => { + if (isHome) { + e.preventDefault(); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + }} + className="shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" + > + <ArkLogoMark className="h-10 w-10 shrink-0" /> + </Link> + <button + type="button" + onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })} + className="max-w-[10rem] truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg sm:inline" + > + {pageTitle || t("brand")} + </button> + </div> <nav className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 min-[1200px]:flex lg:gap-5" @@ -477,13 +570,6 @@ export function PublicLayout() { > {t("favorites")} </Link> - <Link - to="/about" - className={navClassName(na("about"))} - aria-current={na("about") ? "page" : undefined} - > - {t("footerAbout")} - </Link> </nav> <div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none"> @@ -593,14 +679,6 @@ export function PublicLayout() { > {t("favorites")} </Link> - <Link - to="/about" - className={navClassName(na("about"))} - aria-current={na("about") ? "page" : undefined} - onClick={() => setOpen(false)} - > - {t("footerAbout")} - </Link> </div> ) : null} </header> @@ -620,7 +698,7 @@ export function PublicLayout() { isHome ? "flex-1 px-0 pb-6 pt-0 md:px-9 md:pb-10 md:pt-10 xl:px-0" : footerInContentFlow - ? "px-0 pb-0 pt-0 md:px-9 md:pt-10 xl:px-0" + ? "flex-1 px-0 pb-0 pt-0 md:px-9 md:pt-10 xl:px-0" : "flex-1 px-4 pb-6 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pb-10 md:pt-10 xl:px-0" }`} > @@ -637,19 +715,6 @@ export function PublicLayout() { </AnimatePresence> </main> - <footer className="mt-auto bg-transparent md:border-t md:border-ark-line md:bg-ark-nav/90"> - <div className="mx-auto flex h-[52px] max-w-[358px] items-center justify-center px-4 py-4 text-[13px] leading-5 md:h-auto md:max-w-[1280px] md:justify-start md:px-9 md:py-6 md:text-sm xl:px-0"> - <Link - to="/about" - className={`rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${ - na("about") ? "text-ark-gold" : "text-[#A8A9AE]" - }`} - > - {t("footerAbout")} - </Link> - </div> - </footer> - <nav className="sticky inset-x-0 bottom-0 z-40 bg-[#0C0D0F]/90 backdrop-blur md:hidden"> <div className="grid h-[78px] grid-cols-4 gap-3 px-5 py-4 text-center text-[11px] leading-[17.6px]"> <BottomNavIcon diff --git a/src/mocks/mockPosts.ts b/src/mocks/mockPosts.ts index 8e86149..6c8cc97 100644 --- a/src/mocks/mockPosts.ts +++ b/src/mocks/mockPosts.ts @@ -156,6 +156,19 @@ export const MOCK_POSTS: Post[] = [ isRecommended: false, publishedAt: "2026-01-19T16:20:00.000Z", updatedAt: "2026-01-19T16:20:00.000Z", + // Mock: only the FIRST URL in the text is previewed. + linkPreview: { + url: "https://coinmarketcap.com/currencies/ark-defai/", + canonicalUrl: "https://coinmarketcap.com/currencies/ark-defai/", + siteName: "coinmarketcap.com", + title: "ARK DeFAI Price, Chart & Market Cap", + description: + "Track ARK DeFAI live price, market cap, volume and historical chart on CoinMarketCap. Verified contract address, holders and on-chain analytics.", + imageUrl: img(81, 1200, 630), + imageWidth: 1200, + imageHeight: 630, + themeColor: "#2962FF", + }, }, // 6) 纯文本 + 单链接(简短公告) @@ -254,6 +267,15 @@ export const MOCK_POSTS: Post[] = [ categorySlug: "meeting", language: "zh-CN", text: "📌 ARK DeFAI 方舟晨间时刻\n\n🧠 会议主题:市场概况交流 & 市场问题讨论。\n🕙 会议时间:3月1日(日)10:00\n🎬 直播腾讯会议链接:https://meeting.tencent.com/l/G718S4Sedm38", + linkPreview: { + url: "https://meeting.tencent.com/l/G718S4Sedm38", + canonicalUrl: "https://meeting.tencent.com/l/G718S4Sedm38", + siteName: "meeting.tencent.com", + title: "腾讯会议 · ARK DeFAI 方舟晨间时刻", + description: + "点击直接加入直播会议。需要 App 或浏览器插件。会议号会在点击后自动补全。", + themeColor: "#0080FF", + }, attachments: [ { id: "a-010", diff --git a/src/motion/variants.ts b/src/motion/variants.ts index 34d641f..e37f035 100644 --- a/src/motion/variants.ts +++ b/src/motion/variants.ts @@ -41,11 +41,16 @@ export const staggerContainer: Variants = { }, }; -/** Route enter/exit transition used with AnimatePresence. */ +/** + * Route enter/exit transition used with AnimatePresence (mode="wait"). The old + * page is removed instantly (exit duration 0) so it never lingers as a ghost + * frame behind the incoming page; only the new page animates, fading in. This + * keeps a visible transition without any cross-fade overlap. + */ export const pageTransition: Variants = { - initial: { opacity: 0, y: 8 }, - enter: { opacity: 1, y: 0, transition: { duration: 0.24, ease: EASE_OUT } }, - exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: EASE_OUT } }, + initial: { opacity: 0 }, + enter: { opacity: 1, transition: { duration: 0.22, ease: EASE_OUT } }, + exit: { opacity: 0, transition: { duration: 0 } }, }; /** Springy hover lift for cards. Use rest/hover states on an `m` element. */ diff --git a/src/pages/About/index.tsx b/src/pages/About/index.tsx deleted file mode 100644 index 4d18e46..0000000 --- a/src/pages/About/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useI18n } from "../../i18n"; -import { Reveal } from "../../motion"; - -export function AboutPage() { - const { t } = useI18n(); - return ( - <div className="mx-auto max-w-2xl space-y-6"> - <Reveal> - <h1 className="text-2xl font-bold">{t("aboutTitle")}</h1> - </Reveal> - <Reveal delay={0.08}> - <p className="text-neutral-300 leading-relaxed whitespace-pre-line"> - {t("aboutIntro")} - </p> - </Reveal> - </div> - ); -} diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx index 0638a8e..e459607 100644 --- a/src/pages/Favorites/index.tsx +++ b/src/pages/Favorites/index.tsx @@ -2,9 +2,12 @@ import { Heart } from "lucide-react"; import { Link } from "react-router-dom"; import { useI18n } from "../../i18n"; import { Reveal } from "../../motion"; +import { useSetPageTitle } from "../../components/PageTitleContext"; export default function Favorites() { const { t } = useI18n(); + // Show "我的收藏" in the global header, consistent with the other pages. + useSetPageTitle(t("favorites")); return ( <Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center"> diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 5019140..f6a263b 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -137,10 +137,15 @@ export function Home() { for (let index = 0; index < figmaOrderedCategories.length; index += 9) { categoryPages.push(figmaOrderedCategories.slice(index, index + 9)); } - const activeCategoryCount = categoryPages[activeCategoryPage]?.length ?? 0; - const activeCategoryRows = Math.ceil(activeCategoryCount / 3); + // Use the tallest page so the carousel height doesn't shrink between + // pages — otherwise the section below jumps up when swiping to a page + // with fewer categories. + const maxCategoryRows = categoryPages.reduce( + (max, page) => Math.max(max, Math.ceil(page.length / 3)), + 0, + ); const mobileCategoryHeight = - activeCategoryRows * 88 + Math.max(0, activeCategoryRows - 1) * 8; + maxCategoryRows * 88 + Math.max(0, maxCategoryRows - 1) * 8; useEffect(() => { const row = categoryRowRef.current; diff --git a/src/pages/PostRedirect/index.tsx b/src/pages/PostRedirect/index.tsx index 3a97e4c..99f9a7f 100644 --- a/src/pages/PostRedirect/index.tsx +++ b/src/pages/PostRedirect/index.tsx @@ -19,9 +19,12 @@ export function PostRedirect() { if (POST_STREAM_USES_MOCK) { const post = MOCK_POSTS.find((p) => p.id === id); - navigate(post ? `/browse#post-${post.id}` : "/browse", { - replace: true, - }); + navigate( + post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse", + { + replace: true, + }, + ); return; } @@ -29,7 +32,7 @@ export function PostRedirect() { `/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`, ) .then((post) => { - navigate(`/browse#post-${post.id}`, { + navigate(`/browse?post=${encodeURIComponent(post.id)}`, { replace: true, }); }) diff --git a/src/types/post.ts b/src/types/post.ts index 8ac3418..4ffe78b 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -14,6 +14,9 @@ export type PostTypeFilter = PostType | "all"; export type AttachmentKind = "image" | "video" | "document"; export type PostLocaleTexts = { + /** Short display title for list/card surfaces. */ + title?: string; + /** Full post body text. */ text: string; }; @@ -34,6 +37,24 @@ export type Attachment = { thumbnailUrl?: string; }; +/** + * Preview metadata for the first URL found in a post's text. See + * `docs/link-preview.md` for the back-end contract. + */ +export type LinkPreview = { + url: string; + canonicalUrl: string; + siteName: string; + title: string; + description: string; + imageUrl?: string; + imageWidth?: number; + imageHeight?: number; + favicon?: string; + /** Hex color used for the left accent bar (e.g. "#12FF80"). */ + themeColor?: string; +}; + export type Post = { id: string; postType?: PostType | string; @@ -41,6 +62,8 @@ export type Post = { categorySlug: string; language: string; sourceLanguage?: string; + /** Short display title for list/card surfaces. */ + title?: string; text?: string; localizations?: Partial<PostLocalizations>; attachments: Attachment[]; @@ -49,6 +72,8 @@ export type Post = { updatedAt?: string; createdAt?: string; tags?: string[]; + /** Preview card for the first URL in `text`. At most one per post. */ + linkPreview?: LinkPreview; }; export type PostListResponse = { diff --git a/src/utils/postResourceAdapter.ts b/src/utils/postResourceAdapter.ts index fec65b3..aa850d0 100644 --- a/src/utils/postResourceAdapter.ts +++ b/src/utils/postResourceAdapter.ts @@ -1,6 +1,9 @@ import type { Category, Resource } from "../api"; import type { Attachment, Post } from "../types/post"; -import { postDisplayText } from "../components/messageStream/utils/postText"; +import { + postDisplayText, + postTitleText, +} from "../components/messageStream/utils/postText"; export type PostBackedResource = Resource & { downloadPostId?: string; @@ -35,7 +38,7 @@ export function postToResource( categories: Category[] = [], ): PostBackedResource { const first = post.attachments[0]; - const title = postDisplayText(post, lang) || first?.filename || post.id; + const title = postTitleText(post, lang) || first?.filename || post.id; const category = categories.find((c) => c.id === post.categoryId); return { id: post.id,