Compare commits

..

6 Commits

Author SHA1 Message Date
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
31 changed files with 2113 additions and 51 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 - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -22,6 +28,9 @@ jobs:
- name: Format check - name: Format check
run: npm run format:check run: npm run format:check
- name: Test
run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
env: env:

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.

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", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"", "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": { "dependencies": {
"@rainbow-me/rainbowkit": "^2.2.11", "@rainbow-me/rainbowkit": "^2.2.11",
@@ -21,14 +23,19 @@
"wagmi": "^2.19.5" "wagmi": "^2.19.5"
}, },
"devDependencies": { "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": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^29.1.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^5.6.3", "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-576.png` — mobile/tablet banner crop from node `3726:13099`.
- `banner-440.png` — mobile banner crop from node `3726:14199`. - `banner-440.png` — mobile banner crop from node `3726:14199`.
- `banner-375.png` — mobile banner crop from node `3726:14238`. - `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. 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 { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
@@ -7,12 +8,17 @@ import { CategoryPage } from "./pages/CategoryPage";
import { SearchPage } from "./pages/SearchPage"; import { SearchPage } from "./pages/SearchPage";
import { FavoritesPage } from "./pages/FavoritesPage"; import { FavoritesPage } from "./pages/FavoritesPage";
import { ResourceDetail } from "./pages/ResourceDetail"; import { ResourceDetail } from "./pages/ResourceDetail";
import { WalletPage } from "./pages/WalletPage";
import { AboutPage } from "./pages/AboutPage"; import { AboutPage } from "./pages/AboutPage";
import { adminUiPrefix } from "./adminPaths"; import { adminUiPrefix } from "./adminPaths";
import { AdminRouteTree } from "./adminRouteTree"; import { AdminRouteTree } from "./adminRouteTree";
import { AdminRouterModeProvider } from "./adminRouterMode"; import { AdminRouterModeProvider } from "./adminRouterMode";
const WalletPage = lazy(() =>
import("./pages/WalletPage").then((module) => ({
default: module.WalletPage,
})),
);
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true"; const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
export default function App() { export default function App() {
@@ -28,7 +34,14 @@ export default function App() {
<Route path="/search" element={<SearchPage />} /> <Route path="/search" element={<SearchPage />} />
<Route path="/favorites" element={<FavoritesPage />} /> <Route path="/favorites" element={<FavoritesPage />} />
<Route path="/resource/:id" element={<ResourceDetail />} /> <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 path="/about" element={<AboutPage />} />
</Route> </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,16 +1,16 @@
const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
export const recommendationCoverFallbacks = [ export const officialRecommendationCoverFallbacks = [
`${FIGMA_ASSET_BASE}/recommendation-1.png`, `${FIGMA_ASSET_BASE}/official-recommendation-1.png`,
`${FIGMA_ASSET_BASE}/recommendation-2.png`, `${FIGMA_ASSET_BASE}/official-recommendation-2.png`,
`${FIGMA_ASSET_BASE}/recommendation-3.png`, `${FIGMA_ASSET_BASE}/official-recommendation-3.png`,
`${FIGMA_ASSET_BASE}/recommendation-4.png`, `${FIGMA_ASSET_BASE}/official-recommendation-4.png`,
`${FIGMA_ASSET_BASE}/recommendation-5.png`, `${FIGMA_ASSET_BASE}/official-recommendation-5.png`,
] as const; ] as const;
export function FigmaBanner() { export function FigmaBanner() {
return ( 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 <source
media="(max-width: 439px)" media="(max-width: 439px)"
srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`} srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`}

View File

@@ -5,14 +5,14 @@ import { assetUrl, postJSON } from "../api";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useMemo } from "react"; import { useMemo } from "react";
import { formatDateYmd } from "../utils/format"; import { formatDateYmd } from "../utils/format";
import { recommendationCoverFallbacks } from "./FigmaBanner"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
function isPlaceholderAsset(path: string | undefined | null) { function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover"); return !path || path.includes("placeholder-cover");
} }
const CARD_CLASS = 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({ export function RecommendedCard({
r, r,
@@ -25,8 +25,8 @@ export function RecommendedCard({
const cover = useMemo(() => { const cover = useMemo(() => {
const original = r.coverImage || r.previewUrl; const original = r.coverImage || r.previewUrl;
if (isPlaceholderAsset(original)) { if (isPlaceholderAsset(original)) {
return recommendationCoverFallbacks[ return officialRecommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length visualIndex % officialRecommendationCoverFallbacks.length
]; ];
} }
return assetUrl(original); return assetUrl(original);
@@ -105,8 +105,8 @@ export function ComingSoonRecommendedCard({
visualIndex?: number; visualIndex?: number;
}) { }) {
const cover = const cover =
recommendationCoverFallbacks[ officialRecommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length visualIndex % officialRecommendationCoverFallbacks.length
]; ];
return ( 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,7 +77,7 @@ export function PublicLayout() {
return ( return (
<div className="min-h-full flex flex-col pb-20 md:pb-0"> <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"> <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 (左對齊,可橫向滑動) | 搜尋 + 語言 */} {/* 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 lg:gap-4">
<Link <Link
@@ -85,13 +85,13 @@ export function PublicLayout() {
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" 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" /> <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-[7.125rem] truncate text-ark-gold sm:inline">
{t("brand")} {t("brand")}
</span> </span>
</Link> </Link>
<nav <nav
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 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")} aria-label={t("mainNav")}
> >
<Link <Link
@@ -152,15 +152,15 @@ export function PublicLayout() {
</Link> </Link>
</nav> </nav>
<div className="flex shrink-0 items-center justify-end gap-2"> <div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
<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="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]" /> <SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input <input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && goSearch()} onKeyDown={(e) => e.key === "Enter" && goSearch()}
placeholder={t("searchPlaceholder")} 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>
<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"> <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 aria-hidden
/> />
<select <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} value={lang}
onChange={(e) => setLang(e.target.value as Lang)} onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")} aria-label={t("langLabel")}
@@ -182,7 +182,7 @@ export function PublicLayout() {
</div> </div>
<button <button
type="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)} onClick={() => setOpen((v) => !v)}
aria-label="menu" aria-label="menu"
> >
@@ -193,7 +193,7 @@ export function PublicLayout() {
</div> </div>
{open ? ( {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"> <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]" /> <SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input <input
@@ -280,12 +280,12 @@ export function PublicLayout() {
) : null} ) : null}
</header> </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 /> <Outlet />
</main> </main>
<footer className="mt-auto border-t border-ark-line bg-ark-nav/90 mb-20 md:mb-0"> <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 <Link
to="/about" to="/about"
className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"

View File

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

View File

@@ -58,7 +58,7 @@ export function Home() {
} }
return ( 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"> <section className="-mt-6 md:mt-0">
<FigmaBanner /> <FigmaBanner />
</section> </section>
@@ -69,7 +69,7 @@ export function Home() {
viewAllTo="/browse" viewAllTo="/browse"
viewAllLabel={t("viewAll")} 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) => { {cats.map((c) => {
const { line1, line2 } = categoryCardLines(c.name); const { line1, line2 } = categoryCardLines(c.name);
return ( return (

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 { WalletLoginControls } from "../components/WalletLoginControls";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { wagmiConfig } from "../wagmiConfig";
export function WalletPage() { export function WalletPage() {
const { t } = useI18n(); const { t } = useI18n();
@@ -16,7 +20,27 @@ export function WalletPage() {
<li>{t("walletStepSign")}</li> <li>{t("walletStepSign")}</li>
</ul> </ul>
<div className="rounded-2xl border border-ark-line bg-ark-panel p-6 space-y-4"> <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 /> <WalletLoginControls />
</RainbowKitProvider>
</WagmiProvider>
) : (
<p
className="text-sm text-amber-500/90 leading-relaxed"
title={t("walletMissingProjectId")}
>
{t("walletSetupNeeded")}
</p>
)}
</div> </div>
</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);
});
});

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