Compare commits

...

11 Commits

Author SHA1 Message Date
TerryM
aaebd7ccd1 chore: comment legacy api nginx route
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-24 00:43:40 +08:00
3f0a9f72d9 1
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 48s
2026-05-24 00:31:42 +08:00
769087ba4a Route same-origin API via /apnew/api to bypass ALB /api* rule.
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 53s
ALB sends /api/* to an unreachable backend target group (502 on apex).
Use VITE_API_PREFIX=/apnew with nginx proxy to backend-1 until the listener rule is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:56:38 +08:00
2c710e2e24 Same-origin API: empty VITE_API_URL with nginx proxy to backend-1.
Frontends call /api/ on ark-library.com; nginx forwards internally to 100.93.205.19.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:42:59 +08:00
TerryM
e6bc212c4e fix: align official recommendations behavior
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-19 00:34:29 +08:00
TerryM
40143afc39 FIX: Remove yellow ring
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-18 07:44:35 +08:00
TerryM
2c76039c44 feat: apply figma responsive home design
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 49s
2026-05-17 19:38:43 +08:00
TerryM
5b67279734 ci: pin node for jsdom tests
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m28s
2026-05-16 21:12:48 +08:00
TerryM
1c1ef4801b test: switch vitest to happy-dom
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m0s
2026-05-16 21:01:41 +08:00
TerryM
a29ec8ed92 test: add frontend test suite
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 43s
2026-05-16 18:21:37 +08:00
TerryM
f59d1e8e2a docs: add frontend onboarding docs
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 55s
2026-05-16 18:14:55 +08:00
38 changed files with 2330 additions and 99 deletions

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# API origin. Leave empty for same-origin/local Vite proxy.
VITE_API_URL=
# Reown / WalletConnect project id. Required for WalletConnect QR/mobile login.
VITE_WALLETCONNECT_PROJECT_ID=
# Public production deploy disables admin routes.
VITE_DISABLE_ADMIN=false
# Admin-only build mode.
VITE_ADMIN_ONLY=false
# Optional admin UI base path. Leave empty to use default app behavior.
VITE_ADMIN_UI_PREFIX=

View File

@@ -13,6 +13,12 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies
run: npm ci
@@ -22,10 +28,14 @@ jobs:
- name: Format check
run: npm run format:check
- name: Test
run: npm test
- name: Build
run: npm run build
env:
VITE_API_URL: https://api.ark-library.com
VITE_API_URL: ""
VITE_API_PREFIX: "/apnew"
VITE_DISABLE_ADMIN: "true"
- name: Setup SSH key

View File

@@ -0,0 +1,63 @@
---
name: arkie-frontend-onboarding
description: Onboard an AI agent to the Arkie Library frontend repo. Use when starting work in this repository, checking branch/deploy rules, or refreshing project context before coding.
---
# Arkie Frontend Onboarding
Use this skill before making non-trivial changes in the Arkie Library frontend repo.
## 1. Read project context
Read these files in order:
1. `README.md`
2. `AGENTS.md`
3. `docs/workflow.md`
4. `docs/deploy.md` if the task touches deploy, CI, environment variables, or `main` branch pushes.
## 2. Check current git state
Run:
```bash
git status --short --branch
git branch --show-current
```
Rules:
- `main` is the production deploy branch.
- `terry-staging` is the staging/work branch.
- Do not commit or push unless Terry explicitly asks.
## 3. Recall memory
Search project memory for relevant context before decisions, especially for:
- branch/deploy workflow
- frontend conventions
- admin UI behavior
- TypeScript/format failures
- prior fixes touching the same files
## 4. Validate before finishing
For code changes, run:
```bash
npx tsc --noEmit
npm run format:check
npm test
```
Run `npm run build` when changes affect routes, config, build, deploy, env vars, or dependencies.
## 5. Report clearly
Summarize:
- files changed
- commands run and results
- current branch/status
- whether anything needs pull/push/deploy

78
AGENTS.md Normal file
View File

@@ -0,0 +1,78 @@
# AI Agent Instructions
This file is the first-stop context for AI coding agents working in this repo.
## Repository identity
- Project: Arkie Library Frontend / ARK database web UI.
- Package name: `ark-database-web`.
- Stack: React 18, TypeScript, Vite, Tailwind CSS, React Router, RainbowKit/Wagmi.
- Backend API is expected at `/api`; uploaded assets under `/uploads`.
## Branch rules
- Current deploy branch: `main`.
- Work/staging branch available: `terry-staging`.
- Do not commit or push unless Terry explicitly asks.
- Pushing to `main` triggers production frontend deploy via Gitea Actions.
- Before branch-changing or pulling, run `git status --short --branch` and preserve local work.
## Required checks
Before proposing a push or deploy, run:
```bash
npx tsc --noEmit
npm run format:check
npm test
```
If code formatting is needed, run:
```bash
npm run format
```
Notes:
- `tsconfig.json` has `strict`, `noUnusedLocals`, and `noUnusedParameters`; unused imports fail CI.
- `dist/` is build output and should not be edited manually.
## App structure quick map
- `src/main.tsx` chooses normal app vs admin-only build using `VITE_ADMIN_ONLY`.
- `src/App.tsx` contains public routes and conditionally exposes admin routes unless `VITE_DISABLE_ADMIN === "true"`.
- `src/api.ts` defines `apiBase`, fetch helpers, and shared resource/category types.
- `src/i18n.tsx` contains all UI copy for `zh-TW`, `zh-CN`, and `en`.
- `src/pages/admin/` contains admin UI screens.
- `src/adminPaths.ts` handles admin path prefix logic. Keep it in sync with backend/nginx admin host config if changed.
## Environment variables
See `.env.example` and `README.md` for details. Do not commit real secrets or private deployment keys.
Common production public build env:
```bash
VITE_API_URL=https://api.ark-library.com
VITE_DISABLE_ADMIN=true
```
## Deployment context
`.gitea/workflows/deploy.yml` deploys on push to `main`:
1. `npm ci`
2. `npx tsc --noEmit`
3. `npm run format:check`
4. `npm test`
5. `npm run build` with production public env
6. rsync `dist/` to both frontend servers
7. compare remote `index.html` checksums
## Agent behavior preferences
- Answer Terry in concise Chinese unless the task requires code/docs in English.
- Prefer small, direct fixes over broad refactors.
- Update README/docs/memory when learning non-obvious project facts.
- Search existing project memory before making decisions about workflow, deploy, or conventions.

97
README.md Normal file
View File

@@ -0,0 +1,97 @@
# Arkie Library Frontend
React + Vite frontend for the ARK Library / ARK database site. The app serves public resource browsing, search, favorites, wallet login UI, and an optional admin UI for resource management.
## Tech stack
- React 18 + TypeScript
- Vite 5
- React Router
- Tailwind CSS
- RainbowKit / Wagmi / Viem for wallet connection
- Gitea Actions deploy workflow on `main`
## Quick start
```bash
npm ci
npm run dev
```
Local dev server: <http://localhost:5173>
In development, Vite proxies these paths to the backend at `http://127.0.0.1:8080`:
- `/api`
- `/uploads`
If `VITE_API_URL` is set, API calls use that absolute base URL instead.
## Useful commands
```bash
npm run dev # start Vite dev server
npx tsc --noEmit # TypeScript check; CI requires this
npm run format:check # Prettier check; CI requires this
npm run format # format source files
npm test # run Vitest test suite
npm run build # production build to dist/
npm run preview # preview built app locally
```
Before pushing, run at least:
```bash
npx tsc --noEmit
npm run format:check
npm test
```
## Environment variables
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
| Variable | Purpose |
| --- | --- |
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. |
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
## Project layout
```text
src/
main.tsx # app entry; switches public vs admin-only build
App.tsx # public app + optional admin routes
AppAdminOnly.tsx # admin-only app entry
api.ts # fetch helpers and shared API types
i18n.tsx # zh-TW / zh-CN / en copy dictionary
adminPaths.ts # admin UI prefix logic
adminRouteTree.tsx # admin routes
components/ # reusable public components
layouts/ # public/admin layout shells
pages/ # public pages
pages/admin/ # admin pages
utils/ # formatting/display helpers
```
Important config files:
- `vite.config.ts` — Vite build and local backend proxy.
- `tailwind.config.js` — ARK color palette and font stack.
- `Dockerfile` / `nginx.conf` — container build and static SPA serving.
- `.gitea/workflows/deploy.yml` — deploys `main` to both frontend servers.
## Branch and deploy workflow
- `main` is the deploy branch. Pushing to `main` triggers `.gitea/workflows/deploy.yml`.
- `terry-staging` exists as a staging/work branch for later work.
- The deploy workflow runs `npm ci`, `npx tsc --noEmit`, `npm run format:check`, `npm test`, `npm run build`, then rsyncs `dist/` to both frontend servers and verifies matching checksums.
See also:
- `AGENTS.md` — instructions for AI coding agents.
- `docs/workflow.md` — recommended day-to-day workflow.
- `docs/deploy.md` — deploy details and troubleshooting.

View File

@@ -0,0 +1,105 @@
# Shared SPA locations. Browser calls same-origin /apnew/api/ (VITE_API_PREFIX=/apnew).
# /apnew/api/ avoids ALB listener rule that sends /api/* to an unreachable backend target group.
# Nginx proxies internally to ark-library-backend-1 (Tailscale); Host header for backend TLS.
# Legacy /api/ locations are commented below for reference only.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
gzip_min_length 256;
location ^~ /apnew/api/admin {
return 404;
}
# Legacy same-origin /api admin block. Disabled while production uses /apnew/api.
# location ^~ /api/admin {
# return 404;
# }
location ^~ /admin {
return 404;
}
location ^~ /apnew/api/ {
proxy_pass https://100.93.205.19/api/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host api.ark-library.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 512m;
client_body_timeout 600s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# Legacy same-origin /api proxy. Disabled while production uses /apnew/api.
# location ^~ /api/ {
# proxy_pass https://100.93.205.19/api/;
# proxy_http_version 1.1;
# proxy_ssl_server_name on;
# proxy_set_header Host api.ark-library.com;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# client_max_body_size 512m;
# client_body_timeout 600s;
# proxy_read_timeout 600s;
# proxy_send_timeout 600s;
# }
location ^~ /uploads/ {
proxy_pass https://100.93.205.19/uploads/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host api.ark-library.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
default_type text/plain;
return 200 "ok\n";
}
location = /healthz {
default_type text/plain;
return 200 "ok\n";
}
location = /index.html {
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# Exact `/` so the HTML shell is never edge-cached without validators (avoids stale index.html → 404 on hashed /index-*.js or /assets/*).
location = / {
try_files /index.html =404;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
location = /assets/logo-primary.webp {
try_files $uri =404;
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
}
location ^~ /assets/ark-library/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
}
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Hashed entry chunk at /index-[hash].js (Vite entryFileNames). Do not 308 to /assets — file lives here.
location ~* ^/index-[A-Za-z0-9_-]+\.(js|mjs)$ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -0,0 +1,18 @@
# Native system nginx (not Docker). SPA root: /var/www/ark-library
# Snippet: /etc/nginx/snippets/ark-library-frontend.inc
# ALB terminates TLS; apex uses X-Forwarded-Proto so we do not 301-loop behind the LB.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name ark-library.com www.ark-library.com;
if ($http_x_forwarded_proto != "https") {
return 301 https://$host$request_uri;
}
root /var/www/ark-library;
index index.html;
include /etc/nginx/snippets/ark-library-frontend.inc;
}

83
docs/deploy.md Normal file
View File

@@ -0,0 +1,83 @@
# Deployment
Production frontend deploy is handled by Gitea Actions in `.gitea/workflows/deploy.yml`.
## Trigger
A push to `main` triggers the deploy workflow.
```yaml
on:
push:
branches:
- main
```
## CI/deploy steps
1. Checkout code.
2. Set up Node.js 22 with `actions/setup-node`.
3. Install dependencies with `npm ci`.
4. Type check with `npx tsc --noEmit`.
5. Check formatting with `npm run format:check`.
6. Run tests with `npm test`.
7. Build with `npm run build` using:
```bash
VITE_API_URL=https://api.ark-library.com
VITE_DISABLE_ADMIN=true
```
8. Configure SSH key from `DEPLOY_KEY` secret.
9. `rsync --delete` built `dist/` to both frontend servers:
```text
ec2-user@FRONTEND_1_HOST:/var/www/ark-library/
ec2-user@FRONTEND_2_HOST:/var/www/ark-library/
```
10. Verify both servers have matching `index.html` SHA-256 checksums.
11. Remove temporary SSH key.
## Required repository secrets
The workflow expects these Gitea secrets:
- `DEPLOY_KEY`
- `FRONTEND_1_HOST`
- `FRONTEND_2_HOST`
## Common failures
### Node version is too old
The workflow pins Node.js 22 using `actions/setup-node`. This keeps the self-hosted runner from using an older system Node version during `npm ci`, tests, and build.
If `actions/setup-node` cannot run on the self-hosted runner, upgrade the runner host's Node.js installation to Node 22 or at least Node 20.19 before restarting the runner service.
### TypeScript fails on unused imports
This repo uses `noUnusedLocals` and `noUnusedParameters`. Remove unused imports/variables and rerun:
```bash
npx tsc --noEmit
```
### Format check fails
Run:
```bash
npm run format
npm run format:check
```
Then commit the formatting changes.
### Build uses wrong API
Check `VITE_API_URL` in `.gitea/workflows/deploy.yml` or local `.env`.
### One frontend server differs from the other
The workflow compares remote `index.html` checksums. If it fails, inspect the rsync step and both `FRONTEND_*_HOST` values.

84
docs/workflow.md Normal file
View File

@@ -0,0 +1,84 @@
# Development Workflow
This repo is intentionally simple: make changes, validate locally, then push only when ready.
## Start a session
```bash
git status --short --branch
git fetch origin main
```
If working on staging:
```bash
git switch terry-staging
git pull --ff-only
```
If working directly on production deploy branch:
```bash
git switch main
git pull --ff-only origin main
```
## Make changes
Use the existing structure:
- Public pages: `src/pages/`
- Admin pages: `src/pages/admin/`
- Shared components: `src/components/`
- API helpers/types: `src/api.ts`
- Translations/copy: `src/i18n.tsx`
- Display helpers: `src/utils/`
Avoid editing generated `dist/` files.
## Validate
Run:
```bash
npx tsc --noEmit
npm run format:check
npm test
npm run build
```
For formatting fixes:
```bash
npm run format
```
## Commit
Check the diff first:
```bash
git diff
git status --short
```
Use concise commits, for example:
```bash
git add <files>
git commit -m "fix: remove unused imports"
```
## Push
Only push when Terry asks.
```bash
git push origin main
```
or for staging:
```bash
git push origin terry-staging
```

1225
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,9 @@
"build": "vite build",
"preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\""
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@rainbow-me/rainbowkit": "^2.2.11",
@@ -21,14 +23,19 @@
"wagmi": "^2.19.5"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"jsdom": "^29.1.1",
"postcss": "^8.4.47",
"prettier": "^3.8.3",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
"vite": "^5.4.10",
"vitest": "^2.1.9"
}
}

View File

@@ -7,6 +7,6 @@ Source: Figma file `uHDZkVHjAp7BXDKQKB0PM4`, responsive reference node `3761:109
- `banner-576.png` — mobile/tablet banner crop from node `3726:13099`.
- `banner-440.png` — mobile banner crop from node `3726:14199`.
- `banner-375.png` — mobile banner crop from node `3726:14238`.
- `recommendation-1.png` ... `recommendation-5.png` — official recommendation cover exports from the 1920px frame card image nodes.
- `official-recommendation-1.png` ... `official-recommendation-5.png` — official recommendation cover exports from the 1920px frame card image nodes; used only as fallback/placeholder covers so real resource cards keep accurate API-provided imagery.
These files are visual UI assets only. They do not change backend data or API contracts.

View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

View File

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

View File

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

View File

@@ -1,3 +1,4 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { I18nProvider } from "./i18n";
import { PublicLayout } from "./layouts/PublicLayout";
@@ -7,12 +8,17 @@ import { CategoryPage } from "./pages/CategoryPage";
import { SearchPage } from "./pages/SearchPage";
import { FavoritesPage } from "./pages/FavoritesPage";
import { ResourceDetail } from "./pages/ResourceDetail";
import { WalletPage } from "./pages/WalletPage";
import { AboutPage } from "./pages/AboutPage";
import { adminUiPrefix } from "./adminPaths";
import { AdminRouteTree } from "./adminRouteTree";
import { AdminRouterModeProvider } from "./adminRouterMode";
const WalletPage = lazy(() =>
import("./pages/WalletPage").then((module) => ({
default: module.WalletPage,
})),
);
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
export default function App() {
@@ -28,7 +34,14 @@ export default function App() {
<Route path="/search" element={<SearchPage />} />
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/resource/:id" element={<ResourceDetail />} />
<Route path="/wallet" element={<WalletPage />} />
<Route
path="/wallet"
element={
<Suspense fallback={null}>
<WalletPage />
</Suspense>
}
/>
<Route path="/about" element={<AboutPage />} />
</Route>

71
src/api.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
async function loadApi(apiUrl = "") {
vi.resetModules();
vi.stubEnv("VITE_API_URL", apiUrl);
return import("./api");
}
function jsonResponse(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
describe("api helpers", () => {
it("normalizes nullish API arrays", async () => {
const { itemsOrEmpty } = await loadApi();
expect(itemsOrEmpty(null)).toEqual([]);
expect(itemsOrEmpty(undefined)).toEqual([]);
expect(itemsOrEmpty(["a"])).toEqual(["a"]);
});
it("builds asset URLs from API base while preserving absolute URLs", async () => {
const { assetUrl } = await loadApi("https://api.example.com");
expect(assetUrl("/uploads/file.png")).toBe(
"https://api.example.com/uploads/file.png",
);
expect(assetUrl("https://cdn.example.com/file.png")).toBe(
"https://cdn.example.com/file.png",
);
expect(assetUrl(undefined)).toBe("");
});
it("fetches JSON with API base and throws response text on failure", async () => {
const { getJSON } = await loadApi("https://api.example.com");
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true }))
.mockResolvedValueOnce(new Response("boom", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
await expect(getJSON("/api/resources")).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/resources",
);
await expect(getJSON("/api/fail")).rejects.toThrow("boom");
});
it("posts JSON with optional bearer token", async () => {
const { postJSON } = await loadApi();
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
vi.stubGlobal("fetch", fetchMock);
await expect(
postJSON("/api/admin/resources", { title: "Demo" }, "token-123"),
).resolves.toEqual({ id: 1 });
expect(fetchMock).toHaveBeenCalledWith("/api/admin/resources", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer token-123",
},
body: JSON.stringify({ title: "Demo" }),
});
});
});

View File

@@ -1,4 +1,5 @@
export const apiBase = import.meta.env.VITE_API_URL || "";
export const apiPrefix = import.meta.env.VITE_API_PREFIX || "";
export const apiBase = (import.meta.env.VITE_API_URL || "") + apiPrefix;
/** Go JSON encodes nil slices as null — normalize before .map() */
export function itemsOrEmpty<T>(items: T[] | null | undefined): T[] {

View File

@@ -1,16 +1,16 @@
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
export const recommendationCoverFallbacks = [
`${FIGMA_ASSET_BASE}/recommendation-1.png`,
`${FIGMA_ASSET_BASE}/recommendation-2.png`,
`${FIGMA_ASSET_BASE}/recommendation-3.png`,
`${FIGMA_ASSET_BASE}/recommendation-4.png`,
`${FIGMA_ASSET_BASE}/recommendation-5.png`,
export const officialRecommendationCoverFallbacks = [
`${FIGMA_ASSET_BASE}/official-recommendation-1.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-2.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-3.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-4.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-5.png`,
] as const;
export function FigmaBanner() {
return (
<picture className="block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] max-md:-mx-4 max-md:rounded-none max-md:border-x-0 md:rounded-xl">
<picture className="-mx-4 block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] min-[440px]:-mx-5 sm:-mx-6 md:mx-0 md:rounded-xl">
<source
media="(max-width: 439px)"
srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`}

View File

@@ -27,11 +27,11 @@ export function LatestUpdateRow({
className="h-10 w-10 text-ark-gold"
/>
</div>
<div className="min-w-0 flex-1 py-0.5">
<div className="flex min-w-0 flex-1 self-stretch py-0.5 flex-col">
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
{r.title}
</div>
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6">
<div className="mt-auto grid gap-1 text-sm text-[#9b9ca6]">
<span>{r.categoryName}</span>
<span>
{resourceTypeLabel(t, r.type)}
@@ -58,11 +58,11 @@ export function ComingSoonLatestUpdateRow({ index = 0 }: { index?: number }) {
<div className="flex shrink-0 items-center justify-center pt-0.5">
<CategoryIcon iconKey={iconKey} className="h-10 w-10 text-ark-gold" />
</div>
<div className="min-w-0 flex-1 py-0.5">
<div className="flex min-w-0 flex-1 self-stretch py-0.5 flex-col">
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
</div>
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6">
<div className="mt-auto grid gap-1 text-sm text-[#9b9ca6]">
<span></span>
<span>Coming soon</span>
</div>

View File

@@ -5,14 +5,14 @@ import { assetUrl, postJSON } from "../api";
import { useI18n } from "../i18n";
import { useMemo } from "react";
import { formatDateYmd } from "../utils/format";
import { recommendationCoverFallbacks } from "./FigmaBanner";
import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover");
}
const CARD_CLASS =
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px]";
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
export function RecommendedCard({
r,
@@ -25,8 +25,8 @@ export function RecommendedCard({
const cover = useMemo(() => {
const original = r.coverImage || r.previewUrl;
if (isPlaceholderAsset(original)) {
return recommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length
return officialRecommendationCoverFallbacks[
visualIndex % officialRecommendationCoverFallbacks.length
];
}
return assetUrl(original);
@@ -105,8 +105,8 @@ export function ComingSoonRecommendedCard({
visualIndex?: number;
}) {
const cover =
recommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length
officialRecommendationCoverFallbacks[
visualIndex % officialRecommendationCoverFallbacks.length
];
return (

View File

@@ -0,0 +1,100 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import type { Resource } from "../api";
import { I18nProvider } from "../i18n";
import { ResourceCard } from "./ResourceCard";
const resource: Resource = {
id: "resource-1",
title: "Demo Resource",
description: "Short description",
type: "pdf",
language: "zh-TW",
categoryId: 1,
categorySlug: "docs",
categoryName: "文件",
coverImage: "/uploads/cover.png",
fileUrl: "/uploads/demo.pdf",
previewUrl: "/uploads/preview.pdf",
badgeLabel: "官方",
isDownloadable: true,
isRecommended: true,
updatedAt: "2026-05-16T00:00:00Z",
tags: ["ark"],
};
function renderCard(onFavoriteToggle = vi.fn()) {
return {
user: userEvent.setup(),
onFavoriteToggle,
...render(
<MemoryRouter>
<I18nProvider>
<ResourceCard r={resource} onFavoriteToggle={onFavoriteToggle} />
</I18nProvider>
</MemoryRouter>,
),
};
}
function okResponse() {
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("ResourceCard", () => {
it("renders resource summary and detail link", () => {
renderCard();
expect(screen.getByText("Demo Resource")).toBeInTheDocument();
expect(screen.getByText("文件")).toBeInTheDocument();
expect(screen.getByText("官方")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /預覽/ })).toHaveAttribute(
"href",
"/resource/resource-1",
);
});
it("toggles local favorite state and syncs best-effort API delta", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal("fetch", fetchMock);
const { user, onFavoriteToggle } = renderCard();
await user.click(screen.getByRole("button", { name: /收藏/ }));
expect(JSON.parse(localStorage.getItem("ark_favorites") || "[]")).toEqual([
"resource-1",
]);
expect(fetchMock).toHaveBeenCalledWith(
"/api/resources/resource-1/favorite",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ add: true }),
}),
);
expect(onFavoriteToggle).toHaveBeenCalledTimes(1);
});
it("tracks downloads then opens the downloadable file", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal("fetch", fetchMock);
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
const { user } = renderCard();
await user.click(screen.getByRole("button", { name: /下載/ }));
expect(fetchMock).toHaveBeenCalledWith(
"/api/resources/resource-1/download",
expect.objectContaining({ method: "POST" }),
);
expect(openMock).toHaveBeenCalledWith(
"/uploads/demo.pdf",
"_blank",
"noopener,noreferrer",
);
});
});

View File

@@ -0,0 +1,52 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ResourceListFooter } from "./ResourceListFooter";
const t = (key: string) =>
({
listRange: "顯示 {{from}}{{to}},共 {{total}} 筆",
paginationPrev: "上一頁",
paginationNext: "下一頁",
pageIndicator: "{{c}} / {{p}} 頁",
})[key] ?? key;
describe("ResourceListFooter", () => {
it("renders range, page count, and disabled prev on first page", () => {
render(
<ResourceListFooter
page={1}
limit={24}
total={50}
t={t}
onPrev={vi.fn()}
onNext={vi.fn()}
/>,
);
expect(screen.getByText("顯示 124共 50 筆")).toBeInTheDocument();
expect(screen.getByText("1 / 3 頁")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "上一頁" })).toBeDisabled();
expect(screen.getByRole("button", { name: "下一頁" })).toBeEnabled();
});
it("calls navigation handlers when buttons are enabled", () => {
const onPrev = vi.fn();
const onNext = vi.fn();
render(
<ResourceListFooter
page={2}
limit={24}
total={50}
t={t}
onPrev={onPrev}
onNext={onNext}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "上一頁" }));
fireEvent.click(screen.getByRole("button", { name: "下一頁" }));
expect(onPrev).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
});
});

27
src/favorites.test.ts Normal file
View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isFavorite, readFavorites, toggleFavorite } from "./favorites";
describe("favorites localStorage helpers", () => {
it("returns an empty list for missing, invalid, or non-array values", () => {
expect(readFavorites()).toEqual([]);
localStorage.setItem("ark_favorites", "not-json");
expect(readFavorites()).toEqual([]);
localStorage.setItem("ark_favorites", JSON.stringify({ id: "r1" }));
expect(readFavorites()).toEqual([]);
});
it("filters non-string entries and toggles favorite ids", () => {
localStorage.setItem("ark_favorites", JSON.stringify(["r1", 123, "r2"]));
expect(readFavorites()).toEqual(["r1", "r2"]);
expect(toggleFavorite("r3")).toBe(true);
expect(isFavorite("r3")).toBe(true);
expect(readFavorites()).toEqual(["r1", "r2", "r3"]);
expect(toggleFavorite("r1")).toBe(false);
expect(isFavorite("r1")).toBe(false);
expect(readFavorites()).toEqual(["r2", "r3"]);
});
});

View File

@@ -77,21 +77,21 @@ export function PublicLayout() {
return (
<div className="min-h-full flex flex-col pb-20 md:pb-0">
<header className="sticky top-0 z-40 border-b border-ark-line bg-ark-nav/98 backdrop-blur-md">
<div className="mx-auto max-w-[1280px] px-4 py-[15px] md:px-8 xl:px-0">
<div className="mx-auto max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:px-9 xl:px-0">
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
<div className="flex h-10 items-center gap-2 lg:gap-4">
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
<Link
to="/"
className="flex min-w-0 shrink-0 items-center gap-2.5 rounded-sm text-xl font-bold tracking-wide text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
<ArkLogoMark className="h-10 w-10 shrink-0" />
<span className="max-w-[9rem] truncate text-ark-gold sm:inline md:max-w-[10rem] lg:max-w-none">
<span className="max-w-[8rem] truncate text-ark-gold sm:inline">
{t("brand")}
</span>
</Link>
<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 md: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"
aria-label={t("mainNav")}
>
<Link
@@ -152,15 +152,15 @@ export function PublicLayout() {
</Link>
</nav>
<div className="flex shrink-0 items-center justify-end gap-2">
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex lg:pr-4">
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
<div className="hidden h-10 min-w-0 flex-1 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex min-[1200px]:w-44 min-[1200px]:flex-none lg:pr-4 xl:w-52">
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && goSearch()}
placeholder={t("searchPlaceholder")}
className="w-24 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20] md:w-28 lg:w-44 xl:w-52"
className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]"
/>
</div>
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-2 py-2 md:flex lg:px-3">
@@ -170,7 +170,7 @@ export function PublicLayout() {
aria-hidden
/>
<select
className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20] lg:max-w-none"
className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 lg:max-w-none"
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")}
@@ -182,7 +182,7 @@ export function PublicLayout() {
</div>
<button
type="button"
className="md:hidden inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
onClick={() => setOpen((v) => !v)}
aria-label="menu"
>
@@ -193,7 +193,7 @@ export function PublicLayout() {
</div>
{open ? (
<div className="md:hidden border-t border-ark-line bg-ark-nav px-4 py-3 grid gap-2">
<div className="grid gap-2 border-t border-ark-line bg-ark-nav px-4 py-3 min-[440px]:px-5 sm:px-6 md:px-9 min-[1200px]:hidden">
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input
@@ -280,12 +280,12 @@ export function PublicLayout() {
) : null}
</header>
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-6 md:px-8 md:py-10 xl:px-0">
<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">
<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 md:px-8 xl:px-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">
<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"

View File

@@ -1,11 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
import "./index.css";
import "@rainbow-me/rainbowkit/styles.css";
import { wagmiConfig } from "./wagmiConfig";
const queryClient = new QueryClient();
@@ -27,20 +23,9 @@ void (async () => {
const { default: App } = await import("./App");
ReactDOM.createRoot(root).render(
<React.StrictMode>
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
theme={darkTheme({
accentColor: "#d4af37",
accentColorForeground: "#0a0a0a",
borderRadius: "medium",
})}
modalSize="wide"
>
<App />
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
</React.StrictMode>,
);
})();

View File

@@ -53,13 +53,40 @@ export function Browse() {
useEffect(() => {
setErr(null);
if (sort === "recommended") {
const p = new URLSearchParams();
p.set("lang", lang);
p.set("limit", "100");
getJSON<{ items: Resource[] }>(`/api/resources/recommended?${p}`)
.then((r) => {
const tagLower = tag.toLowerCase();
const officialItems = itemsOrEmpty(r.items)
.filter((item) => item.isRecommended)
.filter((item) => type === "all" || item.type === type)
.filter((item) => !resourceLang || item.language === resourceLang)
.filter(
(item) =>
!tagLower ||
item.tags?.some(
(itemTag) => itemTag.toLowerCase() === tagLower,
),
);
setTotal(officialItems.length);
setItems(officialItems.slice((page - 1) * limit, page * limit));
})
.catch((e) => setErr(String(e)));
return;
}
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
.then((r) => {
setItems(itemsOrEmpty(r.items));
setTotal(typeof r.total === "number" ? r.total : 0);
})
.catch((e) => setErr(String(e)));
}, [query]);
}, [lang, limit, page, query, resourceLang, sort, tag, type]);
const setPage = (next: number) => {
const n = new URLSearchParams(sp);

View File

@@ -8,10 +8,7 @@ import {
ComingSoonLatestUpdateRow,
LatestUpdateRow,
} from "../components/LatestUpdateRow";
import {
ComingSoonRecommendedCard,
RecommendedCard,
} from "../components/RecommendedCard";
import { RecommendedCard } from "../components/RecommendedCard";
import { SectionHeader } from "../components/SectionHeader";
import { useI18n } from "../i18n";
import { categoryCardLines } from "../utils/categoryDisplay";
@@ -23,6 +20,7 @@ export function Home() {
const [latest, setLatest] = useState<Resource[]>([]);
const [err, setErr] = useState<string | null>(null);
const recRowRef = useRef<HTMLDivElement>(null);
const [canScrollRec, setCanScrollRec] = useState(false);
useEffect(() => {
const q = `?lang=${encodeURIComponent(lang)}`;
@@ -42,11 +40,27 @@ export function Home() {
const iconKeyForResource = (r: Resource) =>
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
useEffect(() => {
const row = recRowRef.current;
if (!row) {
setCanScrollRec(false);
return;
}
const updateCanScroll = () => {
setCanScrollRec(row.scrollWidth > row.clientWidth + 1);
};
updateCanScroll();
const resizeObserver = new ResizeObserver(updateCanScroll);
resizeObserver.observe(row);
return () => resizeObserver.disconnect();
}, [rec.length]);
const scrollRec = (dir: 1 | -1) => {
recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" });
};
const recommendedPlaceholderCount = Math.max(0, 5 - rec.length);
const latestPlaceholderCount = Math.max(0, 5 - latest.length);
if (err) {
@@ -58,7 +72,7 @@ export function Home() {
}
return (
<div className="space-y-12 pb-10 md:space-y-14 md:pb-16">
<div className="space-y-[30px] pb-10 md:space-y-10 md:pb-16 xl:space-y-[34px]">
<section className="-mt-6 md:mt-0">
<FigmaBanner />
</section>
@@ -69,7 +83,7 @@ export function Home() {
viewAllTo="/browse"
viewAllLabel={t("viewAll")}
/>
<div className="mt-7 grid grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid-cols-5 md:gap-3 xl:grid-cols-7 xl:gap-4">
<div className="mt-7 grid grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
{cats.map((c) => {
const { line1, line2 } = categoryCardLines(c.name);
return (
@@ -115,20 +129,11 @@ export function Home() {
<RecommendedCard r={r} visualIndex={index} />
</div>
))}
{Array.from({ length: recommendedPlaceholderCount }).map(
(_, index) => (
<div
key={`recommended-coming-soon-${index}`}
className="snap-start"
>
<ComingSoonRecommendedCard visualIndex={rec.length + index} />
</div>
),
)}
</div>
<div className="h-1 rounded-full bg-black/80 md:hidden">
<div className="h-full w-24 rounded-full bg-[#353740]" />
</div>
{canScrollRec ? (
<button
type="button"
onClick={() => scrollRec(1)}
@@ -137,6 +142,7 @@ export function Home() {
>
<ChevronRight className="h-5 w-5" />
</button>
) : null}
</div>
</section>

View File

@@ -1,5 +1,9 @@
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
import { WagmiProvider } from "wagmi";
import "@rainbow-me/rainbowkit/styles.css";
import { WalletLoginControls } from "../components/WalletLoginControls";
import { useI18n } from "../i18n";
import { wagmiConfig } from "../wagmiConfig";
export function WalletPage() {
const { t } = useI18n();
@@ -16,7 +20,27 @@ export function WalletPage() {
<li>{t("walletStepSign")}</li>
</ul>
<div className="rounded-2xl border border-ark-line bg-ark-panel p-6 space-y-4">
{import.meta.env.VITE_WALLETCONNECT_PROJECT_ID ? (
<WagmiProvider config={wagmiConfig}>
<RainbowKitProvider
theme={darkTheme({
accentColor: "#d4af37",
accentColorForeground: "#0a0a0a",
borderRadius: "medium",
})}
modalSize="wide"
>
<WalletLoginControls />
</RainbowKitProvider>
</WagmiProvider>
) : (
<p
className="text-sm text-amber-500/90 leading-relaxed"
title={t("walletMissingProjectId")}
>
{t("walletSetupNeeded")}
</p>
)}
</div>
</div>
);

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
resourceLanguageLabel,
resourceTypeDisplay,
resourceTypeLabel,
typeFilterLabel,
} from "./resourceTypeLabels";
const t = (key: string) =>
({
filterAll: "全部",
type_image: "圖片",
type_video: "影片",
lang_zh_TW: "繁中",
lang_zh_CN: "簡中",
lang_en: "英文",
})[key] ?? key;
describe("resource labels", () => {
it("localizes known resource types and falls back to raw type", () => {
expect(typeFilterLabel(t, "all")).toBe("全部");
expect(resourceTypeLabel(t, "image")).toBe("圖片");
expect(resourceTypeDisplay(t, "video")).toBe("影片");
expect(resourceTypeLabel(t, "unknown")).toBe("unknown");
});
it("normalizes resource language codes", () => {
expect(resourceLanguageLabel(t, "zh-TW")).toBe("繁中");
expect(resourceLanguageLabel(t, "zh-hans")).toBe("簡中");
expect(resourceLanguageLabel(t, "EN")).toBe("英文");
expect(resourceLanguageLabel(t, "ja")).toBe("ja");
});
});

49
src/test/setup.ts Normal file
View File

@@ -0,0 +1,49 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
function createLocalStorageMock(): Storage {
let store: Record<string, string> = {};
return {
get length() {
return Object.keys(store).length;
},
clear() {
store = {};
},
getItem(key: string) {
return Object.prototype.hasOwnProperty.call(store, key)
? store[key]
: null;
},
key(index: number) {
return Object.keys(store)[index] ?? null;
},
removeItem(key: string) {
delete store[key];
},
setItem(key: string, value: string) {
store[key] = String(value);
},
};
}
beforeEach(() => {
const storage = createLocalStorageMock();
Object.defineProperty(globalThis, "localStorage", {
value: storage,
configurable: true,
});
Object.defineProperty(window, "localStorage", {
value: storage,
configurable: true,
});
});
afterEach(() => {
cleanup();
globalThis.localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { categoryCardLines } from "./categoryDisplay";
describe("categoryCardLines", () => {
it("splits Chinese and ASCII parenthetical subtitles", () => {
expect(categoryCardLines("官方公告(繁中)")).toEqual({
line1: "官方公告",
line2: "(繁中)",
});
expect(categoryCardLines("Tutorials (EN)")).toEqual({
line1: "Tutorials",
line2: "(EN)",
});
});
it("uses trimmed description as subtitle when name has no parentheses", () => {
expect(categoryCardLines("影片", " 官方教學 ")).toEqual({
line1: "影片",
line2: "官方教學",
});
expect(categoryCardLines("文件")).toEqual({ line1: "文件" });
});
});

11
src/video.test.ts Normal file
View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { isLikelyVideoPath } from "./video";
describe("isLikelyVideoPath", () => {
it("detects common video extensions and ignores query strings", () => {
expect(isLikelyVideoPath("/uploads/intro.MP4?token=1")).toBe(true);
expect(isLikelyVideoPath("https://cdn.example.com/demo.webm")).toBe(true);
expect(isLikelyVideoPath("/uploads/file.pdf")).toBe(false);
expect(isLikelyVideoPath(undefined)).toBe(false);
});
});

1
src/vite-env.d.ts vendored
View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_PREFIX?: string;
readonly VITE_WALLETCONNECT_PROJECT_ID: string;
readonly VITE_ADMIN_UI_PREFIX?: string;
/** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */

View File

@@ -1,7 +1,11 @@
import { defineConfig } from "vite";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiProxyTarget = env.DEV_API_PROXY_TARGET || "http://127.0.0.1:8080";
return {
plugins: [react()],
build: {
rollupOptions: {
@@ -16,8 +20,14 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
"/api": { target: "http://127.0.0.1:8080", changeOrigin: true },
"/uploads": { target: "http://127.0.0.1:8080", changeOrigin: true },
"/apnew/api": {
target: apiProxyTarget,
changeOrigin: true,
rewrite: (path) => path.replace(/^\/apnew/, ""),
},
"/api": { target: apiProxyTarget, changeOrigin: true },
"/uploads": { target: apiProxyTarget, changeOrigin: true },
},
},
};
});

16
vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
environmentOptions: {
jsdom: {
url: "http://localhost/",
},
},
setupFiles: "./src/test/setup.ts",
css: false,
},
});