Compare commits

..
Author SHA1 Message Date
superlbr ca1d182795 upgrade dep: umi^4.0.0-canary.20220429.4 2022-04-30 06:41:00 +08:00
superlbr 4042d5ba3a upgrade dep: umi 4.x & react 18 2022-04-28 10:44:42 +08:00
zuiidea da4abc2293 ✨ adding page, removing some components 2021-06-06 16:27:47 +08:00
zuiidea 13c13961c7 ✨ improving useRequest 2021-04-18 21:57:18 +08:00
zuiidea 0d1c4f23e9 Merge branch 'develop-next' of https://github.com/zuiidea/antd-admin into develop-next 2021-04-18 17:03:50 +08:00
Baorong Li f3c6ccf7b2 chore config 2021-04-13 16:11:45 +08:00
zuiidea a18c9bcb7e ✨
adding user page
2021-04-12 22:32:51 +08:00
zuiidea 12eab5f5c7 ✨ adding useRequest 2021-04-11 22:14:18 +08:00
zuiidea 1dbeb05005 ✨ Upgrading components to typescript 2021-04-05 22:54:36 +08:00
zuiidea a0000d7371 ✨ adding login page. 2021-04-05 16:54:22 +08:00
zuiidea b0cb27712d Merge branch 'master' into develop-next
Conflicts:
	.umirc.js
	mock/route.js
	package.json
	src/components/Layout/Bread.js
	src/components/Layout/Menu.js
	src/components/index.js
	src/layouts/BaseLayout.js
	src/layouts/PrimaryLayout.js
	src/layouts/index.js
	src/models/app.js
	src/pages/404.tsx
	src/pages/chart/ECharts/AirportCoordComponent.js
	src/pages/chart/ECharts/BubbleGradientComponent.js
	src/pages/chart/ECharts/CalendarComponent.js
	src/pages/chart/ECharts/GCalendarComponent.js
	src/pages/chart/ECharts/LunarCalendarComponent.js
	src/pages/chart/ECharts/MainPageComponent.js
	src/pages/chart/ECharts/MapChartComponent.js
	src/pages/chart/ECharts/ThemeChartComponent.js
	src/pages/chart/ECharts/TreemapComponent.js
	src/pages/dashboard/components/browser.js
	src/pages/dashboard/components/comments.js
	src/pages/dashboard/components/completed.js
	src/pages/dashboard/components/numberCard.js
	src/pages/dashboard/components/recentSales.js
	src/pages/dashboard/components/sales.js
	src/pages/dashboard/index.js
	src/pages/dashboard/model.js
	src/pages/editor/index.js
	src/pages/index.js
	src/pages/login/index.js
	src/pages/login/model.js
	src/pages/post/components/List.js
	src/pages/post/index.js
	src/pages/post/model.js
	src/pages/request/index.js
	src/pages/user/[id]/index.js
	src/pages/user/[id]/index.less
	src/pages/user/[id]/models/detail.js
	src/pages/user/components/Filter.js
	src/pages/user/components/List.js
	src/pages/user/components/Modal.js
	src/pages/user/index.js
	src/pages/user/model.js
	src/services/index.js
	src/utils/index.js
2021-04-05 16:14:07 +08:00
zainchen 0bb381f419 feat: updating umi and typescript 2021-04-04 23:20:50 +08:00
zuiidea 8c01bc1488 ⚡️ Upgrading umi@3.4 2021-04-04 10:42:59 +08:00
zuiidea d8baaad3b1 🔥 Removing unnecessary pages. 2021-04-03 21:52:26 +08:00
453 changed files with 13726 additions and 42735 deletions
-26
View File
@@ -1,26 +0,0 @@
---
description: All code comments must be written in English
alwaysApply: true
---
# English-only comments
- Write **all** new and updated comments in **English**: `//`, `/* */`, `///`, JSDoc, inline `<!-- -->` in templates, and similar.
- **Do not** add or keep Chinese (or other non-English) text in comments when editing files.
- User-facing copy (UI strings, i18n keys, docs meant for end users) is **not** covered by this rule—only **source comments**.
```typescript
// BAD — non-English comment
// Initialiser le cache (example: any non-English language)
// GOOD
// Initialize the cache from the last session snapshot.
```
```tsx
// BAD
{/* Chargement… */}
// GOOD
{/* Loading spinner */}
```
+16
View File
@@ -0,0 +1,16 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
+3
View File
@@ -0,0 +1,3 @@
BROWSER=none
HOST=0.0.0.0
PORT=7000
+1
View File
@@ -0,0 +1 @@
docs/**/*.js
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "react-app",
"rules": {
"import/no-anonymous-default-export": 0,
"jsx-a11y/href-no-hash": 0,
"no-console": "warn",
"valid-jsdoc": "warn"
}
}
-30
View File
@@ -1,30 +0,0 @@
# Git hooks (repository root)
This directory holds **monorepo root-level** Git hooks. On commit, checks run in the matching subproject based on staged file paths (for example, `vp staged` under `apps/with-lingui` so that directory’s `vite.config.ts` is loaded correctly).
Per-app `.vite-hooks/` under template apps (e.g. `apps/with-lingui/.vite-hooks/`) are for **copying a template out on its own**. When developing in this repo, use the root hooks so `vp staged` at the repo root does not fail to find `vite.config.ts`.
## Monorepo and `vp config`
The root `package.json` `preinstall` sets `core.hooksPath=.githooks` before dependencies install and writes `vp.hooks.disabled=true` in **this clone** so parallel `prepare` scripts (`vp config`) from multiple workspaces do not fight over `.git/config` and cause lock conflicts. Template apps still use `"prepare": "vp config"`; when copied out alone they do not get the root `preinstall`.
## Enabling (one-time)
From the repository root:
```bash
git config core.hooksPath .githooks
```
Or:
```bash
pnpm run setup:git-hooks
```
Verify:
```bash
git config core.hooksPath
# Expected: .githooks
```
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env sh
# Monorepo pre-commit: run Vite+ / checks in the app that owns staged files.
# Template copies under apps/*/ keep their own .vite-hooks for standalone use; this repo uses root hooksPath instead.
set -e
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
FAILED=0
run_in_dir() {
dir="$1"
shift
(cd "$ROOT/$dir" && "$@") || FAILED=1
}
if echo "$STAGED" | grep -q '^apps/with-lingui/'; then
run_in_dir apps/with-lingui pnpm exec vp staged
fi
if echo "$STAGED" | grep -q '^apps/basic/'; then
run_in_dir apps/basic pnpm exec vp staged
fi
if echo "$STAGED" | grep -q '^packages/create/'; then
run_in_dir . pnpm --dir packages/create run check-types
fi
if echo "$STAGED" | grep -q '^apps/docs/'; then
run_in_dir apps/docs pnpm run lint
fi
exit "$FAILED"
+8
View File
@@ -0,0 +1,8 @@
> 请酌情提供细节,并保持issue精简便于查阅
> 1. 功能需求 => 详细说明
> 2. Bug反馈
> 2.1 简单描述下报错
> 2.2 你期望什么结果
> 2.3 如何操作导致的
> 2.4 可提供运行环境信息
> 3. 代码求助 => 可以提,未必及时答复
@@ -1,69 +0,0 @@
# Publish npm package `init-antd-admin` from packages/create
#
# Setup (one-time):
# 1. npmjs.com → Access Tokens → Granular or Automation token with publish rights
# 2. Repo → Settings → Secrets and variables → Actions → New repository secret
# Name: NPM_TOKEN Value: <token>
#
# Release:
# A) Manual: Actions → "Publish init-antd-admin" → Run workflow
# B) Tag: git tag init-antd-admin-v1.0.3 && git push origin init-antd-admin-v1.0.3
# (tag suffix must match packages/create/package.json "version")
name: Publish init-antd-admin
on:
workflow_dispatch:
push:
tags:
- "init-antd-admin-v*"
permissions:
contents: read
id-token: write
concurrency:
group: publish-init-antd-admin
cancel-in-progress: true
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test init-antd-admin
run: pnpm test
working-directory: packages/create
- name: Build init-antd-admin
run: pnpm run build
working-directory: packages/create
- name: Publish to npm
# Provenance only in CI (OIDC). publishConfig must not force provenance or local npm publish fails.
run: npm publish --access public --provenance
working-directory: packages/create
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
-51
View File
@@ -1,51 +0,0 @@
name: with-lingui
on:
push:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
pull_request:
paths:
- "apps/with-lingui/**"
- ".github/workflows/with-lingui-ci.yml"
- "pnpm-lock.yaml"
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10.33.2
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium
working-directory: apps/with-lingui
run: pnpm exec playwright install chromium
- name: Typecheck and lint
working-directory: apps/with-lingui
run: pnpm exec vp check --no-fmt
- name: I18n check
working-directory: apps/with-lingui
run: pnpm run i18n:check
- name: E2E (core)
working-directory: apps/with-lingui
run: pnpm run test:e2e
- name: Build
working-directory: apps/with-lingui
run: pnpm run build
+18 -42
View File
@@ -1,50 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
node_modules
npm-debug.log
yarn-error.log
yarn.lock
package-lock.json
pnpm-lock.yaml
# ide
.idea
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
# Mac General
.DS_Store
*.pem
.AppleDouble
.LSOverride
.pnpm-store
# umi
.umi
.umi-production
# Git worktrees
.worktrees/
# jslingui
src/locales/_build
src/locales/**/*.js
# Cursor: share project rules only; ignore other local Cursor data (agent rules: each app’s AGENTS.md)
.cursor/*
!.cursor/rules/
!.cursor/rules/**
./docs/superpowers
.vscode
+6
View File
@@ -0,0 +1,6 @@
*.svg
*.ejs
.DS_Store
.umi
.umi-production
src/locales/**/*.json
+5
View File
@@ -0,0 +1,5 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5"
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": ["stylelint-config-standard", "stylelint-config-prettier"],
"rules": {
"declaration-empty-line-before": null,
"no-descending-specificity": null,
"selector-pseudo-class-no-unknown": null,
"selector-pseudo-element-colon-notation": null
}
}
+18
View File
@@ -0,0 +1,18 @@
language: node_js
node_js:
- node
script:
- npm run build
before_install:
- yarn global add now
after_script:
- |
if [[ $TRAVIS_BRANCH == 'master' ]]; then
echo $PROD_SITE_NOW_CONFIG >> dist/vercel.json && echo $PROD_DOC_NOW_CONFIG >> docs/vercel.json;
else
echo $DEV_SITE_NOW_CONFIG >> dist/vercel.json && echo $DEV_DOC_NOW_CONFIG >> docs/vercel.json;
fi
- cd ./dist
- now -A vercel.json -t $NOW_TOKEN --prod -c
- cd ../docs
- now -A vercel.json -t $NOW_TOKEN --prod -c
-23
View File
@@ -1,23 +0,0 @@
# antd-admin (monorepo)
Enterprise admin templates and docs. **AI and coding rules live in each app’s `AGENTS.md`**, not here.
## Layout
| Path | Role |
| --- | --- |
| `apps/basic` | English Vite+ template — see `apps/basic/AGENTS.md` |
| `apps/with-lingui` | Lingui i18n template — see `apps/with-lingui/AGENTS.md` |
| `apps/docs` | Nextra docs site |
| `packages/create` | `init-antd-admin` CLI |
## Commands (repo root)
- `pnpm install` — sets `core.hooksPath=.githooks` via `preinstall`
- `pnpm dev` / `pnpm build` / `pnpm fmt` / `pnpm lint` — turbo across workspaces
**Template app work:** `cd apps/basic` or `cd apps/with-lingui`, then use **`vp`** (`vp install`, `vp check`, …). Do not run app `vp staged` from root without that `cd`.
## Git hooks
`.githooks/pre-commit` runs checks by staged paths: `pnpm exec vp staged` in `apps/basic` / `apps/with-lingui`, typecheck for `packages/create`, lint for `apps/docs`.
+87
View File
@@ -0,0 +1,87 @@
<p align="center">
<a href="http://github.com/zuiidea/antd-admin">
<img alt="antd-admin" height="64" src="./docs/_media/logo.svg">
</a>
</p>
<h1 align="center">AntD Admin</h1>
<div align="center">
一套优秀的中后台前端解决方案
[![antd](https://img.shields.io/badge/antd-^3.10.0-blue.svg?style=flat-square)](https://github.com/ant-design/ant-design)
[![umi](https://img.shields.io/badge/umi-^2.2.1-orange.svg?style=flat-square)](https://github.com/umijs/umi)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![MIT](https://img.shields.io/dub/l/vibe-d.svg?style=flat-square)](http://opensource.org/licenses/MIT)
![Travis (.org)](https://img.shields.io/travis/zuiidea/antd-admin.svg)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/pulls)
[![Gitter](https://img.shields.io/gitter/room/antd-admin/antd-admin.svg)](https://gitter.im/antd-admin/antd-admin)
</div>
- 在线演示 - [https://antd-admin.zuiidea.com](https://antd-admin.zuiidea.com)
- 使用文档 - [https://doc.antd-admin.zuiidea.com/#/zh-cn/](https://doc.antd-admin.zuiidea.com/#/zh-cn/)
- 常见问题 - [https://doc.antd-admin.zuiidea.com/#/zh-cn/faq](https://doc.antd-admin.zuiidea.com/#/zh-cn/faq)
- 更新日志 - [https://doc.antd-admin.zuiidea.com/#/zh-cn/change-log](https://doc.antd-admin.zuiidea.com/#/zh-cn/change-log)
[English](./README.md) | 简体中文
## 特性
- 国际化,源码中抽离翻译字段,按需加载语言包
- 动态权限,不同权限对应不同菜单
- 优雅美观,Ant Design 设计体系
- Mock 数据,本地数据调试
## 使用
1. 下载项目代码。
```bash
git clone https://github.com/zuiidea/antd-admin.git my-project
cd my-project
```
2. 进入目录安装依赖,国内用户推荐使用 [cnpm](https://cnpmjs.org) 进行加速。
```bash
yarn install
```
或者
```bash
npm install
```
3. 启动本地服务器。
```bash
npm run start
```
4. 启动完成后打开浏览器访问 [http://localhost:7000](http://localhost:7000),如果需要更改启动端口,可在 `.env` 文件中配置。
> 更多信息请参考 [使用文档](https://doc.antd-admin.zuiidea.com/#/zh-cn/)。
## 支持环境
现代浏览器。
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/opera/opera_48x48.png" alt="Opera" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Opera |
| --------- | --------- | --------- | --------- | --------- |
|IE11, Edge| last 2 versions| last 2 versions| last 2 versions| last 2 versions
## 参与贡献
我们非常欢迎你的贡献,你可以通过以下方式和我们一起共建 :smiley:
- 在你的公司或个人项目中使用 AntD Admin。
- 通过 [Issue](http://github.com/zuiidea/antd-admin/issues) 报告 bug 或进行咨询。
- 提交 [Pull Request](http://github.com/zuiidea/antd-admin/pulls) 改进代码。
> 强烈推荐阅读 [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way)、[《如何向开源社区提问题》](https://github.com/seajs/seajs/issues/545) 和 [《如何有效地报告 Bug》](http://www.chiark.greenend.org.uk/%7Esgtatham/bugs-cn.html)、[《如何向开源项目提交无法解答的问题》](https://zhuanlan.zhihu.com/p/25795393),更好的问题更容易获得帮助。
+58 -55
View File
@@ -1,83 +1,86 @@
# Antd Admin
## Introduction
<p align="center">
<a href="http://github.com/zuiidea/antd-admin">
<img alt="antd-admin" height="64" src="./docs/_media/logo.svg">
</a>
</p>
Antd Admin provides production-style admin templates with modern frontend tooling, mock-first development, and practical testing coverage.
<h1 align="center">AntD Admin</h1>
Available templates:
<div align="center">
- `basic`: Admin template with plain-string UI copy (default English; no bundled Lingui)
- `with-lingui`: Lingui i18n workflow (`en` + `zh`) with locale switching
An excellent front-end solution for enterprise applications.
Scaffolding CLI:
[![antd](https://img.shields.io/badge/antd-^4.0.0-blue.svg?style=flat-square)](https://github.com/ant-design/ant-design)
[![umi](https://img.shields.io/badge/umi-^2.2.1-orange.svg?style=flat-square)](https://github.com/umijs/umi)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![MIT](https://img.shields.io/dub/l/vibe-d.svg?style=flat-square)](http://opensource.org/licenses/MIT)
![Travis (.org)](https://img.shields.io/travis/zuiidea/antd-admin.svg)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/zuiidea/antd-admin/pulls)
[![Gitter](https://img.shields.io/gitter/room/antd-admin/antd-admin.svg)](https://gitter.im/antd-admin/antd-admin)
- `init-antd-admin` (from `packages/create`)
</div>
## Key Features
- Preview - [https://antd-admin.zuiidea.com](https://antd-admin.zuiidea.com)
- Documentation - [https://doc.antd-admin.zuiidea.com](https://doc.antd-admin.zuiidea.com)
- FAQ - [https://doc.antd-admin.zuiidea.com/#/faq](https://doc.antd-admin.zuiidea.com/#/faq)
- ChangeLog - [https://doc.antd-admin.zuiidea.com/#/change-log](https://doc.antd-admin.zuiidea.com/#/change-log)
- JWT login with access/refresh flow
- Backend-driven menu and permission guards
- URL-synced table state (pagination, sorting, search)
- Dark mode theme switching
- Mock-first local development (no backend required)
- Modular API layer (`defineApi` per domain, Zod schemas, shared `API_PREFIX`)
- Typed API boundaries and reusable CRUD patterns
- React 19 + Ant Design 6 + TanStack Router/Query + Zustand + Zod
- Optional Lingui-based i18n workflow (`with-lingui`)
- E2E coverage with Playwright
English | [简体中文](./README-zh_CN.md)
## Quick Start
## Features
Use `init-antd-admin` to scaffold a standalone app from official templates.
- Internationalization, extracting translation fields from source code, loading language packs on demand
- Dynamic permissions, different permissions for different menus
- Elegant and beautiful, Ant Design system
- Mock data, local data debugging
### Interactive
## Usage
1. Clone project code.
```bash
npx init-antd-admin@latest
pnpm dlx init-antd-admin@latest
yarn dlx init-antd-admin@latest
bunx init-antd-admin@latest
git clone https://github.com/zuiidea/antd-admin.git my-project
cd my-project
```
### Non-interactive examples
2. Installation dependence.
```bash
pnpm dlx init-antd-admin@latest my-app --example basic -m pnpm
pnpm dlx init-antd-admin@latest my-app --example with-lingui --skip-install
yarn install
```
### Common options
Or
```bash
npm install
```
3. Start local server.
```bash
npm run start
```
4. After the startup is complete, open a browser and visit [http://localhost:7000](http://localhost:7000), If you need to change the startup port, you can configure it in the `.env` file.
| Option | Description |
| ---------------------------- | ------------------------------------------ |
| `[project-directory]` | Target folder |
| `-e, --example <name\|url>` | Template name (`basic`, `with-lingui`) or URL |
| `--example-path <path>` | Subpath inside remote repository |
| `-m, --package-manager <pm>` | `npm` / `pnpm` / `yarn` / `bun` |
| `--skip-install` | Skip dependency install |
| `--skip-transforms` | Skip rewrite transforms |
| `--no-git` | Skip git initialization |
> More instructions at [documentation](https://doc.antd-admin.zuiidea.com)。
For detailed CLI behavior, see `[packages/create/README.md](./packages/create/README.md)`.
## Browsers support
### AI / agent guidance
Modern browsers.
Each template app keeps **`AGENTS.md`** as the single source of truth (Cursor, Claude Code, Codex, etc.). Monorepo overview: [AGENTS.md](./AGENTS.md) at the repo root.
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Safari | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/opera/opera_48x48.png" alt="Opera" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Opera |
| --------- | --------- | --------- | --------- | --------- |
|IE11, Edge| last 2 versions| last 2 versions| last 2 versions| last 2 versions
Production docs: [https://antd-admin-doc.zuiidea.top](https://antd-admin-doc.zuiidea.top)
## Contributing
## Links
We very much welcome your contribution, you can build together with us in the following ways :smiley:
- [Ant Design](https://github.com/ant-design/ant-design)
- [React](https://react.dev)
- [Vite+](https://viteplus.dev)
- [TypeScript](https://www.typescriptlang.org/)
- [Issues](https://github.com/zuiidea/antd-admin/issues)
- [Stars](https://github.com/zuiidea/antd-admin/stargazers)
- [PRs Welcome](https://github.com/zuiidea/antd-admin/pulls)
## License
MIT
- Use Ant Design Pro in your daily work.
- Submit [GitHub issues](http://github.com/zuiidea/antd-admin/issues)s to report bugs or ask questions.
- Propose [Pull Request](http://github.com/zuiidea/antd-admin/pulls) to improve our code.
@@ -1,71 +0,0 @@
---
name: add-resource
description: Add a new CRUD resource in antd-admin-basic (mirror users). Use when scaffolding roles, tenants, or any list+form resource with MSW, routes, menu, and e2e.
---
# Add Resource Recipe
Add a list CRUD flow that mirrors **Users** (TanStack Router + Query + MSW). **No codegen** — copy and adapt by hand. References: `src/routes/_auth/users/` and matching `e2e/*.spec.ts`. UI copy is **English plain strings** (no i18n).
## 0. Verification commands (after your changes)
Run from the **app root** (this project directory — where `package.json` and `vite.config.ts` live):
```bash
pnpm run build # refresh TanStack routeTree when routes change
vp check --no-fmt
pnpm run test:e2e -- e2e/<slug>.spec.ts
```
## 1. Zod and types (`src/api/<slug>/schemas.ts`)
- Define `<Entity>Schema`, `Create<Entity>RequestSchema`, `Update<Entity>RequestSchema` (`Update` = `Create.partial()`).
- For list `output`, reuse `PaginatedDataSchema(<Entity>Schema)` (unwrapped `data`; envelope stays in `request.ts`).
- Add list query schema (e.g. `List<Entity>QuerySchema`) when the table has filters/pagination.
- Export `type <Entity> = z.infer<typeof ...>`.
## 2. Resource API module (`src/api/<slug>/index.ts`)
- Export path constants if useful for MSW/docs.
- Define APIs with `defineApi` from `@/api/request`: `list` / `create` / `update` / `delete` (and `queryKeys`).
- `method` defaults to `GET`; mutations set `POST` / `PUT` / `DELETE`.
- Use segmented `params` / `query` / `body` + `output`; do not call axios from routes.
## 3. Mock data (`src/mocks/data.ts`)
- Append a `MOCK_<ENTITIES>` array. For **admin** users, `permissions` must include `<slug>:view` (and create/edit/delete if you add writes), aligned with `GET /api/auth/permissions`.
## 4. MSW (`src/mocks/handlers/<slug>.ts` + `handlers/index.ts`)
- Follow `handlers/user.ts`: paginated `GET` + optional `keyword`, `POST` / `PUT` / `DELETE`; use `successWithSchema` / `paginatedWithSchema` / `successWithNullBody` for success and `errorResponse` for errors.
- Register `...<slug>Handlers` in `handlers/index.ts`.
## 5. Route page (`src/routes/_auth/<slug>/`)
- `createFileRoute("/_auth/<slug>/")`, wiring `useResourceCRUD` with resource functions (`listX`, `createX`, …) and `queryKeys` from `@/api/<slug>` (recommended: `optimistic: { update: true, delete: true }`), plus `useCrudToasts`, `useUrlSearchState`, `useTableFitHeight` (see `users/` for splitting `-Toolbar` / `-FormModal`).
- Use English string literals for labels, placeholders, and messages.
## 6. Menu and permissions (`src/utils/appMenu.ts`)
- Add a menu entry to `APP_MENU_TREE`: `path: "/<slug>"`, `permissions: ["<slug>:view"]`, `icon` using a Sidebar-registered key (see existing `IconLucide*` entries in `Sidebar`).
- Keep behavior consistent with `canAccessPath` / 403.
## 6b. Sidebar labels and icons (`src/components/Layout/Sidebar/index.tsx`)
- In `MENU_LABELS`, add an English label for the menu item `name` (must match the `name` string from `appMenu`).
- In `MENU_ICON_MAP`, map the new `icon` key to a `lucide-react` icon and add the matching `import`.
## 7. E2E (`e2e/<slug>.spec.ts`)
- Copy structure from `users.spec.ts`: `loginAsAdmin` → `goto /<slug>`, assert headers / search placeholder, etc.
## 8. Verification
- `vp check --no-fmt`
- `pnpm run test:e2e` (focused spec) or full suite as needed
- `pnpm run build` and watch chunk sizes in the log
## Notes
- Before adding or renaming files, confirm nothing already exists at that path so you do not overwrite hand-written code.
- After manual schema edits, treat the diff as source of truth; there is no automatic merge.
-36
View File
@@ -1,36 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# LinguiJS compiled catalogs
src/locales/*/messages.js
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.agent
.pnpm-store
.cursor
.tanstack
# Playwright
playwright-report
test-results
-1
View File
@@ -1 +0,0 @@
vp staged
-76
View File
@@ -1,76 +0,0 @@
# antd-admin-basic
Vite+ template app (React 19, Ant Design 6, TanStack Router/Query, MSW). Default UI copy is English (no i18n). CLI details: [viteplus.dev](https://viteplus.dev).
## Commands
Run from **this app root** (directory with `package.json` / `vite.config.ts`):
| Task | Command |
| ------------------------- | ---------------------------------------------------- |
| Install deps | `vp install` (after every pull) |
| Dev / build / preview | `vp dev`, `vp build`, `vp preview` |
| Format / lint / typecheck | `vp check`, `vp check --no-fmt`, `vp lint`, `vp fmt` |
| Unit tests | `vp test run` |
| E2E | `pnpm run test:e2e` (or focused spec via Playwright) |
| Add/remove deps | `vp add`, `vp remove` — not raw `pnpm`/`npm`/`yarn` |
**Pitfalls:** Do not install Vitest/Oxlint/Oxfmt/tsdown directly; use `vp`. Do not run `vp vitest` — use `vp test`. If a `package.json` script name conflicts with a built-in `vp` command, use `vp run <script>`. Import toolchain modules from `vite-plus` (e.g. `defineConfig`, test utils), not standalone `vite`/`vitest` packages.
**Before finishing:** `vp check` and `vp test run`; run focused e2e when UI flows change.
---
## Frontend
**Purpose:** Predictable, minimal UI changes under `src/`.
**Do:** Preserve route/UI contracts unless asked. Reuse components/hooks. Token-driven styles via theme/store. Ant Design styling order: component API → `theme.useToken()` + inline `style` → `className` + CSS. English plain string literals. Follow `src/components` and `src/routes` layout.
**Do not:** New UI libs when Ant Design suffices; bypass theme/store; CSS-first overrides when props/`styles` work; unrelated refactors in the same change.
**Validation:** `vp check --no-fmt`; focused e2e when flows change.
---
## API
**Purpose:** **schemas → `defineApi` modules → UI**; transport in `src/api/request.ts` only.
**Do:** Define APIs with `defineApi` in `src/api/<module>/index.ts` (`method` default `GET`; `params`/`query`/`body` + `output`). Zod in `src/api/<module>/schemas.ts`; shared helpers in `src/api/shared/`. MSW envelopes `{ code, data, message }` aligned with schemas. Co-locate query keys (e.g. `userQueryKeys`). `hydrateSession` from `@/api/auth` after login/register/session restore.
**Do not:** Call axios or `@/api/request` from routes/components — use resource functions (`listUsers`, …). Ad-hoc response shapes; skip schema parsing; mix unrelated domains in one module.
**Validation:** `vp check --no-fmt`.
---
## Testing
**Purpose:** Fast, deterministic tests for core flows (auth, users CRUD, permissions).
**Do:** User-visible assertions; reuse test helpers; keep mocks/schemas in sync; focused runs while iterating.
**Do not:** Brittle DOM-internal selectors; bloated fixtures; duplicate scenarios.
**Validation:** Focused tests first; `vp check --no-fmt`.
---
## Refactor
**Purpose:** Less duplication, same behavior and public contracts.
**Do:** One concern per change; extract shared utils/hooks/components; reviewable diffs; comments only when non-obvious.
**Do not:** Behavior changes unless requested; broad renames; drop unique test coverage; heavy deps for tiny wins.
**Considerations:** Main chunk ~135KB gzip ~41KB; vendor splits for antd/tanstack — avoid large extra UI libs. Validate at API boundaries (Zod), not app-wide runtime checks.
**Validation:** `vp check --no-fmt`; significant changes → `vp build` and watch chunk sizes in log.
---
## Add CRUD resource
Multi-step checklist (mirror **users**): use skill **`.agents/skills/add-resource/SKILL.md`** (invoke in Copilot/Codex/Claude or `@` that path in chat). After routes change, run `pnpm run build` to refresh `routeTree.gen.ts`.
-212
View File
@@ -1,212 +0,0 @@
<p align="center">
<a href="http://github.com/zuiidea/antd-admin" target="_blank">
<img alt="antd-admin-logo" height="80" src="./public/favicon.svg">
</a>
</p>
<h1 align="center">Antd Admin</h1>
<div align="center">
Admin scaffold: MSW mocks, minimal RBAC, full CRUD, and Playwright E2E — built with React 19, Ant Design 6, and Vite+. AI-friendly with clear patterns, shared abstractions, **`AGENTS.md`** agent rules, and type-safe contracts at every boundary. Default UI language is English; for Lingui i18n (en + zh) out of the box, see [`apps/with-lingui`](../with-lingui/).
[![antd](https://img.shields.io/badge/AntD-^6.0.0-1890ff?style=flat-square&logo=ant-design)](https://github.com/ant-design/ant-design)
[![react](https://img.shields.io/badge/React-19.2-61dafb?style=flat-square&logo=react)](https://react.dev)
[![vite](https://img.shields.io/badge/Vite+-Latest-646cff?style=flat-square&logo=vite)](https://viteplus.dev)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0+-3178c6?style=flat-square&logo=typescript)](https://www.typescriptlang.org/)
[![GitHub issues](https://img.shields.io/github/issues/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/issues)
[![GitHub stars](https://img.shields.io/github/stars/zuiidea/antd-admin?style=flat-square)](https://github.com/zuiidea/antd-admin/stargazers)
[![License](https://img.shields.io/badge/License-MIT-blue?style=flat-square)](http://opensource.org/licenses/MIT)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen?style=flat-square)](https://github.com/zuiidea/antd-admin/pulls)
</div>
## Tech Stack
| Category | Technology |
| ------------ | ---------------------------------------------------------------------- |
| Build Tool | [Vite+](https://viteplus.dev) (VoidZero unified toolchain) |
| UI Framework | [Ant Design 6.x](https://ant.design) |
| Routing | [TanStack Router](https://tanstack.com/router) (file-based, type-safe) |
| Async State | [TanStack Query v5](https://tanstack.com/query) |
| Local State | [Zustand](https://zustand.docs.pmnd.rs) (persisted auth & settings) |
| Validation | [Zod v4](https://zod.dev) (schemas, API contracts, form validation) |
| UI copy | Plain strings in source (default English); no bundled i18n stack |
| Icons | [lucide-react](https://lucide.dev/guide/packages/lucide-react) |
| API Mocking | [MSW 2.x](https://mswjs.io) (Service Worker based) |
| E2E Testing | [Playwright](https://playwright.dev) |
| Language | TypeScript (strict mode) |
## Features
- **JWT Authentication** — Login with access/refresh token flow, persisted via Zustand
- **Dynamic Menu & RBAC** — Backend-driven sidebar menu with permission guards and 403 page
- **URL-First State** — Table search params (page, pageSize, keyword, sort) synced to URL
- **Default locale** — UI copy in plain strings (English by default); Ant Design `ConfigProvider` uses `en_US`.
- **Dark Mode** — One-click toggle with Ant Design theme algorithm
- **Modular API layer** — `defineApi` modules under `src/api/<domain>/`, shared Zod helpers, single `API_PREFIX`
- **Full Type Safety** — Zod schemas validate API boundaries at runtime where used
- **Zero-Config Mocking** — MSW intercepts API calls in development, no backend needed
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org) >= 20
- [Vite+](https://viteplus.dev/guide/) CLI (`vp`)
### Install & Run
```bash
pnpm install
pnpm run dev
```
Open [http://localhost:5173](http://localhost:5173) — you'll be redirected to the login page.
**Default credentials:** `admin` / `admin`
### Build
```bash
pnpm run build
pnpm run preview
```
### Project Setup
```bash
pnpm run prepare
```
### Code Quality
```bash
pnpm run fmt
pnpm run lint
pnpm run check
```
### Unit Tests
```bash
pnpm run test:unit
```
### E2E Tests
```bash
pnpm run test:e2e
pnpm run test:e2e -- e2e/login.spec.ts e2e/users.spec.ts # core flows only
pnpm run test:e2e:ui # interactive UI mode
```
## Project Structure
```text
AGENTS.md # Agent rules (frontend/api/testing/refactor)
.agents/skills/add-resource/ # CRUD scaffold checklist (Skill)
src/
├── api/ # HTTP + contracts (see “API layer” below)
│ ├── request.ts # axios, defineApi, token refresh, envelope unwrap
│ ├── schemas.ts # deprecated barrel; prefer @/api/shared and module schemas
│ ├── shared/ # API_PREFIX, pagination, params, menu, envelope helpers
│ ├── auth/ # paths, schemas, login/register/hydrateSession
│ └── users/ # schemas, listUsers CRUD, userQueryKeys, path constants
├── components/ # Reusable UI primitives and layout shells
│ ├── Layout/ # MainLayout, Header, Sidebar, UserMenu, AppFooter
│ ├── DataTable/ # Shared table frame/skeleton/empty state
│ ├── FormModal/ # Generic modal form wrapper
│ ├── FilterToolbar/ # Reusable list page toolbar
│ ├── Auth/ # Permission gate component
│ ├── Aurora/ # Login page background effect
│ ├── Icon/ # Shared icons (GitHub, Theme, etc.)
│ ├── NotFound/ # Not-found visual component
│ └── RouteError.tsx # Route-level error boundary UI
├── hooks/ # Shared hooks (theme, CRUD, URL state, permissions)
│ ├── useAppTheme.ts
│ ├── usePermission.ts
│ ├── useResourceCRUD.ts
│ ├── useCrudToasts.ts
│ ├── useUrlSearchState.ts
│ ├── useTableFitHeight.ts
│ └── tokenBuilders.ts
├── mocks/ # MSW bootstrap, handlers, seed data, test helpers
│ ├── browser.ts
│ ├── createHandler.ts
│ ├── createHandler.test.ts
│ ├── data.ts
│ ├── utils.ts
│ └── handlers/
├── routes/ # TanStack Router file routes
│ ├── __root.tsx # QueryClient + ConfigProvider (en_US) wiring
│ ├── _auth.tsx # Protected route layout
│ ├── _auth/dashboard/index.tsx
│ ├── _auth/users/index.tsx
│ ├── _auth/users/-Toolbar.tsx
│ ├── _auth/users/-FormModal.tsx
│ ├── _auth/403/index.tsx
│ ├── login/index.tsx
│ ├── register/index.tsx
│ ├── 404/index.tsx
│ └── index.tsx
├── stores/ # Persisted auth/settings stores
├── utils/ # constants, app menu helpers
├── main.tsx # App bootstrap (MSW init + React render)
└── routeTree.gen.ts # Generated TanStack route tree
e2e/ # Playwright E2E suites
├── helpers.ts
├── login.spec.ts
├── users.spec.ts
├── auth-refresh.spec.ts
├── rbac.spec.ts
└── url-state.spec.ts
```
## API layer
Data flow: **Zod schemas → `defineApi` in `src/api/<module>/index.ts` → routes/components call resource functions** (never axios or `@/api/request` directly).
| Piece | Role |
| ----------------- | ------------------------------------------------------------------------------------------------- |
| `request.ts` | Shared HTTP client, `{ code, data, message }` handling, 401 refresh |
| `shared/` | `API_PREFIX` (`/api`), pagination/query helpers, shared menu types |
| `auth/`, `users/` | Domain schemas + `defineApi` exports; MSW handlers import the same path constants |
| `schemas.ts` | Compatibility re-exports; new code should import from `@/api/shared` and `@/api/<module>/schemas` |
Change the REST prefix in one place: `src/api/shared/prefix.ts` (`API_PREFIX`).
## Language
User-facing copy lives in components and routes as plain strings. **Default language is English**; Ant Design is wired to `en_US` in `__root.tsx`. This template does not include a locale switcher or translation catalogs—you can add your own i18n library or start from the [`with-lingui`](../with-lingui/) template for LinguiJS (en + zh).
## Extending the template
- **New CRUD resource:** Follow `.agents/skills/add-resource/SKILL.md` (mirror `users`), then `vp check --no-fmt`.
- **Real backend:** Point `VITE_API_BASE_URL` in env and disable or remove MSW in `main.tsx` when you no longer need mocks.
- **Add i18n:** Integrate your preferred stack, or copy patterns from [`apps/with-lingui`](../with-lingui/).
### AI guidance
**Single source of truth:** **`AGENTS.md`** in this app. New CRUD flows: `@.agents/skills/add-resource/SKILL.md`.
## Developer Notes
- Prefer Vite+ commands for installs, checks, and scripts. This repo is configured around `vp`.
- Run `vp check --no-fmt` for type/lint validation.
- Core regression coverage lives in the login and users E2E flows.
## Pages
| Route | Description |
| ------------ | ----------------------------------------------------------- |
| `/login` | Login form with validation |
| `/dashboard` | Statistics overview cards |
| `/users` | User CRUD table with search, pagination, create/edit/delete |
| `/403` | Forbidden error page |
| `/404` | Not found error page |
## License
MIT
-39
View File
@@ -1,39 +0,0 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
const expiredAuthStorage = () =>
JSON.stringify({
state: {
tokens: {
accessToken: "__EXPIRED_ACCESS__",
refreshToken: "mock-refresh-token",
},
isAuthenticated: true,
},
version: 0,
});
test.describe("Auth refresh", () => {
test.describe.configure({ retries: 2 });
test("401 on list triggers refresh then succeeds", async ({ page }) => {
await loginAsAdmin(page);
await page.evaluate((payload) => {
localStorage.setItem("auth-storage", payload);
}, expiredAuthStorage());
await page.reload({ waitUntil: "load" });
const usersListOk = page.waitForResponse(
(res) => res.url().includes("/api/users") && res.request().method() === "GET" && res.ok(),
{ timeout: 60_000 },
);
await page.goto("/users", { waitUntil: "domcontentloaded" });
await usersListOk;
await expect(page).toHaveURL(/\/users(\/|\?|$)/);
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible({ timeout: 30_000 });
});
});
-24
View File
@@ -1,24 +0,0 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
export async function loginAsAdmin(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("admin");
await page.getByLabel(/Password|密码/).fill("admin");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
export async function gotoUsers(page: Page) {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page).toHaveURL(/users/);
}
export async function loginAsGuest(page: Page) {
await page.goto("/login");
await page.getByLabel(/Username|用户名/).fill("guest");
await page.getByLabel(/Password|密码/).fill("guest");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page).toHaveURL(/dashboard/);
}
-27
View File
@@ -1,27 +0,0 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("Login Flow", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/login");
});
test("should display login form", async ({ page }) => {
await expect(page.getByLabel(/Username|用户名/)).toBeVisible();
await expect(page.getByLabel(/Password|密码/)).toBeVisible();
await expect(page.getByRole("button", { name: /Sign In|登录/ })).toBeVisible();
});
test("should login successfully with admin/admin", async ({ page }) => {
await loginAsAdmin(page);
await expect(page.getByText("Total Revenue")).toBeVisible();
});
test("should show error for wrong credentials", async ({ page }) => {
await page.getByLabel(/Username|用户名/).fill("wrong");
await page.getByLabel(/Password|密码/).fill("wrong");
await page.getByRole("button", { name: /Sign In|登录/ }).click();
await expect(page.getByText("Invalid username or password")).toBeVisible();
});
});
-10
View File
@@ -1,10 +0,0 @@
import { test, expect } from "@playwright/test";
import { loginAsGuest } from "./helpers";
test.describe("RBAC", () => {
test("guest without user:view cannot open /users", async ({ page }) => {
await loginAsGuest(page);
await page.goto("/users");
await expect(page).toHaveURL(/403/);
});
});
-19
View File
@@ -1,19 +0,0 @@
import { test, expect } from "@playwright/test";
import { loginAsAdmin } from "./helpers";
test.describe("URL search state", () => {
test("persists keyword after reload", async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await page.reload();
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
});
-41
View File
@@ -1,41 +0,0 @@
import { test, expect } from "@playwright/test";
import { gotoUsers, loginAsAdmin } from "./helpers";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await gotoUsers(page);
});
test("should display user table", async ({ page }) => {
const thead = page.locator(".ant-table-thead");
await expect(thead.getByText(/Username|用户名/)).toBeVisible();
await expect(thead.getByText(/Email|邮箱/)).toBeVisible();
await expect(thead.getByText(/Roles|角色/)).toBeVisible();
});
test("should search users", async ({ page }) => {
const searchInput = page.getByPlaceholder(/Search User|搜索用户/);
await searchInput.fill("zhao.ming");
await searchInput.press("Enter");
await expect(page).toHaveURL(/keyword=zhao\.ming/);
await expect(page.getByText("zhao.ming", { exact: true })).toBeVisible();
});
test("should open create user modal", async ({ page }) => {
await page.getByRole("button", { name: /Create User|创建用户/ }).click();
await expect(page.getByRole("dialog").getByText(/New User|新建用户/)).toBeVisible();
});
});
test.describe("User Management — role in URL", () => {
test.beforeEach(async ({ page }) => {
await loginAsAdmin(page);
await page.goto("/users?role=admin");
await expect(page.getByPlaceholder(/Search User|搜索用户/)).toBeVisible({ timeout: 15_000 });
});
test("should filter users by role", async ({ page }) => {
await expect(page).toHaveURL(/role=admin/);
await expect(page.getByRole("cell", { name: "ops.admin@northstar.io" })).toBeVisible();
});
});
-20
View File
@@ -1,20 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<title>Antd Admin</title>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-46
View File
@@ -1,46 +0,0 @@
{
"name": "antd-admin-basic",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vp dev",
"build": "tsc && vp build",
"preview": "vp preview",
"prepare": "vp config",
"fmt": "vp fmt",
"lint": "vp lint",
"check": "vp check --no-fmt",
"test:unit": "vp test run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@tanstack/react-query": "^5.104.3",
"@tanstack/react-router": "^1.170.45",
"antd": "^6.6.6",
"axios": "^1.20.0",
"lucide-react": "^1.59.0",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"zod": "^4.6.7",
"zustand": "^5.0.15"
},
"devDependencies": {
"@playwright/test": "^1.64.1",
"@tanstack/router-plugin": "^1.168.47",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react-swc": "^4.3.3",
"msw": "^2.15.0",
"typescript": "~6.0.3",
"vite": "npm:@voidzero-dev/vite-plus-core@0.3.3",
"vite-plus": "0.3.3"
},
"packageManager": "pnpm@10.33.2",
"msw": {
"workerDirectory": [
"public"
]
}
}
-29
View File
@@ -1,29 +0,0 @@
import { defineConfig, devices } from "@playwright/test";
declare const process: { env: { CI?: string } };
const ci = Boolean(process.env.CI);
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: ci,
retries: ci ? 2 : 0,
workers: ci ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "vp dev",
url: "http://localhost:5173",
reuseExistingServer: !ci,
},
});
-4247
View File
File diff suppressed because it is too large Load Diff
-346
View File
@@ -1,346 +0,0 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = "2.15.0";
const INTEGRITY_CHECKSUM = "03cb67ac84128e63d7cd722a6e5b7f1e";
const IS_MOCKED_RESPONSE = Symbol("isMockedResponse");
const activeClientIds = new Set();
addEventListener("install", function () {
self.skipWaiting();
});
addEventListener("activate", function (event) {
event.waitUntil(self.clients.claim());
});
addEventListener("message", async function (event) {
const clientId = Reflect.get(event.source || {}, "id");
if (!clientId || !self.clients) {
return;
}
const client = await self.clients.get(clientId);
if (!client) {
return;
}
const allClients = await self.clients.matchAll({
type: "window",
});
switch (event.data) {
case "KEEPALIVE_REQUEST": {
sendToClient(client, {
type: "KEEPALIVE_RESPONSE",
});
break;
}
case "INTEGRITY_CHECK_REQUEST": {
sendToClient(client, {
type: "INTEGRITY_CHECK_RESPONSE",
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
});
break;
}
case "MOCK_ACTIVATE": {
activeClientIds.add(clientId);
sendToClient(client, {
type: "MOCKING_ENABLED",
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
});
break;
}
case "CLIENT_CLOSED": {
activeClientIds.delete(clientId);
const remainingClients = allClients.filter((client) => {
return client.id !== clientId;
});
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister();
}
break;
}
}
});
addEventListener("fetch", function (event) {
const requestInterceptedAt = Date.now();
// Bypass navigation requests.
if (event.request.mode === "navigate") {
return;
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (event.request.cache === "only-if-cached" && event.request.mode !== "same-origin") {
return;
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return;
}
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId, requestInterceptedAt));
});
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event);
const requestCloneForEvents = event.request.clone();
const response = await getResponse(event, client, requestId, requestInterceptedAt);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents);
// Omit the body of server-sent event stream responses.
// Cloning such responses would prevent client-side stream cancelations
// from reaching the original stream (a teed stream only cancels its
// source once both of its branches cancel) and would buffer the
// entire stream into the unconsumed clone indefinitely.
const isEventStreamResponse = response.headers
.get("content-type")
?.toLowerCase()
.startsWith("text/event-stream");
// Clone the response so both the client and the library could consume it.
const responseClone = isEventStreamResponse ? null : response.clone();
sendToClient(
client,
{
type: "RESPONSE",
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: response.type,
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseClone ? responseClone.body : null,
},
},
},
responseClone && responseClone.body ? [serializedRequest.body, responseClone.body] : [],
);
}
return response;
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (activeClientIds.has(event.clientId)) {
return client;
}
if (client?.frameType === "top-level") {
return client;
}
const allClients = await self.clients.matchAll({
type: "window",
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === "visible";
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone();
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers);
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get("accept");
if (acceptHeader) {
const values = acceptHeader.split(",").map((value) => value.trim());
const filteredValues = values.filter((value) => value !== "msw/passthrough");
if (filteredValues.length > 0) {
headers.set("accept", filteredValues.join(", "));
} else {
headers.delete("accept");
}
}
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request);
const clientMessage = await sendToClient(
client,
{
type: "REQUEST",
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
);
switch (clientMessage.type) {
case "MOCK_RESPONSE": {
return respondWithMock(clientMessage.data);
}
case "PASSTHROUGH": {
return passthrough();
}
}
return passthrough();
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error);
}
resolve(event.data);
};
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]);
});
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
const mockedResponse = new Response(response.body, response);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
};
}
-63
View File
@@ -1,63 +0,0 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RegisterRequestSchema,
} from "@/api/auth/schemas";
import type {
AuthTokens,
LoginRequest,
PermissionsList,
RegisterRequest,
} from "@/api/auth/schemas";
import { UserSchema, type User } from "@/api/users/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
import { APP_MENU_TREE, filterMenuTreeByPermissions } from "@/utils/appMenu";
import { useAuthStore } from "@/stores/auth";
export { AUTH_ENDPOINTS } from "@/api/auth/paths";
export const login = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.login,
body: LoginRequestSchema,
output: AuthTokensSchema,
});
export const register = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.register,
body: RegisterRequestSchema,
output: AuthTokensSchema,
});
export const logout = defineApi({
method: "POST",
path: AUTH_ENDPOINTS.logout,
output: z.null(),
});
export const getAuthUser = defineApi({
path: AUTH_ENDPOINTS.user,
output: AuthUserResponseSchema,
});
export const getPermissions = defineApi({
path: AUTH_ENDPOINTS.permissions,
output: PermissionsListSchema,
});
/** Fetches user + permissions, then updates auth store `user` and sidebar `menus`. */
export async function hydrateSession(): Promise<void> {
const [userBase, permissions] = await Promise.all([getAuthUser(), getPermissions()]);
const user = UserSchema.parse({ ...userBase, permissions });
const menus = filterMenuTreeByPermissions(APP_MENU_TREE, permissions);
const { setUser, setMenus } = useAuthStore.getState();
setUser(user);
setMenus(menus);
}
export type { AuthTokens, LoginRequest, User, PermissionsList, RegisterRequest };
-12
View File
@@ -1,12 +0,0 @@
import { API_PREFIX } from "@/api/shared/prefix";
const AUTH_BASE = `${API_PREFIX}/auth`;
export const AUTH_ENDPOINTS = {
login: `${AUTH_BASE}/login`,
register: `${AUTH_BASE}/register`,
refresh: `${AUTH_BASE}/refresh`,
logout: `${AUTH_BASE}/logout`,
user: `${AUTH_BASE}/user`,
permissions: `${AUTH_BASE}/permissions`,
} as const;
-48
View File
@@ -1,48 +0,0 @@
import { z } from "zod/v4";
import { UserSchema } from "@/api/users/schemas";
/** GET `/api/auth/user` body (no `permissions`; load via `/api/auth/permissions`). */
export const AuthUserResponseSchema = UserSchema.omit({ permissions: true });
export type AuthUserResponse = z.infer<typeof AuthUserResponseSchema>;
export const AuthTokensSchema = z.object({
accessToken: z.string().min(1),
refreshToken: z.string().min(1),
});
export type AuthTokens = z.infer<typeof AuthTokensSchema>;
export const RefreshTokenRequestSchema = z.object({
refreshToken: z.string().min(1),
});
export type RefreshTokenRequest = z.infer<typeof RefreshTokenRequestSchema>;
export const LoginRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
/** Ant Design Form may submit `""` for an empty optional email */
const registerOptionalEmailSchema = z
.union([z.string(), z.undefined()])
.transform((v) => {
if (v === undefined) return undefined;
const t = String(v).trim();
return t === "" ? undefined : t;
})
.pipe(z.union([z.string().email(), z.undefined()]));
export const RegisterRequestSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
email: registerOptionalEmailSchema,
});
export type RegisterRequest = z.infer<typeof RegisterRequestSchema>;
export const PermissionsListSchema = z.array(z.string());
export type PermissionsList = z.infer<typeof PermissionsListSchema>;
-302
View File
@@ -1,302 +0,0 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
import { z } from "zod/v4";
import { API_BASE_URL } from "@/utils/constants";
import { AuthTokensSchema, RefreshTokenRequestSchema } from "@/api/auth/schemas";
import { getAccessToken, useAuthStore } from "@/stores/auth";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
/** Avoid importing `@/api/auth` index here (circular with `defineApi` consumers). */
const REFRESH_PATH = AUTH_ENDPOINTS.refresh;
export class ApiError extends Error {
code: number;
constructor(code: number, message: string) {
super(message);
this.name = "ApiError";
this.code = code;
}
}
export class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
type HttpRouter = {
navigate: (opts: { to: string }) => void | Promise<void>;
};
let httpRouter: HttpRouter | null = null;
export function installHttpRouter(router: HttpRouter): void {
httpRouter = router;
}
function navigateTo403(): void {
void httpRouter?.navigate({ to: "/403" });
}
function navigateToLogin(): void {
void httpRouter?.navigate({ to: "/login" });
}
type RequestConfig = InternalAxiosRequestConfig & {
skipAuthRefresh?: boolean;
_retry?: boolean;
};
export type CallOptions = {
signal?: AbortSignal;
};
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
type Envelope = {
code?: number;
data?: unknown;
message?: string;
};
function cleanParams(params?: Record<string, unknown>): Record<string, unknown> | undefined {
if (!params) return undefined;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (value != null) out[key] = value;
}
return Object.keys(out).length > 0 ? out : undefined;
}
function unwrapEnvelope(json: unknown): unknown {
if (json == null || typeof json !== "object") return json;
const envelope = json as Envelope;
if (envelope.code !== undefined && envelope.code !== 0) {
throw new ApiError(envelope.code, envelope.message ?? "Unknown error");
}
if (envelope.code !== undefined && envelope.data !== undefined) {
return envelope.data;
}
return json;
}
const client = axios.create({
baseURL: API_BASE_URL,
timeout: 15_000,
headers: { "Content-Type": "application/json" },
});
client.interceptors.request.use((config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
let inflightRefresh: Promise<boolean> | null = null;
async function doRefreshSessionTokens(): Promise<boolean> {
const { tokens, setTokens, logout } = useAuthStore.getState();
const refreshToken = tokens?.refreshToken;
if (!refreshToken) {
logout();
navigateToLogin();
return false;
}
const body = RefreshTokenRequestSchema.parse({ refreshToken });
try {
const res = await client.post(REFRESH_PATH, body, {
skipAuthRefresh: true,
} as RequestConfig);
const next = AuthTokensSchema.parse(res.data);
setTokens(next);
return true;
} catch {
logout();
navigateToLogin();
return false;
}
}
async function refreshSessionTokens(): Promise<boolean> {
if (inflightRefresh) return inflightRefresh;
inflightRefresh = doRefreshSessionTokens().finally(() => {
inflightRefresh = null;
});
return inflightRefresh;
}
client.interceptors.response.use(
(response) => {
response.data = unwrapEnvelope(response.data);
return response;
},
async (error: AxiosError) => {
const status = error.response?.status;
const config = error.config as RequestConfig | undefined;
if (
status === 401 &&
config &&
!config._retry &&
!config.skipAuthRefresh &&
config.url !== REFRESH_PATH &&
!config.url?.endsWith(REFRESH_PATH)
) {
config._retry = true;
const refreshed = await refreshSessionTokens();
if (refreshed) {
return client.request(config);
}
throw new HttpError(401, "Unauthorized");
}
if (status === 403) {
navigateTo403();
throw new HttpError(403, "Forbidden");
}
if (status != null) {
throw new HttpError(status, `HTTP ${status}: ${error.response?.statusText ?? "Error"}`);
}
throw error;
},
);
async function transportRequest<T>(opts: {
method: HttpMethod;
url: string;
params?: Record<string, unknown>;
body?: unknown;
signal?: AbortSignal;
skipAuthRefresh?: boolean;
}): Promise<T> {
const res = await client.request<T>({
method: opts.method,
url: opts.url,
params: cleanParams(opts.params),
data: opts.body,
signal: opts.signal,
skipAuthRefresh: opts.skipAuthRefresh,
} as RequestConfig);
return res.data;
}
type ApiDefConfig<
TParams extends z.ZodType | undefined,
TQuery extends z.ZodType | undefined,
TBody extends z.ZodType | undefined,
TOut extends z.ZodType,
> = {
method?: HttpMethod;
path: string;
params?: TParams;
query?: TQuery;
body?: TBody;
output: TOut;
};
type CallArgs<
TParams extends z.ZodType | undefined,
TQuery extends z.ZodType | undefined,
TBody extends z.ZodType | undefined,
> = TParams extends z.ZodType
? TQuery extends z.ZodType
? TBody extends z.ZodType
? { params: z.infer<TParams>; query: z.infer<TQuery>; body: z.infer<TBody> }
: { params: z.infer<TParams>; query: z.infer<TQuery> }
: TBody extends z.ZodType
? { params: z.infer<TParams>; body: z.infer<TBody> }
: z.infer<TParams>
: TQuery extends z.ZodType
? TBody extends z.ZodType
? { query: z.infer<TQuery>; body: z.infer<TBody> }
: z.infer<TQuery>
: TBody extends z.ZodType
? z.infer<TBody>
: void;
function renderPath(path: string, params?: Record<string, unknown>): string {
if (!params) return path;
let url = path;
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
}
return url;
}
export function defineApi<
TParams extends z.ZodType | undefined = undefined,
TQuery extends z.ZodType | undefined = undefined,
TBody extends z.ZodType | undefined = undefined,
TOut extends z.ZodType = z.ZodType,
>(
def: ApiDefConfig<TParams, TQuery, TBody, TOut>,
): (
...args: CallArgs<TParams, TQuery, TBody> extends void
? [options?: CallOptions]
: [args: CallArgs<TParams, TQuery, TBody>, options?: CallOptions]
) => Promise<z.infer<TOut>> {
const method: HttpMethod = def.method ?? "GET";
const segmentCount = (def.params ? 1 : 0) + (def.query ? 1 : 0) + (def.body ? 1 : 0);
return async (...allArgs: unknown[]): Promise<z.infer<TOut>> => {
let callArgs: unknown;
let options: CallOptions | undefined;
if (segmentCount === 0) {
options = allArgs[0] as CallOptions | undefined;
} else {
callArgs = allArgs[0];
options = allArgs[1] as CallOptions | undefined;
}
let params: Record<string, unknown> | undefined;
let query: Record<string, unknown> | undefined;
let body: unknown;
if (segmentCount === 0) {
// no input segments
} else if (segmentCount === 1) {
if (def.params) {
params = def.params.parse(callArgs) as Record<string, unknown>;
} else if (def.query) {
query = def.query.parse(callArgs) as Record<string, unknown>;
} else if (def.body) {
body = def.body.parse(callArgs);
}
} else {
const shaped = (callArgs ?? {}) as {
params?: unknown;
query?: unknown;
body?: unknown;
};
if (def.params) {
params = def.params.parse(shaped.params) as Record<string, unknown>;
}
if (def.query) {
query = def.query.parse(shaped.query) as Record<string, unknown>;
}
if (def.body) {
body = def.body.parse(shaped.body);
}
}
const data = await transportRequest<unknown>({
method,
url: renderPath(def.path, params),
params: query,
body: def.body ? body : undefined,
signal: options?.signal,
});
return def.output.parse(data) as z.infer<TOut>;
};
}
-4
View File
@@ -1,4 +0,0 @@
/** @deprecated Import from `@/api/<module>/schemas` or `@/api/shared`; re-exports for compatibility. */
export * from "@/api/shared";
export * from "@/api/users/schemas";
export * from "@/api/auth/schemas";
-26
View File
@@ -1,26 +0,0 @@
import { z } from "zod/v4";
export function ApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
code: z.int(),
data: dataSchema,
message: z.string(),
});
}
export type ApiResponse<T> = {
code: number;
data: T;
message: string;
};
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
code: z.int(),
data: z.object({
list: z.array(itemSchema),
total: z.int(),
}),
message: z.string(),
});
}
-5
View File
@@ -1,5 +0,0 @@
export * from "./envelope";
export * from "./menu";
export * from "./pagination";
export * from "./params";
export * from "./prefix";
-37
View File
@@ -1,37 +0,0 @@
import { z } from "zod/v4";
const BaseMenuNodeSchema = z.object({
id: z.string(),
name: z.string(),
icon: z.string().nullable(),
permissions: z.array(z.string()).nullable(),
sort: z.int(),
hidden: z.boolean(),
});
export type MenuItem =
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "item";
path: string;
children: MenuItem[] | null;
})
| (z.infer<typeof BaseMenuNodeSchema> & {
kind: "group";
path: null;
children: MenuItem[];
});
export const MenuItemSchema: z.ZodType<MenuItem> = z.lazy(() =>
z.discriminatedUnion("kind", [
BaseMenuNodeSchema.extend({
kind: z.literal("item"),
path: z.string(),
children: z.array(MenuItemSchema).nullable(),
}),
BaseMenuNodeSchema.extend({
kind: z.literal("group"),
path: z.null(),
children: z.array(MenuItemSchema),
}),
]),
);
-14
View File
@@ -1,14 +0,0 @@
import { z } from "zod/v4";
export type PaginatedData<T> = {
list: T[];
total: number;
};
/** Unwrapped list payload (envelope `data`); use as `defineApi` `output`. */
export function PaginatedDataSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.int(),
});
}
-16
View File
@@ -1,16 +0,0 @@
import { z } from "zod/v4";
export const IdParamsSchema = z.object({
id: z.string().min(1),
});
export type IdParams = z.infer<typeof IdParamsSchema>;
export const SearchParamsSchema = z.object({
page: z.number().int().positive().catch(1),
pageSize: z.number().int().positive().catch(10),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
});
export type SearchParams = z.infer<typeof SearchParamsSchema>;
-2
View File
@@ -1,2 +0,0 @@
/** REST API path prefix (no trailing slash). */
export const API_PREFIX = "/api" as const;
-55
View File
@@ -1,55 +0,0 @@
import { z } from "zod/v4";
import { defineApi } from "@/api/request";
import { IdParamsSchema } from "@/api/shared/params";
import { PaginatedDataSchema } from "@/api/shared/pagination";
import {
CreateUserRequestSchema,
ListUsersQuerySchema,
UpdateUserRequestSchema,
UserSchema,
} from "@/api/users/schemas";
import type {
CreateUserRequest,
ListUsersQuery,
UpdateUserRequest,
User,
} from "@/api/users/schemas";
import { API_PREFIX } from "@/api/shared/prefix";
export const USERS_PATH = `${API_PREFIX}/users`;
export const USERS_MEMBER_PATH = `${USERS_PATH}/:id`;
export const userQueryKeys = {
all: ["users"] as const,
list: (query: ListUsersQuery) => [...userQueryKeys.all, "list", query] as const,
};
export const listUsers = defineApi({
path: USERS_PATH,
query: ListUsersQuerySchema,
output: PaginatedDataSchema(UserSchema),
});
export const createUser = defineApi({
method: "POST",
path: USERS_PATH,
body: CreateUserRequestSchema,
output: UserSchema,
});
export const updateUser = defineApi({
method: "PUT",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
body: UpdateUserRequestSchema,
output: UserSchema,
});
export const deleteUser = defineApi({
method: "DELETE",
path: USERS_MEMBER_PATH,
params: IdParamsSchema,
output: z.null(),
});
export type { CreateUserRequest, ListUsersQuery, UpdateUserRequest, User };
-45
View File
@@ -1,45 +0,0 @@
import { z } from "zod/v4";
export const UserSchema = z.object({
id: z.string(),
username: z.string(),
avatar: z.string().nullable(),
email: z.string().nullable(),
roles: z.array(z.string()),
permissions: z.array(z.string()),
});
export type User = z.infer<typeof UserSchema>;
export const ListUsersQuerySchema = z.object({
limit: z.number().int().positive().optional(),
offset: z.number().int().nonnegative().optional(),
keyword: z.string().optional(),
role: z.string().optional(),
sortField: z.string().optional(),
sortOrder: z.enum(["ascend", "descend"]).optional(),
});
export type ListUsersQuery = z.infer<typeof ListUsersQuerySchema>;
/** Ant Design Input submits `""` when empty; treat as null for optional email */
const createUserEmailSchema = z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v == null) return null;
const t = String(v).trim();
return t === "" ? null : t;
})
.pipe(z.union([z.string().email(), z.null()]));
export const CreateUserRequestSchema = z.object({
username: z.string().min(1),
email: createUserEmailSchema,
roles: z.array(z.string()).min(1),
});
export type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;
export const UpdateUserRequestSchema = CreateUserRequestSchema.partial();
export type UpdateUserRequest = z.infer<typeof UpdateUserRequestSchema>;
@@ -1,88 +0,0 @@
.aurora__root {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 0;
--aurora: repeating-linear-gradient(
100deg,
#3b82f6 10%,
#a5b4fc 15%,
#93c5fd 20%,
#ddd6fe 25%,
#60a5fa 30%
);
--dark-gradient: repeating-linear-gradient(
100deg,
#000 0%,
#000 7%,
transparent 10%,
transparent 12%,
#000 16%
);
--white-gradient: repeating-linear-gradient(
100deg,
#fff 0%,
#fff 7%,
transparent 10%,
transparent 12%,
#fff 16%
);
}
.aurora__glow {
position: absolute;
inset: -10px;
background-image: var(--white-gradient), var(--aurora);
background-size: 300% 200%;
background-position:
50% 50%,
50% 50%;
opacity: 0.5;
filter: blur(10px) invert(1);
-webkit-mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
mask-image: radial-gradient(ellipse at 100% 0%, #000 10%, transparent 70%);
}
html[data-theme="dark"] .aurora__glow {
background-image: var(--dark-gradient), var(--aurora);
filter: blur(10px);
}
.aurora__glow::after {
content: "";
position: absolute;
inset: 0;
background-image: var(--white-gradient), var(--aurora);
background-size: 200% 100%;
background-position:
50% 50%,
50% 50%;
background-attachment: fixed;
mix-blend-mode: difference;
}
html[data-theme="dark"] .aurora__glow::after {
background-image: var(--dark-gradient), var(--aurora);
}
@keyframes aurora-keyframes {
0% {
background-position: 50%, 50%;
}
to {
background-position: 350%, 350%;
}
}
@media (prefers-reduced-motion: no-preference) {
.aurora__glow::after {
animation: aurora-keyframes 60s linear infinite;
}
}
@media (prefers-reduced-motion: reduce) {
.aurora__glow::after {
animation: none;
}
}
@@ -1,14 +0,0 @@
import "./index.css";
export type AuroraProps = {
className?: string;
};
export function Aurora({ className }: AuroraProps) {
const rootClass = className ? `aurora__root ${className}` : "aurora__root";
return (
<div className={rootClass} aria-hidden>
<div className="aurora__glow" />
</div>
);
}
-13
View File
@@ -1,13 +0,0 @@
import type { ReactNode } from "react";
import { usePermission } from "@/hooks/usePermission";
interface AuthProps {
permission: string;
children: ReactNode;
fallback?: ReactNode;
}
export function Auth({ permission, children, fallback = null }: AuthProps) {
const allowed = usePermission(permission);
return allowed ? <>{children}</> : <>{fallback}</>;
}
@@ -1,62 +0,0 @@
import { Flex, theme } from "antd";
import { BarChart3 } from "lucide-react";
import type { ReactElement } from "react";
/**
* Table empty state: icon in soft tile + bold title + secondary description (dashboard-style).
*/
export function DataTableEmpty(): ReactElement {
const { token } = theme.useToken();
return (
<Flex
vertical
align="center"
justify="center"
gap={token.marginMD}
className="data-table-empty"
style={{
paddingBlock: token.paddingXL,
paddingInline: token.paddingLG,
maxWidth: 360,
marginInline: "auto",
}}
>
<Flex
align="center"
justify="center"
style={{
width: 44,
height: 44,
borderRadius: token.borderRadiusLG,
background: token.colorFillTertiary,
color: token.colorTextQuaternary,
}}
>
<BarChart3 size={22} strokeWidth={1.75} aria-hidden />
</Flex>
<Flex vertical align="center" gap={token.marginXXS} style={{ textAlign: "center" }}>
<span
style={{
fontSize: token.fontSizeLG,
fontWeight: token.fontWeightStrong,
color: token.colorText,
lineHeight: token.lineHeightLG,
}}
>
No data
</span>
<span
style={{
fontSize: token.fontSize,
fontWeight: 400,
color: token.colorTextSecondary,
lineHeight: token.lineHeight,
}}
>
Nothing to show in this list yet
</span>
</Flex>
</Flex>
);
}
@@ -1,298 +0,0 @@
import { Skeleton, Table, theme } from "antd";
import type { TableProps } from "antd";
import type { ColumnGroupType, ColumnsType, ColumnType } from "antd/es/table";
import type { GlobalToken } from "antd/es/theme/interface";
import type { CSSProperties } from "react";
import { useLayoutEffect, useMemo, useRef, useState } from "react";
function rowHeight(size: TableProps["size"]): number {
if (size === "large") return 56;
if (size === "middle") return 48;
return 40;
}
function numericScrollY(scroll: TableProps<object>["scroll"]): number | undefined {
const y = scroll?.y;
return typeof y === "number" && Number.isFinite(y) && y > 0 ? y : undefined;
}
function clampRows(bodyHeight: number, rowH: number): number {
return Math.max(3, Math.floor(bodyHeight / rowH));
}
function initialRowCount(scroll: TableProps<object>["scroll"], size: TableProps["size"]): number {
const y = numericScrollY(scroll);
return y != null ? clampRows(y, rowHeight(size)) : 10;
}
export type DataTableSkeletonProps<RecordType extends object> = Pick<
TableProps<RecordType>,
"columns" | "rowSelection" | "pagination" | "size" | "scroll" | "rootClassName" | "style"
> & {
/**
* Fixed skeleton row count. When omitted, rows are derived from `scroll.y` (if numeric)
* or from the table frame height so the body roughly fills the visible area.
*/
rowCount?: number;
};
function isActionColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
return (
col.key === "actions" ||
col.dataIndex === "actions" ||
(typeof col.title === "string" && col.title.toLowerCase() === "actions")
);
}
function isIdColumn<RecordType extends object>(col: ColumnType<RecordType>): boolean {
return col.key === "id" || col.dataIndex === "id";
}
function isColumnGroup<RecordType extends object>(
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
): col is ColumnGroupType<RecordType> {
return "children" in col && Array.isArray(col.children) && col.children.length > 0;
}
/** ~ text icon button, capped small */
function actionSkeletonDiameter<RecordType extends object>(col: ColumnType<RecordType>): number {
if (typeof col.width === "number") {
return Math.min(24, Math.max(20, col.width - 36));
}
return 22;
}
const DATA_CELL_MIN = 48;
function dataCellMinWidth<RecordType extends object>(col: ColumnType<RecordType>): number {
const cap = typeof col.minWidth === "number" ? col.minWidth : col.width;
if (typeof cap !== "number") return DATA_CELL_MIN;
return Math.max(DATA_CELL_MIN, Math.min(cap, 320));
}
function cellJustify(align: ColumnType<object>["align"]): CSSProperties["justifyContent"] {
if (align === "right") return "flex-end";
if (align === "center") return "center";
return "flex-start";
}
function skeletonInputBarStyle(
token: GlobalToken,
opts: { floor: number; maxWidth: number | string; flex: string },
): CSSProperties {
return {
width: "100%",
minWidth: opts.floor,
maxWidth: opts.maxWidth,
height: token.controlHeightSM,
borderRadius: token.borderRadiusSM,
flex: opts.flex,
};
}
function mapSkeletonColumn<RecordType extends object>(
col: ColumnType<RecordType> | ColumnGroupType<RecordType>,
token: GlobalToken,
): ColumnType<RecordType> | ColumnGroupType<RecordType> {
if (isColumnGroup(col)) {
return {
...col,
children: (col.children as ColumnsType<RecordType>).map((child) =>
mapSkeletonColumn(child, token),
),
};
}
const c = col as ColumnType<RecordType>;
return {
...c,
sorter: false,
sortOrder: undefined,
render: () => {
if (isActionColumn(c)) {
const w = actionSkeletonDiameter(c);
return (
<span
style={{
display: "flex",
width: "100%",
justifyContent: cellJustify(c.align),
lineHeight: 1,
}}
>
<Skeleton.Button
active
size="small"
shape="circle"
style={{ width: w, height: w, minWidth: w, flexShrink: 0 }}
/>
</span>
);
}
const justify = cellJustify(c.align);
const id = isIdColumn(c);
if (id) {
const floor = 28;
const maxBar = typeof c.width === "number" ? Math.max(28, Math.min(c.width - 10, 52)) : 40;
return (
<Skeleton.Input
active
size="small"
style={skeletonInputBarStyle(token, {
floor,
maxWidth: maxBar,
flex: "0 1 auto",
})}
/>
);
}
const floor = dataCellMinWidth(c);
const bar = skeletonInputBarStyle(token, {
floor,
maxWidth: "100%",
flex: "1 1 auto",
});
return (
<div
style={{
width: "100%",
minWidth: floor,
maxWidth: "100%",
display: "flex",
alignItems: "center",
justifyContent: justify,
boxSizing: "border-box",
...(c.ellipsis ? { overflow: "hidden" as const } : {}),
}}
>
<Skeleton.Input active size="small" style={bar} />
</div>
);
},
};
}
export function DataTableSkeleton<RecordType extends object = object>({
columns,
rowSelection,
pagination,
size = "small",
scroll,
rootClassName,
style,
rowCount,
}: DataTableSkeletonProps<RecordType>) {
const { token } = theme.useToken();
const frameRef = useRef<HTMLDivElement>(null);
const [measuredRows, setMeasuredRows] = useState(() => initialRowCount(scroll, size));
useLayoutEffect(() => {
if (rowCount != null) return;
const y = numericScrollY(scroll);
if (y != null) {
setMeasuredRows(clampRows(y, rowHeight(size)));
return;
}
const el = frameRef.current;
if (!el) return;
const rowH = rowHeight(size);
const measure = () => {
const thead = el.querySelector<HTMLElement>(".ant-table-thead");
const pag = pagination === false ? null : el.querySelector<HTMLElement>(".ant-pagination");
const theadH = thead?.offsetHeight ?? 39;
const pagH = pag ? pag.offsetHeight + 8 : 0;
const bodyH = Math.max(0, el.clientHeight - theadH - pagH - 2);
setMeasuredRows((prev) => {
const next = clampRows(bodyH, rowH);
return prev === next ? prev : next;
});
};
const ro = new ResizeObserver(() => requestAnimationFrame(measure));
ro.observe(el);
let raf = requestAnimationFrame(() => {
raf = requestAnimationFrame(measure);
});
return () => {
ro.disconnect();
cancelAnimationFrame(raf);
};
}, [rowCount, pagination, scroll?.y, size]);
const effectiveRowCount = rowCount ?? measuredRows;
const skeletonColumns = useMemo(() => {
if (!columns?.length) return [];
return (columns as ColumnsType<RecordType>).map((col) => mapSkeletonColumn(col, token));
}, [columns, token]);
const skeletonData = useMemo(
() =>
Array.from({ length: effectiveRowCount }, (_, i) => ({
__sk: String(i),
})) as unknown as RecordType[],
[effectiveRowCount],
);
const skeletonPagination = useMemo((): TableProps<RecordType>["pagination"] => {
if (pagination === false) return false;
if (typeof pagination === "object" && pagination !== null) {
return {
...pagination,
disabled: true,
onChange: undefined,
onShowSizeChange: undefined,
};
}
return {
current: 1,
pageSize: 10,
total: 100,
disabled: true,
showSizeChanger: true,
showTotal: () => (
<Skeleton.Input active size="small" style={{ width: 88, height: token.fontSize }} />
),
};
}, [pagination, token.fontSize]);
const selection =
rowSelection != null
? { ...rowSelection, getCheckboxProps: () => ({ disabled: true }) }
: undefined;
return (
<div
ref={frameRef}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
overflow: "hidden",
...style,
}}
>
<Table<RecordType>
rootClassName={rootClassName}
style={{ flex: 1, minHeight: 0, width: "100%" }}
size={size}
columns={skeletonColumns}
dataSource={skeletonData}
rowKey={(record) => String((record as unknown as { __sk: string }).__sk)}
pagination={skeletonPagination}
rowSelection={selection}
scroll={scroll}
showSorterTooltip={false}
/>
</div>
);
}
@@ -1,35 +0,0 @@
.data-table__table.ant-table-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.data-table__table .ant-spin-nested-loading,
.data-table__table .ant-spin-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.data-table__table .ant-table {
flex: 1;
min-height: 0;
}
.data-table__table .ant-table-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Empty state: let placeholder row breathe in flex-height tables */
.data-table__table .ant-table-tbody > tr.ant-table-placeholder > td {
border-bottom: none;
}
.data-table__table .ant-table-tbody > tr.ant-table-placeholder .ant-table-cell {
padding-block: 0;
}
@@ -1,126 +0,0 @@
import { Table, theme } from "antd";
import type { TableProps } from "antd";
import type { CSSProperties, ReactElement, ReactNode, Ref } from "react";
import { useMemo } from "react";
import { DataTableEmpty } from "./DataTableEmpty";
import { DataTableSkeleton } from "./DataTableSkeleton";
import "./index.css";
/** Merged onto `Table` `rootClassName` with the flex layout class. */
export const DATA_TABLE_ROOT_CLASS = "data-table__table";
export type DataTableProps<RecordType extends object = object> = {
/**
* When the table uses `scroll.y`, set true so the frame does not shrink in the flex column
* and optional `frameHeight` can pin the outer height.
*/
lockScrollHeight?: boolean;
/** Outer `max-height` (px) */
maxHeight?: number;
/** When `lockScrollHeight`, optional fixed outer `height` (usually same as max height) */
frameHeight?: number;
/** Flex column wrapping frame + optional `bottomExtra` */
layoutRef?: Ref<HTMLDivElement>;
/** Bordered box around the table (e.g. for `scrollHeight` vs `clientHeight` checks) */
frameRef?: Ref<HTMLDivElement>;
/** Optional content below the bordered table frame */
bottomExtra?: ReactNode;
} & TableProps<RecordType>;
export function DataTable<RecordType extends object = object>(
props: DataTableProps<RecordType>,
): ReactElement {
const {
lockScrollHeight,
maxHeight,
frameHeight,
layoutRef,
frameRef,
bottomExtra,
rootClassName,
style: tableStyle,
...tableProps
} = props;
const { token } = theme.useToken();
const { loading, ...restTableProps } = tableProps;
const spinLoading =
loading === true ||
(typeof loading === "object" &&
loading !== null &&
(loading as { spinning?: boolean }).spinning !== false);
const outerStyle: CSSProperties = {
flexGrow: 0,
flexShrink: lockScrollHeight ? 0 : 1,
flexBasis: "auto",
minHeight: 0,
maxHeight,
...(lockScrollHeight && frameHeight != null ? { height: frameHeight } : {}),
alignSelf: "stretch",
border: `1px solid ${token.colorBorderSecondary}`,
borderRadius: token.borderRadiusLG,
overflow: "hidden",
display: "flex",
flexDirection: "column",
};
const mergedRootClassName = [DATA_TABLE_ROOT_CLASS, rootClassName].filter(Boolean).join(" ");
const mergedLocale = useMemo(
() => ({
...restTableProps.locale,
emptyText: restTableProps.locale?.emptyText ?? <DataTableEmpty />,
}),
[restTableProps.locale],
);
return (
<div
ref={layoutRef}
style={{
flex: "1 1 0%",
minHeight: 0,
overflow: "hidden",
display: "flex",
flexDirection: "column",
gap: token.marginSM,
}}
>
<div ref={frameRef} style={outerStyle}>
<div
className="data-table__inner"
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{spinLoading ? (
<DataTableSkeleton<RecordType>
columns={restTableProps.columns}
rowSelection={restTableProps.rowSelection}
pagination={restTableProps.pagination}
size={restTableProps.size}
scroll={restTableProps.scroll}
rootClassName={mergedRootClassName}
style={{ flex: 1, minHeight: 0, ...tableStyle }}
/>
) : (
<Table<RecordType>
{...restTableProps}
locale={mergedLocale}
loading={loading}
rootClassName={mergedRootClassName}
style={{ flex: 1, minHeight: 0, ...tableStyle }}
/>
)}
</div>
</div>
{bottomExtra}
</div>
);
}
@@ -1,168 +0,0 @@
import { Button, Flex, Popover, theme, Tooltip } from "antd";
import { ListFilter } from "lucide-react";
import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
export type FilterToolbarSlot = {
key: string;
/** Used to decide how many filters fit in one row before the rest go into the popover */
minWidth: number;
children: React.ReactNode;
};
export type FilterToolbarProps = {
slots: FilterToolbarSlot[];
/** Right side: primary actions (not collapsed) */
actions: React.ReactNode;
/** Reserve width for the “more filters” control when some slots are in the popover */
collapseTriggerReserve?: number;
/** Accessible label + optional popover title */
moreFiltersLabel: React.ReactNode;
moreFiltersTitle?: React.ReactNode;
};
function maxVisibleSlots(
slotWidths: number[],
availableForLeft: number,
innerGap: number,
triggerReserve: number,
): number {
const n = slotWidths.length;
if (n === 0) return 0;
const sum = (count: number) => {
let s = 0;
for (let i = 0; i < count; i++) s += slotWidths[i] ?? 0;
return s;
};
const inlineCost = (m: number) => {
if (m <= 0) return 0;
return sum(m) + (m - 1) * innerGap;
};
const totalLeft = (m: number) => {
if (m <= 0) return n > 0 ? triggerReserve : 0;
const base = inlineCost(m);
if (m >= n) return base;
return base + innerGap + triggerReserve;
};
for (let m = n; m >= 0; m--) {
if (totalLeft(m) <= availableForLeft) return m;
}
return 0;
}
export const FilterToolbar = forwardRef<HTMLDivElement, FilterToolbarProps>(function FilterToolbar(
{ slots, actions, collapseTriggerReserve = 40, moreFiltersLabel, moreFiltersTitle },
ref,
) {
const { token } = theme.useToken();
const gap = token.marginSM;
const containerRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(slots.length);
const [popoverOpen, setPopoverOpen] = useState(false);
const widths = useMemo(() => slots.map((s) => s.minWidth), [slots]);
const assignRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
},
[ref],
);
const remeasure = useCallback(() => {
const root = containerRef.current;
const act = actionsRef.current;
if (!root) return;
const actionsW = act?.offsetWidth ?? 0;
const hasActions = Boolean(act);
const between = hasActions ? gap : 0;
const available = Math.max(0, root.clientWidth - actionsW - between);
const next = maxVisibleSlots(widths, available, gap, collapseTriggerReserve);
setVisibleCount((prev) => (prev === next ? prev : next));
}, [widths, gap, collapseTriggerReserve]);
useLayoutEffect(() => {
remeasure();
const root = containerRef.current;
const act = actionsRef.current;
if (!root) return;
const ro = new ResizeObserver(() => remeasure());
ro.observe(root);
if (act) ro.observe(act);
return () => ro.disconnect();
}, [remeasure, slots.length, widths]);
useLayoutEffect(() => {
setPopoverOpen(false);
}, [visibleCount]);
const overflowSlots = slots.slice(visibleCount);
const inlineSlots = slots.slice(0, visibleCount);
const popoverContent =
overflowSlots.length > 0 ? (
<Flex vertical gap={token.marginMD} style={{ width: "100%" }}>
{overflowSlots.map((slot) => (
<div key={slot.key} style={{ minWidth: slot.minWidth }}>
{slot.children}
</div>
))}
</Flex>
) : null;
return (
<Flex
ref={assignRef}
wrap={false}
gap={gap}
align="center"
justify="space-between"
style={{ flexShrink: 0, minWidth: 0 }}
>
<Flex
wrap={false}
gap={gap}
align="center"
style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden" }}
>
{inlineSlots.map((slot) => (
<div
key={slot.key}
style={{
flex: "0 0 auto",
minWidth: slot.minWidth,
maxWidth: "100%",
}}
>
{slot.children}
</div>
))}
{overflowSlots.length > 0 ? (
<Popover
title={moreFiltersTitle ?? moreFiltersLabel}
content={popoverContent}
trigger="click"
placement="bottomLeft"
open={popoverOpen}
onOpenChange={setPopoverOpen}
overlayInnerStyle={{ maxWidth: "min(100vw - 32px, 360px)" }}
>
<Tooltip title={moreFiltersLabel}>
<Button
type="default"
icon={<ListFilter size={token.fontSize} />}
aria-label={typeof moreFiltersLabel === "string" ? moreFiltersLabel : undefined}
style={{ flex: "0 0 auto" }}
/>
</Tooltip>
</Popover>
) : null}
</Flex>
<Flex ref={actionsRef} wrap={false} gap={gap} align="center" style={{ flexShrink: 0 }}>
{actions}
</Flex>
</Flex>
);
});
@@ -1,51 +0,0 @@
import { Form, Modal, theme } from "antd";
import type { FormInstance } from "antd/es/form";
import type { ReactNode } from "react";
export type BaseFormModalProps<TValues> = {
open: boolean;
title: ReactNode;
okText: ReactNode;
cancelText: ReactNode;
form: FormInstance<TValues>;
confirmLoading?: boolean;
onCancel: () => void;
onFinish: (values: TValues) => void;
children: ReactNode;
};
export function BaseFormModal<TValues>({
open,
title,
okText,
cancelText,
form,
confirmLoading,
onCancel,
onFinish,
children,
}: BaseFormModalProps<TValues>) {
const { token } = theme.useToken();
return (
<Modal
centered
title={title}
open={open}
okText={okText}
cancelText={cancelText}
onCancel={onCancel}
onOk={() => form.submit()}
confirmLoading={confirmLoading}
styles={{
body: {
paddingTop: token.padding,
},
}}
>
<Form form={form} layout="vertical" scrollToFirstError onFinish={onFinish}>
{children}
</Form>
</Modal>
);
}
-11
View File
@@ -1,11 +0,0 @@
export function GitHub({ size }: { size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.833.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
/>
</svg>
);
}
-21
View File
@@ -1,21 +0,0 @@
export function Theme({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 3l0 18" />
<path d="M12 9l4.65 -4.65" />
<path d="M12 14.3l7.37 -7.37" />
<path d="M12 19.6l8.85 -8.85" />
</svg>
);
}
-2
View File
@@ -1,2 +0,0 @@
export { Theme } from "./Theme";
export { GitHub } from "./GitHub";
@@ -1,39 +0,0 @@
import { Flex, Typography, theme } from "antd";
import { GitHub } from "@/components/Icon";
const ANTD_ADMIN_REPO = "https://github.com/zuiidea/antd-admin";
export function AppFooter() {
const { token } = theme.useToken();
const iconSize = Math.max(12, Math.round(Number(token.fontSizeSM)));
return (
<Flex
align="center"
justify="center"
wrap
gap={4}
style={{
lineHeight: token.lineHeight,
}}
>
<Typography.Text type="secondary" style={{ fontSize: token.fontSizeSM, marginBottom: 0 }}>
Powered by
</Typography.Text>
<a
href={ANTD_ADMIN_REPO}
target="_blank"
rel="noopener noreferrer"
style={{
color: token.colorLink,
display: "inline-flex",
alignItems: "center",
gap: 4,
}}
>
<GitHub size={iconSize} />
antd-admin
</a>
</Flex>
);
}
@@ -1,142 +0,0 @@
import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd";
import type { ItemType } from "antd/es/breadcrumb/Breadcrumb";
import { useSettingsStore } from "@/stores/settings";
import { Link, useLocation, useMatches } from "@tanstack/react-router";
import { Home, PanelLeft, ShieldAlert, Users } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Theme } from "@/components/Icon";
const { Header: AntHeader } = Layout;
const PATH_LABEL: Record<string, string> = {
"/dashboard": "Dashboard",
"/users": "Users",
"/403": "403",
};
function normalizePath(pathname: string): string {
if (pathname === "/") return pathname;
return pathname.replace(/\/+$/, "") || "/";
}
export type HeaderProps = {
/**
* When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned.
* Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins).
*/
showBreadcrumb?: boolean;
};
export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) {
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const location = useLocation();
const matches = useMatches();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const iconSize = token.fontSize;
const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => {
const row = (
<>
<Icon size={iconSize} aria-hidden style={{ flexShrink: 0, opacity: 0.88 }} />
<span>{label}</span>
</>
);
const rowStyle = {
display: "inline-flex" as const,
alignItems: "center" as const,
gap: token.marginXS,
color: "inherit" as const,
};
if (linkTo) {
return (
<Link to={linkTo} style={rowStyle}>
{row}
</Link>
);
}
return <span style={rowStyle}>{row}</span>;
};
const path = normalizePath(location.pathname);
const segments = path.split("/").filter(Boolean);
const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard";
const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard";
const leafIcon: LucideIcon =
firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home;
const leafLabel = leafLabelKey;
const breadcrumbItems: ItemType[] = [];
const onDashboard = path === "/dashboard" || path === "/";
if (onDashboard) {
breadcrumbItems.push({
title: crumb(Home, "Dashboard"),
});
} else {
breadcrumbItems.push({
title: crumb(Home, "Dashboard", "/dashboard"),
});
breadcrumbItems.push({
title: crumb(leafIcon, leafLabel),
});
if (segments.length > 1) {
const tail = segments.slice(1).join(" / ");
if (tail) {
breadcrumbItems.push({ title: tail });
}
}
}
const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined;
const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true;
const showBreadcrumb =
showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0;
return (
<AntHeader
style={{
background: "transparent",
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: `0 ${token.padding}px`,
gap: token.sizeLG,
display: "flex",
}}
>
<Flex align="center" flex={1} style={{ minWidth: 0 }}>
{isMobile ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
/>
) : null}
{showBreadcrumb ? (
<>
{isMobile ? <Divider vertical /> : null}
<Breadcrumb items={breadcrumbItems} />
</>
) : null}
</Flex>
<Space>
<Button
type="text"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label="Toggle Theme"
/>
</Space>
</AntHeader>
);
}
@@ -1,39 +0,0 @@
import { Layout, theme, Flex } from "antd";
import { Outlet } from "@tanstack/react-router";
import { Sidebar } from "../Sidebar";
import { Header } from "../Header";
const { Content } = Layout;
export function MainLayout() {
const { token } = theme.useToken();
return (
<Layout style={{ minHeight: "100vh" }}>
<Sidebar />
<Flex
vertical
style={{
flex: 1,
minWidth: 0,
minHeight: "100vh",
}}
>
<Header />
<Content
className="main-layout-main"
style={{
flex: 1,
padding: token.paddingLG,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "auto",
}}
>
<Outlet />
</Content>
</Flex>
</Layout>
);
}
@@ -1,62 +0,0 @@
/* Allow collapsed Menu popup submenus to overflow the Sider rail */
.ant-layout-sider-collapsed > .ant-layout-sider-children {
overflow: visible !important;
}
.sidebar-collapsed-brand {
position: relative;
width: min(40px, 100%);
max-width: 100%;
aspect-ratio: 1;
height: auto;
flex-shrink: 1;
min-width: 0;
border-radius: var(--ant-border-radius-lg, 12px);
transition: background-color 0.2s ease;
box-sizing: border-box;
}
.sidebar-collapsed-brand__logoLayer,
.sidebar-collapsed-brand__toggleLayer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.sidebar-collapsed-brand__logoLayer {
opacity: 1;
transition: opacity 0.2s ease;
pointer-events: none;
}
.sidebar-collapsed-brand__toggleLayer {
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__logoLayer {
opacity: 0;
}
.sidebar-collapsed-brand:hover .sidebar-collapsed-brand__toggleLayer {
opacity: 1;
pointer-events: auto;
}
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__logoLayer {
opacity: 0;
}
.sidebar-collapsed-brand:focus-within .sidebar-collapsed-brand__toggleLayer {
opacity: 1;
pointer-events: auto;
}
.sidebar-collapsed-brand__toggle.ant-btn {
width: 100%;
height: 100%;
margin: 0;
}
@@ -1,401 +0,0 @@
import { Menu, Layout, theme, Flex, Grid, Drawer, Button } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useLocation } from "@tanstack/react-router";
import {
Book,
Briefcase,
CircleDashed,
Folder,
Home,
PanelLeft,
SlidersHorizontal,
Star,
User,
Users,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import type { MenuItem as MenuItemType } from "@/api/schemas";
import type { MenuProps } from "antd";
import { UserMenu } from "../UserMenu";
import "./index.css";
const { Sider } = Layout;
/** API menu `name` → English labels for known keys; unknown keys pass through as `menu.name`. */
const MENU_LABELS: Record<string, string> = {
Platform: "Platform",
Projects: "Projects",
Dashboard: "Dashboard",
Users: "Users",
"Design Engineering": "Design Engineering",
"Sales & Marketing": "Sales & Marketing",
};
type AntMenuItem = Required<MenuProps>["items"][number];
type BuildMenuResult = {
items: AntMenuItem[];
keyToPath: Record<string, string>;
pathToKeyChain: Record<string, string[]>;
};
const MENU_ICON_MAP: Record<string, LucideIcon> = {
IconLucideLayoutDashboard: Home,
IconLucideUsers: User,
IconLucideUserList: Users,
/** Back-compat for older menu payloads still using IconLucideHistory */
IconLucideHistory: Users,
IconLucideStar: Star,
IconLucideSettings: SlidersHorizontal,
IconLucideBriefcase: Briefcase,
IconLucideBookOpen: Book,
IconLucideFolderKanban: Folder,
IconLucideSparkles: Zap,
};
function renderMenuIcon(icon: string | null, size = 16) {
const Icon = (icon && MENU_ICON_MAP[icon]) || CircleDashed;
return <Icon size={size} />;
}
function buildMenuItems(
menus: MenuItemType[],
token: ReturnType<typeof theme.useToken>["token"],
collapsed = false,
iconSize = 16,
parentKeys: string[] = [],
): BuildMenuResult {
const sorted = menus.filter((m) => !m.hidden).sort((a, b) => a.sort - b.sort);
const keyToPath: Record<string, string> = {};
const pathToKeyChain: Record<string, string[]> = {};
const items: AntMenuItem[] = [];
for (const menu of sorted) {
const label = MENU_LABELS[menu.name] ?? menu.name;
const key = menu.id;
if (menu.kind === "group") {
const built = buildMenuItems(menu.children, token, collapsed, iconSize, parentKeys);
Object.assign(keyToPath, built.keyToPath);
Object.assign(pathToKeyChain, built.pathToKeyChain);
if (built.items.length > 0) {
items.push({
type: "group",
key,
label: collapsed ? (
<span
style={{
width: "100%",
display: "inline-flex",
justifyContent: "center",
}}
>
<span
aria-label={String(label)}
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: token.colorTextQuaternary,
display: "inline-block",
}}
/>
</span>
) : (
<span
style={{
fontSize: token.fontSizeSM,
color: token.colorTextQuaternary,
}}
>
{label}
</span>
),
children: built.items,
});
}
continue;
}
const nextParents = [...parentKeys, key];
keyToPath[key] = menu.path;
const existing = pathToKeyChain[menu.path];
if (!existing || nextParents.length > existing.length) {
pathToKeyChain[menu.path] = nextParents;
}
let children: AntMenuItem[] | undefined;
if (menu.children?.length) {
const built = buildMenuItems(menu.children, token, collapsed, iconSize, nextParents);
Object.assign(keyToPath, built.keyToPath);
Object.assign(pathToKeyChain, built.pathToKeyChain);
children = built.items.length ? built.items : undefined;
}
items.push({
key,
label,
icon: renderMenuIcon(menu.icon, iconSize),
children,
});
}
return { items, keyToPath, pathToKeyChain };
}
export function Sidebar() {
const menus = useAuthStore((s) => s.menus);
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const collapsed = useSettingsStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed);
const toggleSidebar = useSettingsStore((s) => s.toggleSidebar);
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.lg;
const mobileOpen = collapsed;
const builtMenu = useMemo(
() => buildMenuItems(menus, token, !isMobile && collapsed, token.size),
[menus, token, isMobile, collapsed, token.size],
);
const { selectedKey, routeOpenKeys } = useMemo(() => {
const chain = builtMenu.pathToKeyChain[location.pathname] ?? [];
return { selectedKey: chain.at(-1), routeOpenKeys: chain.slice(0, -1) };
}, [builtMenu, location.pathname]);
const routeOpenKeysSig = routeOpenKeys.join("\0");
const [openKeys, setOpenKeys] = useState<string[]>([]);
useEffect(() => {
if (isMobile) {
setSidebarCollapsed(false);
}
}, [isMobile, setSidebarCollapsed]);
/** Merge open keys required by the route with any submenu the user already expanded. */
useEffect(() => {
setOpenKeys((prev) => {
const next = new Set(prev);
for (const k of routeOpenKeys) next.add(k);
return [...next];
});
}, [location.pathname, routeOpenKeysSig]);
const userMenuItems: MenuProps["items"] = [
{
key: "logout",
label: "Sign Out",
onClick: () => {
if (isMobile) {
setSidebarCollapsed(false);
}
logout();
void navigate({ to: "/login" });
},
},
];
const sidebarContent = (isCollapsed: boolean, omitBrandToggle = false) => (
<Flex
vertical
style={{
height: "100%",
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<Flex
vertical
justify="center"
align={isCollapsed ? "center" : "stretch"}
style={{
paddingBlock: token.paddingSM,
/* Collapsed rail is 64px: keep horizontal padding minimal so 40px brand / icons are not clipped by Sider overflow. */
paddingInline: isCollapsed ? token.paddingXXS : token.paddingSM,
minHeight: 64,
flexShrink: 0,
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<Flex
align="center"
gap={token.marginSM}
style={{
width: "100%",
minWidth: 0,
maxWidth: "100%",
boxSizing: "border-box",
/* Collapsed: outer row already has paddingSM; extra inline padding would clip the 40px brand box. */
paddingInline: isCollapsed ? 0 : token.paddingXS,
minHeight: 40,
justifyContent: isCollapsed ? "center" : "flex-start",
}}
>
{isCollapsed ? (
<div className="sidebar-collapsed-brand">
<div className="sidebar-collapsed-brand__logoLayer">
<img
src={APP_FAVICON_SRC}
alt="logo"
width={24}
height={24}
style={{
borderRadius: token.borderRadius,
display: "block",
objectFit: "contain",
}}
/>
</div>
<div className="sidebar-collapsed-brand__toggleLayer">
<Button
type="text"
size="small"
className="sidebar-collapsed-brand__toggle"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
/>
</div>
</div>
) : (
<>
<Flex
align="center"
gap={token.marginSM}
style={{
minWidth: 0,
flex: 1,
overflow: "hidden",
}}
>
<img
src={APP_FAVICON_SRC}
alt="logo"
width={24}
height={24}
style={{
borderRadius: token.borderRadius,
display: "block",
flexShrink: 0,
objectFit: "contain",
}}
/>
<div
style={{
minWidth: 0,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
fontWeight: 600,
fontSize: token.fontSizeLG,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</div>
</Flex>
{!omitBrandToggle ? (
<Button
type="text"
size="small"
onClick={toggleSidebar}
icon={<PanelLeft size={token.size} />}
aria-label="Toggle sidebar"
style={{ flexShrink: 0 }}
/>
) : null}
</>
)}
</Flex>
</Flex>
<Menu
mode="inline"
selectedKeys={selectedKey ? [selectedKey] : []}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
items={builtMenu.items}
getPopupContainer={() => document.body}
onClick={({ key }) => {
const path = builtMenu.keyToPath[String(key)];
if (!path) return;
if (isMobile) {
setSidebarCollapsed(false);
}
void navigate({ to: path });
}}
style={{
borderRight: "none",
flex: 1,
minWidth: 0,
maxWidth: "100%",
width: "100%",
boxSizing: "border-box",
overflowX: "hidden",
overflowY: "auto",
background: "transparent",
}}
/>
<UserMenu collapsed={isCollapsed} user={user} userMenuItems={userMenuItems} />
</Flex>
);
if (isMobile) {
return (
<Drawer
open={mobileOpen}
placement="left"
onClose={() => setSidebarCollapsed(false)}
size={320}
styles={{
body: {
padding: 0,
background: token.colorBgLayout,
overflow: "hidden",
maxWidth: "100%",
boxSizing: "border-box",
},
header: { display: "none" },
mask: { opacity: 0.5 },
}}
>
{sidebarContent(false, true)}
</Drawer>
);
}
return (
<Sider
key={collapsed ? "collapsed" : "expanded"}
theme="light"
collapsible
collapsed={collapsed}
trigger={null}
width={240}
collapsedWidth={64}
breakpoint="lg"
onBreakpoint={(broken) => {
if (broken) {
setSidebarCollapsed(false);
}
}}
style={{
borderRight: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgLayout,
alignSelf: "stretch",
minHeight: "100vh",
overflow: "visible",
}}
>
{sidebarContent(collapsed)}
</Sider>
);
}
@@ -1,7 +0,0 @@
.user-menu-trigger {
transition: background-color 0.2s ease;
}
.user-menu-trigger:hover {
background-color: var(--user-menu-hover-bg);
}
@@ -1,103 +0,0 @@
import { Avatar, Button, Dropdown, Flex, Typography, theme } from "antd";
import type { MenuProps } from "antd";
import type { User } from "@/api/schemas";
import { MoreVertical } from "lucide-react";
import "./index.css";
const { Text } = Typography;
interface UserMenuProps {
collapsed: boolean;
user: User | null;
userMenuItems: MenuProps["items"];
}
export function UserMenu({ collapsed, user, userMenuItems }: UserMenuProps) {
const { token } = theme.useToken();
const avatarSrc = (user?.avatar ?? "").trim() || undefined;
const avatarSize = collapsed ? 32 : token.controlHeight;
const avatar = (
<Avatar
size={avatarSize}
src={avatarSrc || undefined}
shape="circle"
style={{
flexShrink: 0,
width: avatarSize,
}}
>
{user?.username?.[0]?.toUpperCase()}
</Avatar>
);
const moreButton = (
<Button type="text" size="small" icon={<MoreVertical size={token.fontSize} />} />
);
return (
<Dropdown menu={{ items: userMenuItems }} trigger={["click"]}>
<div
style={{
margin: token.marginXS,
}}
>
<Flex
className="user-menu-trigger"
align="center"
justify="flex-start"
gap={token.marginSM}
style={{
width: "100%",
cursor: "pointer",
borderRadius: token.borderRadius,
padding: token.paddingXS,
["--user-menu-hover-bg" as string]: token.colorFillTertiary,
}}
>
{avatar}
{!collapsed ? (
<>
<Flex
vertical
justify="center"
gap={2}
style={{
flex: "1 1 0%",
minWidth: 0,
maxWidth: "100%",
overflow: "hidden",
}}
>
<Text
ellipsis
style={{
lineHeight: 1,
display: "block",
maxWidth: "100%",
}}
>
{user?.username ?? "—"}
</Text>
<Text
ellipsis
style={{
lineHeight: 1,
display: "block",
maxWidth: "100%",
fontSize: token.fontSizeSM,
color: token.colorTextQuaternary,
}}
>
{user?.email ?? "—"}
</Text>
</Flex>
{moreButton}
</>
) : null}
</Flex>
</div>
</Dropdown>
);
}
@@ -1,68 +0,0 @@
import { useNavigate } from "@tanstack/react-router";
import { ArrowLeft, Home, SearchX } from "lucide-react";
import { Button, Flex, Result, Space, theme } from "antd";
/** Shared 404 UI for `/404` route and root `notFoundComponent`. */
export function NotFound() {
const navigate = useNavigate();
const { token } = theme.useToken();
const goDashboard = () => {
void navigate({ to: "/dashboard" });
};
const goBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) {
window.history.back();
} else {
goDashboard();
}
};
return (
<Flex
vertical
style={{
/* Lock to viewport so tall Result + padding cannot grow past 100vh and scroll the page */
height: "100dvh",
maxHeight: "100dvh",
overflow: "hidden",
boxSizing: "border-box",
background: token.colorBgLayout,
color: token.colorText,
}}
>
<Flex
flex={1}
align="center"
justify="center"
style={{
minHeight: 0,
width: "100%",
padding: token.paddingXL,
overflow: "hidden",
boxSizing: "border-box",
}}
>
<Result
// Avoid status="404"|"403"|"500": antd ignores `icon` and shows built-in art; keep Lucide as the main icon.
icon={
<SearchX size={64} strokeWidth={1.25} color={token.colorTextQuaternary} aria-hidden />
}
title="404"
subTitle="Sorry, the page you visited does not exist."
extra={
<Space wrap size="middle">
<Button type="primary" icon={<Home size={16} aria-hidden />} onClick={goDashboard}>
Back to Home
</Button>
<Button icon={<ArrowLeft size={16} aria-hidden />} onClick={goBack}>
Go back
</Button>
</Space>
}
/>
</Flex>
</Flex>
);
}
-24
View File
@@ -1,24 +0,0 @@
import { Button, Result, theme } from "antd";
import { useRouter, type ErrorComponentProps } from "@tanstack/react-router";
export function RouteError({ error }: ErrorComponentProps) {
const router = useRouter();
const { token } = theme.useToken();
const detail = error instanceof Error ? error.message : String(error);
return (
<div style={{ padding: token.paddingLG, maxWidth: 560, margin: "48px auto" }}>
<Result
status="error"
title="Something went wrong"
subTitle={detail || "Please try again or return to the dashboard."}
extra={
<Button type="primary" onClick={() => void router.invalidate()}>
Retry
</Button>
}
/>
</div>
);
}
-119
View File
@@ -1,119 +0,0 @@
import { theme } from "antd";
import type { ConfigProviderProps, ThemeConfig } from "antd";
/**
* Common theme token builders
* Reduces duplication between light and dark theme definitions
*/
export const SHARED_DESIGN_TOKENS = {
fontFamily:
"ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
borderRadius: 8,
borderRadiusSM: 6,
borderRadiusLG: 12,
} as const;
export const CONTROL_COMPONENTS = {
Button: {
primaryShadow: "none",
defaultShadow: "none",
dangerShadow: "none",
},
Input: {
activeShadow: "none",
},
} as const;
/**
* Calculate table row selected color based on theme algorithm
*/
export function buildTableTokens(cfg: ThemeConfig) {
const rowSelectedBg = theme.getDesignToken(cfg).colorFillAlter;
return {
rowSelectedBg,
rowSelectedHoverBg: rowSelectedBg,
};
}
/**
* Build menu tokens for light theme
*/
export const MENU_LIGHT = {
itemSelectedBg: "rgba(0, 0, 0, 0.06)",
itemSelectedColor: "rgba(0, 0, 0, 0.88)",
subMenuItemSelectedColor: "rgba(0, 0, 0, 0.88)",
itemHoverBg: "rgba(0, 0, 0, 0.04)",
itemHoverColor: "rgba(0, 0, 0, 0.88)",
itemActiveBg: "rgba(0, 0, 0, 0.08)",
itemBorderRadius: SHARED_DESIGN_TOKENS.borderRadiusSM,
itemMarginInline: 8,
horizontalItemSelectedColor: "rgba(0, 0, 0, 0.88)",
horizontalItemHoverColor: "rgba(0, 0, 0, 0.88)",
} as const;
/**
* Build menu tokens for dark theme
*/
export const MENU_DARK = {
itemSelectedBg: "rgba(255, 255, 255, 0.08)",
itemSelectedColor: "rgba(255, 255, 255, 0.85)",
subMenuItemSelectedColor: "rgba(255, 255, 255, 0.85)",
itemHoverBg: "rgba(255, 255, 255, 0.06)",
itemHoverColor: "rgba(255, 255, 255, 0.85)",
itemActiveBg: "rgba(255, 255, 255, 0.1)",
itemBorderRadius: SHARED_DESIGN_TOKENS.borderRadiusSM,
itemMarginInline: 8,
horizontalItemSelectedColor: "rgba(255, 255, 255, 0.85)",
horizontalItemHoverColor: "rgba(255, 255, 255, 0.85)",
} as const;
/**
* Build light theme config
*/
export function buildLightThemeConfig(): ConfigProviderProps {
const lightSeed: ThemeConfig["token"] = {
colorBgLayout: "#ffffff",
...SHARED_DESIGN_TOKENS,
};
return {
theme: {
algorithm: theme.defaultAlgorithm,
token: lightSeed,
components: {
...CONTROL_COMPONENTS,
Table: buildTableTokens({
algorithm: theme.defaultAlgorithm,
token: lightSeed,
}),
Menu: MENU_LIGHT,
},
},
};
}
/**
* Build dark theme config
*/
export function buildDarkThemeConfig(): ConfigProviderProps {
const darkSeed: ThemeConfig["token"] = {
...SHARED_DESIGN_TOKENS,
};
return {
theme: {
algorithm: theme.darkAlgorithm,
token: darkSeed,
components: {
...CONTROL_COMPONENTS,
Table: buildTableTokens({
algorithm: theme.darkAlgorithm,
token: darkSeed,
}),
Menu: MENU_DARK,
},
},
};
}
-14
View File
@@ -1,14 +0,0 @@
import { useMemo } from "react";
import type { ConfigProviderProps } from "antd";
import { buildLightThemeConfig, buildDarkThemeConfig } from "./tokenBuilders";
import { useSettingsStore } from "@/stores/settings";
/**
* Theme selection for ConfigProvider: light/dark share radius, font, control tweaks.
* @see https://ant.design/docs/react/customize-theme
*/
export function useAppTheme(): ConfigProviderProps {
const darkMode = useSettingsStore((s) => s.darkMode);
return useMemo(() => (darkMode ? buildDarkThemeConfig() : buildLightThemeConfig()), [darkMode]);
}
-66
View File
@@ -1,66 +0,0 @@
import { useMemo } from "react";
import type { MessageInstance } from "antd/es/message/interface";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type CrudToastLifecycles<TCreate, TUpdate> = {
createLifecycle?: MutationLifecycle<TCreate>;
updateLifecycle?: MutationLifecycle<TUpdate>;
deleteLifecycle?: MutationLifecycle<string>;
};
export function useCrudToasts<TCreate, TUpdate>(options: {
message: MessageInstance;
resourceKey: string;
}): CrudToastLifecycles<TCreate, TUpdate> {
const { message, resourceKey } = options;
const keys = useMemo(
() => ({
create: `${resourceKey}-mutation-create`,
update: `${resourceKey}-mutation-update`,
delete: `${resourceKey}-mutation-delete`,
}),
[resourceKey],
);
return useMemo(
() => ({
createLifecycle: {
onSuccess: () => {
message.success({ content: "Created successfully", key: keys.create });
},
onError: () => {
message.error({ content: "Create failed", key: keys.create });
},
},
updateLifecycle: {
onMutate: () => {
message.loading({ content: "Updating…", key: keys.update, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Updated successfully", key: keys.update });
},
onError: () => {
message.error({ content: "Update failed", key: keys.update });
},
},
deleteLifecycle: {
onMutate: () => {
message.loading({ content: "Deleting…", key: keys.delete, duration: 0 });
},
onSuccess: () => {
message.success({ content: "Deleted successfully", key: keys.delete });
},
onError: () => {
message.error({ content: "Delete failed", key: keys.delete });
},
},
}),
[keys.create, keys.delete, keys.update, message],
);
}
-10
View File
@@ -1,10 +0,0 @@
import { useAuthStore } from "@/stores/auth";
export function hasPermission(point: string): boolean {
return useAuthStore.getState().hasPermission(point);
}
export function usePermission(point: string): boolean {
const user = useAuthStore((s) => s.user);
return user?.permissions.includes(point) ?? false;
}
@@ -1,67 +0,0 @@
import { describe, expect, it } from "vite-plus/test";
import { applyOptimisticListDelete, applyOptimisticListUpdate } from "./useResourceCRUD";
type Row = { id: string; title: string; n: number };
describe("applyOptimisticListUpdate", () => {
it("merges patch into the matching row by id", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListUpdate(data, { id: "2", title: "B2" });
expect(next.list).toEqual([
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B2", n: 2 },
]);
expect(next.total).toBe(2);
});
});
describe("applyOptimisticListDelete", () => {
it("removes row and decrements total", () => {
const data: { list: Row[]; total: number } = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([{ id: "2", title: "B", n: 2 }]);
expect(next.total).toBe(1);
});
it("does not take total below zero", () => {
const data = { list: [{ id: "1", title: "A", n: 0 }] as Row[], total: 1 };
const next = applyOptimisticListDelete(data, "1");
expect(next.list).toEqual([]);
expect(next.total).toBe(0);
});
});
describe("rollback contract (QueryClient simulation)", () => {
it("restore snapshot after failed optimistic update", () => {
const key = ["items", 10, 0] as const;
const before = {
list: [
{ id: "1", title: "A", n: 1 },
{ id: "2", title: "B", n: 2 },
],
total: 2,
};
const snapshot = structuredClone(before);
let cache: typeof before = structuredClone(before);
const setData = (next: typeof before) => {
cache = next;
};
setData(applyOptimisticListUpdate(cache, { id: "2", title: "BAD" }));
expect(cache.list[1]?.title).toBe("BAD");
setData(snapshot);
expect(cache).toEqual(before);
expect(key[0]).toBe("items");
});
});
-179
View File
@@ -1,179 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
type MutationLifecycle<TValues> = {
onMutate?: (values: TValues) => void;
onSuccess?: (values: TValues) => void;
onError?: (values: TValues) => void;
};
export type ResourceListData<TItem extends { id: string }> = {
list: readonly TItem[];
total: number;
};
type OptimisticFlags = {
update?: boolean;
delete?: boolean;
};
type UseResourceCRUDOptions<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
> = {
queryKey: readonly unknown[];
invalidateKey?: readonly unknown[];
queryFn: () => Promise<unknown>;
select: (raw: unknown) => TListData;
createFn: (values: TCreateValues) => Promise<unknown>;
updateFn: (values: TUpdateValues) => Promise<unknown>;
deleteFn: (id: string) => Promise<unknown>;
createLifecycle?: MutationLifecycle<TCreateValues>;
updateLifecycle?: MutationLifecycle<TUpdateValues>;
deleteLifecycle?: MutationLifecycle<string>;
optimistic?: OptimisticFlags;
};
export type ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> = {
data: TListData | undefined;
isLoading: boolean;
createMutation: UseMutationResult<unknown, Error, TCreateValues>;
updateMutation: UseMutationResult<unknown, Error, TUpdateValues>;
deleteMutation: UseMutationResult<unknown, Error, string>;
};
/** @deprecated Prefer `ResourceCRUDResult` — alias kept for plan wording compatibility */
export type CrudResult<TListData, TCreateValues, TUpdateValues> = ResourceCRUDResult<
TListData,
TCreateValues,
TUpdateValues
>;
type RollbackCtx<TListData> = { snapshot: TListData | undefined };
export function applyOptimisticListUpdate<
TItem extends { id: string },
TUpdate extends { id: string },
>(data: ResourceListData<TItem>, values: TUpdate): { list: TItem[]; total: number } {
const { id, ...patch } = values as TUpdate & Record<string, unknown>;
return {
list: data.list.map((row) =>
row.id === id ? ({ ...(row as object), ...patch } as TItem) : row,
),
total: data.total,
};
}
export function applyOptimisticListDelete<TItem extends { id: string }>(
data: ResourceListData<TItem>,
id: string,
): { list: TItem[]; total: number } {
return {
list: data.list.filter((row) => row.id !== id),
total: Math.max(0, data.total - 1),
};
}
export function useResourceCRUD<
TListData extends ResourceListData<{ id: string }>,
TCreateValues,
TUpdateValues extends { id: string },
>(
options: UseResourceCRUDOptions<TListData, TCreateValues, TUpdateValues>,
): ResourceCRUDResult<TListData, TCreateValues, TUpdateValues> {
const queryClient = useQueryClient();
const invalidateKey = options.invalidateKey ?? [options.queryKey[0]];
const queryKey = options.queryKey;
const opt = options.optimistic;
const query = useQuery({
queryKey,
queryFn: options.queryFn,
select: options.select,
});
const createMutation = useMutation({
mutationFn: options.createFn,
onMutate: (values) => {
options.createLifecycle?.onMutate?.(values);
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.createLifecycle?.onSuccess?.(values);
},
onError: (_error, values) => {
options.createLifecycle?.onError?.(values);
},
});
const updateMutation = useMutation({
mutationFn: options.updateFn,
onMutate: async (values): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.update && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListUpdate(
previous as ResourceListData<{ id: string }>,
values as { id: string } & Record<string, unknown>,
) as unknown as TListData,
);
}
options.updateLifecycle?.onMutate?.(values);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, values) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.updateLifecycle?.onSuccess?.(values);
},
onError: (_error, values, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.updateLifecycle?.onError?.(values);
},
});
const deleteMutation = useMutation({
mutationFn: options.deleteFn,
onMutate: async (id): Promise<RollbackCtx<TListData>> => {
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TListData>(queryKey);
const applied = Boolean(opt?.delete && previous);
if (applied) {
queryClient.setQueryData<TListData>(
queryKey,
applyOptimisticListDelete(
previous as ResourceListData<{ id: string }>,
id,
) as unknown as TListData,
);
}
options.deleteLifecycle?.onMutate?.(id);
return { snapshot: applied ? previous : undefined };
},
onSuccess: (_data, id) => {
void queryClient.invalidateQueries({ queryKey: invalidateKey });
options.deleteLifecycle?.onSuccess?.(id);
},
onError: (_error, id, context) => {
const snap = context?.snapshot;
if (snap !== undefined) {
queryClient.setQueryData(queryKey, snap);
}
options.deleteLifecycle?.onError?.(id);
},
});
return {
data: query.data,
isLoading: query.isLoading,
createMutation,
updateMutation,
deleteMutation,
};
}
-133
View File
@@ -1,133 +0,0 @@
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
export type UseTableFitHeightArgs = {
pageShellRef: RefObject<HTMLDivElement | null>;
toolbarRef: RefObject<HTMLDivElement | null>;
middleRef: RefObject<HTMLDivElement | null>;
tableFrameRef: RefObject<HTMLDivElement | null>;
marginLG: number;
rowCount: number;
isLoading: boolean;
showPagination: boolean;
};
export function useTableFitHeight(args: UseTableFitHeightArgs) {
const {
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
marginLG,
rowCount,
isLoading,
showPagination,
} = args;
const tableAvailableRef = useRef(0);
const [tableAreaMaxHeight, setTableAreaMaxHeight] = useState<number | undefined>(() =>
typeof window !== "undefined" ? Math.max(240, Math.floor(window.innerHeight - 280)) : undefined,
);
const [tableScrollY, setTableScrollY] = useState<number | undefined>(() => {
if (typeof window === "undefined") return undefined;
const maxH = Math.max(240, Math.floor(window.innerHeight - 280));
return Math.max(120, maxH - 40);
});
useLayoutEffect(() => {
const shell = pageShellRef.current;
const toolbarEl = toolbarRef.current;
const mid = middleRef.current;
if (!shell || !toolbarEl || !mid) return;
const headReserve = 40;
const rowEstimate = 44;
const paginationBlock = 56;
let rafRetry = 0;
const maxRafRetries = 8;
const measure = () => {
const toolbarRect = toolbarEl.getBoundingClientRect();
const mainEl = shell.closest<HTMLElement>(".main-layout-main");
const mainRect = mainEl?.getBoundingClientRect();
const viewportBottom =
typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
const clipBottom = mainRect
? mainRect.bottom
: Math.min(shell.getBoundingClientRect().bottom, viewportBottom);
const maxMiddleFromShell = Math.max(
0,
Math.floor(clipBottom - toolbarRect.bottom - marginLG),
);
const midHRaw = mid.clientHeight;
const midH = midHRaw > 0 ? Math.min(midHRaw, maxMiddleFromShell) : maxMiddleFromShell;
if (maxMiddleFromShell === 0 && rafRetry < maxRafRetries) {
rafRetry += 1;
requestAnimationFrame(() => requestAnimationFrame(measure));
return;
}
const frameInner = Math.max(0, Math.floor(midH));
tableAvailableRef.current = frameInner;
setTableAreaMaxHeight(frameInner > 0 ? frameInner : undefined);
const pagReserve = showPagination ? paginationBlock : 0;
const bodyMax = Math.max(120, frameInner - headReserve - pagReserve);
if (isLoading && rowCount === 0) {
setTableScrollY(bodyMax);
return;
}
if (rowCount === 0 && !isLoading) {
setTableScrollY(undefined);
return;
}
const naturalBodyH = headReserve + rowCount * rowEstimate;
const naturalTotalH = naturalBodyH + pagReserve;
const slack = 12;
if (naturalTotalH + slack > frameInner) {
setTableScrollY(bodyMax);
return;
}
setTableScrollY(undefined);
requestAnimationFrame(() => {
const frame = tableFrameRef.current;
const avail = tableAvailableRef.current;
const minFrame = headReserve + pagReserve;
if (!frame || avail <= minFrame) return;
if (frame.scrollHeight > frame.clientHeight + 1) {
setTableScrollY(Math.max(120, avail - headReserve - pagReserve));
}
});
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(shell);
ro.observe(mid);
return () => ro.disconnect();
}, [
showPagination,
rowCount,
isLoading,
marginLG,
pageShellRef,
toolbarRef,
middleRef,
tableFrameRef,
]);
return {
tableAreaMaxHeight,
tableScrollY,
lockScrollHeight: tableScrollY != null,
};
}
-29
View File
@@ -1,29 +0,0 @@
import { useCallback, useEffect, useState } from "react";
export function useUrlSearchState<TSearch extends { keyword: string; offset: number }>(args: {
search: TSearch;
setSearch: (next: TSearch) => void;
}) {
const { search, setSearch } = args;
const [keywordInput, setKeywordInput] = useState(search.keyword);
useEffect(() => {
setKeywordInput(search.keyword);
}, [search.keyword]);
const commitKeyword = useCallback(() => {
const trimmed = keywordInput.trim();
setSearch({ ...search, keyword: trimmed, offset: 0 });
}, [keywordInput, search, setSearch]);
const applyKeyword = useCallback(
(kw: string) => {
const trimmed = kw.trim();
setKeywordInput(trimmed);
setSearch({ ...search, keyword: trimmed, offset: 0 });
},
[search, setSearch],
);
return { keywordInput, setKeywordInput, commitKeyword, applyKeyword };
}
-44
View File
@@ -1,44 +0,0 @@
:root {
--app-scrollbar-size: 8px;
--app-scrollbar-track: rgba(0, 0, 0, 0);
--app-scrollbar-thumb: rgba(0, 0, 0, 0.22);
--app-scrollbar-thumb-hover: rgba(0, 0, 0, 0.38);
}
:root[data-theme="dark"] {
--app-scrollbar-track: rgba(255, 255, 255, 0);
--app-scrollbar-thumb: rgba(255, 255, 255, 0.22);
--app-scrollbar-thumb-hover: rgba(255, 255, 255, 0.38);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--app-scrollbar-thumb) var(--app-scrollbar-track);
}
/* Chromium / Safari / Edge */
*::-webkit-scrollbar {
width: var(--app-scrollbar-size);
height: var(--app-scrollbar-size);
}
*::-webkit-scrollbar-track {
background: var(--app-scrollbar-track);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb {
background: var(--app-scrollbar-thumb);
border-radius: 9999px;
border: 2px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--app-scrollbar-thumb-hover);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
-46
View File
@@ -1,46 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { useAuthStore } from "./stores/auth";
import { useSettingsStore } from "./stores/settings";
import { hydrateSession } from "./api/auth";
import { installHttpRouter } from "./api/request";
const router = createRouter({ routeTree });
installHttpRouter(router);
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
async function enableMocking() {
const enableMockInBuild = import.meta.env.VITE_ENABLE_MOCK === "true";
if (!import.meta.env.DEV && !enableMockInBuild) return;
const { worker } = await import("./mocks/browser");
return worker.start({ onUnhandledRequest: "bypass" });
}
enableMocking()
.then(async () => {
await Promise.all([useSettingsStore.persist.rehydrate(), useAuthStore.persist.rehydrate()]);
const { isAuthenticated, tokens } = useAuthStore.getState();
if (isAuthenticated && tokens) {
try {
await hydrateSession();
} catch {
useAuthStore.getState().logout();
}
}
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
);
})
.catch((err) => {
console.error("Failed to initialize app:", err);
});
-4
View File
@@ -1,4 +0,0 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
@@ -1,71 +0,0 @@
import { describe, expect, it } from "vite-plus/test";
import { z } from "zod/v4";
import {
paginatedListSchema,
paginatedWithSchema,
successWithNullBody,
successWithSchema,
} from "./createHandler";
import { UserSchema } from "@/api/schemas";
describe("successWithSchema", () => {
it("throws ZodError when payload does not match schema (mock drift surfaces here)", () => {
expect(() =>
successWithSchema(UserSchema, {
id: "1",
username: "x",
} as unknown),
).toThrow(z.ZodError);
});
it("returns JSON body with parsed data when valid", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = successWithSchema(UserSchema, row);
const body = (await res.json()) as { code: number; data: typeof row };
expect(body.code).toBe(0);
expect(body.data.username).toBe("u");
});
});
describe("paginatedListSchema / paginatedWithSchema", () => {
it("rejects invalid list shape", () => {
const schema = paginatedListSchema(UserSchema);
expect(() =>
schema.parse({
list: [{ id: "1" }],
total: 1,
}),
).toThrow(z.ZodError);
});
it("accepts valid paginated payload via paginatedWithSchema", async () => {
const row = {
id: "1",
username: "u",
avatar: null,
email: null,
roles: [] as string[],
permissions: [] as string[],
};
const res = paginatedWithSchema(UserSchema, { list: [row], total: 1 });
const body = (await res.json()) as { data: { list: (typeof row)[]; total: number } };
expect(body.data.total).toBe(1);
expect(body.data.list).toHaveLength(1);
});
});
describe("successWithNullBody", () => {
it("wraps null data with schema validation", async () => {
const res = successWithNullBody();
const body = (await res.json()) as { code: number; data: null };
expect(body.data).toBeNull();
expect(body.code).toBe(0);
});
});
-69
View File
@@ -1,69 +0,0 @@
import { HttpResponse, delay as mswDelay } from "msw";
import { z } from "zod/v4";
import type { ZodTypeAny } from "zod/v4";
export interface ApiResponse<T> {
code: number;
data: T;
message: string;
}
export async function withDelay(ms: number = 200): Promise<void> {
await mswDelay(ms);
}
export function successResponse<T>(data: T, message: string = "ok") {
return HttpResponse.json({
code: 0,
data,
message,
});
}
/** Parse `data` with Zod before wrapping — catches mock ↔ contract drift early. */
export function successWithSchema<T extends ZodTypeAny>(
schema: T,
data: unknown,
message: string = "ok",
) {
const parsed = schema.parse(data);
return successResponse(parsed, message);
}
export function paginatedListSchema<T extends ZodTypeAny>(itemSchema: T) {
return z.object({
list: z.array(itemSchema),
total: z.number().int().nonnegative(),
});
}
export function paginatedWithSchema<T extends ZodTypeAny>(
itemSchema: T,
data: unknown,
message: string = "ok",
) {
return successWithSchema(paginatedListSchema(itemSchema), data, message);
}
const NullBodySchema = z.null();
export function successWithNullBody(message: string = "ok") {
return successWithSchema(NullBodySchema, null, message);
}
export function errorResponse(code: number, message: string, data: unknown = null) {
return HttpResponse.json({
code,
data,
message,
});
}
export const ERROR_CODES = {
INVALID_CREDENTIALS: 1001,
NOT_FOUND: 1002,
UNAUTHORIZED: 1003,
FORBIDDEN: 1004,
BAD_REQUEST: 1005,
INTERNAL_ERROR: 1006,
} as const;
-42
View File
@@ -1,42 +0,0 @@
import type { User } from "@/api/schemas";
import { vercelAvatarUrl } from "./utils";
const MOCK_IDENTITIES: ReadonlyArray<[string, string]> = [
["admin", "ops.admin@northstar.io"],
["zhao.ming", "zhao.ming@northstar.io"],
["sarah.chen", "sarah.chen@northstar.io"],
["james.park", "james.park@northstar.io"],
["emma.garcia", "emma.garcia@northstar.io"],
["ryan.kim", "ryan.kim@northstar.io"],
["olivia.tanaka", "olivia.tanaka@northstar.io"],
["liam.patel", "liam.patel@northstar.io"],
["ava.nguyen", "ava.nguyen@northstar.io"],
["noah.berg", "noah.berg@northstar.io"],
["mia.silva", "mia.silva@northstar.io"],
["lucas.rossi", "lucas.rossi@northstar.io"],
["isabella.ali", "isabella.ali@northstar.io"],
["ethan.kovacs", "ethan.kovacs@northstar.io"],
["sophia.dubois", "sophia.dubois@northstar.io"],
["mateo.santos", "mateo.santos@northstar.io"],
["harper.ivanov", "harper.ivanov@northstar.io"],
["elijah.mohamed", "elijah.mohamed@northstar.io"],
["amelia.schmidt", "amelia.schmidt@northstar.io"],
["henry.okafor", "henry.okafor@northstar.io"],
];
export const GUEST_AUTH_USER_BODY = {
id: "99",
username: "guest",
avatar: null as null,
email: "guest@example.com",
roles: [] as string[],
};
export const MOCK_USERS: User[] = MOCK_IDENTITIES.map(([username, email], i) => ({
id: String(i + 1),
username,
avatar: vercelAvatarUrl(username),
email,
roles: i === 0 ? ["admin"] : ["editor"],
permissions: i === 0 ? ["user:view", "user:create", "user:edit", "user:delete"] : ["user:view"],
}));
-160
View File
@@ -1,160 +0,0 @@
import { http } from "msw";
import { MOCK_USERS, GUEST_AUTH_USER_BODY } from "../data";
import {
withDelay,
errorResponse,
ERROR_CODES,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import {
AuthTokensSchema,
AuthUserResponseSchema,
LoginRequestSchema,
PermissionsListSchema,
RefreshTokenRequestSchema,
RegisterRequestSchema,
} from "@/api/schemas";
import { AUTH_ENDPOINTS } from "@/api/auth/paths";
const GUEST_REFRESH = "mock-guest-refresh";
const REGISTERED_ACCESS = "mock-registered-access";
const REGISTERED_REFRESH = "mock-registered-refresh";
/** Last successful register (in-memory) so /user matches the new account in MSW */
let mockRegisteredSession: { username: string; email: string | null } | null = null;
function isGuestAuth(authorization: string | null): boolean {
return (authorization ?? "").includes("mock-guest-access");
}
function isRegisteredAuth(authorization: string | null): boolean {
return (authorization ?? "").includes(REGISTERED_ACCESS);
}
export const authHandlers = [
http.post(AUTH_ENDPOINTS.login, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = LoginRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid login body");
}
const { username, password } = parsed.data;
if (username === "guest" && password === "guest") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access",
refreshToken: GUEST_REFRESH,
});
}
if (username === "admin" && password === "admin") {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-access-token",
refreshToken: "mock-refresh-token",
});
}
return errorResponse(ERROR_CODES.INVALID_CREDENTIALS, "Invalid username or password");
}),
http.post(AUTH_ENDPOINTS.register, async ({ request }) => {
await withDelay(300);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RegisterRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid register body");
}
const { username, password, email } = parsed.data;
if (password.length < 6) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Password must be at least 6 characters");
}
const taken = username === "guest" || MOCK_USERS.some((u) => u.username === username);
if (taken) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Username already taken");
}
mockRegisteredSession = {
username,
email: email ?? null,
};
return successWithSchema(AuthTokensSchema, {
accessToken: REGISTERED_ACCESS,
refreshToken: REGISTERED_REFRESH,
});
}),
http.post(AUTH_ENDPOINTS.refresh, async ({ request }) => {
await withDelay(100);
let json: unknown;
try {
json = await request.json();
} catch {
return errorResponse(ERROR_CODES.BAD_REQUEST, "Invalid JSON body");
}
const parsed = RefreshTokenRequestSchema.safeParse(json);
if (!parsed.success) {
return errorResponse(ERROR_CODES.BAD_REQUEST, "refreshToken is required");
}
if (parsed.data.refreshToken === GUEST_REFRESH) {
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-guest-access-refreshed",
refreshToken: "mock-guest-refresh-refreshed",
});
}
if (
parsed.data.refreshToken === REGISTERED_REFRESH ||
parsed.data.refreshToken.startsWith(`${REGISTERED_REFRESH}-`)
) {
return successWithSchema(AuthTokensSchema, {
accessToken: `${REGISTERED_ACCESS}-refreshed`,
refreshToken: `${REGISTERED_REFRESH}-refreshed`,
});
}
return successWithSchema(AuthTokensSchema, {
accessToken: "mock-new-access-token",
refreshToken: "mock-new-refresh-token",
});
}),
http.post(AUTH_ENDPOINTS.logout, () => successWithNullBody()),
http.get(AUTH_ENDPOINTS.user, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth)) {
const session = mockRegisteredSession;
if (session) {
return successWithSchema(AuthUserResponseSchema, {
id: "reg-mock",
username: session.username,
avatar: null,
email: session.email,
roles: ["editor"],
});
}
}
if (isGuestAuth(auth)) {
return successWithSchema(AuthUserResponseSchema, GUEST_AUTH_USER_BODY);
}
const { permissions: _permissions, ...userWithoutPermissions } = MOCK_USERS[0]!;
return successWithSchema(AuthUserResponseSchema, userWithoutPermissions);
}),
http.get(AUTH_ENDPOINTS.permissions, ({ request }) => {
const auth = request.headers.get("authorization");
if (isRegisteredAuth(auth) && mockRegisteredSession) {
return successWithSchema(PermissionsListSchema, ["user:view"] as string[]);
}
if (isGuestAuth(auth)) {
return successWithSchema(PermissionsListSchema, [] as string[]);
}
return successWithSchema(PermissionsListSchema, MOCK_USERS[0]!.permissions);
}),
];
-4
View File
@@ -1,4 +0,0 @@
import { authHandlers } from "./auth";
import { userHandlers } from "./user";
export const handlers = [...authHandlers, ...userHandlers];
-68
View File
@@ -1,68 +0,0 @@
import { http, HttpResponse } from "msw";
import { UserSchema } from "@/api/schemas";
import { MOCK_USERS } from "../data";
import { filterUsers, paginateList, parsePaginationParams } from "../utils";
import {
withDelay,
errorResponse,
ERROR_CODES,
paginatedWithSchema,
successWithSchema,
successWithNullBody,
} from "../createHandler";
import { USERS_MEMBER_PATH, USERS_PATH } from "@/api/users";
let users = [...MOCK_USERS];
export const EXPIRED_ACCESS_TOKEN = "__EXPIRED_ACCESS__";
export const userHandlers = [
http.get(USERS_PATH, async ({ request }) => {
await withDelay(200);
const auth = request.headers.get("authorization");
if (auth === `Bearer ${EXPIRED_ACCESS_TOKEN}`) {
return new HttpResponse(null, { status: 401 });
}
const url = new URL(request.url);
const { limit, offset } = parsePaginationParams(url.searchParams);
const keyword = url.searchParams.get("keyword") ?? "";
const role = url.searchParams.get("role") ?? "";
const filtered = filterUsers(users, { keyword, role });
const list = paginateList(filtered, limit, offset);
return paginatedWithSchema(UserSchema, { list, total: filtered.length });
}),
http.post(USERS_PATH, async ({ request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const newUser = {
id: String(users.length + 1),
username: String(body.username),
avatar: null,
email: typeof body.email === "string" ? body.email : null,
roles: (body.roles as string[]) ?? [],
permissions: [] as string[],
};
users.push(newUser);
return successWithSchema(UserSchema, newUser);
}),
http.put(USERS_MEMBER_PATH, async ({ params, request }) => {
await withDelay(200);
const body = (await request.json()) as Record<string, unknown>;
const idx = users.findIndex((u) => u.id === params.id);
if (idx === -1) {
return errorResponse(ERROR_CODES.NOT_FOUND, "User not found");
}
users[idx] = { ...users[idx], ...body } as (typeof users)[number];
return successWithSchema(UserSchema, users[idx]);
}),
http.delete(USERS_MEMBER_PATH, async ({ params }) => {
await withDelay(200);
users = users.filter((u) => u.id !== params.id);
return successWithNullBody();
}),
];
-52
View File
@@ -1,52 +0,0 @@
/**
* Mock-only helpers: demo avatars, list filtering, pagination.
*/
/** Deterministic demo avatar URL from username (mock seed data only). */
export function vercelAvatarUrl(username: string): string {
return `https://vercel.com/api/www/avatar?u=${encodeURIComponent(username)}`;
}
export interface User {
id: string;
username: string;
email?: string | null;
roles: string[];
[key: string]: any;
}
export interface FilterOptions {
keyword?: string;
role?: string;
}
export function filterUsers(users: User[], options: FilterOptions): User[] {
let filtered = [...users];
if (options.keyword) {
filtered = filtered.filter(
(u) =>
u.username.includes(options.keyword!) || (u.email && u.email.includes(options.keyword!)),
);
}
if (options.role) {
filtered = filtered.filter((u) => u.roles.includes(options.role!));
}
return filtered;
}
export function paginateList<T>(items: T[], limit: number, offset: number): T[] {
return items.slice(offset, offset + limit);
}
export function parsePaginationParams(searchParams: URLSearchParams) {
const limit = Number(searchParams.get("limit") ?? searchParams.get("pageSize") ?? 50);
const offset = Number(searchParams.get("offset") ?? 0);
const page = Number(searchParams.get("page") ?? 1);
const actualOffset = searchParams.get("offset") !== null ? offset : (page - 1) * limit;
return { limit, offset: actualOffset };
}
-206
View File
@@ -1,206 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as AuthRouteImport } from './routes/_auth'
import { Route as R404IndexRouteImport } from './routes/404/index'
import { Route as LoginIndexRouteImport } from './routes/login/index'
import { Route as RegisterIndexRouteImport } from './routes/register/index'
import { Route as Auth403IndexRouteImport } from './routes/_auth/403/index'
import { Route as AuthDashboardIndexRouteImport } from './routes/_auth/dashboard/index'
import { Route as AuthUsersIndexRouteImport } from './routes/_auth/users/index'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthRoute = AuthRouteImport.update({
id: '/_auth',
getParentRoute: () => rootRouteImport,
} as any)
const R404IndexRoute = R404IndexRouteImport.update({
id: '/404/',
path: '/404/',
getParentRoute: () => rootRouteImport,
} as any)
const LoginIndexRoute = LoginIndexRouteImport.update({
id: '/login/',
path: '/login/',
getParentRoute: () => rootRouteImport,
} as any)
const RegisterIndexRoute = RegisterIndexRouteImport.update({
id: '/register/',
path: '/register/',
getParentRoute: () => rootRouteImport,
} as any)
const Auth403IndexRoute = Auth403IndexRouteImport.update({
id: '/403/',
path: '/403/',
getParentRoute: () => AuthRoute,
} as any)
const AuthDashboardIndexRoute = AuthDashboardIndexRouteImport.update({
id: '/dashboard/',
path: '/dashboard/',
getParentRoute: () => AuthRoute,
} as any)
const AuthUsersIndexRoute = AuthUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/403/': typeof Auth403IndexRoute
'/dashboard/': typeof AuthDashboardIndexRoute
'/users/': typeof AuthUsersIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/404': typeof R404IndexRoute
'/login': typeof LoginIndexRoute
'/register': typeof RegisterIndexRoute
'/403': typeof Auth403IndexRoute
'/dashboard': typeof AuthDashboardIndexRoute
'/users': typeof AuthUsersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_auth': typeof AuthRouteWithChildren
'/404/': typeof R404IndexRoute
'/login/': typeof LoginIndexRoute
'/register/': typeof RegisterIndexRoute
'/_auth/403/': typeof Auth403IndexRoute
'/_auth/dashboard/': typeof AuthDashboardIndexRoute
'/_auth/users/': typeof AuthUsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/404/'
| '/login/'
| '/register/'
| '/403/'
| '/dashboard/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/404' | '/login' | '/register' | '/403' | '/dashboard' | '/users'
id:
| '__root__'
| '/'
| '/_auth'
| '/404/'
| '/login/'
| '/register/'
| '/_auth/403/'
| '/_auth/dashboard/'
| '/_auth/users/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthRoute: typeof AuthRouteWithChildren
R404IndexRoute: typeof R404IndexRoute
LoginIndexRoute: typeof LoginIndexRoute
RegisterIndexRoute: typeof RegisterIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth': {
id: '/_auth'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthRouteImport
parentRoute: typeof rootRouteImport
}
'/404/': {
id: '/404/'
path: '/404'
fullPath: '/404/'
preLoaderRoute: typeof R404IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/login/': {
id: '/login/'
path: '/login'
fullPath: '/login/'
preLoaderRoute: typeof LoginIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/register/': {
id: '/register/'
path: '/register'
fullPath: '/register/'
preLoaderRoute: typeof RegisterIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_auth/403/': {
id: '/_auth/403/'
path: '/403'
fullPath: '/403/'
preLoaderRoute: typeof Auth403IndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/dashboard/': {
id: '/_auth/dashboard/'
path: '/dashboard'
fullPath: '/dashboard/'
preLoaderRoute: typeof AuthDashboardIndexRouteImport
parentRoute: typeof AuthRoute
}
'/_auth/users/': {
id: '/_auth/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthUsersIndexRouteImport
parentRoute: typeof AuthRoute
}
}
}
interface AuthRouteChildren {
Auth403IndexRoute: typeof Auth403IndexRoute
AuthDashboardIndexRoute: typeof AuthDashboardIndexRoute
AuthUsersIndexRoute: typeof AuthUsersIndexRoute
}
const AuthRouteChildren: AuthRouteChildren = {
Auth403IndexRoute: Auth403IndexRoute,
AuthDashboardIndexRoute: AuthDashboardIndexRoute,
AuthUsersIndexRoute: AuthUsersIndexRoute,
}
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthRoute: AuthRouteWithChildren,
R404IndexRoute: R404IndexRoute,
LoginIndexRoute: LoginIndexRoute,
RegisterIndexRoute: RegisterIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
-6
View File
@@ -1,6 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { NotFound } from "@/components/NotFound";
export const Route = createFileRoute("/404/")({
component: NotFound,
});
-59
View File
@@ -1,59 +0,0 @@
import { useEffect, useMemo } from "react";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { ConfigProvider, App } from "antd";
import enUS from "antd/locale/en_US";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useSettingsStore } from "@/stores/settings";
import { useAppTheme } from "@/hooks/useAppTheme";
import { NotFound } from "@/components/NotFound";
import { RouteError } from "@/components/RouteError";
import "@/index.css";
function AppQueryBridge({ children }: { children: React.ReactNode }) {
const { message } = App.useApp();
const queryClient = useMemo(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
mutations: {
onError: (err) => {
message.error(err instanceof Error ? err.message : String(err));
},
},
},
}),
[message],
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
function RootComponent() {
const darkMode = useSettingsStore((s) => s.darkMode);
const configProviderProps = useAppTheme();
useEffect(() => {
document.documentElement.lang = "en";
}, []);
useEffect(() => {
document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light");
}, [darkMode]);
return (
<ConfigProvider {...configProviderProps} locale={enUS}>
<App>
<AppQueryBridge>
<Outlet />
</AppQueryBridge>
</App>
</ConfigProvider>
);
}
export const Route = createRootRoute({
component: RootComponent,
notFoundComponent: NotFound,
errorComponent: RouteError,
});
-32
View File
@@ -1,32 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useAuthStore } from "@/stores/auth";
import { MainLayout } from "@/components/Layout/MainLayout";
import { canAccessPath, normalizeAppPath } from "@/utils/appMenu";
import { hydrateSession } from "@/api/auth";
export const Route = createFileRoute("/_auth")({
beforeLoad: async ({ location }) => {
const { isAuthenticated, user, tokens } = useAuthStore.getState();
if (!isAuthenticated) {
throw redirect({ to: "/login" });
}
if (tokens && !user) {
try {
await hydrateSession();
} catch {
useAuthStore.getState().logout();
throw redirect({ to: "/login" });
}
}
const { user: nextUser } = useAuthStore.getState();
const path = normalizeAppPath(location.pathname);
if (path === "/403") return;
if (!canAccessPath(location.pathname, nextUser?.permissions)) {
throw redirect({ to: "/403" });
}
},
component: MainLayout,
});
-39
View File
@@ -1,39 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Home, ShieldAlert } from "lucide-react";
import { Button, Flex, Result, theme } from "antd";
export const Route = createFileRoute("/_auth/403/")({
component: ForbiddenPage,
});
function ForbiddenPage() {
const navigate = useNavigate();
const { token } = theme.useToken();
const goDashboard = () => {
void navigate({ to: "/dashboard" });
};
return (
<Flex
flex={1}
align="center"
justify="center"
style={{ minHeight: 0, width: "100%", padding: token.paddingXL }}
>
<Result
// Avoid status="403": antd ignores `icon` for built-in illustrations; keep Lucide as the main icon.
icon={
<ShieldAlert size={64} strokeWidth={1.25} color={token.colorTextQuaternary} aria-hidden />
}
title="403"
subTitle="Sorry, you don't have permission to access this page."
extra={
<Button type="primary" icon={<Home size={16} aria-hidden />} onClick={goDashboard}>
Back to Home
</Button>
}
/>
</Flex>
);
}
@@ -1,42 +0,0 @@
.dash-card-interactive.ant-card {
transition:
border-color 0.2s ease,
background-color 0.2s ease;
box-shadow: none;
}
.dash-card-interactive.ant-card:hover {
background-color: var(--dash-card-hover-bg) !important;
border-color: var(--dash-card-hover-border) !important;
box-shadow: var(--dash-card-hover-shadow);
}
.dash-recent-row {
cursor: pointer;
transition: background-color 0.2s ease;
}
.dash-recent-row:hover {
background-color: var(--dash-recent-hover-bg);
}
.dash-timeline {
padding-top: 4px;
}
.dash-timeline .ant-timeline-item-content {
min-height: 52px;
}
.dash-skel-stat .ant-skeleton-input {
min-height: unset !important;
}
.dash-skel-recent .ant-skeleton-input {
min-height: unset !important;
margin-block: 0 !important;
}
.dash-skel-recent .ant-skeleton-avatar {
margin-inline-end: 0 !important;
}
@@ -1,346 +0,0 @@
import type { CSSProperties } from "react";
import { useMemo } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { Card, Col, Row, Typography, Avatar, theme, Flex, Skeleton, Timeline, Tag } from "antd";
import { useQuery } from "@tanstack/react-query";
import { DollarSign, Users, CreditCard, Activity } from "lucide-react";
import "./index.css";
const { Title, Text } = Typography;
export const Route = createFileRoute("/_auth/dashboard/")({
component: DashboardPage,
});
async function fetchDashboardShell() {
await new Promise((r) => setTimeout(r, 1000));
return true;
}
type AntToken = ReturnType<typeof theme.useToken>["token"];
/** Matches loaded stat card body height: title row, value, description. */
function StatCardSkeleton({ token }: { token: AntToken }) {
const titleLine = Math.round(token.fontSizeSM * token.lineHeight);
/** Lucide default icon box 24px to align with the first row of real cards. */
const iconBox = 24;
const titleRowHeight = Math.max(titleLine, iconBox);
const valueLine = Math.round(24 * 1);
const descLine = Math.round(token.fontSizeSM * token.lineHeight);
return (
<Card styles={{ body: { padding: token.paddingLG } }}>
<div className="dash-skel-stat">
<Flex
justify="space-between"
align="center"
style={{ marginBottom: token.marginXS, minHeight: titleRowHeight }}
>
<Skeleton.Input active size="small" style={{ width: "55%", height: titleLine }} />
<Skeleton.Avatar active size="small" style={{ width: iconBox, height: iconBox }} />
</Flex>
<Flex vertical gap={token.marginXS}>
<Skeleton.Input active style={{ width: "25%", height: valueLine }} />
<Skeleton.Input
active
size="small"
style={{ width: "75%", maxWidth: 200, height: descLine }}
/>
</Flex>
</div>
</Card>
);
}
function DashboardSkeleton() {
const { token } = theme.useToken();
const body = { padding: token.paddingLG } as const;
const cardTitleSkel = (w: number) => (
<Skeleton.Input
active
style={{
width: w,
height: Math.round(Number(token.fontSizeHeading5) * Number(token.lineHeightHeading5)),
}}
/>
);
return (
<Flex vertical gap={token.marginLG}>
<Row gutter={[16, 16]}>
{[0, 1, 2, 3].map((i) => (
<Col xs={24} sm={12} lg={6} key={i}>
<StatCardSkeleton token={token} />
</Col>
))}
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={14}>
<Card title={cardTitleSkel(88)} styles={{ body: body }} style={{ height: "100%" }}>
<Skeleton.Node active style={{ width: "100%", height: 300 }}>
<div style={{ width: "100%", height: "100%" }} />
</Skeleton.Node>
</Card>
</Col>
<Col xs={24} lg={10}>
<Card title={cardTitleSkel(112)} styles={{ body: body }} style={{ height: "100%" }}>
<Flex vertical className="dash-skel-recent">
{[0, 1, 2, 3, 4].map((i) => (
<Flex
key={i}
align="center"
justify="space-between"
gap={token.marginSM}
style={{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadius,
}}
>
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0, flex: 1 }}>
<Skeleton.Avatar active size={40} shape="circle" />
<Flex vertical style={{ minWidth: 0, flex: 1 }} gap={2}>
<Skeleton.Input active size="small" style={{ width: "50%", height: 12 }} />
<Skeleton.Input active size="small" style={{ width: "85%", height: 12 }} />
</Flex>
</Flex>
</Flex>
))}
</Flex>
</Card>
</Col>
</Row>
</Flex>
);
}
function DashboardPage() {
const { token } = theme.useToken();
const { isPending } = useQuery({
queryKey: ["dashboard"],
queryFn: fetchDashboardShell,
staleTime: 60_000,
});
/* Hover: emphasize border; keep background matching the card so light theme doesn't gray the whole block */
const cardHoverStyle = {
["--dash-card-hover-bg" as string]: token.colorBgContainer,
["--dash-card-hover-border" as string]: token.colorPrimaryBorderHover,
["--dash-card-hover-shadow" as string]: "none",
} as CSSProperties;
const stats = useMemo(
() => [
{
title: "Total Revenue",
value: "$45,231.89",
description: "+20.1% from last month",
icon: <DollarSign style={{ color: token.colorTextSecondary }} />,
},
{
title: "Subscriptions",
value: "+2350",
description: "+180.1% from last month",
icon: <Users style={{ color: token.colorTextSecondary }} />,
},
{
title: "Sales",
value: "+12,234",
description: "+19% from last month",
icon: <CreditCard style={{ color: token.colorTextSecondary }} />,
},
{
title: "Active Now",
value: "+573",
description: "+201 since last hour",
icon: <Activity style={{ color: token.colorTextSecondary }} />,
},
],
[token.colorTextSecondary],
);
const recentSales = useMemo(
() => [
{
name: "Olivia Martin",
email: "olivia.martin@email.com",
amount: "+$1,999.00",
initials: "OM",
},
{
name: "Jackson Lee",
email: "jackson.lee@email.com",
amount: "+$39.00",
initials: "JL",
},
{
name: "Isabella Nguyen",
email: "isabella.nguyen@email.com",
amount: "+$299.00",
initials: "IN",
},
{
name: "William Kim",
email: "will@email.com",
amount: "+$99.00",
initials: "WK",
},
{
name: "Sofia Davis",
email: "sofia.davis@email.com",
amount: "+$39.00",
initials: "SD",
},
],
[],
);
const timelineItems = useMemo(
() => [
{
color: "green" as const,
content: (
<Flex vertical gap={4}>
<Text strong>08:30 · Deploy V3.2.0</Text>
<Text type="secondary">Release branch merged and production rollout completed.</Text>
</Flex>
),
},
{
color: "blue" as const,
content: (
<Flex vertical gap={4}>
<Text strong>10:10 · Menu policy updated</Text>
<Text type="secondary">Admin changed sidebar visibility and permission mapping.</Text>
</Flex>
),
},
{
color: "gold" as const,
content: (
<Flex vertical gap={4}>
<Text strong>13:20 · Security review</Text>
<Text type="secondary">Token refresh behavior and 403 routes validated.</Text>
</Flex>
),
},
{
color: "red" as const,
content: (
<Flex vertical gap={4}>
<Text strong>15:50 · Incident recovery</Text>
<Text type="secondary">User creation spike handled and queue restored.</Text>
</Flex>
),
},
],
[],
);
if (isPending) {
return <DashboardSkeleton />;
}
return (
<Flex vertical gap={token.marginLG}>
<Row gutter={[16, 16]}>
{stats.map((stat) => (
<Col xs={24} sm={12} lg={6} key={stat.title}>
<Card
className="dash-card-interactive"
style={cardHoverStyle}
styles={{ body: { padding: token.paddingLG } }}
>
<Flex justify="space-between" align="center" style={{ marginBottom: token.marginXS }}>
<Text strong style={{ fontSize: token.fontSizeSM }}>
{stat.title}
</Text>
{stat.icon}
</Flex>
<div style={{ fontSize: 24, fontWeight: "bold", marginBottom: 4 }}>{stat.value}</div>
<Text ellipsis type="secondary" style={{ fontSize: token.fontSizeSM }}>
{stat.description}
</Text>
</Card>
</Col>
))}
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={14}>
<Card
className="dash-card-interactive"
style={{ ...cardHoverStyle, height: "100%" }}
title={
<Flex align="center" gap={token.marginSM}>
<Title level={5} style={{ margin: 0 }}>
Timeline
</Title>
<Tag variant="filled" color="processing">
Today
</Tag>
</Flex>
}
>
<Timeline className="dash-timeline" items={timelineItems} />
</Card>
</Col>
<Col xs={24} lg={10}>
<Card
className="dash-card-interactive"
style={{ ...cardHoverStyle, height: "100%" }}
title={
<Title level={5} style={{ margin: 0 }}>
Recent Sales
</Title>
}
>
<Flex
vertical
style={{
["--dash-recent-hover-bg" as string]: token.colorBgTextHover,
}}
>
{recentSales.map((item) => (
<Flex
key={item.email}
className="dash-recent-row"
align="center"
justify="space-between"
style={{
padding: `${token.paddingXS}px ${token.paddingSM}px`,
borderRadius: token.borderRadius,
}}
>
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
<Avatar
size={40}
shape="circle"
style={{
flexShrink: 0,
backgroundColor: token.colorFillSecondary,
color: token.colorTextSecondary,
fontWeight: 600,
fontSize: token.fontSizeSM,
}}
>
{item.initials}
</Avatar>
<Flex vertical style={{ minWidth: 0 }}>
<Text strong style={{ fontSize: token.fontSizeSM, lineHeight: 1 }}>
{item.name}
</Text>
<Text type="secondary" style={{ fontSize: token.fontSizeSM }} ellipsis>
{item.email}
</Text>
</Flex>
</Flex>
<div style={{ fontWeight: 500 }}>{item.amount}</div>
</Flex>
))}
</Flex>
</Card>
</Col>
</Row>
</Flex>
);
}
@@ -1,59 +0,0 @@
import { Form, Input, Select } from "antd";
import type { FormInstance } from "antd/es/form";
import type { CreateUserRequest, User } from "@/api/schemas";
import { BaseFormModal } from "@/components/FormModal";
export type FormModalProps = {
open: boolean;
editingUser: User | null;
form: FormInstance<CreateUserRequest>;
confirmLoading: boolean;
onCancel: () => void;
onFinish: (values: CreateUserRequest) => void;
};
export function FormModal({
open,
editingUser,
form,
confirmLoading,
onCancel,
onFinish,
}: FormModalProps) {
return (
<BaseFormModal<CreateUserRequest>
open={open}
title={editingUser ? "Edit User" : "New User"}
okText="OK"
cancelText="Cancel"
form={form}
confirmLoading={confirmLoading}
onCancel={onCancel}
onFinish={onFinish}
>
<Form.Item
name="username"
label="Username"
rules={[{ required: true, message: "Please enter username" }]}
>
<Input />
</Form.Item>
<Form.Item
name="roles"
label="Roles"
rules={[{ required: true, message: "Please select roles" }]}
>
<Select
mode="multiple"
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
</BaseFormModal>
);
}
@@ -1,93 +0,0 @@
import { Button, Input, Select, theme } from "antd";
import { Plus, UserRound } from "lucide-react";
import { forwardRef, useMemo } from "react";
import { FilterToolbar } from "@/components/FilterToolbar";
/** Search + role slot `minWidth` for FilterToolbar collapse math */
const FILTER_CONTROL_WIDTH = 220;
export type ToolbarProps = {
keywordInput: string;
onKeywordChange: (value: string) => void;
onSearch: (keyword: string) => void;
onClearSearch: () => void;
roleValue: string | undefined;
onRoleChange: (role: string) => void;
onCreateClick: () => void;
};
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{
keywordInput,
onKeywordChange,
onSearch,
onClearSearch,
roleValue,
onRoleChange,
onCreateClick,
},
ref,
) {
const { token } = theme.useToken();
const slots = useMemo(
() => [
{
key: "keyword",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Input.Search
allowClear
placeholder="Search User"
style={{ width: FILTER_CONTROL_WIDTH }}
value={keywordInput}
onChange={(e) => onKeywordChange(e.target.value)}
onSearch={(v) => onSearch(v)}
onClear={onClearSearch}
/>
),
},
{
key: "role",
minWidth: FILTER_CONTROL_WIDTH,
children: (
<Select
allowClear
placeholder="Role"
style={{ width: FILTER_CONTROL_WIDTH }}
prefix={<UserRound size={token.fontSize} />}
value={roleValue}
onChange={(v) => onRoleChange(v ?? "")}
options={[
{ label: "Admin", value: "admin" },
{ label: "Editor", value: "editor" },
]}
/>
),
},
],
[
keywordInput,
onClearSearch,
onKeywordChange,
onRoleChange,
onSearch,
roleValue,
token.fontSize,
],
);
return (
<FilterToolbar
ref={ref}
slots={slots}
actions={
<Button type="primary" icon={<Plus size={token.fontSize} />} onClick={onCreateClick}>
Create User
</Button>
}
moreFiltersLabel="More filters"
moreFiltersTitle="More filters"
/>
);
});
-345
View File
@@ -1,345 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Avatar, Button, Space, Form, App, Dropdown, theme, Tag, Flex } from "antd";
import type { TablePaginationConfig } from "antd/es/table/interface";
import { useMemo, useRef, useState } from "react";
import { createUser, deleteUser, listUsers, updateUser, userQueryKeys } from "@/api/users";
import type { User, CreateUserRequest } from "@/api/schemas";
import { z } from "zod/v4";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
import { DataTable } from "@/components/DataTable";
import { useResourceCRUD } from "@/hooks/useResourceCRUD";
import { useTableFitHeight } from "@/hooks/useTableFitHeight";
import { useCrudToasts } from "@/hooks/useCrudToasts";
import { useUrlSearchState } from "@/hooks/useUrlSearchState";
import { Toolbar } from "./-Toolbar";
import { FormModal } from "./-FormModal";
const UserSearchParamsSchema = z.object({
limit: z.number().int().positive().catch(100),
offset: z.number().int().nonnegative().catch(0),
sortField: z.string().nullable().catch(null),
sortOrder: z.enum(["ascend", "descend"]).nullable().catch(null),
keyword: z.string().catch(""),
role: z.string().catch(""),
});
type UserSearch = z.infer<typeof UserSearchParamsSchema>;
export const Route = createFileRoute("/_auth/users/")({
validateSearch: (search) => UserSearchParamsSchema.parse(search),
component: UsersPage,
});
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
const { message, modal } = App.useApp();
const { token } = theme.useToken();
const [modalOpen, setModalOpen] = useState(false);
const pageShellRef = useRef<HTMLDivElement>(null);
const toolbarRowRef = useRef<HTMLDivElement>(null);
const middleSectionRef = useRef<HTMLDivElement>(null);
const tableFrameRef = useRef<HTMLDivElement>(null);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [form] = Form.useForm();
const currentPage = Math.floor(search.offset / search.limit) + 1;
const setSearch = (next: UserSearch) => {
void navigate({ search: next });
};
const { keywordInput, setKeywordInput, applyKeyword } = useUrlSearchState({
search,
setSearch,
});
const crudToasts = useCrudToasts<CreateUserRequest, CreateUserRequest & { id: string }>({
message,
resourceKey: "users",
});
const listQuery = {
limit: search.limit,
offset: search.offset,
keyword: search.keyword || undefined,
role: search.role || undefined,
sortField: search.sortField ?? undefined,
sortOrder: search.sortOrder ?? undefined,
};
const { data, isLoading, createMutation, updateMutation, deleteMutation } = useResourceCRUD<
{ list: User[]; total: number },
CreateUserRequest,
CreateUserRequest & { id: string }
>({
queryKey: userQueryKeys.list(listQuery),
queryFn: () => listUsers(listQuery),
select: (raw) => raw as { list: User[]; total: number },
createFn: (values) => createUser(values),
updateFn: ({ id, ...values }) => updateUser({ params: { id }, body: values }),
deleteFn: (id) => deleteUser({ id }),
optimistic: { update: true, delete: true },
createLifecycle: {
onSuccess: (values) => {
crudToasts.createLifecycle?.onSuccess?.(values);
setModalOpen(false);
form.resetFields();
},
onError: crudToasts.createLifecycle?.onError,
},
updateLifecycle: {
onMutate: crudToasts.updateLifecycle?.onMutate,
onSuccess: (values) => {
crudToasts.updateLifecycle?.onSuccess?.(values);
setModalOpen(false);
setEditingUser(null);
form.resetFields();
},
onError: crudToasts.updateLifecycle?.onError,
},
deleteLifecycle: {
onMutate: crudToasts.deleteLifecycle?.onMutate,
onSuccess: crudToasts.deleteLifecycle?.onSuccess,
onError: crudToasts.deleteLifecycle?.onError,
},
});
const confirmDelete = (record: User) => {
modal.confirm({
title: "Are you absolutely sure?",
content: "This action cannot be undone. This will permanently delete the user.",
okText: "Delete",
okType: "danger",
cancelText: "Cancel",
onOk: () => deleteMutation.mutate(record.id),
});
};
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
sorter: true,
sortOrder: search.sortField === "id" ? search.sortOrder : null,
},
{
title: "Username",
dataIndex: "username",
key: "username",
sorter: true,
sortOrder: search.sortField === "username" ? search.sortOrder : null,
render: (_: unknown, record: User) => {
const src = (record.avatar ?? "").trim() || undefined;
return (
<Flex align="center" gap={token.marginSM} style={{ minWidth: 0 }}>
<Avatar size={24} src={src} shape="circle" style={{ flexShrink: 0 }}>
{record.username?.[0]?.toUpperCase()}
</Avatar>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.username}
</span>
</Flex>
);
},
},
{
title: "Email",
dataIndex: "email",
key: "email",
sorter: true,
sortOrder: search.sortField === "email" ? search.sortOrder : null,
},
{
title: "Roles",
dataIndex: "roles",
key: "roles",
sorter: true,
sortOrder: search.sortField === "roles" ? search.sortOrder : null,
render: (roles: string[]) => (
<Space wrap>
{roles.map((role) => (
<Tag
key={role}
variant="outlined"
styles={{
root: {
borderRadius: 9999,
background: "transparent",
boxShadow: "none",
},
}}
>
{role}
</Tag>
))}
</Space>
),
},
{
title: "Actions",
key: "actions",
width: 60,
align: "right" as const,
sorter: false,
render: (_: unknown, record: User) => (
<Dropdown
menu={{
items: [
{
key: "edit",
icon: <Pencil size={token.fontSize} />,
label: "Edit",
onClick: () => {
setEditingUser(record);
form.setFieldsValue(record);
setModalOpen(true);
},
},
{
key: "delete",
icon: <Trash2 size={token.fontSize} />,
label: "Delete",
danger: true,
onClick: () => confirmDelete(record),
},
],
}}
placement="bottomRight"
>
<Button
type="text"
icon={<MoreVertical size={token.fontSize} />}
aria-label="Row actions"
/>
</Dropdown>
),
},
];
const showPagination = (data?.total ?? 0) > search.limit;
const { tableAreaMaxHeight, tableScrollY, lockScrollHeight } = useTableFitHeight({
pageShellRef,
toolbarRef: toolbarRowRef,
middleRef: middleSectionRef,
tableFrameRef,
marginLG: token.marginLG,
rowCount: data?.list?.length ?? 0,
isLoading,
showPagination,
});
const tablePagination: false | TablePaginationConfig = useMemo(
() =>
showPagination
? {
total: data?.total ?? 0,
current: currentPage,
pageSize: search.limit,
showSizeChanger: true,
showTotal: (total) => `${total} rows`,
onChange: (page, pageSize) => {
void navigate({
search: {
...search,
limit: pageSize ?? search.limit,
offset: (page - 1) * (pageSize ?? search.limit),
},
});
},
}
: false,
[showPagination, data?.total, currentPage, search, navigate],
);
return (
<Flex
ref={pageShellRef}
vertical
gap={token.marginMD}
style={{ flex: "1 1 0%", minHeight: 0, overflow: "hidden" }}
>
<Toolbar
ref={toolbarRowRef}
keywordInput={keywordInput}
onKeywordChange={setKeywordInput}
onSearch={applyKeyword}
onClearSearch={() => {
setKeywordInput("");
setSearch({ ...search, keyword: "", offset: 0 });
}}
roleValue={search.role || undefined}
onRoleChange={(role) =>
void navigate({
search: {
...search,
role,
offset: 0,
},
})
}
onCreateClick={() => {
setEditingUser(null);
form.resetFields();
setModalOpen(true);
}}
/>
<DataTable<User>
layoutRef={middleSectionRef}
frameRef={tableFrameRef}
lockScrollHeight={lockScrollHeight}
maxHeight={tableAreaMaxHeight}
frameHeight={
tableScrollY != null && tableAreaMaxHeight != null ? tableAreaMaxHeight : undefined
}
rowKey="id"
size="small"
columns={columns}
dataSource={data?.list ?? []}
loading={isLoading}
pagination={tablePagination}
style={{ flex: 1, minHeight: 0 }}
scroll={tableScrollY != null ? { x: "max-content", y: tableScrollY } : { x: "max-content" }}
onChange={(_pagination, _filters, sorter) => {
if (Array.isArray(sorter)) return;
const nextSortField = sorter.order ? String(sorter.field) : "username";
const nextSortOrder = sorter.order ? sorter.order : "descend";
void navigate({
search: {
...search,
sortField: nextSortField,
sortOrder: nextSortOrder,
},
});
}}
/>
<FormModal
open={modalOpen}
editingUser={editingUser}
form={form}
confirmLoading={createMutation.isPending || updateMutation.isPending}
onCancel={() => {
setModalOpen(false);
setEditingUser(null);
form.resetFields();
}}
onFinish={(values) => {
if (editingUser) {
updateMutation.mutate({ ...values, id: editingUser.id });
} else {
createMutation.mutate(values);
}
}}
/>
</Flex>
);
}
-7
View File
@@ -1,7 +0,0 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
beforeLoad: () => {
throw redirect({ to: "/login" });
},
});
-21
View File
@@ -1,21 +0,0 @@
@supports not (backdrop-filter: blur(1px)) {
.login-page__card.ant-card {
background: var(--login-card-fallback-bg) !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
}
}
/* Keep browser autofill from painting a solid background over the glass card. */
.login-page__card .ant-input:-webkit-autofill,
.login-page__card .ant-input:-webkit-autofill:hover,
.login-page__card .ant-input:-webkit-autofill:focus,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:hover,
.login-page__card .ant-input-affix-wrapper input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--ant-color-text, currentColor) !important;
caret-color: var(--ant-color-text, currentColor);
-webkit-box-shadow: 0 0 0 1000px transparent inset !important;
box-shadow: 0 0 0 1000px transparent inset !important;
transition: background-color 9999s ease-out 0s;
}
-220
View File
@@ -1,220 +0,0 @@
import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex, Checkbox, Space } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth";
import { useSettingsStore } from "@/stores/settings";
import { hydrateSession, login } from "@/api/auth";
import { LoginRequestSchema } from "@/api/schemas";
import type { LoginRequest } from "@/api/schemas";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Theme } from "@/components/Icon";
import { AppFooter } from "@/components/Layout/AppFooter";
import { Aurora } from "@/components/Aurora";
import "./index.css";
export const Route = createFileRoute("/login/")({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
throw redirect({ to: "/dashboard" });
}
},
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const setTokens = useAuthStore((s) => s.setTokens);
const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode);
const darkMode = useSettingsStore((s) => s.darkMode);
const { token } = theme.useToken();
const loginMutation = useMutation({
mutationFn: async (values: LoginRequest) => {
const tokens = await login(values);
setTokens(tokens);
await hydrateSession();
},
onSuccess: () => {
message.success("Login successful");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Login failed");
},
});
const shellStyle: CSSProperties = {
position: "relative",
isolation: "isolate",
minHeight: "100vh",
backgroundColor: token.colorBgLayout,
color: token.colorText,
};
const contentStyle: CSSProperties = {
position: "relative",
zIndex: 1,
flex: "1 1 0%",
minWidth: 0,
minHeight: 0,
overflow: "hidden",
};
const glassMix = darkMode ? "52%" : "42%";
const backdrop = darkMode ? "blur(22px) saturate(1.2)" : "blur(18px) saturate(1.35)";
const cardStyle: CSSProperties = {
width: "100%",
maxWidth: 384,
background: `color-mix(in srgb, ${token.colorBgContainer} ${glassMix}, transparent)`,
backdropFilter: backdrop,
WebkitBackdropFilter: backdrop,
borderColor: token.colorBorderSecondary,
boxShadow: token.boxShadow,
["--login-card-fallback-bg" as string]: token.colorBgElevated,
};
return (
<Flex vertical style={shellStyle}>
<Aurora />
<Flex vertical style={contentStyle}>
<Flex
flex={1}
align="center"
justify="center"
style={{ padding: token.padding, minHeight: 0 }}
>
<Card
className="login-page__card"
style={cardStyle}
styles={{
body: { padding: token.paddingLG, background: "transparent" },
}}
>
<Flex
align="center"
justify="center"
gap={token.margin}
wrap="wrap"
style={{ marginBottom: token.marginLG }}
>
<img
src={APP_FAVICON_SRC}
alt=""
width={32}
height={32}
draggable={false}
style={{ display: "block", flexShrink: 0 }}
/>
<Typography.Title
level={3}
style={{
margin: 0,
fontWeight: "bold",
letterSpacing: "-0.025em",
lineHeight: 1.2,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</Typography.Title>
</Flex>
<Form
layout="vertical"
onFinish={(values) => {
loginMutation.mutate(LoginRequestSchema.parse(values));
}}
initialValues={{ username: "admin", password: "admin", remember: true }}
requiredMark={false}
>
<Form.Item
name="username"
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter username" }]}
>
<Input id="login-username" aria-label="Username" placeholder="admin" size="large" />
</Form.Item>
<Form.Item
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[{ required: true, message: "Please enter password" }]}
style={{ marginBottom: token.marginLG }}
>
<Input.Password
id="login-password"
aria-label="Password"
placeholder="admin"
size="large"
/>
</Form.Item>
<Flex
justify="space-between"
align="center"
style={{ marginBottom: token.marginLG }}
wrap="wrap"
>
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox>Auto login</Checkbox>
</Form.Item>
<Typography.Link
href="#"
onClick={(e) => e.preventDefault()}
style={{ fontSize: token.fontSizeSM }}
>
Forgot password?
</Typography.Link>
</Flex>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
<Button
type="primary"
htmlType="submit"
loading={loginMutation.isPending}
block
size="large"
>
Sign In
</Button>
</Form.Item>
</Form>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Don&apos;t have an account?{" "}
<Link to="/register" style={{ color: token.colorPrimary }}>
Sign up
</Link>
</Typography.Text>
</Flex>
</Card>
</Flex>
<Flex
vertical
align="center"
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
textAlign: "center",
}}
>
<Flex justify="center" style={{ marginBottom: token.marginSM }}>
<Space>
<Button
type="text"
size="small"
onClick={toggleDarkMode}
icon={<Theme size={token.size} />}
aria-label="Toggle Theme"
/>
</Space>
</Flex>
<AppFooter />
</Flex>
</Flex>
</Flex>
);
}
-211
View File
@@ -1,211 +0,0 @@
import { Link, createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { Form, Input, Button, Card, App, theme, Typography, Flex } from "antd";
import type { CSSProperties } from "react";
import { useMutation } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth";
import { hydrateSession, register } from "@/api/auth";
import { RegisterRequestSchema } from "@/api/schemas";
import type { RegisterRequest } from "@/api/auth";
import { APP_BRAND_NAME, APP_FAVICON_SRC } from "@/utils/constants";
import { Aurora } from "@/components/Aurora";
import { AppFooter } from "@/components/Layout/AppFooter";
import "../login/index.css";
export const Route = createFileRoute("/register/")({
beforeLoad: () => {
const { isAuthenticated } = useAuthStore.getState();
if (isAuthenticated) {
throw redirect({ to: "/dashboard" });
}
},
component: RegisterPage,
});
function RegisterPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const setTokens = useAuthStore((s) => s.setTokens);
const { token } = theme.useToken();
const registerMutation = useMutation({
mutationFn: async (values: RegisterRequest) => {
const tokens = await register(values);
setTokens(tokens);
await hydrateSession();
},
onSuccess: () => {
message.success("Account created successfully");
void navigate({ to: "/dashboard" });
},
onError: (err) => {
message.error(err instanceof Error ? err.message : "Registration failed");
},
});
const shellStyle: CSSProperties = {
position: "relative",
isolation: "isolate",
minHeight: "100vh",
backgroundColor: token.colorBgLayout,
color: token.colorText,
};
const contentStyle: CSSProperties = {
position: "relative",
zIndex: 1,
flex: "1 1 0%",
minWidth: 0,
minHeight: 0,
overflow: "hidden",
};
const cardStyle: CSSProperties = {
width: "100%",
maxWidth: 384,
background: `color-mix(in srgb, ${token.colorBgContainer} 44%, transparent)`,
backdropFilter: "blur(18px) saturate(1.35)",
WebkitBackdropFilter: "blur(18px) saturate(1.35)",
borderColor: token.colorBorderSecondary,
boxShadow: token.boxShadow,
["--login-card-fallback-bg" as string]: token.colorBgElevated,
};
return (
<Flex vertical style={shellStyle}>
<Aurora />
<Flex vertical style={contentStyle}>
<Flex
flex={1}
align="center"
justify="center"
style={{ padding: token.padding, minHeight: 0 }}
>
<Card
className="login-page__card"
style={cardStyle}
styles={{
body: { padding: token.paddingLG, background: "transparent" },
}}
>
<Flex
align="center"
justify="center"
gap={token.margin}
wrap="wrap"
style={{ marginBottom: token.marginLG }}
>
<img
src={APP_FAVICON_SRC}
alt=""
width={32}
height={32}
draggable={false}
style={{ display: "block", flexShrink: 0 }}
/>
<Typography.Title
level={3}
style={{
margin: 0,
fontWeight: "bold",
letterSpacing: "-0.025em",
lineHeight: 1.2,
textTransform: "uppercase",
}}
>
{APP_BRAND_NAME}
</Typography.Title>
</Flex>
<Form
layout="vertical"
onFinish={(values) => {
const payload = RegisterRequestSchema.parse(values);
registerMutation.mutate(payload);
}}
initialValues={{ username: "", password: "", confirmPassword: "", email: "" }}
validateTrigger={"onBlur"}
requiredMark={false}
>
<Form.Item
name="username"
label={<span style={{ fontWeight: 500 }}>Username</span>}
rules={[{ required: true, message: "Please enter your username" }]}
>
<Input placeholder="new-user" size="large" />
</Form.Item>
<Form.Item
name="email"
label={<span style={{ fontWeight: 500 }}>Email address</span>}
rules={[{ type: "email", message: "Please enter a valid email address" }]}
>
<Input placeholder="you@example.com" size="large" />
</Form.Item>
<Form.Item
name="password"
label={<span style={{ fontWeight: 500 }}>Password</span>}
rules={[
{ required: true, message: "Please enter your password" },
{ min: 6, message: "Password must be at least 6 characters long" },
]}
>
<Input.Password placeholder="Enter at least 6 characters" size="large" />
</Form.Item>
<Form.Item
name="confirmPassword"
label={<span style={{ fontWeight: 500 }}>Confirm password</span>}
dependencies={["password"]}
rules={[
{ required: true, message: "Please confirm your password" },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue("password") === value) {
return Promise.resolve();
}
return Promise.reject(new Error("Passwords do not match"));
},
}),
]}
>
<Input.Password placeholder="Re-enter your password" size="large" />
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: token.marginLG }}>
<Button
type="primary"
htmlType="submit"
loading={registerMutation.isPending}
block
size="large"
>
Create account
</Button>
</Form.Item>
<Flex justify="center" style={{ marginTop: token.margin }}>
<Typography.Text type="secondary">
Already have an account?{" "}
<Link to="/login" style={{ color: token.colorPrimary }}>
Sign in
</Link>
</Typography.Text>
</Flex>
</Form>
</Card>
</Flex>
<Flex
vertical
align="center"
style={{
padding: `${token.paddingSM}px ${token.padding}px`,
textAlign: "center",
}}
>
<AppFooter />
</Flex>
</Flex>
</Flex>
);
}

Some files were not shown because too many files have changed in this diff Show More