feat: apply figma browse mobile redesign

This commit is contained in:
TerryM
2026-05-28 10:36:38 +08:00
parent 3825c4ec2f
commit 49f61b89f1
26 changed files with 401 additions and 264 deletions

View File

@@ -0,0 +1,157 @@
---
title: "Browse 页全部资料Figma 1:1 视觉对齐"
type: brainstorm
date: 2026-05-28
---
# Browse 页全部资料Figma 1:1 视觉对齐
## Problem Statement
`/browse` 路由(「全部资料」页)的视觉与 Figma 设计稿node `4206-6051`)有两处显著落差,需要对齐:
1. **筛选条 (FilterChips)** 当前是椭圆 pill chip + 边框 + 溢出折叠按钮Figma 是下划线 tab 风格(文字 + 橘色底线,无边框、无背景)。
2. **mobile 底部导航第三格**目前是「官方推荐」(连到 `/browse?sort=recommended`Figma 是「我的收藏」。
页面标题、Header、讯息流 bubble 已对齐,不需要再动。
## Context
- 当前 FilterChips 实现:`src/components/messageStream/FilterChips.tsx`,含 9 种 typeall/image/video/music/ppt/pdf/text/link/archive`SlidersHorizontal` overflow expand 按钮。
- Figma 只展示 mobile viewport且只露 6 种 type全部/图片/视频/音乐/PPT/PDF
- 现有 mobile 底部 nav`src/layouts/PublicLayout.tsx` 内的 `BottomNavIcon`4 格home / document / heart / update。
- 资产已经存在 `heart-active.svg` / `heart-inactive.svg`,不需要新增图示档。
- 「我的收藏」功能本身**不实作**,只做 stub「即将推出」页。等用户系统建立后再做真功能。
- text / link / archive 三种 type 现在确实有资料可筛,不能直接砍掉。
## Chosen Approach
**Approach A + 全 viewport responsive**
- 筛选条改成下划线 tab 风格,**所有 viewport** 统一使用mobile / tablet / desktop
- 9 种 type 全部保留,溢出改成「水平横向滚动」(无 expand 按钮,自由滑),符合 Telegram 风格 + Figma 风格。
- mobile 底部 nav 第 3 格:图示沿用 heartlabel 改 `t("favorites")`,连结改 `/favorites`
- 新增 `/favorites` routestub 页内容为「我的收藏 — 即将推出」+ 返回首页 CTA。
- 桌机顶部 nav **不动**(「官方推荐」入口保留在 desktop top nav
## Why This Approach
- **为什么所有 viewport 统一筛选条样式**Terry 明确要求 responsive across screen sizes同一组件维护两套样式是不必要的复杂度下划线 tab 在桌机宽度下也清爽(更胜 pill chip
- **为什么水平滚动而非「⋯」展开**Figma 没画出展开行为标准的「Telegram filter bar」模式就是横滑少一个状态机本专案的 `MessageStream` 已经是无限滚动列表,再加一个 expand 反而割裂体验。
- **为什么不砍掉 text/link/archive**:这些是真有资料的 type砍掉会让桌机用户失去筛选能力横滑设计已经能容纳全部 9 种。
- **为什么 favorites 做 stub 不做完整功能**Terry 选 Q2 选项 3等用户系统再做明确不留 localStorage 技术债。
- **为什么沿用 heart 图示**资产已经存在Figma 视觉风格与现有 heart 相容;不增加设计审查负担。
### 已拒绝的替代方案
- **B桌机也加「我的收藏」顶部 nav 入口)**:会让桌机 nav 多一个空壳连结,不必要。
- **C移除 text/link/archive type 入口)**:损失既有功能,不符合「视觉对齐 ≠ 砍功能」原则。
- **localStorage 收藏功能**Terry 选 Q2 选项 3明确不做。
- **「⋯」expand 按钮Figma-faithful**Figma 没明确画出展开行为,是我推测的;横滑更直接。
## Design
### 1. FilterChips 重做
**文件**`src/components/messageStream/FilterChips.tsx`(改写,非新增)
**新视觉规范**
- 容器:水平横向滚动 (`overflow-x-auto`),隐藏 scrollbarsticky top 保留
- 每个 tab纯文字按钮无 border、无 background、无 rounded
- inactive`text-neutral-400`hover `text-ark-gold/80`
- active`text-ark-gold` + 底部 `2px` 橘色下划线(`border-b-2 border-ark-gold`inactive 底部 `2px` 透明 border占位防跳动
- 间距tab 之间 `gap-5`(或 `gap-6`,实作时微调)
- padding每个 tab 上下 `py-3`,左右无(让 text 自然贴齐 underline
- 字号:`text-sm``text-[15px]`,依照 Figma 比例
- 移除 `SlidersHorizontal` expand 按钮与 `expanded` state
- 移除 measure 隐藏元素 + `ResizeObserver`(不再需要侦测溢出)
**type 顺序保持不变**all / image / video / music / ppt / pdf / text / link / archive
**响应性**
- 因为是水平滚动所有宽度都能容纳mobile 极窄屏自然横滑,桌机宽屏会自然撑开置左
### 2. 新增 `/favorites` Stub 页
**文件**`src/pages/Favorites/index.tsx`(新增)
**结构**
- 居中容器(`flex items-center justify-center min-h-[60vh]`
- 心形 icon用 lucide `Heart`size 48px`text-ark-gold/70`
- 标题:`t("favorites")` → 「我的收藏」
- 副标:`t("favoritesComingSoon")` → 「功能即将推出」
- 描述:`t("favoritesComingSoonDesc")` → 简短说明(一行)
- CTA「返回首页」按钮 → `Link to="/"`,使用既有 `ark-gold` 风格
**响应性**
- 单栏居中,所有 viewport 都用同一份 layout
- 文字 `text-base` 起跳md 以上放大
### 3. App.tsx 加 route
**文件**`src/App.tsx`
- 在公开 routes 区段加入 `<Route path="/favorites" element={<Favorites />} />`
- 透过 `PublicLayout` Outlet 渲染(继承 Header + 底部 nav
### 4. PublicLayout 底部 nav 第 3 格改写
**文件**`src/layouts/PublicLayout.tsx`
- 第 3 个 `BottomNavIcon`
- `to="/browse?sort=recommended"``to="/favorites"`
- `label={t("official")}``label={t("favorites")}`
- `active={...recommended}``active={pathname === "/favorites"}`
- icon 保持 `heart`(资产已存在)
### 5. i18n 增加 key
**文件**`src/i18n.tsx`
- 三语言zh-TW / zh-CN / en各加
- `favorites`:「我的收藏」/「My Favorites」
- `favoritesComingSoon`:「即将推出」/「Coming Soon」
- `favoritesComingSoonDesc`:一行说明
- `backToHome`:「返回首页」/「Back to Home」如不存在
### Data Flow
- FilterChips 的 prop API`type` / `onTypeChange`**不变**,纯视觉重做,使用方(`MessageStream` 等)无需调整。
- `/favorites` stub 无任何资料请求,纯静态页。
### Error Handling
- `/favorites` 是纯静态,无错误状态。
- FilterChips 横滑容器:测试在 `overflow-x: hidden` 的 ancestor 中是否仍可滚动;既有 `global_overflow_x_hidden_mobile_2026_05_27` 的全域规则需要确认不会卡住此处的 horizontal scroll可能需要 `overflow-x-auto` 强制覆盖)。
### Testing
- 既有 `npm test` 应该全过API 没变)
- 视觉测试需要mobile (375px) / tablet (768px) / desktop (1280px) 三档手测
- TypeScript: `npx tsc --noEmit`
- Format: `npm run format:check`
## Implementation Checklist
- [ ] FilterChips.tsx 重写:移除 pill 样式与 expand 按钮,改为下划线 tab + 水平滚动
- [ ] 新增 `src/pages/Favorites/index.tsx` stub 页(含 Heart icon、标题、副标、返回首页 CTA
- [ ] `src/App.tsx` 加入 `/favorites` route 与 import
- [ ] `src/layouts/PublicLayout.tsx` 底部 nav 第 3 格改 label / route / active 判断
- [ ] `src/i18n.tsx` 三语加入 `favorites` / `favoritesComingSoon` / `favoritesComingSoonDesc` / `backToHome`
- [ ] 验证 FilterChips 横滑在 `global overflow-x-hidden` mobile 规则下仍正常
- [ ]`npx tsc --noEmit` + `npm run format:check` + `npm test`
- [ ] mobile / tablet / desktop 三档视觉手测
## Open Questions
- 「我的收藏」stub 页的副标描述文字具体怎么写?(建议:「登入功能开发中,敬请期待」之类,可在实作时定)
- FilterChips 的字号要 `text-sm` 还是 `text-[15px]`?需要量一下 Figma 才能 1:1实作时对图调整
- 桌机顶部 nav 的「官方推荐」是否要在未来 phase 一起处理?(本 spec 暂不动)
## Out of Scope
- 真正的「我的收藏」功能(依赖未来用户系统,独立 spec
- 桌机顶部 nav 调整(包括「官方推荐」入口存废)
- 讯息流 bubble 本身的视觉调整(既有已对齐)
- 移除 text / link / archive 三种 type
- 任何后端改动

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.6453 3H5.373C5.00886 3 4.65963 3.13876 4.40214 3.38576C4.14465 3.63276 4 3.96776 4 4.31707V19.4634C4.00036 19.7517 4.08674 20.0339 4.24894 20.2766C4.41114 20.5193 4.64238 20.7125 4.91533 20.8332C5.1355 20.944 5.38066 21.0012 5.62929 21C5.95995 20.9896 6.27938 20.8824 6.54462 20.6927L11.4416 17.1805C11.6001 17.0665 11.7928 17.0049 11.9908 17.0049C12.1889 17.0049 12.3816 17.0665 12.54 17.1805L17.4371 20.6927C17.6751 20.8639 17.958 20.9681 18.2543 20.9938C18.5506 21.0194 18.8485 20.9654 19.1145 20.8378C19.3806 20.7101 19.6044 20.514 19.7608 20.2712C19.9172 20.0285 20 19.7488 20 19.4634V4.31707C20 3.97078 19.8579 3.63842 19.6044 3.39192C19.3508 3.14542 19.0063 3.00462 18.6453 3Z" fill="#EEB726"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.6453 3H5.373C5.00886 3 4.65963 3.13876 4.40214 3.38576C4.14465 3.63276 4 3.96776 4 4.31707V19.4634C4.00036 19.7517 4.08674 20.0339 4.24894 20.2766C4.41114 20.5193 4.64238 20.7125 4.91533 20.8332C5.1355 20.944 5.38066 21.0012 5.62929 21C5.95995 20.9896 6.27938 20.8824 6.54462 20.6927L11.4416 17.1805C11.6001 17.0665 11.7928 17.0049 11.9908 17.0049C12.1889 17.0049 12.3816 17.0665 12.54 17.1805L17.4371 20.6927C17.6751 20.8639 17.958 20.9681 18.2543 20.9938C18.5506 21.0194 18.8485 20.9654 19.1145 20.8378C19.3806 20.7101 19.6044 20.514 19.7608 20.2712C19.9172 20.0285 20 19.7488 20 19.4634V4.31707C20 3.97078 19.8579 3.63842 19.6044 3.39192C19.3508 3.14542 19.0063 3.00462 18.6453 3Z" fill="#908F92"/>
</svg>

After

Width:  |  Height:  |  Size: 818 B

View File

@@ -1,3 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0L31.7731 0C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM15.0695 39.0698H25.116C26.0403 39.0698 26.7904 38.3196 26.7904 37.3953C26.7904 36.4711 26.0403 35.7209 25.116 35.7209H15.0695C14.1452 35.7209 13.3951 36.4711 13.3951 37.3953C13.3951 38.3196 14.1452 39.0698 15.0695 39.0698ZM15.0695 31.2558H32.93C33.8542 31.2558 34.6044 30.5057 34.6044 29.5814C34.6044 28.6571 33.8542 27.907 32.93 27.907H15.0695C14.1452 27.907 13.3951 28.6571 13.3951 29.5814C13.3951 30.5057 14.1452 31.2558 15.0695 31.2558ZM33.3721 4.71875V10.5091C33.3721 11.4223 34.1103 12.1635 35.0199 12.1635H40.7871L33.3721 4.71875ZM15.0695 23.4419H32.93C33.8542 23.4419 34.6044 22.6917 34.6044 21.7674C34.6044 20.8432 33.8542 20.093 32.93 20.093H15.0695C14.1452 20.093 13.3951 20.8432 13.3951 21.7674C13.3951 22.6917 14.1452 23.4419 15.0695 23.4419Z" fill="white"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.09766 13.3958C7.57534 13.3958 7.97405 13.3965 8.29981 13.4231C8.63965 13.4509 8.95903 13.5106 9.26074 13.6643C9.72355 13.9002 10.1001 14.2767 10.3359 14.7395C10.4896 15.0412 10.5493 15.3607 10.5771 15.7005C10.6037 16.0262 10.6035 16.4249 10.6035 16.9026V17.4934C10.6035 17.9712 10.6037 18.3708 10.5771 18.6966C10.5493 19.0362 10.4894 19.3551 10.3359 19.6565C10.1001 20.1193 9.72347 20.4959 9.26074 20.7317C8.95903 20.8855 8.63965 20.9462 8.29981 20.9739C7.97408 21.0006 7.57529 21.0004 7.09766 21.0003H6.50586C6.0282 21.0004 5.62947 21.0006 5.30371 20.9739C4.96388 20.9462 4.64448 20.8855 4.34277 20.7317C3.88 20.4959 3.5034 20.1194 3.26758 19.6565C3.11405 19.3551 3.05413 19.0362 3.02637 18.6966C2.99975 18.3708 2.99999 17.9712 3 17.4934V16.9026C2.99999 16.4249 2.99977 16.0262 3.02637 15.7005C3.05413 15.3607 3.11388 15.0412 3.26758 14.7395C3.50343 14.2766 3.87991 13.9002 4.34277 13.6643C4.64448 13.5106 4.96388 13.4509 5.30371 13.4231C5.62947 13.3965 6.02818 13.3958 6.50586 13.3958H7.09766ZM16.9375 13.3958C17.4154 13.3958 17.8147 13.3965 18.1406 13.4231C18.4802 13.4509 18.799 13.5107 19.1006 13.6643C19.5634 13.9001 19.9399 14.2766 20.1758 14.7395C20.3295 15.0412 20.3901 15.3606 20.418 15.7005C20.4445 16.0262 20.4443 16.425 20.4443 16.9026V17.4934C20.4443 17.9712 20.4445 18.3708 20.418 18.6966C20.3901 19.0363 20.3294 19.355 20.1758 19.6565C19.9399 20.1195 19.5634 20.4959 19.1006 20.7317C18.799 20.8854 18.4803 20.9462 18.1406 20.9739C17.8148 21.0006 17.4153 21.0004 16.9375 21.0003H16.3467C15.8689 21.0004 15.4694 21.0006 15.1436 20.9739C14.8039 20.9462 14.4852 20.8854 14.1836 20.7317C13.7207 20.4959 13.3443 20.1195 13.1084 19.6565C12.9549 19.355 12.895 19.0362 12.8672 18.6966C12.8406 18.3708 12.8398 17.9712 12.8398 17.4934V16.9026C12.8398 16.425 12.8406 16.0262 12.8672 15.7005C12.895 15.3606 12.9547 15.0412 13.1084 14.7395C13.3443 14.2766 13.7208 13.9001 14.1836 13.6643C14.4851 13.5107 14.804 13.4509 15.1436 13.4231C15.4695 13.3965 15.8688 13.3958 16.3467 13.3958H16.9375ZM15.8818 3.12039C16.3759 2.95986 16.9083 2.95988 17.4023 3.12039C17.7243 3.22501 17.992 3.40858 18.252 3.62918C18.5011 3.84068 18.7834 4.12251 19.1211 4.46024L19.5205 4.85965L19.54 4.87821C19.8778 5.21599 20.1595 5.49817 20.3711 5.74735C20.5918 6.00728 20.7753 6.27589 20.8799 6.59793C21.0404 7.09195 21.0404 7.62444 20.8799 8.11844C20.7753 8.44037 20.5917 8.70819 20.3711 8.96805C20.1596 9.2172 19.8778 9.49943 19.54 9.83719L19.5205 9.85672L19.1406 10.2356L19.1211 10.2552C18.7834 10.593 18.5011 10.8757 18.252 11.0872C17.9921 11.3077 17.7242 11.4904 17.4023 11.595C16.9083 11.7556 16.3759 11.7556 15.8818 11.595C15.56 11.4904 15.292 11.3077 15.0322 11.0872C14.783 10.8756 14.5009 10.593 14.1631 10.2552L14.1436 10.2356L13.7637 9.85672L13.7451 9.83719C13.4072 9.49931 13.1247 9.21727 12.9131 8.96805C12.6925 8.7082 12.5089 8.44036 12.4043 8.11844C12.2438 7.62445 12.2438 7.09194 12.4043 6.59793C12.5089 6.27589 12.6924 6.00728 12.9131 5.74735C13.1246 5.49815 13.4073 5.21603 13.7451 4.87821L14.1631 4.46024C14.5008 4.12249 14.7831 3.84069 15.0322 3.62918C15.2921 3.40856 15.5598 3.22502 15.8818 3.12039ZM7.09766 3.55594C7.57532 3.55593 7.97406 3.5557 8.29981 3.58231C8.63965 3.61008 8.95903 3.67077 9.26074 3.8245C9.72338 4.06033 10.1001 4.43606 10.3359 4.89871C10.4896 5.20038 10.5493 5.51987 10.5771 5.85965C10.6037 6.18537 10.6035 6.58418 10.6035 7.0618V7.6536C10.6035 8.13125 10.6037 8.53 10.5771 8.85575C10.5493 9.19555 10.4896 9.515 10.3359 9.81668C10.1001 10.2795 9.72356 10.6561 9.26074 10.8919C8.95903 11.0457 8.63965 11.1054 8.29981 11.1331C7.97406 11.1597 7.57534 11.1595 7.09766 11.1595H6.50586C6.02819 11.1595 5.62948 11.1597 5.30371 11.1331C4.96388 11.1054 4.64448 11.0457 4.34277 10.8919C3.87991 10.6561 3.50343 10.2795 3.26758 9.81668C3.11388 9.51499 3.05413 9.19556 3.02637 8.85575C2.99977 8.52999 2.99999 8.13126 3 7.6536V7.06278C2.99999 6.58496 2.99975 6.18548 3.02637 5.85965C3.05414 5.51987 3.11387 5.20038 3.26758 4.89871C3.5034 4.43604 3.8801 4.06032 4.34277 3.8245C4.64448 3.67077 4.96388 3.61008 5.30371 3.58231C5.62946 3.5557 6.0282 3.55593 6.50586 3.55594H7.09766Z" fill="#EEB726"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,7 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0H31.7731C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM42.1858 41.8605V13.7615C42.1858 13.6141 42.1277 13.4713 42.0228 13.3663L32.1683 3.51181C32.0633 3.40688 31.9205 3.34884 31.7731 3.34884H9.60438C8.86317 3.34884 8.15545 3.64353 7.6308 4.16595C7.10838 4.6906 6.81368 5.39833 6.81368 6.13953V41.8605C6.81368 42.6017 7.10838 43.3094 7.6308 43.834C8.15545 44.3565 8.86317 44.6512 9.60438 44.6512H39.3951C40.1363 44.6512 40.844 44.3565 41.3687 43.834C41.8911 43.3094 42.1858 42.6017 42.1858 41.8605Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.0234 3.79063C30.0234 2.86635 30.7736 2.11621 31.6979 2.11621C32.6221 2.11621 33.3723 2.86635 33.3723 3.79063V11.6046C33.3723 11.9127 33.6223 12.1627 33.9304 12.1627H41.7444C42.6686 12.1627 43.4188 12.9129 43.4188 13.8371C43.4188 14.7614 42.6686 15.5116 41.7444 15.5116H33.9304C31.7715 15.5116 30.0234 13.7635 30.0234 11.6046V3.79063Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 22.3256C14.1456 22.3256 13.3955 21.5755 13.3955 20.6512C13.3955 19.7269 14.1456 18.9768 15.0699 18.9768H32.9304C33.8547 18.9768 34.6048 19.7269 34.6048 20.6512C34.6048 21.5755 33.8547 22.3256 32.9304 22.3256H15.0699Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 30.1396C14.1456 30.1396 13.3955 29.3895 13.3955 28.4652C13.3955 27.5409 14.1456 26.7908 15.0699 26.7908H32.9304C33.8547 26.7908 34.6048 27.5409 34.6048 28.4652C34.6048 29.3895 33.8547 30.1396 32.9304 30.1396H15.0699Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 37.9536C14.1456 37.9536 13.3955 37.2034 13.3955 36.2792C13.3955 35.3549 14.1456 34.6047 15.0699 34.6047H25.1164C26.0407 34.6047 26.7909 35.3549 26.7909 36.2792C26.7909 37.2034 26.0407 37.9536 25.1164 37.9536H15.0699Z" fill="white"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.09766 13.3958C7.57534 13.3958 7.97405 13.3965 8.29981 13.4231C8.63965 13.4509 8.95903 13.5106 9.26074 13.6643C9.72355 13.9002 10.1001 14.2767 10.3359 14.7395C10.4896 15.0412 10.5493 15.3607 10.5771 15.7005C10.6037 16.0262 10.6035 16.4249 10.6035 16.9026V17.4934C10.6035 17.9712 10.6037 18.3708 10.5771 18.6966C10.5493 19.0362 10.4894 19.3551 10.3359 19.6565C10.1001 20.1193 9.72347 20.4959 9.26074 20.7317C8.95903 20.8855 8.63965 20.9462 8.29981 20.9739C7.97408 21.0006 7.57529 21.0004 7.09766 21.0003H6.50586C6.0282 21.0004 5.62947 21.0006 5.30371 20.9739C4.96388 20.9462 4.64448 20.8855 4.34277 20.7317C3.88 20.4959 3.5034 20.1194 3.26758 19.6565C3.11405 19.3551 3.05413 19.0362 3.02637 18.6966C2.99975 18.3708 2.99999 17.9712 3 17.4934V16.9026C2.99999 16.4249 2.99977 16.0262 3.02637 15.7005C3.05413 15.3607 3.11388 15.0412 3.26758 14.7395C3.50343 14.2766 3.87991 13.9002 4.34277 13.6643C4.64448 13.5106 4.96388 13.4509 5.30371 13.4231C5.62947 13.3965 6.02818 13.3958 6.50586 13.3958H7.09766ZM16.9375 13.3958C17.4154 13.3958 17.8147 13.3965 18.1406 13.4231C18.4802 13.4509 18.799 13.5107 19.1006 13.6643C19.5634 13.9001 19.9399 14.2766 20.1758 14.7395C20.3295 15.0412 20.3901 15.3606 20.418 15.7005C20.4445 16.0262 20.4443 16.425 20.4443 16.9026V17.4934C20.4443 17.9712 20.4445 18.3708 20.418 18.6966C20.3901 19.0363 20.3294 19.355 20.1758 19.6565C19.9399 20.1195 19.5634 20.4959 19.1006 20.7317C18.799 20.8854 18.4803 20.9462 18.1406 20.9739C17.8148 21.0006 17.4153 21.0004 16.9375 21.0003H16.3467C15.8689 21.0004 15.4694 21.0006 15.1436 20.9739C14.8039 20.9462 14.4852 20.8854 14.1836 20.7317C13.7207 20.4959 13.3443 20.1195 13.1084 19.6565C12.9549 19.355 12.895 19.0362 12.8672 18.6966C12.8406 18.3708 12.8398 17.9712 12.8398 17.4934V16.9026C12.8398 16.425 12.8406 16.0262 12.8672 15.7005C12.895 15.3606 12.9547 15.0412 13.1084 14.7395C13.3443 14.2766 13.7208 13.9001 14.1836 13.6643C14.4851 13.5107 14.804 13.4509 15.1436 13.4231C15.4695 13.3965 15.8688 13.3958 16.3467 13.3958H16.9375ZM15.8818 3.12039C16.3759 2.95986 16.9083 2.95988 17.4023 3.12039C17.7243 3.22501 17.992 3.40858 18.252 3.62918C18.5011 3.84068 18.7834 4.12251 19.1211 4.46024L19.5205 4.85965L19.54 4.87821C19.8778 5.21599 20.1595 5.49817 20.3711 5.74735C20.5918 6.00728 20.7753 6.27589 20.8799 6.59793C21.0404 7.09195 21.0404 7.62444 20.8799 8.11844C20.7753 8.44037 20.5917 8.70819 20.3711 8.96805C20.1596 9.2172 19.8778 9.49943 19.54 9.83719L19.5205 9.85672L19.1406 10.2356L19.1211 10.2552C18.7834 10.593 18.5011 10.8757 18.252 11.0872C17.9921 11.3077 17.7242 11.4904 17.4023 11.595C16.9083 11.7556 16.3759 11.7556 15.8818 11.595C15.56 11.4904 15.292 11.3077 15.0322 11.0872C14.783 10.8756 14.5009 10.593 14.1631 10.2552L14.1436 10.2356L13.7637 9.85672L13.7451 9.83719C13.4072 9.49931 13.1247 9.21727 12.9131 8.96805C12.6925 8.7082 12.5089 8.44036 12.4043 8.11844C12.2438 7.62445 12.2438 7.09194 12.4043 6.59793C12.5089 6.27589 12.6924 6.00728 12.9131 5.74735C13.1246 5.49815 13.4073 5.21603 13.7451 4.87821L14.1631 4.46024C14.5008 4.12249 14.7831 3.84069 15.0322 3.62918C15.2921 3.40856 15.5598 3.22502 15.8818 3.12039ZM7.09766 3.55594C7.57532 3.55593 7.97406 3.5557 8.29981 3.58231C8.63965 3.61008 8.95903 3.67077 9.26074 3.8245C9.72338 4.06033 10.1001 4.43606 10.3359 4.89871C10.4896 5.20038 10.5493 5.51987 10.5771 5.85965C10.6037 6.18537 10.6035 6.58418 10.6035 7.0618V7.6536C10.6035 8.13125 10.6037 8.53 10.5771 8.85575C10.5493 9.19555 10.4896 9.515 10.3359 9.81668C10.1001 10.2795 9.72356 10.6561 9.26074 10.8919C8.95903 11.0457 8.63965 11.1054 8.29981 11.1331C7.97406 11.1597 7.57534 11.1595 7.09766 11.1595H6.50586C6.02819 11.1595 5.62948 11.1597 5.30371 11.1331C4.96388 11.1054 4.64448 11.0457 4.34277 10.8919C3.87991 10.6561 3.50343 10.2795 3.26758 9.81668C3.11388 9.51499 3.05413 9.19556 3.02637 8.85575C2.99977 8.52999 2.99999 8.13126 3 7.6536V7.06278C2.99999 6.58496 2.99975 6.18548 3.02637 5.85965C3.05414 5.51987 3.11387 5.20038 3.26758 4.89871C3.5034 4.43604 3.8801 4.06032 4.34277 3.8245C4.64448 3.67077 4.96388 3.61008 5.30371 3.58231C5.62946 3.5557 6.0282 3.55593 6.50586 3.55594H7.09766Z" fill="#908F92"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@@ -1,3 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" fill="white" stroke="white" stroke-width="2.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 920 B

View File

@@ -1,3 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" stroke="white" stroke-width="2.4"/>
</svg>

Before

Width:  |  Height:  |  Size: 907 B

View File

@@ -1,10 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3644_75)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.3496 13.3879L31.4232 2.70005C27.0689 -0.900018 20.9311 -0.900018 16.5769 2.70005L3.65033 13.3879C1.32142 15.3134 0 18.2329 0 21.2791V37.9462C0 43.3584 4.16196 48 9.6 48H14.4C17.051 48 19.2 45.851 19.2 43.2V35.3947C19.2 32.3527 21.485 30.1409 24 30.1409C26.515 30.1409 28.8 32.3527 28.8 35.3947V43.2C28.8 45.851 30.949 48 33.6 48H38.4C43.8382 48 48 43.3584 48 37.9462V21.2791C48 18.2329 46.6786 15.3134 44.3496 13.3879Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_3644_75">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6311 8.02044L14.7837 4.01252C13.1508 2.66249 10.8492 2.66249 9.21633 4.01252L4.36887 8.02044C3.49553 8.74253 3 9.83735 3 10.9797V17.2298C3 19.2594 4.56073 21 6.6 21H8.4C9.39411 21 10.2 20.1941 10.2 19.2V16.273C10.2 15.1323 11.0569 14.3028 12 14.3028C12.9431 14.3028 13.8 15.1323 13.8 16.273V19.2C13.8 20.1941 14.6059 21 15.6 21H17.4C19.4393 21 21 19.2594 21 17.2298V10.9797C21 9.83736 20.5045 8.74253 19.6311 8.02044Z" fill="#EEB726"/>
</svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 592 B

View File

@@ -1,10 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3644_72)">
<path d="M45.4514 37.2049V21.4231C45.4513 18.902 44.3566 16.504 42.4571 14.9334L30.2173 4.81364C26.5656 1.79467 21.4361 1.79474 17.7845 4.81364L5.54473 14.9334C3.64525 16.5039 2.5505 18.9019 2.55037 21.4231V37.2049C2.55037 41.7058 5.99784 45.4504 10.3656 45.4505H14.9105C16.7167 45.4505 18.1816 43.9864 18.1818 42.1803V34.7893C18.1818 31.2688 20.8533 28.5405 24.0004 28.5403C27.1476 28.5403 29.82 31.2687 29.82 34.7893V42.1803C29.8202 43.9864 31.2851 45.4505 33.0913 45.4505H37.6362C42.0039 45.4503 45.4514 41.7057 45.4514 37.2049ZM48.0008 37.2049C48.0008 42.9533 43.5667 47.9997 37.6362 47.9999H33.0913C29.8773 47.9999 27.2718 45.3942 27.2717 42.1803V34.7893C27.2717 32.5491 25.6158 31.0886 24.0004 31.0886C22.385 31.0889 20.7301 32.5493 20.7301 34.7893V42.1803C20.73 45.3942 18.1244 47.9999 14.9105 47.9999H10.3656C4.43509 47.9998 0.000976562 42.9534 0.000976562 37.2049V21.4231C0.00109717 18.1759 1.40889 15.0449 3.91946 12.969L16.1603 2.84921C20.7545 -0.949061 27.2473 -0.949111 31.8415 2.84921L44.0813 12.969C46.5921 15.0449 48.0007 18.1758 48.0008 21.4231V37.2049Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_3644_72">
<rect width="48" height="48" fill="white"/>
</clipPath>
</defs>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.6311 8.02044L14.7837 4.01252C13.1508 2.66249 10.8492 2.66249 9.21633 4.01252L4.36887 8.02044C3.49553 8.74253 3 9.83735 3 10.9797V17.2298C3 19.2594 4.56073 21 6.6 21H8.4C9.39411 21 10.2 20.1941 10.2 19.2V16.273C10.2 15.1323 11.0569 14.3028 12 14.3028C12.9431 14.3028 13.8 15.1323 13.8 16.273V19.2C13.8 20.1941 14.6059 21 15.6 21H17.4C19.4393 21 21 19.2594 21 17.2298V10.9797C21 9.83736 20.5045 8.74253 19.6311 8.02044Z" fill="#908F92"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 592 B

View File

@@ -1,3 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 0.799805C36.813 0.799805 47.2002 11.187 47.2002 24C47.2002 36.813 36.813 47.2002 24 47.2002C11.187 47.2002 0.799805 36.813 0.799805 24C0.799805 11.187 11.187 0.799805 24 0.799805ZM24 9C23.3373 9 22.7998 9.53745 22.7998 10.2002V24.2246C22.7999 25.0225 23.098 25.7924 23.6357 26.3818L30.4131 33.8096C30.8597 34.2988 31.6189 34.3331 32.1084 33.8867C32.5979 33.4401 32.633 32.681 32.1865 32.1914L25.4092 24.7637C25.2749 24.6164 25.2003 24.424 25.2002 24.2246V10.2002C25.2002 9.53745 24.6627 9 24 9Z" fill="white"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.007 17.3962H19.2285C19.6546 17.3962 20 17.0853 20 16.7018V3.69439C20 3.31089 19.6546 3 19.2285 3H16.8936V7.6225C16.8936 7.79995 16.6686 7.90128 16.5108 7.79487L15.0378 6.8014C14.9527 6.74403 14.8358 6.74403 14.7508 6.8014L13.2778 7.79487C13.12 7.90128 12.8949 7.79995 12.8949 7.6225V3H7.00167C5.34888 3 4 4.20928 4 5.70168V18.2983C4 19.7859 5.34355 21 7.00167 21H19.2285C19.6546 21 20 20.6891 20 20.3056V19.8901C20 19.5066 19.6546 19.1957 19.2285 19.1957H7.007C6.45785 19.1957 6.00466 18.7926 6.00466 18.2935C6.00466 17.7993 6.45252 17.3962 7.007 17.3962Z" fill="#EEB726"/>
</svg>

Before

Width:  |  Height:  |  Size: 627 B

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -1,4 +1,3 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M44.7998 24C44.7998 12.5125 35.4875 3.2002 24 3.2002C12.5125 3.2002 3.2002 12.5125 3.2002 24C3.2002 35.4875 12.5125 44.7998 24 44.7998C35.4875 44.7998 44.7998 35.4875 44.7998 24ZM47.2002 24C47.2002 36.813 36.813 47.2002 24 47.2002C11.187 47.2002 0.799805 36.813 0.799805 24C0.799805 11.187 11.187 0.799805 24 0.799805C36.813 0.799805 47.2002 11.187 47.2002 24Z" fill="white"/>
<path d="M32.1865 32.1912C32.6331 32.6807 32.5979 33.4398 32.1084 33.8865C31.6189 34.333 30.8598 34.2987 30.4131 33.8093L23.6357 26.3816C23.0979 25.7921 22.7998 25.0224 22.7998 24.2244V10.2C22.7998 9.53721 23.3373 8.99976 24 8.99976C24.6627 8.99976 25.2002 9.53721 25.2002 10.2V24.2244C25.2002 24.4238 25.2748 24.6161 25.4092 24.7634L32.1865 32.1912Z" fill="white"/>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.007 17.3962H19.2285C19.6546 17.3962 20 17.0853 20 16.7018V3.69439C20 3.31089 19.6546 3 19.2285 3H16.8936V7.6225C16.8936 7.79995 16.6686 7.90128 16.5108 7.79487L15.0378 6.8014C14.9527 6.74403 14.8358 6.74403 14.7508 6.8014L13.2778 7.79487C13.12 7.90128 12.8949 7.79995 12.8949 7.6225V3H7.00167C5.34888 3 4 4.20928 4 5.70168V18.2983C4 19.7859 5.34355 21 7.00167 21H19.2285C19.6546 21 20 20.6891 20 20.3056V19.8901C20 19.5066 19.6546 19.1957 19.2285 19.1957H7.007C6.45785 19.1957 6.00466 18.7926 6.00466 18.2935C6.00466 17.7993 6.45252 17.3962 7.007 17.3962Z" fill="#908F92"/>
</svg>

Before

Width:  |  Height:  |  Size: 856 B

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -7,6 +7,7 @@ import { CategoryPage } from "./pages/Category";
import { SearchPage } from "./pages/Search";
import { PostRedirect } from "./pages/PostRedirect";
import { AboutPage } from "./pages/About";
import Favorites from "./pages/Favorites";
import { adminUiPrefix } from "./adminPaths";
import { AdminRouteTree } from "./adminRouteTree";
import { AdminRouterModeProvider } from "./adminRouterMode";
@@ -30,6 +31,7 @@ export default function App() {
<Route path="/search" element={<SearchPage />} />
<Route path="/resource/:id" element={<PostRedirect />} />
<Route path="/about" element={<AboutPage />} />
<Route path="/favorites" element={<Favorites />} />
</Route>
{adminEnabled ? (

View File

@@ -0,0 +1,15 @@
import type { SVGProps } from "react";
export function DownloadCloudIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
viewBox="0 0 14 14"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
{...props}
>
<path d="M10.7387 5.85011C10.587 3.88544 8.96824 2.33398 7.00033 2.33398C5.31699 2.33398 3.87199 3.4472 3.40574 5.07077C2.07574 5.54083 1.16699 6.82374 1.16699 8.27338C1.16699 10.1447 2.66241 11.6673 4.50033 11.6673H9.91699C11.5253 11.6673 12.8337 10.3352 12.8337 8.69762C12.8337 7.36168 11.9712 6.21495 10.7387 5.85011ZM8.96158 8.14908L7.29491 9.84605C7.21366 9.92877 7.10699 9.97035 7.00033 9.97035C6.89366 9.97035 6.78699 9.92877 6.70574 9.84605L5.03908 8.14908C4.91991 8.02774 4.88408 7.84532 4.94866 7.68665C5.01324 7.52841 5.16533 7.42489 5.33366 7.42489H6.16699V5.72792C6.16699 5.25956 6.54033 4.87944 7.00033 4.87944C7.46033 4.87944 7.83366 5.25956 7.83366 5.72792V7.42489H8.66699C8.83533 7.42489 8.98741 7.52841 9.05199 7.68665C9.11658 7.84532 9.08074 8.02774 8.96158 8.14908Z" />
</svg>
);
}

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle } from "lucide-react";
import { LoaderCircle } from "lucide-react";
import { DownloadCloudIcon } from "../icons/DownloadCloudIcon";
import { useState, type MouseEvent } from "react";
import { useI18n } from "../../i18n";
import type { Attachment } from "../../types/post";
@@ -35,20 +36,23 @@ export function AttachmentDownloadPill({
type="button"
onClick={handleDownload}
disabled={isDownloading}
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/45 text-[10px] text-white shadow-lg ring-1 ring-white/15 backdrop-blur-md transition hover:bg-black/60 disabled:cursor-wait ${className}`}
className={`group z-10 inline-flex overflow-hidden rounded-full bg-black/80 text-[11px] text-white shadow-lg ring-1 ring-inset ring-white/20 backdrop-blur-md transition hover:bg-black/90 disabled:cursor-wait ${className}`}
aria-label={
isDownloading ? t("downloading") : `Download ${attachment.filename}`
}
aria-busy={isDownloading}
>
<span className="flex h-6 w-6 items-center justify-center bg-white/10 transition group-hover:bg-white/15">
<span className="flex h-6 w-6 items-center justify-center bg-[#545454]/50 transition group-hover:bg-[#545454]/70">
{isDownloading ? (
<LoaderCircle className="h-3 w-3 animate-spin" strokeWidth={2.3} />
<LoaderCircle
className="h-3.5 w-3.5 animate-spin"
strokeWidth={2.3}
/>
) : (
<ArrowDownToLine className="h-3 w-3" strokeWidth={2.3} />
<DownloadCloudIcon className="h-3.5 w-3.5" />
)}
</span>
<span className="flex h-6 items-center gap-0.5 px-1.5">
<span className="flex h-6 items-center gap-0.5 px-2">
{isDownloading ? (
t("downloading")
) : (

View File

@@ -1,5 +1,3 @@
import { SlidersHorizontal } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../i18n";
import { typeFilterLabel } from "../../resourceTypeLabels";
@@ -22,95 +20,37 @@ export type FilterChipsProps = {
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const labelsKey = useMemo(
() => TYPE_FILTERS.map((tp) => typeFilterLabel(t, tp)).join("|"),
[t],
);
useEffect(() => {
const checkOverflow = () => {
const container = containerRef.current;
const measure = measureRef.current;
if (!container || !measure) return;
const nextHasOverflow = measure.scrollWidth > container.clientWidth + 1;
setHasOverflow(nextHasOverflow);
if (!nextHasOverflow) setExpanded(false);
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
if (containerRef.current) resizeObserver.observe(containerRef.current);
if (measureRef.current) resizeObserver.observe(measureRef.current);
return () => resizeObserver.disconnect();
}, [labelsKey]);
const chipClass = (active: boolean) =>
`inline-flex h-8 min-w-[72px] shrink-0 items-center justify-center rounded-full border px-3 text-xs leading-none transition ${
const tabClass = (active: boolean) =>
[
"relative shrink-0 whitespace-nowrap px-1 py-3 text-[15px] leading-none outline-none transition-colors",
"border-b-2",
active
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
}`;
? "border-ark-gold text-ark-gold font-medium"
: "border-transparent text-neutral-400 hover:text-ark-gold/80",
].join(" ");
return (
<div
ref={containerRef}
className="sticky top-0 z-10 overflow-hidden border-b border-ark-line bg-ark-bg/90 py-2 backdrop-blur md:rounded-t-xl"
>
<div className="flex items-start gap-1.5">
<div
className={`flex min-w-0 flex-1 gap-1.5 ${
expanded
? "flex-wrap whitespace-normal"
: "overflow-hidden whitespace-nowrap"
}`}
>
{TYPE_FILTERS.map((tp) => {
const active = type === tp;
return (
<button
key={tp}
type="button"
onClick={() => onTypeChange(tp)}
className={chipClass(active)}
>
{typeFilterLabel(t, tp)}
</button>
);
})}
</div>
{hasOverflow ? (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 transition hover:border-ark-gold/60 hover:text-ark-gold2 min-[440px]:w-9 md:w-10"
aria-label={expanded ? "Collapse filters" : "Expand filters"}
aria-expanded={expanded}
>
<SlidersHorizontal
className="h-3.5 w-3.5 md:h-4 md:w-4"
strokeWidth={2.2}
/>
</button>
) : null}
</div>
<div className="sticky top-0 z-10 border-b border-ark-line bg-ark-bg/95 backdrop-blur md:rounded-t-xl">
<div
ref={measureRef}
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 -z-10 flex h-0 max-w-none gap-1.5 overflow-hidden whitespace-nowrap"
className="flex items-end gap-5 overflow-x-auto overflow-y-hidden px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
role="tablist"
>
{TYPE_FILTERS.map((tp) => (
<span key={tp} className={chipClass(type === tp)}>
{typeFilterLabel(t, tp)}
</span>
))}
{TYPE_FILTERS.map((tp) => {
const active = type === tp;
return (
<button
key={tp}
type="button"
role="tab"
aria-selected={active}
onClick={() => onTypeChange(tp)}
className={tabClass(active)}
>
{typeFilterLabel(t, tp)}
</button>
);
})}
</div>
</div>
);

View File

@@ -26,29 +26,31 @@ export function pickBubble(post: Post): BubbleComponent {
export function MessageBubble({ post }: { post: Post }) {
const { lang } = useI18n();
const Bubble = pickBubble(post);
const isTextOnly = post.attachments.length === 0;
const isVisual = post.attachments.some(
(a) => a.kind === "image" || a.kind === "video",
);
const isVisual =
Bubble === AlbumBubble ||
Bubble === VideoBubble ||
Bubble === ImageBubble ||
Bubble === ImageWithTextBubble;
return (
<div
id={`post-${post.id}`}
className="mx-auto w-full max-w-[380px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
>
<article
className={`relative rounded-2xl bg-ark-panel text-left shadow-sm ${
isVisual ? "w-full" : "w-fit max-w-full"
} ${isTextOnly ? "px-3 py-2" : "p-2"}`}
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${
isVisual ? "p-0" : "px-4 py-3"
}`}
>
<Bubble post={post} />
<time
dateTime={post.publishedAt}
className="ml-2 mt-1 inline-block float-right text-[10.5px] leading-none text-neutral-500"
className={`block text-right text-[12px] leading-[19px] text-[#A8A9AE] ${
isVisual ? "px-4 pb-3 pt-3" : "mt-3"
}`}
>
{formatDateTime(post.publishedAt, lang)}
</time>
<span className="block clear-both" />
</article>
</div>
);

View File

@@ -72,12 +72,12 @@ export function MessageStream({ scope }: MessageStreamProps) {
};
return (
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<div className="mx-auto max-w-full px-4 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
<div className="flex flex-col gap-2 pb-10 pt-2">
<div className="flex flex-col gap-3 pt-2">
{groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-2">
<div key={group.dayKey} className="flex flex-col gap-3">
{group.items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle, X } from "lucide-react";
import { LoaderCircle, X } from "lucide-react";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../../../i18n";
@@ -12,8 +13,15 @@ import { postDisplayText } from "../utils/postText";
const MAX_VISIBLE = 4;
function imageRatio(att: Attachment) {
return att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
function albumGridClass(count: number) {
const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]";
if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`;
return `${height} grid grid-cols-2 grid-rows-2`;
}
function albumItemClass(index: number, count: number) {
if (count === 3 && index === 0) return "row-span-2";
return "";
}
function ImageListDownloadButton({
@@ -51,7 +59,7 @@ function ImageListDownloadButton({
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.3} />
) : (
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
<DownloadCloudIcon className="h-4 w-4" />
)}
</button>
);
@@ -142,54 +150,22 @@ export function AlbumBubble({ post }: { post: Post }) {
const [listOpen, setListOpen] = useState(false);
const images = post.attachments;
const text = postDisplayText(post, lang);
const shouldMerge = images.length > MAX_VISIBLE;
if (!shouldMerge) {
return (
<div className="flex flex-col gap-1.5">
{images.map((att, i) => (
<div
key={att.id}
className="relative max-h-[180px] w-full overflow-hidden rounded-xl min-[440px]:max-h-[200px] md:max-h-[240px] lg:max-h-[280px]"
>
<button
type="button"
onClick={() => openLightbox(images, i, text, post.id)}
className="block w-full"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: imageRatio(att) }}
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
</div>
))}
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(text)}
</div>
) : null}
</div>
);
}
const visible = images.slice(0, MAX_VISIBLE);
const extra = images.length - MAX_VISIBLE;
const layoutCount = Math.min(images.length, MAX_VISIBLE);
return (
<div className="flex flex-col gap-1.5">
<div className="grid h-[220px] grid-cols-2 grid-rows-2 gap-[2px] overflow-hidden rounded-xl min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]">
<div className="flex flex-col">
<div className={`${albumGridClass(layoutCount)} gap-px overflow-hidden`}>
{visible.map((att, i) => {
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
return (
<div
key={att.id}
className="relative h-full w-full overflow-hidden"
className={`relative h-full w-full overflow-hidden ${albumItemClass(
i,
layoutCount,
)}`}
>
<button
type="button"
@@ -204,12 +180,10 @@ export function AlbumBubble({ post }: { post: Post }) {
src={att.thumbnailUrl ?? att.url}
alt={att.filename}
loading="lazy"
className={`h-full w-full object-cover ${
isLastSlot ? "blur-sm scale-105" : ""
}`}
className="h-full w-full object-cover"
/>
{isLastSlot ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-3xl font-semibold text-white">
<div className="absolute inset-0 flex items-center justify-center bg-black/55 text-4xl font-bold text-white">
+{extra}
</div>
) : null}
@@ -222,7 +196,7 @@ export function AlbumBubble({ post }: { post: Post }) {
})}
</div>
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle } from "lucide-react";
import { LoaderCircle } from "lucide-react";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
import { useState } from "react";
import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
@@ -56,7 +57,7 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.3} />
) : (
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
<DownloadCloudIcon className="h-4 w-4" />
)}
</div>
</button>

View File

@@ -6,15 +6,12 @@ export function ImageBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const att = post.attachments[0];
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<div className="relative w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]">
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
<button
type="button"
onClick={() => openLightbox([att], 0, undefined, post.id)}
className="block w-full"
className="block h-full w-full"
aria-label={att.filename}
>
<img
@@ -22,7 +19,6 @@ export function ImageBubble({ post }: { post: Post }) {
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: ratio }}
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />

View File

@@ -11,31 +11,27 @@ export function ImageWithTextBubble({ post }: { post: Post }) {
const att = post.attachments[0];
const text = postDisplayText(post, lang);
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<div className="relative overflow-hidden rounded-xl bg-black/20">
<button
type="button"
onClick={() => openLightbox([att], 0, text, post.id)}
className="block w-full"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="block h-auto w-full"
style={{ aspectRatio: ratio }}
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
<div className="flex flex-col">
<div className="relative h-[180px] w-full overflow-hidden bg-black min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]">
<button
type="button"
onClick={() => openLightbox([att], 0, text, post.id)}
className="block h-full w-full"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
/>
</button>
<AttachmentDownloadPill postId={post.id} attachment={att} />
</div>
{text ? (
<div className="bg-gradient-to-b from-ark-panel/90 to-ark-panel px-4 py-3 text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words">
{autolink(text)}
</div>
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}
</div>

View File

@@ -1,4 +1,5 @@
import { ArrowDownToLine, LoaderCircle, Play, X } from "lucide-react";
import { LoaderCircle, Play, X } from "lucide-react";
import { DownloadCloudIcon } from "../../icons/DownloadCloudIcon";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../../../i18n";
@@ -12,6 +13,17 @@ import { postDisplayText } from "../utils/postText";
const MAX_VISIBLE = 4;
function videoGridClass(count: number) {
const height = "h-[230px] min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]";
if (count === 2) return `${height} grid grid-cols-1 grid-rows-2`;
return `${height} grid grid-cols-2 grid-rows-2`;
}
function videoItemClass(index: number, count: number) {
if (count === 3 && index === 0) return "row-span-2";
return "";
}
function formatDuration(sec: number | undefined): string {
if (!sec || sec <= 0) return "";
const m = Math.floor(sec / 60);
@@ -54,7 +66,7 @@ function VideoAttachmentCard({
className={`relative w-full overflow-hidden bg-black ${
compact
? "h-full"
: "max-h-[220px] rounded-xl min-[440px]:max-h-[250px] md:max-h-[300px] lg:max-h-[340px]"
: "h-[180px] min-[440px]:h-[210px] md:h-[260px] lg:h-[300px]"
}`}
style={compact ? undefined : { aspectRatio: videoRatio(attachment) }}
onClick={() => {
@@ -180,7 +192,7 @@ function AttachmentListDownloadButton({
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.3} />
) : (
<ArrowDownToLine className="h-4 w-4" strokeWidth={2.3} />
<DownloadCloudIcon className="h-4 w-4" />
)}
</button>
);
@@ -297,32 +309,38 @@ export function VideoBubble({ post }: { post: Post }) {
const [listOpen, setListOpen] = useState(false);
const videos = post.attachments.filter(isVideoAttachment);
const text = postDisplayText(post, lang);
const shouldMerge = videos.length > MAX_VISIBLE;
if (!videos.length) return null;
if (shouldMerge) {
if (videos.length >= 2) {
const visible = videos.slice(0, MAX_VISIBLE);
const extra = videos.length - MAX_VISIBLE;
const layoutCount = Math.min(videos.length, MAX_VISIBLE);
return (
<div className="flex flex-col gap-1.5">
<div className="grid h-[220px] grid-cols-2 grid-rows-2 gap-[2px] overflow-hidden rounded-xl bg-black min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]">
<div className="flex flex-col">
<div
className={`${videoGridClass(layoutCount)} gap-px overflow-hidden bg-black`}
>
{visible.map((att, i) => {
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
return (
<VideoAttachmentCard
<div
key={att.id}
postId={post.id}
attachment={att}
compact
overlayCount={isLastSlot ? extra : undefined}
onMoreClick={() => setListOpen(true)}
/>
className={`h-full w-full ${videoItemClass(i, layoutCount)}`}
>
<VideoAttachmentCard
postId={post.id}
attachment={att}
compact
overlayCount={isLastSlot ? extra : undefined}
onMoreClick={() => setListOpen(true)}
/>
</div>
);
})}
</div>
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}
@@ -342,12 +360,10 @@ export function VideoBubble({ post }: { post: Post }) {
}
return (
<div className="flex flex-col gap-1.5">
{videos.map((att) => (
<VideoAttachmentCard key={att.id} postId={post.id} attachment={att} />
))}
<div className="flex flex-col">
<VideoAttachmentCard postId={post.id} attachment={videos[0]} />
{text ? (
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words px-4 pt-3 text-[14px] leading-6 text-neutral-100">
{autolink(text)}
</div>
) : null}

View File

@@ -118,6 +118,10 @@ const zhDict: Dict = {
adminSearchQuery: "查询词",
adminSearchTime: "时间",
adminSearchId: "编号",
favorites: "我的收藏",
favoritesComingSoon: "功能即将推出",
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
backToHome: "返回首页",
};
const enDict: Dict = {
@@ -228,6 +232,11 @@ const enDict: Dict = {
adminSearchQuery: "Query",
adminSearchTime: "Time",
adminSearchId: "ID",
favorites: "My Favorites",
favoritesComingSoon: "Coming Soon",
favoritesComingSoonDesc:
"Sign-in and favorites are in development. Stay tuned.",
backToHome: "Back to Home",
};
const languageNames: Record<Lang, Dict> = {

View File

@@ -266,6 +266,8 @@ export function PublicLayout() {
const na = (which: PublicNavWhich) =>
navIsActive(pathname, search, hash, which);
const footerInContentFlow =
pathname === "/browse" || pathname.startsWith("/category/");
const goSearch = () => {
const s = q.trim();
@@ -276,7 +278,7 @@ export function PublicLayout() {
};
return (
<div className="min-h-full flex flex-col pb-20 md:pb-0">
<div className="min-h-full flex flex-col">
<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-[20px] py-[12px] md:hidden">
<Link
@@ -528,23 +530,33 @@ export function PublicLayout() {
) : null}
</header>
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-6 min-[440px]:px-5 sm:px-6 md:px-9 md:py-10 xl:px-0">
<main
className={`mx-auto w-full max-w-[1280px] px-4 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pt-10 xl:px-0 ${
footerInContentFlow ? "pb-0" : "flex-1 pb-6 md:pb-10"
}`}
>
<Outlet />
</main>
<footer className="mt-auto border-t border-ark-line bg-ark-nav/90 mb-20 md:mb-0">
<div className="mx-auto flex max-w-[1280px] flex-wrap gap-x-6 gap-y-2 px-4 py-6 text-sm text-neutral-400 min-[440px]:px-5 sm:px-6 md:px-9 xl:px-0">
<footer
className={`bg-transparent md:border-t md:border-ark-line md:bg-ark-nav/90 ${
footerInContentFlow ? "mt-3" : "mt-auto"
}`}
>
<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"
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="md:hidden fixed bottom-0 inset-x-0 z-40 border-t border-ark-line bg-ark-nav/95 backdrop-blur">
<div className="grid grid-cols-4 gap-1 px-1 py-2 text-center text-[11px]">
<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-[13px] leading-[18px]">
<BottomNavIcon
to="/"
label={t("home")}
@@ -560,13 +572,10 @@ export function PublicLayout() {
}
/>
<BottomNavIcon
to="/browse?sort=recommended"
label={t("official")}
icon="heart"
active={
pathname === "/browse" &&
new URLSearchParams(search).get("sort") === "recommended"
}
to="/favorites"
label={t("favorites")}
icon="bookmark"
active={pathname === "/favorites"}
/>
<BottomNavIcon
to="/browse?sort=latest"
@@ -593,7 +602,7 @@ function BottomNavIcon({
}: {
to: string;
label: string;
icon: "home" | "document" | "heart" | "update";
icon: "home" | "document" | "bookmark" | "update";
active: boolean;
}) {
const src = active
@@ -603,19 +612,16 @@ function BottomNavIcon({
<Link
to={to}
className={[
"flex flex-col items-center gap-1 rounded-lg py-1 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
active ? "text-ark-gold" : "text-neutral-400",
"flex min-w-0 flex-col items-center gap-1 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
active ? "text-ark-gold" : "text-[#908F92]",
].join(" ")}
>
<img
src={src}
alt=""
className={[
"mx-auto h-7 w-7 object-contain",
active ? "opacity-100" : "opacity-55",
].join(" ")}
width={28}
height={28}
className="mx-auto h-6 w-6 object-contain"
width={24}
height={24}
loading="lazy"
decoding="async"
/>

View File

@@ -8,7 +8,7 @@ export function Browse() {
const q = sp.get("q") || "";
return (
<section className="space-y-3">
<h1 className="mx-auto max-w-full px-3 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<h1 className="mx-auto max-w-full px-4 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
{q ? `${t("search")}: ${q}` : t("all")}
</h1>
<MessageStream scope={{ kind: "all" }} />

View File

@@ -0,0 +1,38 @@
import { Heart } from "lucide-react";
import { Link } from "react-router-dom";
import { useI18n } from "../../i18n";
export default function Favorites() {
const { t } = useI18n();
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-ark-gold/30 bg-ark-gold/5">
<Heart
className="h-10 w-10 text-ark-gold/70"
strokeWidth={1.8}
aria-hidden
/>
</div>
<h1 className="text-2xl font-semibold text-neutral-100 md:text-3xl">
{t("favorites")}
</h1>
<p className="text-base font-medium text-ark-gold2 md:text-lg">
{t("favoritesComingSoon")}
</p>
<p className="max-w-md text-sm leading-relaxed text-neutral-400 md:text-base">
{t("favoritesComingSoonDesc")}
</p>
<Link
to="/"
className="mt-4 inline-flex h-11 items-center justify-center rounded-full border border-ark-gold/60 bg-ark-gold/10 px-6 text-sm font-medium text-ark-gold transition hover:bg-ark-gold/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("backToHome")}
</Link>
</div>
);
}