Compare commits
82 Commits
af43b86a61
...
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
|
@@ -1 +1,2 @@
|
||||
TZ=Asia/Shanghai
|
||||
LOG_LEVEL=debug
|
||||
|
||||
3
.gitignore
vendored
@@ -46,3 +46,6 @@ go.work.sum
|
||||
.DS_Store
|
||||
__MACOSX
|
||||
._*
|
||||
|
||||
# go gen
|
||||
*_gen.go
|
||||
|
||||
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" ]
|
||||
0
charts/.gitkeep
Normal file
1681
client/bun.lock
@@ -3,7 +3,7 @@ import pluginQuery from '@tanstack/eslint-plugin-query';
|
||||
|
||||
export default antfu({
|
||||
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,
|
||||
stylistic: {
|
||||
semi: true,
|
||||
@@ -37,21 +37,27 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/zod-adapter": "^1.143.4",
|
||||
"@tanstack/zod-form-adapter": "^0.42.1",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"axios": "^1.13.2",
|
||||
"base-64": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"culori": "^4.0.2",
|
||||
"immer": "^11.1.0",
|
||||
"lodash-es": "^4.17.22",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.69.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"utf8": "^3.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
@@ -60,13 +66,17 @@
|
||||
"@antfu/eslint-config": "^6.7.1",
|
||||
"@eslint-react/eslint-plugin": "^2.3.13",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/eslint-plugin-query": "^5.91.2",
|
||||
"@tanstack/router-plugin": "^1.141.7",
|
||||
"@types/base-64": "^1.0.2",
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/utf8": "^3.0.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -75,6 +85,7 @@
|
||||
"lint-staged": "^16.2.7",
|
||||
"simple-git-hooks": "^2.13.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"type-fest": "^5.4.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4",
|
||||
@@ -85,5 +96,6 @@
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "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 |
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>
|
||||
);
|
||||
}
|
||||
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,13 +6,14 @@ export function useUserInfo() {
|
||||
queryKey: ['userInfo'],
|
||||
queryFn: async () => {
|
||||
const response = await axiosClient.get<{
|
||||
username: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
type: string;
|
||||
nickname: string;
|
||||
subtitle: string;
|
||||
avatar: string;
|
||||
checkin: string | null;
|
||||
bio: string;
|
||||
}
|
||||
>('/user/info');
|
||||
return response.data;
|
||||
@@ -1,5 +1,6 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+Mono:wght@100..900&display=swap");
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@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' });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { axiosClient, HEADER_API_VERSION } from './axios';
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem('token', token);
|
||||
@@ -30,11 +30,11 @@ export function clearTokens() {
|
||||
}
|
||||
|
||||
export async function doSetTokenByCode(code: string) {
|
||||
const { data } = await axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/token', { code });
|
||||
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 axios.post<{ access_token: string; refresh_token: string }>('/api/v1/auth/refresh', { refresh_token: getRefreshToken() });
|
||||
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');
|
||||
}
|
||||
@@ -47,11 +47,11 @@ const SidebarLayoutProfileRoute = SidebarLayoutProfileRouteImport.update({
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof SidebarLayoutIndexRoute
|
||||
'/authorize': typeof AuthorizeRoute
|
||||
'/magicLinkSent': typeof MagicLinkSentRoute
|
||||
'/token': typeof TokenRoute
|
||||
'/profile': typeof SidebarLayoutProfileRoute
|
||||
'/': typeof SidebarLayoutIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/authorize': typeof AuthorizeRoute
|
||||
@@ -71,7 +71,7 @@ export interface FileRoutesById {
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||
fullPaths: '/' | '/authorize' | '/magicLinkSent' | '/token' | '/profile'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/authorize' | '/magicLinkSent' | '/token' | '/profile' | '/'
|
||||
id:
|
||||
@@ -117,7 +117,7 @@ declare module '@tanstack/react-router' {
|
||||
'/_sidebarLayout': {
|
||||
id: '/_sidebarLayout'
|
||||
path: ''
|
||||
fullPath: ''
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof SidebarLayoutRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||
import { CheckinCard } from '@/components/workbenchCards/checkin';
|
||||
import { hasToken } from '@/lib/token';
|
||||
|
||||
export const Route = createFileRoute('/_sidebarLayout/')({
|
||||
@@ -21,7 +20,6 @@ function Index() {
|
||||
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"
|
||||
>
|
||||
<CheckinCard />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -7,7 +7,7 @@ export const Route = createFileRoute('/_sidebarLayout/profile')({
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-col gap-6 px-4 py-6">
|
||||
<div className="flex h-full flex-col gap-6 px-4 py-6">
|
||||
<MainProfile />
|
||||
</div>
|
||||
);
|
||||
@@ -1,7 +1,9 @@
|
||||
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';
|
||||
|
||||
@@ -22,15 +24,21 @@ export const Route = createFileRoute('/authorize')({
|
||||
function RouteComponent() {
|
||||
const token = getToken();
|
||||
const oauthParams = Route.useSearch();
|
||||
if (token !== null) {
|
||||
const base = new URL(window.location.origin);
|
||||
const url = new URL('/api/v1/auth/redirect', base);
|
||||
url.searchParams.set('client_id', oauthParams.client_id);
|
||||
url.searchParams.set('response_type', oauthParams.response_type);
|
||||
url.searchParams.set('redirect_uri', oauthParams.redirect_uri);
|
||||
url.searchParams.set('state', oauthParams.state);
|
||||
window.location.href = url.toString();
|
||||
return null;
|
||||
/**
|
||||
* 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">
|
||||
@@ -6,7 +6,6 @@ import NixOSLogo from '@/assets/nixos.svg?react';
|
||||
const paramsSchema = z.object({
|
||||
email: z.string().optional(),
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/magicLinkSent')({
|
||||
component: RouteComponent,
|
||||
validateSearch: zodValidator(paramsSchema),
|
||||
@@ -16,7 +15,8 @@ function RouteComponent() {
|
||||
const { email } = Route.useSearch();
|
||||
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"
|
||||
>
|
||||
<NixOSLogo className="size-12" />
|
||||
@@ -29,5 +29,7 @@ function RouteComponent() {
|
||||
{email}
|
||||
</div>
|
||||
)
|
||||
: <Navigate to="/login" />;
|
||||
: (
|
||||
<Navigate to="/authorize" />
|
||||
);
|
||||
}
|
||||
@@ -23,10 +23,10 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://10.0.0.250:8000',
|
||||
'/api': 'http://10.0.0.10:8000',
|
||||
},
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
allowedHosts: ['test.sne.moe'],
|
||||
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>
|
||||
@@ -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>
|
||||
24
client/mobile/android/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||