Merge pull request 'terry-staging' (#11) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 35s
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:
258
docs/link-preview.md
Normal file
258
docs/link-preview.md
Normal 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 <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
89
docs/posts-title-api.md
Normal 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 |
|
||||||
87
docs/search-and-tags-api.md
Normal file
87
docs/search-and-tags-api.md
Normal 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`(小改动,待接口可用后进行)。
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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="theme-color" content="#08070c" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark" />
|
||||||
<meta name="application-name" content="ARK 资料库" />
|
<meta name="application-name" content="ARK 资料库" />
|
||||||
|
|||||||
15
src/App.tsx
15
src/App.tsx
@@ -11,7 +11,7 @@ import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations";
|
|||||||
import { SearchPage } from "./pages/Search";
|
import { SearchPage } from "./pages/Search";
|
||||||
import { PostRedirect } from "./pages/PostRedirect";
|
import { PostRedirect } from "./pages/PostRedirect";
|
||||||
import { ScrollToTop } from "./components/ScrollToTop";
|
import { ScrollToTop } from "./components/ScrollToTop";
|
||||||
import { AboutPage } from "./pages/About";
|
import { PageTitleProvider } from "./components/PageTitleContext";
|
||||||
import Favorites from "./pages/Favorites";
|
import Favorites from "./pages/Favorites";
|
||||||
import { adminUiPrefix } from "./adminPaths";
|
import { adminUiPrefix } from "./adminPaths";
|
||||||
import { AdminRouteTree } from "./adminRouteTree";
|
import { AdminRouteTree } from "./adminRouteTree";
|
||||||
@@ -29,13 +29,17 @@ export default function App() {
|
|||||||
<AdminRouterModeProvider value="absolute">
|
<AdminRouterModeProvider value="absolute">
|
||||||
<ImageLightboxProvider>
|
<ImageLightboxProvider>
|
||||||
<VideoPlayerProvider>
|
<VideoPlayerProvider>
|
||||||
|
<PageTitleProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route path="/categories" element={<CategoriesPage />} />
|
<Route
|
||||||
|
path="/categories"
|
||||||
|
element={<CategoriesPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/official-recommendations"
|
path="/official-recommendations"
|
||||||
element={<OfficialRecommendationsPage />}
|
element={<OfficialRecommendationsPage />}
|
||||||
@@ -45,8 +49,10 @@ export default function App() {
|
|||||||
element={<CategoryPage />}
|
element={<CategoryPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route path="/search" element={<SearchPage />} />
|
||||||
<Route path="/resource/:id" element={<PostRedirect />} />
|
<Route
|
||||||
<Route path="/about" element={<AboutPage />} />
|
path="/resource/:id"
|
||||||
|
element={<PostRedirect />}
|
||||||
|
/>
|
||||||
<Route path="/favorites" element={<Favorites />} />
|
<Route path="/favorites" element={<Favorites />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
@@ -62,6 +68,7 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</PageTitleProvider>
|
||||||
</VideoPlayerProvider>
|
</VideoPlayerProvider>
|
||||||
</ImageLightboxProvider>
|
</ImageLightboxProvider>
|
||||||
</AdminRouterModeProvider>
|
</AdminRouterModeProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { PostScope } from "../types/post";
|
import type { PostScope } from "../types/post";
|
||||||
import { MessageStream } from "./messageStream/MessageStream";
|
import { MessageStream } from "./messageStream/MessageStream";
|
||||||
import { SectionHeader } from "./SectionHeader";
|
import { useSetPageTitle } from "./PageTitleContext";
|
||||||
|
|
||||||
type AssetStreamPageProps = {
|
type AssetStreamPageProps = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -8,11 +8,11 @@ type AssetStreamPageProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function AssetStreamPage({ title, scope }: 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 (
|
return (
|
||||||
<section>
|
<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} />
|
<MessageStream scope={scope} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const descriptions: Record<Lang, Record<string, string>> = {
|
|||||||
"按分类探索 ARK 官方资料,快速定位所需教材、公告、视频、图片与文件。",
|
"按分类探索 ARK 官方资料,快速定位所需教材、公告、视频、图片与文件。",
|
||||||
official: "查看 ARK 官方推荐资料,获取优先整理的重点内容与可信资源。",
|
official: "查看 ARK 官方推荐资料,获取优先整理的重点内容与可信资源。",
|
||||||
favorites: "收藏功能开发中,未来可在这里集中管理常用 ARK 资料。",
|
favorites: "收藏功能开发中,未来可在这里集中管理常用 ARK 资料。",
|
||||||
about: "了解 ARK 资料库的用途、资料范围与本站索引说明。",
|
|
||||||
search: "在 ARK 资料库中搜索标题、分类、标签、简介、文件类型与正文内容。",
|
search: "在 ARK 资料库中搜索标题、分类、标签、简介、文件类型与正文内容。",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
@@ -28,8 +27,6 @@ const descriptions: Record<Lang, Record<string, string>> = {
|
|||||||
"View official ARK recommendations and prioritized trusted resources.",
|
"View official ARK recommendations and prioritized trusted resources.",
|
||||||
favorites:
|
favorites:
|
||||||
"Favorites are in development and will help you manage commonly used ARK resources.",
|
"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:
|
||||||
"Search ARK Library titles, categories, tags, summaries, file types, and body text.",
|
"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") };
|
return { title: t("brand"), description: metaDescription(lang, "home") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
41
src/components/PageTitleContext.tsx
Normal file
41
src/components/PageTitleContext.tsx
Normal 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]);
|
||||||
|
}
|
||||||
@@ -108,7 +108,6 @@ function PopularRankRow({
|
|||||||
r.downloadAttachmentId,
|
r.downloadAttachmentId,
|
||||||
r.title,
|
r.title,
|
||||||
);
|
);
|
||||||
showToast(t("downloadOk"));
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t("downloadFail"), "error");
|
showToast(t("downloadFail"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,7 +136,8 @@ function PopularRankRow({
|
|||||||
src={cover}
|
src={cover}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="h-full w-full object-cover"
|
decoding="async"
|
||||||
|
className="h-full w-full object-fill"
|
||||||
onError={() => setCoverFailed(true)}
|
onError={() => setCoverFailed(true)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export function RecommendedCard({
|
|||||||
} else {
|
} else {
|
||||||
await downloadFile(dl, displayTitle);
|
await downloadFile(dl, displayTitle);
|
||||||
}
|
}
|
||||||
showToast(t("downloadOk"));
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t("downloadFail"), "error");
|
showToast(t("downloadFail"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -117,6 +116,7 @@ export function RecommendedCard({
|
|||||||
alt=""
|
alt=""
|
||||||
className="ark-img-fade h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
className="ark-img-fade h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onLoad={(e) => e.currentTarget.classList.add("is-loaded")}
|
onLoad={(e) => e.currentTarget.classList.add("is-loaded")}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -224,6 +224,7 @@ export function ComingSoonRecommendedCard({
|
|||||||
alt=""
|
alt=""
|
||||||
className="h-full w-full object-cover opacity-75 grayscale-[15%]"
|
className="h-full w-full object-cover opacity-75 grayscale-[15%]"
|
||||||
loading="lazy"
|
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">
|
<span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black">
|
||||||
即将到来
|
即将到来
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export function SearchPanel({
|
|||||||
const langParam = useMemo(() => langQuery(lang), [lang]);
|
const langParam = useMemo(() => langQuery(lang), [lang]);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -102,6 +106,13 @@ export function SearchPanel({
|
|||||||
}, [langParam]);
|
}, [langParam]);
|
||||||
|
|
||||||
const showTagPosts = (tag: string) => {
|
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);
|
setSelectedTag(tag);
|
||||||
onQueryChange(tag);
|
onQueryChange(tag);
|
||||||
const searchUrl = buildSearchUrl({
|
const searchUrl = buildSearchUrl({
|
||||||
@@ -133,10 +144,10 @@ export function SearchPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="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-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" />
|
<SearchIcon size={18} className="shrink-0 text-ark-gold" />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -144,7 +155,7 @@ export function SearchPanel({
|
|||||||
onChange={(e) => onQueryChange(e.target.value)}
|
onChange={(e) => onQueryChange(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
onKeyDown={(e) => e.key === "Enter" && onSearch()}
|
||||||
placeholder={t("searchPanelPlaceholder")}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -164,8 +175,8 @@ export function SearchPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-1 flex items-start gap-1.5 text-[11px] leading-4 text-[#777985]">
|
<div className="mt-1 flex items-center gap-1.5 pl-3 text-[12px] leading-4 text-[#777985]">
|
||||||
<Info size={15} className="mt-0.5 shrink-0" />
|
<Info size={14} className="shrink-0" />
|
||||||
<span>{t("searchPanelHint")}</span>
|
<span>{t("searchPanelHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ type AttachmentDownloadPillProps = {
|
|||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
leadingLabel?: string;
|
leadingLabel?: string;
|
||||||
className?: 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({
|
export function AttachmentDownloadPill({
|
||||||
@@ -19,6 +35,8 @@ export function AttachmentDownloadPill({
|
|||||||
attachment,
|
attachment,
|
||||||
leadingLabel,
|
leadingLabel,
|
||||||
className = "absolute left-2 top-2",
|
className = "absolute left-2 top-2",
|
||||||
|
adaptive = false,
|
||||||
|
size = "sm",
|
||||||
}: AttachmentDownloadPillProps) {
|
}: AttachmentDownloadPillProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
@@ -30,7 +48,6 @@ export function AttachmentDownloadPill({
|
|||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
await downloadAttachment(postId, attachment.id, attachment.filename);
|
||||||
showToast(t("downloadOk"));
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t("downloadFail"), "error");
|
showToast(t("downloadFail"), "error");
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={isDownloading}
|
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={
|
aria-label={
|
||||||
isDownloading ? t("downloading") : `Download ${attachment.filename}`
|
isDownloading ? t("downloading") : `Download ${attachment.filename}`
|
||||||
}
|
}
|
||||||
aria-busy={isDownloading}
|
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 ? (
|
{isDownloading ? (
|
||||||
<LoaderCircle
|
<LoaderCircle
|
||||||
className="h-3.5 w-3.5 animate-spin"
|
className={`${iconCls} animate-spin`}
|
||||||
strokeWidth={2.3}
|
strokeWidth={2.3}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<DownloadCloudIcon className="h-3.5 w-3.5" />
|
<DownloadCloudIcon className={iconCls} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex h-6 items-center gap-0.5 px-2">
|
<span className={`flex items-center gap-1 ${textBoxCls}`}>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
t("downloading")
|
t("downloading")
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -68,9 +68,13 @@ export function BubbleImage({
|
|||||||
src={current}
|
src={current}
|
||||||
alt=""
|
alt=""
|
||||||
loading={loading}
|
loading={loading}
|
||||||
className={className}
|
decoding="async"
|
||||||
|
className={`ark-img-fade ${className ?? ""}`}
|
||||||
onLoad={(e) => {
|
onLoad={(e) => {
|
||||||
const img = e.currentTarget;
|
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)
|
if (img.naturalWidth && img.naturalHeight)
|
||||||
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
|
onNaturalSize?.(img.naturalWidth, img.naturalHeight);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function CollapsibleText({
|
|||||||
children,
|
children,
|
||||||
className = "",
|
className = "",
|
||||||
wrapperClassName = "",
|
wrapperClassName = "",
|
||||||
collapsedLines = 8,
|
collapsedLines = 25,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Typography classes applied to the text container. */
|
/** Typography classes applied to the text container. */
|
||||||
@@ -110,7 +110,7 @@ export function CollapsibleText({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
aria-expanded={expanded}
|
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>
|
<span>{expanded ? t("showLess") : t("showMore")}</span>
|
||||||
<m.span
|
<m.span
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
import { typeFilterLabel } from "../../resourceTypeLabels";
|
import { typeFilterLabel } from "../../resourceTypeLabels";
|
||||||
|
|
||||||
@@ -20,19 +21,36 @@ export type FilterChipsProps = {
|
|||||||
|
|
||||||
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
||||||
const { t } = useI18n();
|
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) =>
|
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",
|
"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",
|
"border-b-0 md:border-b-2",
|
||||||
active
|
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",
|
: "border-transparent text-[#97989A] hover:text-ark-gold/80 md:text-neutral-400",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
return (
|
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
|
<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"
|
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"
|
role="tablist"
|
||||||
>
|
>
|
||||||
|
|||||||
65
src/components/messageStream/LinkPreviewCard.tsx
Normal file
65
src/components/messageStream/LinkPreviewCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { Post } from "../../types/post";
|
import type { Post } from "../../types/post";
|
||||||
import { useI18n } from "../../i18n";
|
|
||||||
import { TextBubble } from "./bubbles/TextBubble";
|
import { TextBubble } from "./bubbles/TextBubble";
|
||||||
import { FileDocBubble } from "./bubbles/FileDocBubble";
|
import { FileDocBubble } from "./bubbles/FileDocBubble";
|
||||||
import { ImageBubble } from "./bubbles/ImageBubble";
|
import { ImageBubble } from "./bubbles/ImageBubble";
|
||||||
import { ImageWithTextBubble } from "./bubbles/ImageWithTextBubble";
|
import { ImageWithTextBubble } from "./bubbles/ImageWithTextBubble";
|
||||||
import { AlbumBubble } from "./bubbles/AlbumBubble";
|
import { AlbumBubble } from "./bubbles/AlbumBubble";
|
||||||
import { VideoBubble } from "./bubbles/VideoBubble";
|
import { VideoBubble } from "./bubbles/VideoBubble";
|
||||||
|
import { LinkPreviewCard } from "./LinkPreviewCard";
|
||||||
import { formatDateTime } from "./utils/formatTime";
|
import { formatDateTime } from "./utils/formatTime";
|
||||||
|
|
||||||
type BubbleComponent = ComponentType<{ post: Post }>;
|
type BubbleComponent = ComponentType<{ post: Post }>;
|
||||||
@@ -24,7 +24,6 @@ export function pickBubble(post: Post): BubbleComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MessageBubble({ post }: { post: Post }) {
|
export function MessageBubble({ post }: { post: Post }) {
|
||||||
const { lang } = useI18n();
|
|
||||||
const Bubble = pickBubble(post);
|
const Bubble = pickBubble(post);
|
||||||
const isVisual =
|
const isVisual =
|
||||||
Bubble === AlbumBubble ||
|
Bubble === AlbumBubble ||
|
||||||
@@ -43,13 +42,18 @@ export function MessageBubble({ post }: { post: Post }) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Bubble post={post} />
|
<Bubble post={post} />
|
||||||
|
{post.linkPreview ? (
|
||||||
|
<div className={isVisual ? "px-4 pt-3" : "mt-3"}>
|
||||||
|
<LinkPreviewCard preview={post.linkPreview} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<time
|
<time
|
||||||
dateTime={post.publishedAt}
|
dateTime={post.publishedAt}
|
||||||
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${
|
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>
|
</time>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
368
src/components/messageStream/MessageInlineVideo.tsx
Normal file
368
src/components/messageStream/MessageInlineVideo.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
|
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const filterBarRef = useRef<HTMLDivElement>(null);
|
||||||
const hasMoreRef = useRef(hasMore);
|
const hasMoreRef = useRef(hasMore);
|
||||||
const isLoadingRef = useRef(isLoading);
|
const isLoadingRef = useRef(isLoading);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -70,33 +71,77 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [loadMore]);
|
}, [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
|
// 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.
|
// 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)
|
? hash.slice("#post-".length)
|
||||||
: "";
|
: "";
|
||||||
|
const targetPostId = queryTargetPostId || hashTargetPostId;
|
||||||
const handledTargetRef = useRef<string>("");
|
const handledTargetRef = useRef<string>("");
|
||||||
|
const targetScrollTimersRef = useRef<number[]>([]);
|
||||||
|
|
||||||
|
const clearTargetScrollTimers = () => {
|
||||||
|
for (const timer of targetScrollTimersRef.current) {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
}
|
||||||
|
targetScrollTimersRef.current = [];
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handledTargetRef.current = "";
|
handledTargetRef.current = "";
|
||||||
|
clearTargetScrollTimers();
|
||||||
}, [targetPostId]);
|
}, [targetPostId]);
|
||||||
|
|
||||||
|
useEffect(() => clearTargetScrollTimers, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
||||||
|
|
||||||
const el = document.getElementById(`post-${targetPostId}`);
|
const el = document.getElementById(`post-${targetPostId}`);
|
||||||
if (el) {
|
if (el) {
|
||||||
handledTargetRef.current = targetPostId;
|
handledTargetRef.current = targetPostId;
|
||||||
const frame = window.requestAnimationFrame(() => {
|
clearTargetScrollTimers();
|
||||||
el.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
||||||
|
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");
|
el.classList.add("ark-bubble-highlight");
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
() => el.classList.remove("ark-bubble-highlight"),
|
() => el.classList.remove("ark-bubble-highlight"),
|
||||||
2000,
|
2000,
|
||||||
);
|
);
|
||||||
});
|
return;
|
||||||
return () => window.cancelAnimationFrame(frame);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not loaded yet — keep paging until it appears or the stream is exhausted.
|
// Not loaded yet — keep paging until it appears or the stream is exhausted.
|
||||||
@@ -114,7 +159,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||||
|
{/* 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)} />
|
<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">
|
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
const ratios = useImageRatios(visible, sources);
|
const ratios = useImageRatios(visible, sources);
|
||||||
const layout = computeAlbumLayout(ratios);
|
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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* aspect-ratio sets a definite box height; tiles are absolutely
|
{/* aspect-ratio sets a definite box height; tiles are absolutely
|
||||||
@@ -51,6 +57,8 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
top: `${tile.top * 100}%`,
|
top: `${tile.top * 100}%`,
|
||||||
width: `calc(${tile.width * 100}% - ${ALBUM_GAP}px)`,
|
width: `calc(${tile.width * 100}% - ${ALBUM_GAP}px)`,
|
||||||
height: `calc(${tile.height * 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
|
<button
|
||||||
@@ -65,7 +73,7 @@ export function AlbumBubble({ post }: { post: Post }) {
|
|||||||
src={sources[i]}
|
src={sources[i]}
|
||||||
fallbackSrc={[att.thumbUrl, att.url]}
|
fallbackSrc={[att.thumbUrl, att.url]}
|
||||||
loading="lazy"
|
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 ? (
|
{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">
|
<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}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
{!isLastSlot ? (
|
{!isLastSlot ? (
|
||||||
<AttachmentDownloadPill postId={post.id} attachment={att} />
|
<AttachmentDownloadPill
|
||||||
|
postId={post.id}
|
||||||
|
attachment={att}
|
||||||
|
adaptive
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
|||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(postId, att.id, displayFilename);
|
await downloadAttachment(postId, att.id, displayFilename);
|
||||||
showToast(t("downloadOk"));
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t("downloadFail"), "error");
|
showToast(t("downloadFail"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -40,22 +39,23 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
|||||||
att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined);
|
att.thumbnailUrl ?? att.posterUrl ?? (isImage ? att.url : undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex h-[52px] items-center gap-3">
|
<div className="group flex min-h-[64px] items-center gap-3">
|
||||||
{previewUrl && !previewFailed ? (
|
{previewUrl && !previewFailed ? (
|
||||||
<img
|
<img
|
||||||
src={previewUrl}
|
src={previewUrl}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
onError={() => setPreviewFailed(true)}
|
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
|
<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 }}
|
style={{ backgroundColor: color }}
|
||||||
aria-hidden="true"
|
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>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export function SingleImageFrame({
|
|||||||
return (
|
return (
|
||||||
<AdaptiveImageFrame
|
<AdaptiveImageFrame
|
||||||
attachment={attachment}
|
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)}
|
onOpen={() => openLightbox([attachment], 0, text, postId)}
|
||||||
ariaLabel="View image"
|
ariaLabel="View image"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { LoaderCircle, Play, X } from "lucide-react";
|
import { LoaderCircle, Play, X } from "lucide-react";
|
||||||
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
import { CollapsibleText } from "../CollapsibleText";
|
import { CollapsibleText } from "../CollapsibleText";
|
||||||
@@ -56,7 +57,6 @@ function VideoAttachmentCard({
|
|||||||
}) {
|
}) {
|
||||||
const { openVideo } = useVideoPlayer();
|
const { openVideo } = useVideoPlayer();
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
|
||||||
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
||||||
const duration = formatDuration(attachment.durationSec);
|
const duration = formatDuration(attachment.durationSec);
|
||||||
const previewVideoUrl = attachment.url.includes("#")
|
const previewVideoUrl = attachment.url.includes("#")
|
||||||
@@ -71,37 +71,21 @@ function VideoAttachmentCard({
|
|||||||
: "h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]"
|
: "h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]"
|
||||||
}`}
|
}`}
|
||||||
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
|
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
|
||||||
onClick={() => {
|
|
||||||
if (playing) {
|
|
||||||
const v = videoRef.current;
|
|
||||||
openVideo(attachment, v?.currentTime ?? 0);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{playing && !compact ? (
|
{playing && !compact ? (
|
||||||
<>
|
<MessageInlineVideo
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
src={attachment.url}
|
|
||||||
poster={attachment.posterUrl}
|
|
||||||
controls
|
|
||||||
playsInline
|
|
||||||
autoPlay
|
|
||||||
className="absolute inset-0 h-full w-full"
|
|
||||||
/>
|
|
||||||
<AttachmentDownloadPill
|
|
||||||
postId={postId}
|
postId={postId}
|
||||||
attachment={attachment}
|
attachment={attachment}
|
||||||
leadingLabel={duration}
|
leadingLabel={duration}
|
||||||
className="absolute left-2 top-2 z-20"
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{posterUrl ? (
|
{posterUrl ? (
|
||||||
<img
|
<img
|
||||||
src={posterUrl}
|
src={posterUrl}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className={`absolute inset-0 h-full w-full object-cover ${
|
className={`absolute inset-0 h-full w-full object-cover ${
|
||||||
overlayCount ? "blur-sm scale-105" : ""
|
overlayCount ? "blur-sm scale-105" : ""
|
||||||
}`}
|
}`}
|
||||||
@@ -142,7 +126,7 @@ function VideoAttachmentCard({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (compact) openVideo(attachment, 0);
|
if (compact) openVideo(attachment, 0, undefined, postId);
|
||||||
else setPlaying(true);
|
else setPlaying(true);
|
||||||
}}
|
}}
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
@@ -183,7 +167,6 @@ function AttachmentListDownloadButton({
|
|||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
await downloadAttachment(postId, attachment.id, attachment.filename);
|
await downloadAttachment(postId, attachment.id, attachment.filename);
|
||||||
showToast(t("downloadOk"));
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t("downloadFail"), "error");
|
showToast(t("downloadFail"), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -278,6 +261,8 @@ function VideoListDialog({
|
|||||||
<img
|
<img
|
||||||
src={thumb}
|
src={thumb}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -370,7 +355,7 @@ export function VideoBubble({ post }: { post: Post }) {
|
|||||||
onClose={() => setListOpen(false)}
|
onClose={() => setListOpen(false)}
|
||||||
onPick={(att) => {
|
onPick={(att) => {
|
||||||
setListOpen(false);
|
setListOpen(false);
|
||||||
openVideo(att, 0);
|
openVideo(att, 0, undefined, post.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -94,6 +94,36 @@ function buildRealUrl(params: PostStreamParams, cursor?: string): string {
|
|||||||
return `${q ? "/api/posts/search" : "/api/posts"}?${sp.toString()}`;
|
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 {
|
export function usePostStream(params: PostStreamParams): PostStreamResult {
|
||||||
const [items, setItems] = useState<Post[]>([]);
|
const [items, setItems] = useState<Post[]>([]);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
@@ -172,6 +202,17 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
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([]);
|
setItems([]);
|
||||||
cursorRef.current = undefined;
|
cursorRef.current = undefined;
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
@@ -188,6 +229,17 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
params.lang,
|
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(() => {
|
const loadMore = useCallback(() => {
|
||||||
fetchPage(false);
|
fetchPage(false);
|
||||||
}, [fetchPage]);
|
}, [fetchPage]);
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ import {
|
|||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { createPortal } from "react-dom";
|
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 type { Attachment } from "../../../types/post";
|
||||||
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
import { useI18n } from "../../../i18n";
|
|
||||||
import { useToast } from "../../Toast";
|
|
||||||
import { BubbleImage } from "../BubbleImage";
|
import { BubbleImage } from "../BubbleImage";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
import { downloadAttachment } from "../utils/downloadFile";
|
|
||||||
|
|
||||||
type LightboxState = {
|
type LightboxState = {
|
||||||
images: Attachment[];
|
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({
|
function Filmstrip({
|
||||||
images,
|
images,
|
||||||
index,
|
index,
|
||||||
@@ -199,12 +151,11 @@ function LightboxView({
|
|||||||
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
|
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
|
||||||
const goPrev = useCallback(
|
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
|
||||||
() => setIndex((i) => (i - 1 + images.length) % images.length),
|
// keys should all behave like a linear gallery, not a carousel.
|
||||||
[images.length],
|
const goPrev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
|
||||||
);
|
|
||||||
const goNext = useCallback(
|
const goNext = useCallback(
|
||||||
() => setIndex((i) => (i + 1) % images.length),
|
() => setIndex((i) => Math.min(images.length - 1, i + 1)),
|
||||||
[images.length],
|
[images.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -262,46 +213,51 @@ function LightboxView({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{postId ? (
|
{postId ? (
|
||||||
<LightboxDownloadButton postId={postId} attachment={current} />
|
<AttachmentDownloadPill
|
||||||
|
postId={postId}
|
||||||
|
attachment={current}
|
||||||
|
size="lg"
|
||||||
|
className=""
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
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"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image stage */}
|
{/* Image stage */}
|
||||||
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
|
<div className="relative flex min-h-0 w-full flex-1 items-center justify-center">
|
||||||
{hasMany ? (
|
{hasMany && index > 0 ? (
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
goPrev();
|
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"
|
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"
|
aria-label="Previous"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-6 w-6" />
|
<ChevronLeft className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
|
) : null}
|
||||||
|
{hasMany && index < images.length - 1 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
goNext();
|
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"
|
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"
|
aria-label="Next"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-6 w-6" />
|
<ChevronRight className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -10,14 +10,32 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import type { Attachment } from "../../../types/post";
|
import type { Attachment } from "../../../types/post";
|
||||||
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||||
|
|
||||||
|
type OnClose = (finalTime: number) => void;
|
||||||
|
|
||||||
type PlayerState = {
|
type PlayerState = {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
|
onClose?: OnClose;
|
||||||
|
/** Post the video belongs to, needed for the download pill. */
|
||||||
|
postId?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type Ctx = {
|
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;
|
closeVideo: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,8 +52,12 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
|||||||
const [state, setState] = useState<PlayerState>(null);
|
const [state, setState] = useState<PlayerState>(null);
|
||||||
|
|
||||||
const openVideo = useCallback(
|
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), []);
|
const closeVideo = useCallback(() => setState(null), []);
|
||||||
@@ -47,7 +69,11 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
|||||||
<PlayerView
|
<PlayerView
|
||||||
attachment={state.attachment}
|
attachment={state.attachment}
|
||||||
startAt={state.currentTime}
|
startAt={state.currentTime}
|
||||||
onClose={closeVideo}
|
postId={state.postId}
|
||||||
|
onClose={(finalTime) => {
|
||||||
|
state.onClose?.(finalTime);
|
||||||
|
setState(null);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</VideoPlayerContext.Provider>
|
</VideoPlayerContext.Provider>
|
||||||
@@ -57,61 +83,106 @@ export function VideoPlayerProvider({ children }: PropsWithChildren) {
|
|||||||
function PlayerView({
|
function PlayerView({
|
||||||
attachment,
|
attachment,
|
||||||
startAt,
|
startAt,
|
||||||
|
postId,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
startAt: number;
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") onClose();
|
if (e.key === "Escape") close();
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
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 () => {
|
return () => {
|
||||||
window.removeEventListener("keydown", onKey);
|
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]);
|
}, [close]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return;
|
|
||||||
if (startAt > 0) v.currentTime = startAt;
|
|
||||||
v.play().catch(() => {});
|
|
||||||
}, [startAt]);
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
|
||||||
onClick={onClose}
|
onClick={close}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
>
|
>
|
||||||
|
{postId ? (
|
||||||
|
<AttachmentDownloadPill
|
||||||
|
postId={postId}
|
||||||
|
attachment={attachment}
|
||||||
|
size="lg"
|
||||||
|
className="absolute left-4 top-4 z-10"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
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"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X className="h-5 w-5" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<video
|
<div
|
||||||
ref={videoRef}
|
className="relative h-full w-full"
|
||||||
src={attachment.url}
|
|
||||||
poster={attachment.posterUrl}
|
|
||||||
controls
|
|
||||||
playsInline
|
|
||||||
className="max-h-[92vh] max-w-[96vw] outline-none"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<MessageInlineVideo
|
||||||
|
postId={postId ?? ""}
|
||||||
|
attachment={attachment}
|
||||||
|
initialTime={startAt}
|
||||||
|
autoPlay
|
||||||
|
hideDownload
|
||||||
|
hideFullscreen
|
||||||
|
size="lg"
|
||||||
|
onTimeUpdate={(t) => {
|
||||||
|
lastTimeRef.current = t;
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,8 +34,13 @@ export type AlbumLayout = {
|
|||||||
* Keep ratios within a sane range so one extreme image can't make the whole
|
* 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).
|
* 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;
|
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));
|
return Math.min(2, Math.max(0.55, ratio));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +121,11 @@ function layoutPrimaryPlusLine(ratios: number[]): AlbumLayout {
|
|||||||
export function computeAlbumLayout(
|
export function computeAlbumLayout(
|
||||||
rawRatios: (number | undefined)[],
|
rawRatios: (number | undefined)[],
|
||||||
): AlbumLayout | null {
|
): AlbumLayout | null {
|
||||||
const ratios = rawRatios.map(clampRatio);
|
const ratios = rawRatios.map(safeRatio);
|
||||||
if (ratios.length < 2) return null;
|
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);
|
if (ratios.length === 2) return layoutTwo(ratios);
|
||||||
return layoutPrimaryPlusLine(ratios);
|
return layoutPrimaryPlusLine(ratios.map(clampRatio));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,17 @@
|
|||||||
function localeFor(lang: string): string {
|
function pad2(n: number): string {
|
||||||
const locales: Record<string, string> = {
|
return String(n).padStart(2, "0");
|
||||||
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 formatDate(iso: string, lang: string): string {
|
function formatDate(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return new Intl.DateTimeFormat(localeFor(lang), {
|
return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`;
|
||||||
year: "numeric",
|
|
||||||
month: lang === "en" ? "short" : "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatTime(iso: string, lang: string): string {
|
export function formatTime(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return new Intl.DateTimeFormat(localeFor(lang), {
|
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
hour: "numeric",
|
|
||||||
minute: "2-digit",
|
|
||||||
hour12: lang === "en",
|
|
||||||
}).format(d);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(iso: string, lang: string): string {
|
export function formatDateTime(iso: string): string {
|
||||||
return `${formatDate(iso, lang)} ${formatTime(iso, lang)}`;
|
return `${formatDate(iso)} ${formatTime(iso)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) || ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -122,10 +122,6 @@ const zhDict: Dict = {
|
|||||||
resourceLangFilter: "资料语言",
|
resourceLangFilter: "资料语言",
|
||||||
filterTagClear: "清除标签",
|
filterTagClear: "清除标签",
|
||||||
filterLanguageAll: "全部语言",
|
filterLanguageAll: "全部语言",
|
||||||
aboutTitle: "关于本站",
|
|
||||||
aboutIntro:
|
|
||||||
"ARK 数据库汇总官方教材、公告、视频与常用文件,帮助社区快速获取一致版本的可信内容。\n\n本站仅供展示与索引;权利归属以官方公告为准。",
|
|
||||||
footerAbout: "关于本站",
|
|
||||||
footerAdminLogin: "管理员登录",
|
footerAdminLogin: "管理员登录",
|
||||||
adminSearchLogs: "搜索记录",
|
adminSearchLogs: "搜索记录",
|
||||||
adminMetricShares: "分享",
|
adminMetricShares: "分享",
|
||||||
@@ -254,10 +250,6 @@ const enDict: Dict = {
|
|||||||
resourceLangFilter: "Resource language",
|
resourceLangFilter: "Resource language",
|
||||||
filterTagClear: "Clear tag",
|
filterTagClear: "Clear tag",
|
||||||
filterLanguageAll: "All languages",
|
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",
|
footerAdminLogin: "Admin sign-in",
|
||||||
adminSearchLogs: "Search logs",
|
adminSearchLogs: "Search logs",
|
||||||
adminMetricShares: "Shares",
|
adminMetricShares: "Shares",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
||||||
import { pageTransition } from "../motion";
|
import { pageTransition } from "../motion";
|
||||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||||
|
import { usePageTitle } from "../components/PageTitleContext";
|
||||||
|
import { prefetchPostStream } from "../components/messageStream/hooks/usePostStream";
|
||||||
import { BackToTop } from "../components/BackToTop";
|
import { BackToTop } from "../components/BackToTop";
|
||||||
import { DocumentMeta } from "../components/DocumentMeta";
|
import { DocumentMeta } from "../components/DocumentMeta";
|
||||||
import { SearchPanel } from "../components/SearchPanel";
|
import { SearchPanel } from "../components/SearchPanel";
|
||||||
@@ -17,8 +19,7 @@ type PublicNavWhich =
|
|||||||
| "browseLatest"
|
| "browseLatest"
|
||||||
| "browseRecommended"
|
| "browseRecommended"
|
||||||
| "browsePopular"
|
| "browsePopular"
|
||||||
| "favorites"
|
| "favorites";
|
||||||
| "about";
|
|
||||||
|
|
||||||
function navIsActive(
|
function navIsActive(
|
||||||
pathname: string,
|
pathname: string,
|
||||||
@@ -47,8 +48,6 @@ function navIsActive(
|
|||||||
return (
|
return (
|
||||||
pathname === "/favorites" || (pathname === "/" && hash === "#favorites")
|
pathname === "/favorites" || (pathname === "/" && hash === "#favorites")
|
||||||
);
|
);
|
||||||
case "about":
|
|
||||||
return pathname === "/about";
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -292,6 +291,45 @@ export function PublicLayout() {
|
|||||||
navIsActive(pathname, search, hash, which);
|
navIsActive(pathname, search, hash, which);
|
||||||
const isHome = pathname === "/";
|
const isHome = pathname === "/";
|
||||||
const footerInContentFlow = pathname === "/browse";
|
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 popularHref = "/browse?sort=popular";
|
||||||
|
|
||||||
const goSearch = () => {
|
const goSearch = () => {
|
||||||
@@ -311,6 +349,12 @@ export function PublicLayout() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
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 closeOnOutside = (event: MouseEvent | TouchEvent) => {
|
||||||
const target = event.target as Node;
|
const target = event.target as Node;
|
||||||
if (
|
if (
|
||||||
@@ -322,7 +366,10 @@ export function PublicLayout() {
|
|||||||
}
|
}
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
const closeOnScroll = () => setOpen(false);
|
const closeOnScroll = () => {
|
||||||
|
if (Date.now() - openedAt < 250) return;
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("mousedown", closeOnOutside);
|
document.addEventListener("mousedown", closeOnOutside);
|
||||||
document.addEventListener("touchstart", closeOnOutside);
|
document.addEventListener("touchstart", closeOnOutside);
|
||||||
@@ -334,14 +381,44 @@ export function PublicLayout() {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [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 (
|
return (
|
||||||
<div className="min-h-full flex flex-col">
|
<div className="min-h-full flex flex-col">
|
||||||
<DocumentMeta />
|
<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">
|
<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">
|
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
||||||
|
<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
|
<Link
|
||||||
to="/"
|
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")}
|
aria-label={t("brand")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
@@ -349,10 +426,18 @@ export function PublicLayout() {
|
|||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
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" />
|
<ArkLogoMark className="h-8 w-8 shrink-0" />
|
||||||
<span className="truncate text-ark-gold">{t("brand")}</span>
|
|
||||||
</Link>
|
</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]">
|
<div className="flex h-[40px] w-[136px] shrink-0 items-center gap-[8px]">
|
||||||
<button
|
<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">
|
<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 (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
||||||
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
|
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
|
||||||
|
<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
|
<Link
|
||||||
to="/"
|
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"
|
aria-label={t("brand")}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isHome) {
|
if (isHome) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
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" />
|
<ArkLogoMark className="h-10 w-10 shrink-0" />
|
||||||
<span className="max-w-[8rem] truncate text-ark-gold sm:inline">
|
|
||||||
{t("brand")}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</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
|
<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"
|
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")}
|
{t("favorites")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
to="/about"
|
|
||||||
className={navClassName(na("about"))}
|
|
||||||
aria-current={na("about") ? "page" : undefined}
|
|
||||||
>
|
|
||||||
{t("footerAbout")}
|
|
||||||
</Link>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
|
<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")}
|
{t("favorites")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
to="/about"
|
|
||||||
className={navClassName(na("about"))}
|
|
||||||
aria-current={na("about") ? "page" : undefined}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{t("footerAbout")}
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
@@ -620,7 +698,7 @@ export function PublicLayout() {
|
|||||||
isHome
|
isHome
|
||||||
? "flex-1 px-0 pb-6 pt-0 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
? "flex-1 px-0 pb-6 pt-0 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
||||||
: footerInContentFlow
|
: 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"
|
: "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>
|
</AnimatePresence>
|
||||||
</main>
|
</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">
|
<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]">
|
<div className="grid h-[78px] grid-cols-4 gap-3 px-5 py-4 text-center text-[11px] leading-[17.6px]">
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
|
|||||||
@@ -156,6 +156,19 @@ export const MOCK_POSTS: Post[] = [
|
|||||||
isRecommended: false,
|
isRecommended: false,
|
||||||
publishedAt: "2026-01-19T16:20:00.000Z",
|
publishedAt: "2026-01-19T16:20:00.000Z",
|
||||||
updatedAt: "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) 纯文本 + 单链接(简短公告)
|
// 6) 纯文本 + 单链接(简短公告)
|
||||||
@@ -254,6 +267,15 @@ export const MOCK_POSTS: Post[] = [
|
|||||||
categorySlug: "meeting",
|
categorySlug: "meeting",
|
||||||
language: "zh-CN",
|
language: "zh-CN",
|
||||||
text: "📌 ARK DeFAI 方舟晨间时刻\n\n🧠 会议主题:市场概况交流 & 市场问题讨论。\n🕙 会议时间:3月1日(日)10:00\n🎬 直播腾讯会议链接:https://meeting.tencent.com/l/G718S4Sedm38",
|
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: [
|
attachments: [
|
||||||
{
|
{
|
||||||
id: "a-010",
|
id: "a-010",
|
||||||
|
|||||||
@@ -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 = {
|
export const pageTransition: Variants = {
|
||||||
initial: { opacity: 0, y: 8 },
|
initial: { opacity: 0 },
|
||||||
enter: { opacity: 1, y: 0, transition: { duration: 0.24, ease: EASE_OUT } },
|
enter: { opacity: 1, transition: { duration: 0.22, ease: EASE_OUT } },
|
||||||
exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: EASE_OUT } },
|
exit: { opacity: 0, transition: { duration: 0 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Springy hover lift for cards. Use rest/hover states on an `m` element. */
|
/** Springy hover lift for cards. Use rest/hover states on an `m` element. */
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,9 +2,12 @@ import { Heart } from "lucide-react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
import { Reveal } from "../../motion";
|
import { Reveal } from "../../motion";
|
||||||
|
import { useSetPageTitle } from "../../components/PageTitleContext";
|
||||||
|
|
||||||
export default function Favorites() {
|
export default function Favorites() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
// Show "我的收藏" in the global header, consistent with the other pages.
|
||||||
|
useSetPageTitle(t("favorites"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center">
|
<Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center">
|
||||||
|
|||||||
@@ -137,10 +137,15 @@ export function Home() {
|
|||||||
for (let index = 0; index < figmaOrderedCategories.length; index += 9) {
|
for (let index = 0; index < figmaOrderedCategories.length; index += 9) {
|
||||||
categoryPages.push(figmaOrderedCategories.slice(index, index + 9));
|
categoryPages.push(figmaOrderedCategories.slice(index, index + 9));
|
||||||
}
|
}
|
||||||
const activeCategoryCount = categoryPages[activeCategoryPage]?.length ?? 0;
|
// Use the tallest page so the carousel height doesn't shrink between
|
||||||
const activeCategoryRows = Math.ceil(activeCategoryCount / 3);
|
// 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 =
|
const mobileCategoryHeight =
|
||||||
activeCategoryRows * 88 + Math.max(0, activeCategoryRows - 1) * 8;
|
maxCategoryRows * 88 + Math.max(0, maxCategoryRows - 1) * 8;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const row = categoryRowRef.current;
|
const row = categoryRowRef.current;
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ export function PostRedirect() {
|
|||||||
|
|
||||||
if (POST_STREAM_USES_MOCK) {
|
if (POST_STREAM_USES_MOCK) {
|
||||||
const post = MOCK_POSTS.find((p) => p.id === id);
|
const post = MOCK_POSTS.find((p) => p.id === id);
|
||||||
navigate(post ? `/browse#post-${post.id}` : "/browse", {
|
navigate(
|
||||||
|
post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse",
|
||||||
|
{
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ export function PostRedirect() {
|
|||||||
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
|
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
)
|
)
|
||||||
.then((post) => {
|
.then((post) => {
|
||||||
navigate(`/browse#post-${post.id}`, {
|
navigate(`/browse?post=${encodeURIComponent(post.id)}`, {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export type PostTypeFilter = PostType | "all";
|
|||||||
export type AttachmentKind = "image" | "video" | "document";
|
export type AttachmentKind = "image" | "video" | "document";
|
||||||
|
|
||||||
export type PostLocaleTexts = {
|
export type PostLocaleTexts = {
|
||||||
|
/** Short display title for list/card surfaces. */
|
||||||
|
title?: string;
|
||||||
|
/** Full post body text. */
|
||||||
text: string;
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +37,24 @@ export type Attachment = {
|
|||||||
thumbnailUrl?: string;
|
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 = {
|
export type Post = {
|
||||||
id: string;
|
id: string;
|
||||||
postType?: PostType | string;
|
postType?: PostType | string;
|
||||||
@@ -41,6 +62,8 @@ export type Post = {
|
|||||||
categorySlug: string;
|
categorySlug: string;
|
||||||
language: string;
|
language: string;
|
||||||
sourceLanguage?: string;
|
sourceLanguage?: string;
|
||||||
|
/** Short display title for list/card surfaces. */
|
||||||
|
title?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
localizations?: Partial<PostLocalizations>;
|
localizations?: Partial<PostLocalizations>;
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
@@ -49,6 +72,8 @@ export type Post = {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
/** Preview card for the first URL in `text`. At most one per post. */
|
||||||
|
linkPreview?: LinkPreview;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PostListResponse = {
|
export type PostListResponse = {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Category, Resource } from "../api";
|
import type { Category, Resource } from "../api";
|
||||||
import type { Attachment, Post } from "../types/post";
|
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 & {
|
export type PostBackedResource = Resource & {
|
||||||
downloadPostId?: string;
|
downloadPostId?: string;
|
||||||
@@ -35,7 +38,7 @@ export function postToResource(
|
|||||||
categories: Category[] = [],
|
categories: Category[] = [],
|
||||||
): PostBackedResource {
|
): PostBackedResource {
|
||||||
const first = post.attachments[0];
|
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);
|
const category = categories.find((c) => c.id === post.categoryId);
|
||||||
return {
|
return {
|
||||||
id: post.id,
|
id: post.id,
|
||||||
|
|||||||
Reference in New Issue
Block a user