Merge pull request 'terry-staging' (#11) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 35s

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-05-29 19:29:58 +00:00
39 changed files with 1597 additions and 361 deletions

258
docs/link-preview.md Normal file
View File

@@ -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=<encoded-absolute-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 `<link rel=canonical>` 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``<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 &lt;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?

89
docs/posts-title-api.md Normal file
View File

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

View File

@@ -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`(小改动,待接口可用后进行)。

View File

@@ -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 资料库" />

View File

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

View File

@@ -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>
);

View File

@@ -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") };
}

View File

@@ -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]);
}

View File

@@ -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)}
/>
) : (

View File

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

View File

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

View File

@@ -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")
) : (

View File

@@ -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);
}}

View File

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

View File

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

View File

@@ -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>
);
}

View File

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

View File

@@ -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>
</>
);
}

View File

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

View File

@@ -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>
);

View File

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

View File

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

View File

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

View File

@@ -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]);

View File

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

View File

@@ -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,
);

View File

@@ -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));
}

View File

@@ -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)}`;
}

View File

@@ -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) || ""
);
}

View File

@@ -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",

View File

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

View File

@@ -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",

View File

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

View File

@@ -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>
);
}

View File

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

View File

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

View File

@@ -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,
});
})

View File

@@ -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 = {

View File

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