Compare commits
115 Commits
a764ced295
...
noa.virell
| Author | SHA1 | Date | |
|---|---|---|---|
|
521f8df465
|
|||
|
bbe03b36e0
|
|||
|
4e45a9b6d0
|
|||
|
27ac4d9b4a
|
|||
|
a60a796345
|
|||
|
14f50ecdb2
|
|||
|
b1c78dce28
|
|||
|
585ec46282
|
|||
|
8f69b61799
|
|||
|
64bab332c9
|
|||
|
38401a5f69
|
|||
|
f03d472c30
|
|||
|
2d6f6700f0
|
|||
|
2e11fc5d9c
|
|||
|
ac428946e7
|
|||
|
e4329dfc2b
|
|||
|
5dbbdc62e6
|
|||
|
200614a5c9
|
|||
|
4ac5b1c101
|
|||
|
b7e6009706
|
|||
|
fd262239e4
|
|||
|
cf761d218d
|
|||
|
110627f27e
|
|||
|
64392c32c6
|
|||
|
3f8f2547be
|
|||
|
632fa6cf8e
|
|||
|
d04f8cdc44
|
|||
|
97f5677a97
|
|||
|
2ed4a4da02
|
|||
|
100fe32f8e
|
|||
|
231f591767
|
|||
|
0e7aaed154
|
|||
|
89c2d11f19
|
|||
|
cd93491d98
|
|||
|
9b83ab565a
|
|||
|
5e17bbd965
|
|||
|
de0d05df0a
|
|||
|
b2c5f8de38
|
|||
|
ecbb890cac
|
|||
|
63f8439886
|
|||
|
194f1fa1fe
|
|||
|
55afbb29b4
|
|||
|
2e76a4c6a7
|
|||
|
5c540db325
|
|||
|
4cda783fed
|
|||
|
c4951f820a
|
|||
|
a04d562d61
|
|||
|
f0cca0cda4
|
|||
|
087cd4ee51
|
|||
|
164e271d81
|
|||
|
1b2933ba0e
|
|||
|
aa85aab55e
|
|||
|
197d14fb72
|
|||
|
725fd18536
|
|||
|
ea28436628
|
|||
|
7e37b92f24
|
|||
|
7edcda544b
|
|||
|
b8a2e24bd0
|
|||
|
8e792ced68
|
|||
|
a80c3cd1dd
|
|||
|
67e22eb793
|
|||
|
aaedddfd2f
|
|||
|
f8a3d0ca45
|
|||
|
6a9c013799
|
|||
|
70846e0d1e
|
|||
|
0710ffce72
|
|||
|
9e840901d1
|
|||
|
0f1c8e327e
|
|||
|
ddffb0da23
|
|||
|
b4d0959de4
|
|||
|
c2fd1c5cc8
|
|||
|
eddfa9a884
|
|||
|
b0684492fa
|
|||
|
aea7fddef0
|
|||
|
ef64c29ea7
|
|||
|
5f7f078f02
|
|||
|
1adfda54a6
|
|||
|
3510d6c1f8
|
|||
|
1fa90b15c3
|
|||
|
aa8e57bd89
|
|||
|
d6acae1625
|
|||
|
8dbdb58327
|
|||
|
61d2d2aef3
|
|||
|
0b710fd538
|
|||
|
d70ade4907
|
|||
|
a98ab26fa4
|
|||
|
62da1e096e
|
|||
|
fd1c89392f
|
|||
|
ae93f49691
|
|||
|
743f8373b0
|
|||
|
4796653896
|
|||
|
4dfd4cd529
|
|||
|
bd8eecbc7d
|
|||
|
cbec9bf2b3
|
|||
|
3d685b5a86
|
|||
|
83fe326962
|
|||
|
5b6bc9ce42
|
|||
|
e0e1abab93
|
|||
|
9f927c907a
|
|||
|
27ba3b7bef
|
|||
|
63f71d3b81
|
|||
|
e40d175c8e
|
|||
|
304e1d95ed
|
|||
|
acd3c95c80
|
|||
| 8973d518a2 | |||
| b5b4bb9d66 | |||
|
4c438cf4e4
|
|||
|
d44eef6bb7
|
|||
|
a49450bf9e
|
|||
|
228d838c37
|
|||
| 580402a5c2 | |||
| d46af028dc | |||
| cdcd05ea52 | |||
|
3f05dbe1e6
|
|||
|
7d76b85055
|
@@ -1,32 +1,2 @@
|
|||||||
|
TZ=Asia/Shanghai
|
||||||
SERVER_APPLICATION=nixcn-cms
|
LOG_LEVEL=debug
|
||||||
SERVER_ADDRESS=:8000
|
|
||||||
SERVER_EXTERNAL_URL=http://test.sne.moe:8080
|
|
||||||
SERVER_DEBUG_MODE=true
|
|
||||||
SERVER_FILE_LOGGER=false
|
|
||||||
|
|
||||||
DATABASE_TYPE=postgres
|
|
||||||
DATABASE_HOST=localhost:5432
|
|
||||||
DATABASE_NAME=postgres
|
|
||||||
DATABASE_USERNAME=postgres
|
|
||||||
DATABASE_PASSWORD=postgres
|
|
||||||
|
|
||||||
CACHE_HOSTS=localhost:6379
|
|
||||||
CACHE_MASTER=
|
|
||||||
CACHE_USERNAME=
|
|
||||||
CACHE_PASSWORD=
|
|
||||||
CACHE_DB=0
|
|
||||||
|
|
||||||
SEARCH_HOST=localhost
|
|
||||||
SEARCH_API_KEY=
|
|
||||||
|
|
||||||
EMAIL_RESEND_API_KEY=re_BMJaPVVB_kgdf1Go7n3dWVywp6hp4WmSA
|
|
||||||
EMAIL_FROM=NixCN CMS Email Verify <nixcn@violet.sne.moe>
|
|
||||||
|
|
||||||
SECRETS_JWT_SECRET=6Wd5xkDkF4XX5q2Ckq6TY6WX
|
|
||||||
SECRETS_TURNSTILE_SECRET=0x4AAAAAACI5pgVONOZ0rzyAYsdUcoOBF8w
|
|
||||||
|
|
||||||
TTL_MAGIC_LINK_TTL=10m
|
|
||||||
TTL_ACCESS_TTL=15s
|
|
||||||
TTL_REFRESH_TTL=168h
|
|
||||||
TTL_CHECKIN_COSE_TTL=10m
|
|
||||||
|
|||||||
3
.gitignore
vendored
@@ -46,3 +46,6 @@ go.work.sum
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
__MACOSX
|
__MACOSX
|
||||||
._*
|
._*
|
||||||
|
|
||||||
|
# go gen
|
||||||
|
*_gen.go
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
"tab_size": 4,
|
"tab_size": 4,
|
||||||
"format_on_save": "on",
|
"format_on_save": "on",
|
||||||
"languages": {
|
"languages": {
|
||||||
|
"Nix": {
|
||||||
|
"tab_size": 2,
|
||||||
|
},
|
||||||
"TypeScript": {
|
"TypeScript": {
|
||||||
|
"tab_size": 2,
|
||||||
"language_servers": [
|
"language_servers": [
|
||||||
"typescript-language-server",
|
"typescript-language-server",
|
||||||
"!vtsls",
|
"!vtsls",
|
||||||
@@ -16,6 +20,7 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"TSX": {
|
"TSX": {
|
||||||
|
"tab_size": 2,
|
||||||
"language_servers": [
|
"language_servers": [
|
||||||
"typescript-language-server",
|
"typescript-language-server",
|
||||||
"!vtsls",
|
"!vtsls",
|
||||||
@@ -24,6 +29,7 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"JavaScript": {
|
"JavaScript": {
|
||||||
|
"tab_size": 2,
|
||||||
"language_servers": [
|
"language_servers": [
|
||||||
"typescript-language-server",
|
"typescript-language-server",
|
||||||
"!vtsls",
|
"!vtsls",
|
||||||
|
|||||||
26
Containerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM docker.io/node:22-alpine AS client-cms-build
|
||||||
|
RUN apk add just -y
|
||||||
|
RUN npm install -g corepack && \
|
||||||
|
corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
ENV VITE_APP_BASE_URL=$CLIENT_BASE_URL
|
||||||
|
COPY . .
|
||||||
|
RUN just build-client-cms
|
||||||
|
|
||||||
|
FROM docker.io/busybox:1.37 AS client-cms
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=client-build /app/.outputs/client/cms/dist .
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["httpd", "-f", "-p", "3000", "-h", "/app", "-v"]
|
||||||
|
|
||||||
|
FROM docker.io/golang:1.25.5-alpine AS backend-build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . /app
|
||||||
|
RUN go mod tidy && \
|
||||||
|
go build -o /app/nixcn-cms
|
||||||
|
|
||||||
|
FROM docker.io/alpine:3.23 AS backend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=backend-build /app/nixcn-cms /app/nixcn-cms
|
||||||
|
EXPOSE 8000
|
||||||
|
ENTRYPOINT [ "/app/nixcn-cms" ]
|
||||||
23
README.md
@@ -1,2 +1,25 @@
|
|||||||
# nixcn-cms
|
# nixcn-cms
|
||||||
|
|
||||||
|
## Contribution
|
||||||
|
|
||||||
|
1. **Root docs serve the zh-CN version** _[MUST]_
|
||||||
|
2. **Use sign-off via `git commit -s`** _[MUST]_
|
||||||
|
3. **Do not modify the `main` branch for any reason** _[MUST]_
|
||||||
|
4. **Do not omit the commit subject for any reason** _[MUST]_
|
||||||
|
5. **Describe all changes in the commit message** _[MUST]_
|
||||||
|
6. **Rebase before submitting patches** _[MUST]_
|
||||||
|
7. **Commit message written in english** _[MUST]_
|
||||||
|
8. **Use OpenPGP/SSH for commit signing** _[MUST]_
|
||||||
|
9. **Split commits for large or multi-part changes** _[OPTION]_
|
||||||
|
10. **Have fun contributing :)** _[VERY NECESSARY]_
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
- Nix
|
||||||
|
- Devenv
|
||||||
|
- Direnv
|
||||||
|
|
||||||
|
## Notice
|
||||||
|
|
||||||
|
1. Client and all nix files use 2 space tab.
|
||||||
|
2. All Golang files and other configs use 4 space tab.
|
||||||
|
|||||||
0
charts/.gitkeep
Normal file
1655
client/bun.lock
@@ -3,7 +3,7 @@ import pluginQuery from '@tanstack/eslint-plugin-query';
|
|||||||
|
|
||||||
export default antfu({
|
export default antfu({
|
||||||
gitignore: true,
|
gitignore: true,
|
||||||
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**'],
|
ignores: ['**/node_modules/**', '**/dist/**', 'bun.lock', '**/routeTree.gen.ts', '**/ui/**', 'src/components/editor/**/*'],
|
||||||
react: true,
|
react: true,
|
||||||
stylistic: {
|
stylistic: {
|
||||||
semi: true,
|
semi: true,
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@marsidev/react-turnstile": "^1.4.0",
|
"@marsidev/react-turnstile": "^1.4.0",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
@@ -29,25 +30,34 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tabler/icons-react": "^3.36.0",
|
"@tabler/icons-react": "^3.36.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/react-form": "^1.27.7",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-router": "^1.141.6",
|
"@tanstack/react-router": "^1.141.6",
|
||||||
"@tanstack/react-router-devtools": "^1.141.6",
|
"@tanstack/react-router-devtools": "^1.141.6",
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"@tanstack/zod-adapter": "^1.143.4",
|
"@tanstack/zod-adapter": "^1.143.4",
|
||||||
|
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||||
|
"@uiw/react-md-editor": "^4.0.11",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"base-64": "^1.0.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"culori": "^4.0.2",
|
"culori": "^4.0.2",
|
||||||
"immer": "^11.1.0",
|
"immer": "^11.1.0",
|
||||||
|
"lodash-es": "^4.17.22",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.69.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"utf8": "^3.0.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.2.1",
|
"zod": "^4.2.1",
|
||||||
"zustand": "^5.0.9"
|
"zustand": "^5.0.9"
|
||||||
@@ -56,13 +66,17 @@
|
|||||||
"@antfu/eslint-config": "^6.7.1",
|
"@antfu/eslint-config": "^6.7.1",
|
||||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||||
"@tanstack/router-plugin": "^1.141.7",
|
"@tanstack/router-plugin": "^1.141.7",
|
||||||
|
"@types/base-64": "^1.0.2",
|
||||||
"@types/culori": "^4.0.1",
|
"@types/culori": "^4.0.1",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@types/utf8": "^3.0.3",
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
@@ -71,6 +85,7 @@
|
|||||||
"lint-staged": "^16.2.7",
|
"lint-staged": "^16.2.7",
|
||||||
"simple-git-hooks": "^2.13.1",
|
"simple-git-hooks": "^2.13.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"type-fest": "^5.4.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4",
|
"vite": "^7.2.4",
|
||||||
@@ -81,5 +96,6 @@
|
|||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "eslint --fix"
|
"*": "eslint --fix"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@10.28.1+sha512.7d7dbbca9e99447b7c3bf7a73286afaaf6be99251eb9498baefa7d406892f67b879adb3a1d7e687fc4ccc1a388c7175fbaae567a26ab44d1067b54fcb0d6a316"
|
||||||
}
|
}
|
||||||
8472
client/cms/pnpm-lock.yaml
generated
Normal file
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -14,9 +15,9 @@ import { Button } from '../ui/button';
|
|||||||
export function QrDialog(
|
export function QrDialog(
|
||||||
{ eventId }: { eventId: string },
|
{ eventId }: { eventId: string },
|
||||||
) {
|
) {
|
||||||
const { data } = useCheckinCode(eventId);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="w-20">签到</Button>
|
<Button className="w-20">签到</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -27,21 +28,41 @@ export function QrDialog(
|
|||||||
请工作人员扫描下面的二维码为你签到。
|
请工作人员扫描下面的二维码为你签到。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<QrDialogContent checkinCode={data.data.checkin_code} />
|
<QrSection eventId={eventId} enabled={open} />
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QrDialogContent({ checkinCode }: { checkinCode: string }) {
|
function QrSection({ eventId, enabled }: { eventId: string; enabled: boolean }) {
|
||||||
|
const { data } = useCheckinCode(eventId, enabled);
|
||||||
|
return data
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||||
|
<QRCode data={data.data.checkin_code} className="size-60" />
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||||
|
{data.data.checkin_code}
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<QrSectionSkeleton />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QrSectionSkeleton() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
<div className="border-2 px-4 py-8 border-muted rounded-xl flex flex-col items-center justify-center p-4">
|
||||||
<QRCode data={checkinCode} className="size-60" />
|
<QRCode data="114514" className="size-60 blur-xs" />
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
<div className="flex flex-1 items-center ml-2 font-mono text-3xl tracking-widest text-primary/80 justify-center">
|
||||||
{checkinCode}
|
Loading...
|
||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</>
|
</>
|
||||||
20
client/cms/src/components/hoc/with-fallback.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import React, { Suspense } from 'react';
|
||||||
|
|
||||||
|
export function withFallback<P extends object>(
|
||||||
|
Component: React.ComponentType<P>,
|
||||||
|
fallback: ReactNode,
|
||||||
|
) {
|
||||||
|
const Wrapped: React.FC<P> = (props) => {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={fallback}>
|
||||||
|
<Component {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Wrapped.displayName = `withFallback(${Component.displayName! || Component.name || 'Component'
|
||||||
|
})`;
|
||||||
|
|
||||||
|
return Wrapped;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
import type { TurnstileInstance } from '@marsidev/react-turnstile';
|
||||||
|
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||||
import { Turnstile } from '@marsidev/react-turnstile';
|
import { Turnstile } from '@marsidev/react-turnstile';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
@@ -15,9 +16,12 @@ import { useGetMagicLink } from '@/hooks/data/useGetMagicLink';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export function LoginForm({
|
export function LoginForm({
|
||||||
|
oauthParams,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<'div'>) {
|
}: React.ComponentProps<'div'> & {
|
||||||
|
oauthParams: AuthorizeSearchParams;
|
||||||
|
}) {
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
@@ -28,7 +32,7 @@ export function LoginForm({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const formData = new FormData(formRef.current!);
|
const formData = new FormData(formRef.current!);
|
||||||
const email = formData.get('email')! as string;
|
const email = formData.get('email')! as string;
|
||||||
mutateAsync({ email, turnstile_token: token! }).then(() => {
|
mutateAsync({ email, turnstile_token: token!, ...oauthParams }).then(() => {
|
||||||
void navigate({ to: '/magicLinkSent', search: { email } });
|
void navigate({ to: '/magicLinkSent', search: { email } });
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
152
client/cms/src/components/profile/edit-profile-dialog.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { useForm } from '@tanstack/react-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import z from 'zod';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldError,
|
||||||
|
FieldLabel,
|
||||||
|
} from '@/components/ui/field';
|
||||||
|
import {
|
||||||
|
Input,
|
||||||
|
} from '@/components/ui/input';
|
||||||
|
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
|
||||||
|
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
username: z.string().min(5),
|
||||||
|
nickname: z.string().min(1),
|
||||||
|
subtitle: z.string().min(1),
|
||||||
|
avatar: z.url().min(1),
|
||||||
|
});
|
||||||
|
export function EditProfileDialog() {
|
||||||
|
const { data: user } = useUserInfo();
|
||||||
|
const { mutateAsync } = useUpdateUser();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
avatar: user.avatar,
|
||||||
|
username: user.username,
|
||||||
|
nickname: user.nickname,
|
||||||
|
subtitle: user.subtitle,
|
||||||
|
},
|
||||||
|
validators: {
|
||||||
|
onBlur: formSchema,
|
||||||
|
},
|
||||||
|
onSubmit: async ({
|
||||||
|
value,
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
await mutateAsync(value);
|
||||||
|
toast.success('个人资料更新成功');
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Form submission error', error);
|
||||||
|
toast.error('更新个人资料失败,请重试');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="w-full" size="lg">编辑个人资料</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void form.handleSubmit();
|
||||||
|
}}
|
||||||
|
className="grid gap-4"
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑个人资料</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form.Field name="username">
|
||||||
|
{field => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="username">用户名</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
placeholder={user.username}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={field.state.meta.errors} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="nickname">
|
||||||
|
{field => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="nickname">昵称</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="nickname"
|
||||||
|
name="nickname"
|
||||||
|
placeholder={user.nickname}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={field.state.meta.errors} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="subtitle">
|
||||||
|
{field => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="subtitle">副标题</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="subtitle"
|
||||||
|
name="subtitle"
|
||||||
|
placeholder={user.subtitle}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={field.state.meta.errors} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="avatar">
|
||||||
|
{field => (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="avatar">头像链接</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="avatar"
|
||||||
|
name="avatar"
|
||||||
|
placeholder={user.avatar}
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={e => field.handleChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
<FieldError errors={field.state.meta.errors} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">取消</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="submit">保存</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
client/cms/src/components/profile/main-profile.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import MDEditor from '@uiw/react-md-editor';
|
||||||
|
import { isNil } from 'lodash-es';
|
||||||
|
import { Mail, Pencil } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import Markdown from 'react-markdown';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { useUpdateUser } from '@/hooks/data/useUpdateUser';
|
||||||
|
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||||
|
import { base64ToUtf8, utf8ToBase64 } from '@/lib/utils';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import { EditProfileDialog } from './edit-profile-dialog';
|
||||||
|
|
||||||
|
export function MainProfile() {
|
||||||
|
const { data: user } = useUserInfo();
|
||||||
|
const [bio, setBio] = useState<string | undefined>(() => base64ToUtf8(user.bio));
|
||||||
|
const [enableBioEdit, setEnableBioEdit] = useState(false);
|
||||||
|
const { mutateAsync } = useUpdateUser();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col justify-center w-full lg:w-auto h-full lg:h-auto lg:flex-row lg:gap-8">
|
||||||
|
<div className="flex w-full flex-row mt-2 lg:mt-0 lg:flex-col lg:w-max">
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<div className="flex flex-col w-full gap-3">
|
||||||
|
<div className="flex flex-row gap-3 w-full lg:flex-col">
|
||||||
|
<Avatar className="size-16 rounded-full border-2 border-muted lg:size-64">
|
||||||
|
<AvatarImage src={user.avatar} alt={user.nickname} />
|
||||||
|
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-1 flex-col justify-center lg:mt-3">
|
||||||
|
<span className="font-semibold text-2xl" aria-hidden="true">{user.nickname}</span>
|
||||||
|
<span className="text-[20px] text-muted-foreground" aria-hidden="true">{user.subtitle}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row gap-2 items-center text-sm px-1 lg:px-0">
|
||||||
|
<Mail className="h-4 w-4 stroke-muted-foreground" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditProfileDialog />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section className="relative rounded-md border border-muted w-full flex-1 lg:flex-auto min-h-72 lg:h-full mt-4 lg:mt-0 prose dark:prose-invert max-w-[1012px] self-center">
|
||||||
|
{/* Bio */}
|
||||||
|
{enableBioEdit
|
||||||
|
? (
|
||||||
|
<MDEditor
|
||||||
|
value={bio}
|
||||||
|
onChange={setBio}
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: <div className="p-6 prose dark:prose-invert"><Markdown>{bio}</Markdown></div>}
|
||||||
|
<Button
|
||||||
|
className="absolute bottom-4 right-4"
|
||||||
|
// eslint-disable-next-line ts/no-misused-promises
|
||||||
|
onClick={async () => {
|
||||||
|
if (!enableBioEdit) {
|
||||||
|
setEnableBioEdit(true);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!isNil(bio)) {
|
||||||
|
try {
|
||||||
|
await mutateAsync({ bio: utf8ToBase64(bio) });
|
||||||
|
setEnableBioEdit(false);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error('个人简介更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
size="icon-sm"
|
||||||
|
variant={enableBioEdit ? 'default' : 'outline'}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
import {
|
|
||||||
IconDashboard,
|
|
||||||
IconSettings,
|
|
||||||
} from '@tabler/icons-react';
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import NixOSLogo from '@/assets/nixos.svg?react';
|
import NixOSLogo from '@/assets/nixos.svg?react';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/sidebar/nav-main';
|
||||||
import { NavSecondary } from '@/components/nav-secondary';
|
import { NavSecondary } from '@/components/sidebar/nav-secondary';
|
||||||
import { NavUser } from '@/components/nav-user';
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -17,28 +11,8 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
|
import { navData } from '@/lib/navData';
|
||||||
const data = {
|
import { NavUser } from './nav-user';
|
||||||
user: {
|
|
||||||
name: 'shadcn',
|
|
||||||
email: 'm@example.com',
|
|
||||||
avatar: '/avatars/shadcn.jpg',
|
|
||||||
},
|
|
||||||
navMain: [
|
|
||||||
{
|
|
||||||
title: '工作台',
|
|
||||||
url: '/',
|
|
||||||
icon: IconDashboard,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
navSecondary: [
|
|
||||||
{
|
|
||||||
title: '设置',
|
|
||||||
url: '#',
|
|
||||||
icon: IconSettings,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||||
return (
|
return (
|
||||||
@@ -48,7 +22,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
className="data-[slot=sidebar-menu-button]:!p-1.5"
|
className="data-[slot=sidebar-menu-button]:p-1.5!"
|
||||||
>
|
>
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<NixOSLogo />
|
<NixOSLogo />
|
||||||
@@ -59,8 +33,8 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={navData.navMain} />
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={navData.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser />
|
<NavUser />
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { Icon } from '@tabler/icons-react';
|
import type { Icon } from '@tabler/icons-react';
|
||||||
import * as React from 'react';
|
import { Link } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
@@ -27,12 +28,16 @@ export function NavSecondary({
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton asChild>
|
<Link to={item.url}>
|
||||||
<a href={item.url}>
|
{({ isActive }) => {
|
||||||
<item.icon />
|
return (
|
||||||
<span>{item.title}</span>
|
<SidebarMenuButton isActive={isActive} tooltip={item.title}>
|
||||||
</a>
|
<item.icon />
|
||||||
</SidebarMenuButton>
|
<span>{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Link>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
@@ -24,8 +24,10 @@ import {
|
|||||||
} from '@/components/ui/sidebar';
|
} from '@/components/ui/sidebar';
|
||||||
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
import { useUserInfo } from '@/hooks/data/useUserInfo';
|
||||||
import { useLogout } from '@/hooks/useLogout';
|
import { useLogout } from '@/hooks/useLogout';
|
||||||
|
import { withFallback } from '../hoc/with-fallback';
|
||||||
|
import { Skeleton } from '../ui/skeleton';
|
||||||
|
|
||||||
export function NavUser() {
|
function NavUser_() {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const { data: user } = useUserInfo();
|
const { data: user } = useUserInfo();
|
||||||
const { logout } = useLogout();
|
const { logout } = useLogout();
|
||||||
@@ -83,3 +85,20 @@ export function NavUser() {
|
|||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NavUserSkeleton() {
|
||||||
|
return (
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||||
|
<div className="flex flex-col flex-1 gap-1">
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<IconDotsVertical className="ml-auto size-4" />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavUser = withFallback(NavUser_, <NavUserSkeleton />);
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import { useRouterState } from '@tanstack/react-router';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
|
import { navData } from '@/lib/navData';
|
||||||
|
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
|
const pathname = useRouterState({ select: state => state.location.pathname });
|
||||||
|
const allNavItems = [...navData.navMain, ...navData.navSecondary];
|
||||||
|
const currentTitle
|
||||||
|
= allNavItems.find(item =>
|
||||||
|
item.url === '/'
|
||||||
|
? pathname === '/'
|
||||||
|
: pathname.startsWith(item.url),
|
||||||
|
)?.title ?? '工作台';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
|
||||||
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
|
||||||
@@ -10,7 +21,7 @@ export function SiteHeader() {
|
|||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
className="mx-2 data-[orientation=vertical]:h-4"
|
className="mx-2 data-[orientation=vertical]:h-4"
|
||||||
/>
|
/>
|
||||||
<h1 className="text-base font-medium">工作台</h1>
|
<h1 className="text-base font-medium">{currentTitle}</h1>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
@@ -190,7 +190,7 @@ function FieldError({
|
|||||||
}: React.ComponentProps<'div'> & {
|
}: React.ComponentProps<'div'> & {
|
||||||
errors?: Array<{ message?: string } | undefined>;
|
errors?: Array<{ message?: string } | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const content = useMemo(async () => {
|
const content = useMemo(() => {
|
||||||
if (children) {
|
if (children) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Skeleton } from '../ui/skeleton';
|
||||||
|
|
||||||
|
export function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
className="gap-6 rounded-xl py-6 h-full"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import { axiosClient } from '@/lib/axios';
|
||||||
|
|
||||||
export function useCheckinCode(eventId: string) {
|
export function useCheckinCode(eventId: string, enabled: boolean) {
|
||||||
return useSuspenseQuery({
|
return useQuery({
|
||||||
queryKey: ['getCheckinCode', eventId],
|
queryKey: ['getCheckinCode', eventId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return axiosClient.get<{
|
return axiosClient.get<{
|
||||||
@@ -13,5 +13,6 @@ export function useCheckinCode(eventId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import type { AuthorizeSearchParams } from '@/routes/authorize';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { axiosClient } from '@/lib/axios';
|
import { axiosClient } from '@/lib/axios';
|
||||||
|
|
||||||
interface GetMagicLinkPayload {
|
interface GetMagicLinkPayload extends AuthorizeSearchParams {
|
||||||
email: string;
|
email: string;
|
||||||
turnstile_token: string;
|
turnstile_token: string;
|
||||||
}
|
}
|
||||||
@@ -9,7 +10,7 @@ interface GetMagicLinkPayload {
|
|||||||
export function useGetMagicLink() {
|
export function useGetMagicLink() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (payload: GetMagicLinkPayload) => {
|
mutationFn: async (payload: GetMagicLinkPayload) => {
|
||||||
return axiosClient.post<object>('/auth/magic', payload);
|
return axiosClient.post<{ status: string }>('/auth/magic', payload);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
22
client/cms/src/hooks/data/useUpdateUser.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { axiosClient } from '@/lib/axios';
|
||||||
|
|
||||||
|
interface UpdateUserPayload {
|
||||||
|
avatar?: string;
|
||||||
|
bio?: string;
|
||||||
|
nickname?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: UpdateUserPayload) => {
|
||||||
|
return axiosClient.patch<{ status: string }>('/user/update', payload);
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['userInfo'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,16 +6,18 @@ export function useUserInfo() {
|
|||||||
queryKey: ['userInfo'],
|
queryKey: ['userInfo'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await axiosClient.get<{
|
const response = await axiosClient.get<{
|
||||||
|
username: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
email: string;
|
email: string;
|
||||||
type: string;
|
type: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
checkin: string | null;
|
bio: string;
|
||||||
}
|
}
|
||||||
>('/user/info');
|
>('/user/info');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7,7 +7,7 @@ export function useLogout() {
|
|||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
void navigate({ to: '/login' });
|
void navigate({ to: '/authorize' });
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return { logout };
|
return { logout };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
67
client/cms/src/lib/axios.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import type { AxiosError, AxiosRequestConfig } from 'axios';
|
||||||
|
import type { JsonValue } from 'type-fest';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { isNil } from 'lodash-es';
|
||||||
|
import { router } from '@/lib/router';
|
||||||
|
import { clearTokens, doRefreshToken, getRefreshToken, getToken, setRefreshToken, setToken } from './token';
|
||||||
|
|
||||||
|
export const HEADER_API_VERSION = {
|
||||||
|
'X-Api-Version': 'latest',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const axiosClient = axios.create({
|
||||||
|
baseURL: '/api/v1/',
|
||||||
|
headers: HEADER_API_VERSION,
|
||||||
|
});
|
||||||
|
|
||||||
|
axiosClient.interceptors.request.use((config) => {
|
||||||
|
const token = getToken();
|
||||||
|
if (token !== null) {
|
||||||
|
config.headers = config.headers ?? {};
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
type RetryConfig = AxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
interface ResponseData {
|
||||||
|
code: number;
|
||||||
|
error_id: string;
|
||||||
|
status: string;
|
||||||
|
data: JsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
axiosClient.interceptors.response.use(async (response) => {
|
||||||
|
const data = response.data as ResponseData;
|
||||||
|
if (data.code !== 200) {
|
||||||
|
return Promise.reject(data);
|
||||||
|
}
|
||||||
|
response.data = data.data;
|
||||||
|
return response;
|
||||||
|
}, async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as RetryConfig | undefined;
|
||||||
|
if (!error.response || error.response.status !== 401 || !originalRequest) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
if (error.response.status === 401 && originalRequest.url !== '/auth/refresh' && !isNil(getRefreshToken())) {
|
||||||
|
try {
|
||||||
|
const maybeRefreshTokenResponse = await doRefreshToken();
|
||||||
|
if (maybeRefreshTokenResponse.status !== 200) {
|
||||||
|
throw new Error('Failed to refresh token');
|
||||||
|
}
|
||||||
|
const { access_token, refresh_token } = maybeRefreshTokenResponse.data;
|
||||||
|
originalRequest.headers = originalRequest.headers ?? {};
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||||
|
setToken(access_token);
|
||||||
|
setRefreshToken(refresh_token);
|
||||||
|
return await axiosClient(originalRequest);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||||
|
catch (e) {
|
||||||
|
// Should remove token (tokens are out of date)
|
||||||
|
clearTokens();
|
||||||
|
await router.navigate({ to: '/authorize' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
21
client/cms/src/lib/navData.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
IconDashboard,
|
||||||
|
IconUser,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
export const navData = {
|
||||||
|
navMain: [
|
||||||
|
{
|
||||||
|
title: '工作台',
|
||||||
|
url: '/',
|
||||||
|
icon: IconDashboard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
navSecondary: [
|
||||||
|
{
|
||||||
|
title: '个人资料',
|
||||||
|
url: '/profile',
|
||||||
|
icon: IconUser,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
14
client/cms/src/lib/random.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generate a cryptographically secure OAuth2 state string
|
||||||
|
* base64url encoded, URL-safe
|
||||||
|
*/
|
||||||
|
export function generateOAuthState(bytes: number = 32): string {
|
||||||
|
const random = new Uint8Array(bytes);
|
||||||
|
crypto.getRandomValues(random);
|
||||||
|
|
||||||
|
// base64url encode
|
||||||
|
return btoa(String.fromCharCode(...random))
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import axios from 'axios';
|
import { axiosClient, HEADER_API_VERSION } from './axios';
|
||||||
|
|
||||||
export function setToken(token: string) {
|
export function setToken(token: string) {
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
@@ -29,6 +29,12 @@ export function clearTokens() {
|
|||||||
setRefreshToken('');
|
setRefreshToken('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function doRefreshToken() {
|
export async function doSetTokenByCode(code: string) {
|
||||||
return axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
|
const { data } = await axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/token', { code }, { headers: HEADER_API_VERSION });
|
||||||
|
setToken(data.access_token);
|
||||||
|
setRefreshToken(data.refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function doRefreshToken() {
|
||||||
|
return axiosClient.post<{ access_token: string; refresh_token: string }>('/auth/refresh', { refresh_token: getRefreshToken() }, { headers: HEADER_API_VERSION });
|
||||||
}
|
}
|
||||||
19
client/cms/src/lib/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ClassValue } from 'clsx';
|
||||||
|
// eslint-disable-next-line unicorn/prefer-node-protocol
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToUtf8(base64: string): string {
|
||||||
|
return new TextDecoder('utf-8').decode(
|
||||||
|
Uint8Array.from(Buffer.from(base64, 'base64')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function utf8ToBase64(utf8: string): string {
|
||||||
|
return Buffer.from(utf8, 'utf-8').toString('base64');
|
||||||
|
}
|
||||||
@@ -9,19 +9,26 @@
|
|||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// 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 rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as TokenRouteImport } from './routes/token'
|
||||||
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
import { Route as MagicLinkSentRouteImport } from './routes/magicLinkSent'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as AuthorizeRouteImport } from './routes/authorize'
|
||||||
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
|
import { Route as SidebarLayoutRouteImport } from './routes/_sidebarLayout'
|
||||||
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
import { Route as SidebarLayoutIndexRouteImport } from './routes/_sidebarLayout/index'
|
||||||
|
import { Route as SidebarLayoutProfileRouteImport } from './routes/_sidebarLayout/profile'
|
||||||
|
|
||||||
|
const TokenRoute = TokenRouteImport.update({
|
||||||
|
id: '/token',
|
||||||
|
path: '/token',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
|
const MagicLinkSentRoute = MagicLinkSentRouteImport.update({
|
||||||
id: '/magicLinkSent',
|
id: '/magicLinkSent',
|
||||||
path: '/magicLinkSent',
|
path: '/magicLinkSent',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const AuthorizeRoute = AuthorizeRouteImport.update({
|
||||||
id: '/login',
|
id: '/authorize',
|
||||||
path: '/login',
|
path: '/authorize',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
const SidebarLayoutRoute = SidebarLayoutRouteImport.update({
|
||||||
@@ -33,45 +40,66 @@ const SidebarLayoutIndexRoute = SidebarLayoutIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => SidebarLayoutRoute,
|
getParentRoute: () => SidebarLayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
||||||
|
id: '/profile',
|
||||||
|
path: '/profile',
|
||||||
|
getParentRoute: () => SidebarLayoutRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/login': typeof LoginRoute
|
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
|
'/authorize': typeof AuthorizeRoute
|
||||||
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/profile': typeof SidebarLayoutProfileRoute
|
||||||
'/': typeof SidebarLayoutIndexRoute
|
'/': typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
'/_sidebarLayout': typeof SidebarLayoutRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/authorize': typeof AuthorizeRoute
|
||||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||||
|
'/token': typeof TokenRoute
|
||||||
|
'/_sidebarLayout/profile': typeof SidebarLayoutProfileRoute
|
||||||
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
'/_sidebarLayout/': typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/login' | '/magicLinkSent' | '/'
|
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/login' | '/magicLinkSent' | '/'
|
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_sidebarLayout'
|
| '/_sidebarLayout'
|
||||||
| '/login'
|
| '/authorize'
|
||||||
| '/magicLinkSent'
|
| '/magicLinkSent'
|
||||||
|
| '/token'
|
||||||
|
| '/_sidebarLayout/profile'
|
||||||
| '/_sidebarLayout/'
|
| '/_sidebarLayout/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
SidebarLayoutRoute: typeof SidebarLayoutRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
AuthorizeRoute: typeof AuthorizeRoute
|
||||||
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
MagicLinkSentRoute: typeof MagicLinkSentRoute
|
||||||
|
TokenRoute: typeof TokenRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
|
'/token': {
|
||||||
|
id: '/token'
|
||||||
|
path: '/token'
|
||||||
|
fullPath: '/token'
|
||||||
|
preLoaderRoute: typeof TokenRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/magicLinkSent': {
|
'/magicLinkSent': {
|
||||||
id: '/magicLinkSent'
|
id: '/magicLinkSent'
|
||||||
path: '/magicLinkSent'
|
path: '/magicLinkSent'
|
||||||
@@ -79,17 +107,17 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof MagicLinkSentRouteImport
|
preLoaderRoute: typeof MagicLinkSentRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/authorize': {
|
||||||
id: '/login'
|
id: '/authorize'
|
||||||
path: '/login'
|
path: '/authorize'
|
||||||
fullPath: '/login'
|
fullPath: '/authorize'
|
||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof AuthorizeRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/_sidebarLayout': {
|
'/_sidebarLayout': {
|
||||||
id: '/_sidebarLayout'
|
id: '/_sidebarLayout'
|
||||||
path: ''
|
path: ''
|
||||||
fullPath: ''
|
fullPath: '/'
|
||||||
preLoaderRoute: typeof SidebarLayoutRouteImport
|
preLoaderRoute: typeof SidebarLayoutRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
@@ -100,14 +128,23 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
|
preLoaderRoute: typeof SidebarLayoutIndexRouteImport
|
||||||
parentRoute: typeof SidebarLayoutRoute
|
parentRoute: typeof SidebarLayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_sidebarLayout/profile': {
|
||||||
|
id: '/_sidebarLayout/profile'
|
||||||
|
path: '/profile'
|
||||||
|
fullPath: '/profile'
|
||||||
|
preLoaderRoute: typeof SidebarLayoutProfileRouteImport
|
||||||
|
parentRoute: typeof SidebarLayoutRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarLayoutRouteChildren {
|
interface SidebarLayoutRouteChildren {
|
||||||
|
SidebarLayoutProfileRoute: typeof SidebarLayoutProfileRoute
|
||||||
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
|
SidebarLayoutIndexRoute: typeof SidebarLayoutIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
|
const SidebarLayoutRouteChildren: SidebarLayoutRouteChildren = {
|
||||||
|
SidebarLayoutProfileRoute: SidebarLayoutProfileRoute,
|
||||||
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
|
SidebarLayoutIndexRoute: SidebarLayoutIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +154,9 @@ const SidebarLayoutRouteWithChildren = SidebarLayoutRoute._addFileChildren(
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
SidebarLayoutRoute: SidebarLayoutRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
AuthorizeRoute: AuthorizeRoute,
|
||||||
MagicLinkSentRoute: MagicLinkSentRoute,
|
MagicLinkSentRoute: MagicLinkSentRoute,
|
||||||
|
TokenRoute: TokenRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
@@ -4,7 +4,24 @@ import { ThemeProvider } from '@/components/theme-provider';
|
|||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import '@/index.css';
|
import '@/index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: (failureCount, error: any) => {
|
||||||
|
// eslint-disable-next-line ts/no-unsafe-assignment
|
||||||
|
const status
|
||||||
|
// eslint-disable-next-line ts/no-unsafe-member-access
|
||||||
|
= error?.response?.status ?? error?.status;
|
||||||
|
|
||||||
|
if (status >= 400 && status < 500) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return failureCount < 3;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function RootLayout() {
|
function RootLayout() {
|
||||||
return (
|
return (
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
import { createFileRoute, Outlet } from '@tanstack/react-router';
|
||||||
import { AppSidebar } from '@/components/app-sidebar';
|
import { AppSidebar } from '@/components/sidebar/app-sidebar';
|
||||||
import { SiteHeader } from '@/components/site-header';
|
import { SiteHeader } from '@/components/site-header';
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
||||||
|
|
||||||
26
client/cms/src/routes/_sidebarLayout/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
|
import { hasToken } from '@/lib/token';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_sidebarLayout/')({
|
||||||
|
component: Index,
|
||||||
|
loader: async () => {
|
||||||
|
if (!hasToken()) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/authorize',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Index() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||||
|
{/* Section Cards */}
|
||||||
|
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card
|
||||||
|
grid grid-cols-1 gap-4 px-4 auto-rows-[minmax(175px,auto)] items-stretch
|
||||||
|
*:data-[slot=card]:bg-linear-to-t *:data-[slot=card]:shadow-xs lg:px-6 @xl/main:grid-cols-2 @5xl/main:grid-cols-4"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
client/cms/src/routes/_sidebarLayout/profile.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { MainProfile } from '@/components/profile/main-profile';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_sidebarLayout/profile')({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-6 px-4 py-6">
|
||||||
|
<MainProfile />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
client/cms/src/routes/authorize.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { zodValidator } from '@tanstack/zod-adapter';
|
||||||
|
import { isNil } from 'lodash-es';
|
||||||
|
import z from 'zod';
|
||||||
|
import { LoginForm } from '@/components/login-form';
|
||||||
|
import { axiosClient } from '@/lib/axios';
|
||||||
|
import { generateOAuthState } from '@/lib/random';
|
||||||
|
import { getToken } from '@/lib/token';
|
||||||
|
|
||||||
|
const authorizeSchema = z.object({
|
||||||
|
response_type: z.literal('code').default('code'),
|
||||||
|
client_id: z.literal('org_client').default('org_client'),
|
||||||
|
redirect_uri: z.string().default(`${new URL(import.meta.env.VITE_APP_BASE_URL as string).toString()}token`),
|
||||||
|
state: z.string().default(generateOAuthState()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthorizeSearchParams = z.infer<typeof authorizeSchema>;
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/authorize')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: zodValidator(authorizeSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const token = getToken();
|
||||||
|
const oauthParams = Route.useSearch();
|
||||||
|
/**
|
||||||
|
* Auth by Token Flow
|
||||||
|
*/
|
||||||
|
if (!isNil(token)) {
|
||||||
|
axiosClient.post<{ redirect_uri: string }>('/auth/exchange', {
|
||||||
|
client_id: oauthParams.client_id,
|
||||||
|
redirect_uri: oauthParams.redirect_uri,
|
||||||
|
state: oauthParams.state,
|
||||||
|
}).then((res) => {
|
||||||
|
window.location.href = res.data.redirect_uri;
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
return 'Token exchange failed';
|
||||||
|
});
|
||||||
|
return 'Redirecting';
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<LoginForm oauthParams={oauthParams} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import NixOSLogo from '@/assets/nixos.svg?react';
|
|||||||
const paramsSchema = z.object({
|
const paramsSchema = z.object({
|
||||||
email: z.string().optional(),
|
email: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Route = createFileRoute('/magicLinkSent')({
|
export const Route = createFileRoute('/magicLinkSent')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
validateSearch: zodValidator(paramsSchema),
|
validateSearch: zodValidator(paramsSchema),
|
||||||
@@ -16,7 +15,8 @@ function RouteComponent() {
|
|||||||
const { email } = Route.useSearch();
|
const { email } = Route.useSearch();
|
||||||
return email !== undefined
|
return email !== undefined
|
||||||
? (
|
? (
|
||||||
<div className="
|
<div
|
||||||
|
className="
|
||||||
bg-background flex min-h-svh flex-row items-center justify-center gap-6 p-6 md:p-10"
|
bg-background flex min-h-svh flex-row items-center justify-center gap-6 p-6 md:p-10"
|
||||||
>
|
>
|
||||||
<NixOSLogo className="size-12" />
|
<NixOSLogo className="size-12" />
|
||||||
@@ -29,5 +29,7 @@ function RouteComponent() {
|
|||||||
{email}
|
{email}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
: <Navigate to="/login" />;
|
: (
|
||||||
|
<Navigate to="/authorize" />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
25
client/cms/src/routes/token.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import z from 'zod';
|
||||||
|
import { doSetTokenByCode } from '@/lib/token';
|
||||||
|
|
||||||
|
const tokenCodeSchema = z.object({
|
||||||
|
code: z.string().nonempty(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/token')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: tokenCodeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { code } = Route.useSearch();
|
||||||
|
const [status, setStatus] = useState('Loading...');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
doSetTokenByCode(code).then(() => {
|
||||||
|
void navigate({ to: '/' });
|
||||||
|
}).catch((_) => {
|
||||||
|
setStatus('Error getting token');
|
||||||
|
});
|
||||||
|
return <div>{status}</div>;
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://10.0.0.10:8000',
|
'/api': 'http://10.0.0.10:8000',
|
||||||
},
|
},
|
||||||
allowedHosts: ['dev.sne.moe'],
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
allowedHosts: ['nix.org.cn', 'nixos.party'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
8
client/mobile/.envrc
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
source_up
|
||||||
|
|
||||||
|
fvm install
|
||||||
|
|
||||||
|
PATH_add .fvm/flutter_sdk/bin
|
||||||
|
PATH_add .fvm/flutter_sdk/bin/cache/dart-sdk/bin
|
||||||
3
client/mobile/.fvmrc
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"flutter": "3.38.0"
|
||||||
|
}
|
||||||
19
client/mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# fvm
|
||||||
|
.fvm/
|
||||||
|
|
||||||
|
# dart
|
||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
.pub-cache/
|
||||||
|
.pub/
|
||||||
|
|
||||||
|
# build
|
||||||
|
build/
|
||||||
|
|
||||||
|
# vscode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# idea
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
android/*.iml
|
||||||
33
client/mobile/.metadata
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# This file tracks properties of this Flutter project.
|
||||||
|
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||||
|
#
|
||||||
|
# This file should be version controlled and should not be manually edited.
|
||||||
|
|
||||||
|
version:
|
||||||
|
revision: "a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7"
|
||||||
|
channel: "stable"
|
||||||
|
|
||||||
|
project_type: app
|
||||||
|
|
||||||
|
# Tracks metadata for the flutter migrate command
|
||||||
|
migration:
|
||||||
|
platforms:
|
||||||
|
- platform: root
|
||||||
|
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
- platform: android
|
||||||
|
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
- platform: ios
|
||||||
|
create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7
|
||||||
|
|
||||||
|
# User provided section
|
||||||
|
|
||||||
|
# List of Local paths (relative to this file) that should be
|
||||||
|
# ignored by the migrate tool.
|
||||||
|
#
|
||||||
|
# Files that are not part of the templates will be ignored by default.
|
||||||
|
unmanaged_files:
|
||||||
|
- 'lib/main.dart'
|
||||||
|
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||||
16
client/mobile/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# nixcn
|
||||||
|
|
||||||
|
A new Flutter project.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
This project is a starting point for a Flutter application.
|
||||||
|
|
||||||
|
A few resources to get you started if this is your first Flutter project:
|
||||||
|
|
||||||
|
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||||
|
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
|
||||||
|
|
||||||
|
For help getting started with Flutter development, view the
|
||||||
|
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||||
|
samples, guidance on mobile development, and a full API reference.
|
||||||
28
client/mobile/analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# This file configures the analyzer, which statically analyzes Dart code to
|
||||||
|
# check for errors, warnings, and lints.
|
||||||
|
#
|
||||||
|
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||||
|
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||||
|
# invoked from the command line by running `flutter analyze`.
|
||||||
|
|
||||||
|
# The following line activates a set of recommended lints for Flutter apps,
|
||||||
|
# packages, and plugins designed to encourage good coding practices.
|
||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
# The lint rules applied to this project can be customized in the
|
||||||
|
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||||
|
# included above or to enable additional rules. A list of all available lints
|
||||||
|
# and their documentation is published at https://dart.dev/lints.
|
||||||
|
#
|
||||||
|
# Instead of disabling a lint rule for the entire project in the
|
||||||
|
# section below, it can also be suppressed for a single line of code
|
||||||
|
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||||
|
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||||
|
# producing the lint.
|
||||||
|
rules:
|
||||||
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
|
|
||||||
|
# Additional information about this file can be found at
|
||||||
|
# https://dart.dev/guides/language/analysis-options
|
||||||
14
client/mobile/android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
gradle-wrapper.jar
|
||||||
|
/.gradle
|
||||||
|
/captures/
|
||||||
|
/gradlew
|
||||||
|
/gradlew.bat
|
||||||
|
/local.properties
|
||||||
|
GeneratedPluginRegistrant.java
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Remember to never publicly share your keystore.
|
||||||
|
# See https://flutter.dev/to/reference-keystore
|
||||||
|
key.properties
|
||||||
|
**/*.keystore
|
||||||
|
**/*.jks
|
||||||
44
client/mobile/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.asnk.applications.nixcn"
|
||||||
|
compileSdk = flutter.compileSdkVersion
|
||||||
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
|
applicationId = "io.asnk.applications.nixcn"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
|
minSdk = flutter.minSdkVersion
|
||||||
|
targetSdk = flutter.targetSdkVersion
|
||||||
|
versionCode = flutter.versionCode
|
||||||
|
versionName = flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source = "../.."
|
||||||
|
}
|
||||||
7
client/mobile/android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
|
the Flutter tool needs it to communicate with the running application
|
||||||
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
</manifest>
|
||||||
45
client/mobile/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application
|
||||||
|
android:label="nixcn"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package io.asnk.applications.nixcn
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="?android:colorBackground" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@android:color/white" />
|
||||||
|
|
||||||
|
<!-- You can insert your own image assets here -->
|
||||||
|
<!-- <item>
|
||||||
|
<bitmap
|
||||||
|
android:gravity="center"
|
||||||
|
android:src="@mipmap/launch_image" />
|
||||||
|
</item> -->
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 442 B |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
18
client/mobile/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<!-- Show a splash screen on the activity. Automatically removed when
|
||||||
|
the Flutter engine draws its first frame -->
|
||||||
|
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||||
|
</style>
|
||||||
|
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||||
|
This theme determines the color of the Android Window while your
|
||||||
|
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||||
|
running.
|
||||||
|
|
||||||
|
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||||
|
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">?android:colorBackground</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||