Compare commits
11 Commits
9ba50565cb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aaebd7ccd1 | ||
| 3f0a9f72d9 | |||
| 769087ba4a | |||
| 2c710e2e24 | |||
|
|
e6bc212c4e | ||
|
|
40143afc39 | ||
|
|
2c76039c44 | ||
|
|
5b67279734 | ||
|
|
1c1ef4801b | ||
|
|
a29ec8ed92 | ||
|
|
f59d1e8e2a |
14
.env.example
Normal 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=
|
||||
@@ -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
|
||||
|
||||
63
.pi/skills/arkie-frontend-onboarding/SKILL.md
Normal 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
@@ -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
@@ -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.
|
||||
105
deploy/nginx-frontend-locations.inc
Normal 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;
|
||||
}
|
||||
18
deploy/nginx-frontend-native.conf
Normal 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
@@ -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
@@ -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
11
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 167 KiB |
17
src/App.tsx
@@ -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
@@ -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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
100
src/components/ResourceCard.test.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
52
src/components/ResourceListFooter.test.tsx
Normal 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("顯示 1–24,共 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
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
15
src/main.tsx
@@ -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>,
|
||||
);
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
33
src/resourceTypeLabels.test.ts
Normal 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
@@ -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();
|
||||
});
|
||||
23
src/utils/categoryDisplay.test.ts
Normal 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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||