add: logout in frontend
This commit is contained in:
parent
75973c72ee
commit
2cf5923b28
75
packages/backend/routes/session/[id]/DELETE.ts
Normal file
75
packages/backend/routes/session/[id]/DELETE.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Context } from "hono";
|
||||||
|
import { Bindings, BlankEnv } from "hono/types";
|
||||||
|
import { ErrorResponse } from "src/schema";
|
||||||
|
import { createHandlers } from "src/utils.ts";
|
||||||
|
import { sqlCred } from "@core/db/dbNew";
|
||||||
|
import { object, string, ValidationError } from "yup";
|
||||||
|
import { setCookie } from "hono/cookie";
|
||||||
|
|
||||||
|
const loginSessionExists = async (sessionID: string) => {
|
||||||
|
const result = await sqlCred`
|
||||||
|
SELECT 1
|
||||||
|
FROM login_sessions
|
||||||
|
WHERE id = ${sessionID}
|
||||||
|
`;
|
||||||
|
return result.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logoutHandler = createHandlers(async (c: Context<BlankEnv & { Bindings: Bindings }, "/session/:id">) => {
|
||||||
|
try {
|
||||||
|
const session_id = c.req.param("id");
|
||||||
|
|
||||||
|
const exists = loginSessionExists(session_id);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
const response: ErrorResponse<string> = {
|
||||||
|
message: "Cannot found given session_id.",
|
||||||
|
errors: [`Session ${session_id} not found`],
|
||||||
|
code: "ENTITY_NOT_FOUND"
|
||||||
|
};
|
||||||
|
return c.json<ErrorResponse<string>>(response, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sqlCred`
|
||||||
|
UPDATE login_sessions
|
||||||
|
SET deactivated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ${session_id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
|
setCookie(c, "session_id", "", {
|
||||||
|
path: "/",
|
||||||
|
maxAge: 0,
|
||||||
|
domain: process.env.DOMAIN,
|
||||||
|
secure: isDev ? true : true,
|
||||||
|
sameSite: isDev ? "None" : "Lax",
|
||||||
|
httpOnly: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.body(null, 204);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ValidationError) {
|
||||||
|
const response: ErrorResponse<string> = {
|
||||||
|
message: "Invalid registration data.",
|
||||||
|
errors: e.errors,
|
||||||
|
code: "INVALID_PAYLOAD"
|
||||||
|
};
|
||||||
|
return c.json<ErrorResponse<string>>(response, 400);
|
||||||
|
} else if (e instanceof SyntaxError) {
|
||||||
|
const response: ErrorResponse<string> = {
|
||||||
|
message: "Invalid JSON payload.",
|
||||||
|
errors: [e.message],
|
||||||
|
code: "INVALID_FORMAT"
|
||||||
|
};
|
||||||
|
return c.json<ErrorResponse<string>>(response, 400);
|
||||||
|
} else {
|
||||||
|
const response: ErrorResponse<string> = {
|
||||||
|
message: "Unknown error.",
|
||||||
|
errors: [(e as Error).message],
|
||||||
|
code: "UNKNOWN_ERROR"
|
||||||
|
};
|
||||||
|
return c.json<ErrorResponse<string>>(response, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
1
packages/backend/routes/session/index.ts
Normal file
1
packages/backend/routes/session/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./[id]/DELETE";
|
@ -7,6 +7,7 @@ import { Variables } from "hono/types";
|
|||||||
import { createCaptchaSessionHandler, verifyChallengeHandler } from "routes/captcha";
|
import { createCaptchaSessionHandler, verifyChallengeHandler } from "routes/captcha";
|
||||||
import { getCaptchaDifficultyHandler } from "routes/captcha/difficulty/GET.ts";
|
import { getCaptchaDifficultyHandler } from "routes/captcha/difficulty/GET.ts";
|
||||||
import { loginHandler } from "@/routes/login/session/POST";
|
import { loginHandler } from "@/routes/login/session/POST";
|
||||||
|
import { logoutHandler } from "@/routes/session";
|
||||||
|
|
||||||
export function configureRoutes(app: Hono<{ Variables: Variables }>) {
|
export function configureRoutes(app: Hono<{ Variables: Variables }>) {
|
||||||
app.get("/", ...rootHandler);
|
app.get("/", ...rootHandler);
|
||||||
@ -17,6 +18,8 @@ export function configureRoutes(app: Hono<{ Variables: Variables }>) {
|
|||||||
|
|
||||||
app.post("/login/session", ...loginHandler);
|
app.post("/login/session", ...loginHandler);
|
||||||
|
|
||||||
|
app.delete("/session/:id", ...logoutHandler);
|
||||||
|
|
||||||
app.post("/user", ...registerHandler);
|
app.post("/user", ...registerHandler);
|
||||||
app.get("/user/session/:id", ...getUserByLoginSessionHandler);
|
app.get("/user/session/:id", ...getUserByLoginSessionHandler);
|
||||||
|
|
||||||
|
@ -1,9 +1,20 @@
|
|||||||
import { LeftArrow } from "@/components/icons/LeftArrow";
|
import { LeftArrow } from "@/components/icons/LeftArrow";
|
||||||
import { RightArrow } from "@/components/icons/RightArrow";
|
import { RightArrow } from "@/components/icons/RightArrow";
|
||||||
import LoginForm from "./LoginForm";
|
import LoginForm from "./LoginForm";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link, redirect } from "@/i18n/navigation";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { getCurrentUser } from "@/lib/userAuth";
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default async function LoginPage() {
|
||||||
|
const user = await getCurrentUser();
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
redirect({
|
||||||
|
href: `/user/${user.uid}/profile`,
|
||||||
|
locale: locale
|
||||||
|
});
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<main className="relative flex-grow pt-8 px-4 md:w-full md:h-full md:flex md:items-center md:justify-center">
|
<main className="relative flex-grow pt-8 px-4 md:w-full md:h-full md:flex md:items-center md:justify-center">
|
||||||
<div
|
<div
|
||||||
|
60
packages/next/app/[locale]/logout/route.ts
Normal file
60
packages/next/app/[locale]/logout/route.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { ApiRequestError, fetcher } from "@/lib/net";
|
||||||
|
import { ErrorResponse } from "@cvsa/backend";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const backendURL = process.env.BACKEND_URL || "";
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionID = cookieStore.get("session_id");
|
||||||
|
if (!sessionID) {
|
||||||
|
const response: ErrorResponse<string> = {
|
||||||
|
message: "No session_id provided",
|
||||||
|
errors: [],
|
||||||
|
code: "ENTITY_NOT_FOUND"
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(response), {
|
||||||
|
status: 401
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetcher(`${backendURL}/session/${sessionID.value}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = response.headers;
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 204,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": (headers["set-cookie"] || [""])[0]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiRequestError && error.response) {
|
||||||
|
const res = error.response;
|
||||||
|
const code = error.code;
|
||||||
|
return new Response(JSON.stringify(res), {
|
||||||
|
status: code
|
||||||
|
});
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
const response: ErrorResponse<string> = {
|
||||||
|
message: error.message,
|
||||||
|
errors: [],
|
||||||
|
code: "SERVER_ERROR"
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(response), {
|
||||||
|
status: 500
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const response: ErrorResponse<string> = {
|
||||||
|
message: "Unknown error occurred",
|
||||||
|
errors: [],
|
||||||
|
code: "UNKNOWN_ERROR"
|
||||||
|
};
|
||||||
|
return new Response(JSON.stringify(response), {
|
||||||
|
status: 500
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,46 +0,0 @@
|
|||||||
import { Header } from "@/components/shell/Header";
|
|
||||||
import { getCurrentUser, User } from "@/lib/userAuth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { zhCN } from "date-fns/locale";
|
|
||||||
|
|
||||||
interface SignupTimeProps {
|
|
||||||
user: User;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SignupTime: React.FC<SignupTimeProps> = ({ user }: SignupTimeProps) => {
|
|
||||||
return (
|
|
||||||
<p className="mt-4">
|
|
||||||
于
|
|
||||||
{format(new Date(user.createdAt), "yyyy-MM-dd HH:mm:ss", {
|
|
||||||
locale: zhCN
|
|
||||||
})}
|
|
||||||
注册。
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ProfilePage() {
|
|
||||||
const user = await getCurrentUser();
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayName = user.nickname || user.username;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Header user={user} />
|
|
||||||
<main className="md:w-xl lg:w-2xl xl:w-3xl md:mx-auto pt-6">
|
|
||||||
<h1>
|
|
||||||
<span className="text-4xl font-extralight">{displayName}</span>
|
|
||||||
<span className="ml-2 text-on-surface-variant dark:text-dark-on-surface-variant">
|
|
||||||
UID{user.uid}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<SignupTime user={user} />
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,5 +1,4 @@
|
|||||||
import { Header } from "@/components/shell/Header";
|
import HeaderServer from "@/components/shell/HeaderServer";
|
||||||
import { getCurrentUser } from "@/lib/userAuth";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
@ -7,10 +6,9 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const user = await getCurrentUser();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header user={user} />
|
<HeaderServer />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
import { Header } from "@/components/shell/Header";
|
|
||||||
import { getCurrentUser } from "@/lib/userAuth";
|
|
||||||
import { VDFtestCard } from "./TestCard";
|
import { VDFtestCard } from "./TestCard";
|
||||||
|
import HeaderServer from "@/components/shell/HeaderServer";
|
||||||
|
|
||||||
export default async function VdfBenchmarkPage() {
|
export default async function VdfBenchmarkPage() {
|
||||||
const user = await getCurrentUser();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header user={user} />
|
<HeaderServer />
|
||||||
<div className="md:w-2/3 lg:w-1/2 xl:w-[37%] md:mx-auto mx-6 mb-12">
|
<div className="md:w-2/3 lg:w-1/2 xl:w-[37%] md:mx-auto mx-6 mb-12">
|
||||||
<VDFtestCard />
|
<VDFtestCard />
|
||||||
<div>
|
<div>
|
||||||
|
@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FilledButton } from "@/components/ui/Buttons/FilledButton";
|
||||||
|
import { Dialog, DialogButton, DialogButtonGroup, DialogHeadline, DialogSupportingText } from "@/components/ui/Dialog";
|
||||||
|
import { Portal } from "@/components/utils/Portal";
|
||||||
|
import { useRouter } from "@/i18n/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const LogoutButton: React.FC = () => {
|
||||||
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FilledButton
|
||||||
|
shape="square"
|
||||||
|
className="mt-5 !text-on-error dark:!text-dark-on-error !bg-error dark:!bg-dark-error font-medium"
|
||||||
|
onClick={() => setShowDialog(true)}
|
||||||
|
>
|
||||||
|
登出
|
||||||
|
</FilledButton>
|
||||||
|
<Portal>
|
||||||
|
<Dialog show={showDialog}>
|
||||||
|
<DialogHeadline>确认登出</DialogHeadline>
|
||||||
|
<DialogSupportingText>确认要退出登录吗?</DialogSupportingText>
|
||||||
|
<DialogButtonGroup close={() => setShowDialog(false)}>
|
||||||
|
<DialogButton onClick={() => setShowDialog(false)}>取消</DialogButton>
|
||||||
|
<DialogButton
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/logout", {
|
||||||
|
method: "POST"
|
||||||
|
});
|
||||||
|
router.push("/");
|
||||||
|
} finally {
|
||||||
|
setShowDialog(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</DialogButton>
|
||||||
|
</DialogButtonGroup>
|
||||||
|
</Dialog>
|
||||||
|
</Portal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
65
packages/next/app/[locale]/user/[uid]/profile/page.tsx
Normal file
65
packages/next/app/[locale]/user/[uid]/profile/page.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { getUserProfile, User } from "@/lib/userAuth";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { zhCN } from "date-fns/locale";
|
||||||
|
import { LogoutButton } from "./LogoutButton";
|
||||||
|
import { numeric } from "yup-numeric";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import HeaderServer from "@/components/shell/HeaderServer";
|
||||||
|
|
||||||
|
const uidSchema = numeric().integer().min(0);
|
||||||
|
|
||||||
|
interface SignupTimeProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SignupTime: React.FC<SignupTimeProps> = ({ user }: SignupTimeProps) => {
|
||||||
|
return (
|
||||||
|
<p className="mt-4">
|
||||||
|
于
|
||||||
|
{format(new Date(user.createdAt), "yyyy-MM-dd HH:mm:ss", {
|
||||||
|
locale: zhCN
|
||||||
|
})}
|
||||||
|
注册。
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ProfilePage({ params }: { params: Promise<{ uid: string }> }) {
|
||||||
|
const { uid } = await params;
|
||||||
|
const t = await getTranslations("profile_page");
|
||||||
|
let parsedUID: number;
|
||||||
|
|
||||||
|
try {
|
||||||
|
uidSchema.validate(uid);
|
||||||
|
parsedUID = parseInt(uid);
|
||||||
|
} catch (error) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserProfile(parsedUID);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = user.nickname || user.username;
|
||||||
|
const loggedIn = user.isLoggedIn;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HeaderServer />
|
||||||
|
<main className="md:w-xl lg:w-2xl xl:w-3xl md:mx-auto pt-6">
|
||||||
|
<h1>
|
||||||
|
<span className="text-4xl font-extralight">{displayName}</span>
|
||||||
|
<span className="ml-2 text-on-surface-variant dark:text-dark-on-surface-variant">
|
||||||
|
UID{user.uid}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<SignupTime user={user} />
|
||||||
|
<p className="mt-4">权限组:{t(`role.${user.role}`)}</p>
|
||||||
|
{loggedIn && <LogoutButton />}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import { Header } from "@/components/shell/Header";
|
import HeaderServer from "@/components/shell/HeaderServer";
|
||||||
import { getCurrentUser } from "@/lib/userAuth";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
@ -7,10 +6,9 @@ export default async function RootLayout({
|
|||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const user = await getCurrentUser();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header user={user} />
|
<HeaderServer />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -15,6 +15,7 @@
|
|||||||
"framer-motion": "^12.15.0",
|
"framer-motion": "^12.15.0",
|
||||||
"fumadocs-mdx": "^11.6.6",
|
"fumadocs-mdx": "^11.6.6",
|
||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
|
"jotai": "^2.12.5",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.1.0",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
@ -23,6 +24,7 @@
|
|||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
"ua-parser-js": "^2.0.3",
|
"ua-parser-js": "^2.0.3",
|
||||||
"yup": "^1.6.1",
|
"yup": "^1.6.1",
|
||||||
|
"yup-numeric": "^0.5.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
@ -367,6 +369,8 @@
|
|||||||
|
|
||||||
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
|
"big.js": ["big.js@5.2.2", "", {}, "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="],
|
||||||
|
|
||||||
|
"bignumber.js": ["bignumber.js@9.3.0", "", {}, "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA=="],
|
||||||
|
|
||||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
||||||
@ -701,6 +705,8 @@
|
|||||||
|
|
||||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||||
|
|
||||||
|
"jotai": ["jotai@2.12.5", "", { "peerDependencies": { "@types/react": ">=17.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@types/react", "react"] }, "sha512-G8m32HW3lSmcz/4mbqx0hgJIQ0ekndKWiYP7kWVKi0p6saLXdSoye+FZiOFyonnd7Q482LCzm8sMDl7Ar1NWDw=="],
|
||||||
|
|
||||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||||
|
|
||||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||||
@ -1213,6 +1219,8 @@
|
|||||||
|
|
||||||
"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],
|
"yup": ["yup@1.6.1", "", { "dependencies": { "property-expr": "^2.0.5", "tiny-case": "^1.0.3", "toposort": "^2.0.2", "type-fest": "^2.19.0" } }, "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA=="],
|
||||||
|
|
||||||
|
"yup-numeric": ["yup-numeric@0.5.0", "", { "dependencies": { "typescript": "^5.4.2" }, "peerDependencies": { "bignumber.js": "^9.1.2", "yup": "^1.3.3" } }, "sha512-IrkLyIY0jLwtomVArrjV1Sv2YHOC715UdRPA7WfAJ0upARXLtmnmzszlPQeEoUxtSb3E9mrF8DoFgiQcRkxOLA=="],
|
||||||
|
|
||||||
"zod": ["zod@3.25.46", "", {}, "sha512-IqRxcHEIjqLd4LNS/zKffB3Jzg3NwqJxQQ0Ns7pdrvgGkwQsEBdEQcOHaBVqvvZArShRzI39+aMST3FBGmTrLQ=="],
|
"zod": ["zod@3.25.46", "", {}, "sha512-IqRxcHEIjqLd4LNS/zKffB3Jzg3NwqJxQQ0Ns7pdrvgGkwQsEBdEQcOHaBVqvvZArShRzI39+aMST3FBGmTrLQ=="],
|
||||||
|
|
||||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||||
|
@ -15,12 +15,12 @@ import { InfoIcon } from "@/components/icons/InfoIcon";
|
|||||||
import { HomeIcon } from "@/components/icons/HomeIcon";
|
import { HomeIcon } from "@/components/icons/HomeIcon";
|
||||||
import { TextButton } from "@/components/ui/Buttons/TextButton";
|
import { TextButton } from "@/components/ui/Buttons/TextButton";
|
||||||
import { Link } from "@/i18n/navigation";
|
import { Link } from "@/i18n/navigation";
|
||||||
import type { UserResponse } from "@cvsa/backend";
|
|
||||||
import { LoginIcon } from "../icons/LoginIcon";
|
import { LoginIcon } from "../icons/LoginIcon";
|
||||||
import { AccountIcon } from "../icons/AccountIcon";
|
import { AccountIcon } from "../icons/AccountIcon";
|
||||||
|
import { User } from "@/lib/userAuth";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
user: UserResponse | null;
|
user: User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeaderDestop = ({ user }: HeaderProps) => {
|
export const HeaderDestop = ({ user }: HeaderProps) => {
|
||||||
@ -44,7 +44,7 @@ export const HeaderDestop = ({ user }: HeaderProps) => {
|
|||||||
text-xl font-medium items-center w-[15rem] min-w-[8rem] mr-4 lg:mr-0 lg:w-[305px] justify-end"
|
text-xl font-medium items-center w-[15rem] min-w-[8rem] mr-4 lg:mr-0 lg:w-[305px] justify-end"
|
||||||
>
|
>
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href="/my/profile">{user.nickname || user.username}</Link>
|
<Link href={`/user/${user.uid}/profile`}>{user.nickname || user.username}</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/login">登录</Link>
|
<Link href="/login">登录</Link>
|
||||||
)}
|
)}
|
||||||
@ -91,7 +91,7 @@ export const HeaderMobile = ({ user }: HeaderProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<Link href="/my/profile">
|
<Link href={`/user/${user.uid}/profile`}>
|
||||||
<TextButton className="w-full h-14 flex justify-start" size="m">
|
<TextButton className="w-full h-14 flex justify-start" size="m">
|
||||||
<div className="flex items-center w-72">
|
<div className="flex items-center w-72">
|
||||||
<AccountIcon className="text-2xl pr-4" />
|
<AccountIcon className="text-2xl pr-4" />
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import useRipple from "@/components/utils/useRipple";
|
import useRipple from "@/components/utils/useRipple";
|
||||||
|
|
||||||
interface FilledButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface FilledButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import useRipple from "@/components/utils/useRipple";
|
import useRipple from "@/components/utils/useRipple";
|
||||||
|
|
||||||
interface TextButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface TextButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
@ -5,13 +7,16 @@ interface TextButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>
|
|||||||
shape?: "round" | "square";
|
shape?: "round" | "square";
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
ripple?: boolean;
|
ripple?: boolean;
|
||||||
|
ref?: React.Ref<HTMLButtonElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextButton = ({
|
export const TextButton = ({
|
||||||
children,
|
children,
|
||||||
size = "s",
|
size = "s",
|
||||||
shape = "round",
|
shape = "round",
|
||||||
className,
|
className = "",
|
||||||
|
disabled,
|
||||||
|
ref,
|
||||||
ripple = true,
|
ripple = true,
|
||||||
...rest
|
...rest
|
||||||
}: TextButtonProps) => {
|
}: TextButtonProps) => {
|
||||||
@ -29,12 +34,19 @@ export const TextButton = ({
|
|||||||
<button
|
<button
|
||||||
className={`text-primary dark:text-dark-primary duration-150 select-none
|
className={`text-primary dark:text-dark-primary duration-150 select-none
|
||||||
flex items-center justify-center relative overflow-hidden
|
flex items-center justify-center relative overflow-hidden
|
||||||
|
disabled:text-on-surface/40 disabled:dark:text-dark-on-surface/40
|
||||||
${sizeClasses} ${shapeClasses} ${className}`}
|
${sizeClasses} ${shapeClasses} ${className}`}
|
||||||
{...rest}
|
{...rest}
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
|
disabled={disabled}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<div className="absolute w-full h-full hover:bg-primary/10 left-0 top-0"></div>
|
<div
|
||||||
|
className={`absolute w-full h-full enabled:hover:bg-primary/10 enabled:dark:hover:bg-dark-primary/10
|
||||||
|
${disabled && "bg-on-surface/10 dark:bg-dark-on-surface/10"}
|
||||||
|
left-0 top-0`}
|
||||||
|
></div>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,12 @@
|
|||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import React from "react";
|
import React, { useRef } from "react";
|
||||||
import { TextButton } from "./Buttons/TextButton";
|
import { TextButton } from "./Buttons/TextButton";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { useKeyboardShortcuts } from "@/components/utils/useKeyboardEvents";
|
||||||
|
import { UAParser } from "ua-parser-js";
|
||||||
|
|
||||||
|
const focusedButtonAtom = atom(-1);
|
||||||
|
|
||||||
export const useDisableBodyScroll = (open: boolean) => {
|
export const useDisableBodyScroll = (open: boolean) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -23,10 +28,14 @@ type ButtonElementAttr = React.HTMLAttributes<HTMLButtonElement>;
|
|||||||
|
|
||||||
type DialogHeadlineProps = OptionalChidrenProps<HeadElementAttr>;
|
type DialogHeadlineProps = OptionalChidrenProps<HeadElementAttr>;
|
||||||
type DialogSupportingTextProps = OptionalChidrenProps<DivElementAttr>;
|
type DialogSupportingTextProps = OptionalChidrenProps<DivElementAttr>;
|
||||||
type DialogButtonGroupProps = OptionalChidrenProps<DivElementAttr>;
|
type DialogButtonGroupProps = DivElementAttr & {
|
||||||
|
children: React.ReactElement<DialogButtonProps> | React.ReactElement<DialogButtonProps>[];
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
interface DialogButtonProps extends OptionalChidrenProps<ButtonElementAttr> {
|
interface DialogButtonProps extends OptionalChidrenProps<ButtonElementAttr> {
|
||||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||||
|
index?: number;
|
||||||
}
|
}
|
||||||
interface DialogProps extends OptionalChidrenProps<DivElementAttr> {
|
interface DialogProps extends OptionalChidrenProps<DivElementAttr> {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@ -63,46 +72,178 @@ export const DialogSupportingText: React.FC<DialogSupportingTextProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DialogButton: React.FC<DialogButtonProps> = ({ children, onClick, ...rest }: DialogButtonProps) => {
|
export const DialogButton: React.FC<DialogButtonProps> = ({ children, onClick, index, ...rest }: DialogButtonProps) => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const focusedButton = useAtomValue(focusedButtonAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
if (focusedButton === index) buttonRef.current.focus();
|
||||||
|
}, [focusedButton]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextButton onClick={onClick} {...rest}>
|
<TextButton onClick={onClick} {...rest} ref={buttonRef}>
|
||||||
{children}
|
{children}
|
||||||
</TextButton>
|
</TextButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DialogButtonGroup: React.FC<DialogButtonGroupProps> = ({ children, ...rest }: DialogButtonGroupProps) => {
|
export const DialogButtonGroup: React.FC<DialogButtonGroupProps> = ({
|
||||||
|
children,
|
||||||
|
close,
|
||||||
|
...rest
|
||||||
|
}: DialogButtonGroupProps) => {
|
||||||
|
const [focusedButton, setFocusedButton] = useAtom(focusedButtonAtom);
|
||||||
|
const count = React.Children.count(children);
|
||||||
|
|
||||||
|
useKeyboardShortcuts([
|
||||||
|
{
|
||||||
|
key: "Tab",
|
||||||
|
callback: () => {
|
||||||
|
setFocusedButton((focusedButton + 1) % count);
|
||||||
|
},
|
||||||
|
preventDefault: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Escape",
|
||||||
|
callback: close,
|
||||||
|
preventDefault: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-2" {...rest}>
|
<div className="flex justify-end gap-2" {...rest}>
|
||||||
{children}
|
{React.Children.map(children, (child, index) => {
|
||||||
|
if (React.isValidElement<DialogButtonProps>(child) && child.type === DialogButton) {
|
||||||
|
return React.cloneElement(child, {
|
||||||
|
index: index
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const useCompabilityCheck = () => {
|
||||||
|
const [supported, setSupported] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parser = new UAParser(navigator.userAgent);
|
||||||
|
const result = parser.getResult();
|
||||||
|
|
||||||
|
const { name: browserName, version: browserVersion } = result.browser;
|
||||||
|
|
||||||
|
let isSupported = false;
|
||||||
|
|
||||||
|
if (!browserVersion) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [major] = browserVersion.split(".").map(Number);
|
||||||
|
|
||||||
|
switch (browserName) {
|
||||||
|
case "Chromium":
|
||||||
|
isSupported = major >= 107;
|
||||||
|
break;
|
||||||
|
case "Firefox":
|
||||||
|
isSupported = major >= 66;
|
||||||
|
break;
|
||||||
|
case "Safari":
|
||||||
|
isSupported = major >= 16;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
isSupported = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSupported(isSupported);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return supported;
|
||||||
|
};
|
||||||
|
|
||||||
export const Dialog: React.FC<DialogProps> = ({ show, children, className }: DialogProps) => {
|
export const Dialog: React.FC<DialogProps> = ({ show, children, className }: DialogProps) => {
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const setFocusedButton = useSetAtom(focusedButtonAtom);
|
||||||
|
const isSupported = useCompabilityCheck();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contentRef.current || !dialogRef.current) return;
|
||||||
|
|
||||||
|
const contentHeight = contentRef.current.offsetHeight;
|
||||||
|
const halfSize = (contentHeight + 48) / 2;
|
||||||
|
dialogRef.current.style.top = `calc(50% - ${halfSize}px)`;
|
||||||
|
|
||||||
|
if (!isSupported) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialogRef.current.style.transition = "grid-template-rows cubic-bezier(0.05, 0.7, 0.1, 1.0) 0.35s";
|
||||||
|
|
||||||
|
if (show) {
|
||||||
|
dialogRef.current.style.gridTemplateRows = "1fr";
|
||||||
|
} else {
|
||||||
|
dialogRef.current.style.gridTemplateRows = "0.6fr";
|
||||||
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFocusedButton(-1);
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
useDisableBodyScroll(show);
|
useDisableBodyScroll(show);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{show && (
|
||||||
<div className="w-full h-full top-0 left-0 absolute flex items-center justify-center">
|
<div className="w-full h-full top-0 left-0 absolute flex justify-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed top-0 left-0 w-full h-full z-40 bg-black/20 pointer-none"
|
className="fixed top-0 left-0 w-full h-full z-40 bg-black/20 pointer-none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.35 }}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
className={`fixed min-w-[17.5rem] sm:max-w-[35rem] h-auto z-50 bg-surface-container-high
|
className={`fixed min-w-[17.5rem] sm:max-w-[35rem] h-auto z-50 bg-surface-container-high
|
||||||
shadow-2xl shadow-shadow/15 rounded-[1.75rem] p-6 dark:bg-dark-surface-container-high mx-2 ${className}`}
|
shadow-2xl shadow-shadow/15 rounded-[1.75rem] p-6 dark:bg-dark-surface-container-high mx-2
|
||||||
initial={{ opacity: 0.5, transform: "scale(1.1)" }}
|
origin-top ${className} overflow-hidden grid ${isSupported && "grid-rows-[0fr]"}`}
|
||||||
animate={{ opacity: 1, transform: "scale(1)" }}
|
initial={{
|
||||||
exit={{ opacity: 0 }}
|
opacity: 0,
|
||||||
transition={{ ease: [0.31, 0.69, 0.3, 1.02], duration: 0.3 }}
|
transform: "translateY(-24px)",
|
||||||
|
gridTemplateRows: isSupported ? undefined : "0fr"
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
transform: "translateY(0px)",
|
||||||
|
gridTemplateRows: isSupported ? undefined : "1fr"
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
transform: "translateY(-24px)",
|
||||||
|
gridTemplateRows: isSupported ? undefined : "0fr"
|
||||||
|
}}
|
||||||
|
transition={{ ease: [0.05, 0.7, 0.1, 1.0], duration: 0.35 }}
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
ref={dialogRef}
|
||||||
>
|
>
|
||||||
{children}
|
<div className="min-h-0">
|
||||||
|
<motion.div
|
||||||
|
className="origin-top"
|
||||||
|
initial={{ opacity: 0, transform: "translateY(5px)" }}
|
||||||
|
animate={{ opacity: 1, transform: "translateY(0px)" }}
|
||||||
|
exit={{ opacity: 0, transform: "translateY(5px)" }}
|
||||||
|
transition={{
|
||||||
|
ease: [0.05, 0.7, 0.1, 1.0],
|
||||||
|
duration: 0.35
|
||||||
|
}}
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -58,7 +58,7 @@ export const SearchBox: React.FC<SearchBoxProps> = ({ close = () => {} }) => {
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="搜索"
|
placeholder="搜索"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="none"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
className="bg-transparent h-full w-full focus:outline-none"
|
className="bg-transparent h-full w-full focus:outline-none"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@ -73,7 +73,7 @@ export const SearchBox: React.FC<SearchBoxProps> = ({ close = () => {} }) => {
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="搜索"
|
placeholder="搜索"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="none"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
className="bg-transparent h-full w-full focus:outline-none"
|
className="bg-transparent h-full w-full focus:outline-none"
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
31
packages/next/components/utils/useKeyboardEvents.tsx
Normal file
31
packages/next/components/utils/useKeyboardEvents.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
export type KeyboardShortcut = {
|
||||||
|
key: string;
|
||||||
|
callback: () => void;
|
||||||
|
preventDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]): void {
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(event: KeyboardEvent) => {
|
||||||
|
shortcuts.forEach((shortcut) => {
|
||||||
|
if (event.key === shortcut.key) {
|
||||||
|
if (shortcut.preventDefault) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
shortcut.callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[shortcuts]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [handleKeyDown]);
|
||||||
|
}
|
@ -7,6 +7,13 @@
|
|||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"nickname": "昵称",
|
"nickname": "昵称",
|
||||||
|
"profile_page": {
|
||||||
|
"role": {
|
||||||
|
"ADMIN": "管理员",
|
||||||
|
"USER": "普通用户",
|
||||||
|
"OWNER": "所有者"
|
||||||
|
}
|
||||||
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"error": {
|
"error": {
|
||||||
"incorrect_password": "密码错误。",
|
"incorrect_password": "密码错误。",
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
import { UserType, sqlCred } from "@cvsa/core";
|
import { UserType, sqlCred } from "@cvsa/core";
|
||||||
|
import { UserProfile } from "../userAuth";
|
||||||
|
|
||||||
export const getUserBySession = async (sessionID: string) => {
|
export const getUserBySession = async (sessionID: string) => {
|
||||||
const users = await sqlCred<UserType[]>`
|
const users = await sqlCred<UserType[]>`
|
||||||
SELECT u.*
|
SELECT user_id as id, username, nickname, "role", user_created_at as created_at
|
||||||
FROM users u
|
FROM get_user_by_session_func(${sessionID});
|
||||||
JOIN login_sessions ls ON u.id = ls.uid
|
|
||||||
WHERE ls.id = ${sessionID};
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
return {
|
return {
|
||||||
uid: user.id,
|
uid: user.id,
|
||||||
@ -20,4 +21,36 @@ export const getUserBySession = async (sessionID: string) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getUserSessions = async (sessionID: string) => {};
|
export const queryUserProfile = async (uid: number, sessionID?: string): Promise<UserProfile | null> => {
|
||||||
|
interface Result extends UserType {
|
||||||
|
logged_in: boolean;
|
||||||
|
}
|
||||||
|
const users = await sqlCred<Result[]>`
|
||||||
|
SELECT
|
||||||
|
u.id, u.username, u.nickname, u."role", u.created_at,
|
||||||
|
CASE
|
||||||
|
WHEN (ls.uid IS NOT NULL AND ls.deactivated_at IS NULL AND ls.expire_at > NOW()) THEN TRUE
|
||||||
|
ELSE FALSE
|
||||||
|
END AS logged_in
|
||||||
|
FROM
|
||||||
|
users u
|
||||||
|
LEFT JOIN
|
||||||
|
login_sessions ls ON ls.uid = u.id AND ls.id = ${sessionID || ""}
|
||||||
|
WHERE
|
||||||
|
u.id = ${uid};
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (users.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = users[0];
|
||||||
|
return {
|
||||||
|
uid: user.id,
|
||||||
|
username: user.username,
|
||||||
|
nickname: user.nickname,
|
||||||
|
role: user.role,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
isLoggedIn: user.logged_in
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import axios, { AxiosRequestConfig, AxiosError, Method } from "axios";
|
import axios, { AxiosRequestConfig, AxiosError, Method, AxiosResponse } from "axios";
|
||||||
|
|
||||||
export class ApiRequestError extends Error {
|
export class ApiRequestError extends Error {
|
||||||
public code: number | undefined;
|
public code: number | undefined;
|
||||||
@ -21,10 +21,20 @@ const httpMethods = {
|
|||||||
patch: axios.patch
|
patch: axios.patch
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function fetcher(url: string): Promise<unknown>;
|
||||||
|
export function fetcher<JSON = unknown>(
|
||||||
|
url: string,
|
||||||
|
init?: Omit<AxiosRequestConfig, "method"> & { method?: Exclude<HttpMethod, "DELETE"> }
|
||||||
|
): Promise<JSON>;
|
||||||
|
export function fetcher(
|
||||||
|
url: string,
|
||||||
|
init: Omit<AxiosRequestConfig, "method"> & { method: "DELETE" }
|
||||||
|
): Promise<AxiosResponse>;
|
||||||
|
|
||||||
export async function fetcher<JSON = unknown>(
|
export async function fetcher<JSON = unknown>(
|
||||||
url: string,
|
url: string,
|
||||||
init?: Omit<AxiosRequestConfig, "method"> & { method?: HttpMethod }
|
init?: Omit<AxiosRequestConfig, "method"> & { method?: HttpMethod }
|
||||||
): Promise<JSON> {
|
): Promise<JSON | AxiosResponse<any, any>> {
|
||||||
const { method = "get", data, ...config } = init || {};
|
const { method = "get", data, ...config } = init || {};
|
||||||
|
|
||||||
const fullConfig: AxiosRequestConfig = {
|
const fullConfig: AxiosRequestConfig = {
|
||||||
@ -38,6 +48,9 @@ export async function fetcher<JSON = unknown>(
|
|||||||
if (["post", "patch", "put"].includes(m)) {
|
if (["post", "patch", "put"].includes(m)) {
|
||||||
const response = await httpMethods[m](url, data, fullConfig);
|
const response = await httpMethods[m](url, data, fullConfig);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
} else if (m === "delete") {
|
||||||
|
const response = await axios.delete(url, fullConfig);
|
||||||
|
return response;
|
||||||
} else {
|
} else {
|
||||||
const response = await httpMethods[m](url, fullConfig);
|
const response = await httpMethods[m](url, fullConfig);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { getUserBySession } from "@/lib/db/user";
|
import { getUserBySession, queryUserProfile } from "@/lib/db/user";
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
uid: number;
|
uid: number;
|
||||||
@ -9,6 +9,10 @@ export interface User {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfile extends User {
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCurrentUser(): Promise<User | null> {
|
export async function getCurrentUser(): Promise<User | null> {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const sessionID = cookieStore.get("session_id");
|
const sessionID = cookieStore.get("session_id");
|
||||||
@ -19,6 +23,21 @@ export async function getCurrentUser(): Promise<User | null> {
|
|||||||
|
|
||||||
return user ?? null;
|
return user ?? null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserProfile(uid: number): Promise<UserProfile | null> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const sessionID = cookieStore.get("session_id");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await queryUserProfile(uid, sessionID?.value);
|
||||||
|
|
||||||
|
return user ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next",
|
"name": "next",
|
||||||
"version": "2.8.0",
|
"version": "2.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 7400",
|
"dev": "next dev --turbopack -p 7400",
|
||||||
@ -21,6 +21,7 @@
|
|||||||
"framer-motion": "^12.15.0",
|
"framer-motion": "^12.15.0",
|
||||||
"fumadocs-mdx": "^11.6.6",
|
"fumadocs-mdx": "^11.6.6",
|
||||||
"i18next": "^25.2.1",
|
"i18next": "^25.2.1",
|
||||||
|
"jotai": "^2.12.5",
|
||||||
"next": "^15.3.3",
|
"next": "^15.3.3",
|
||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.1.0",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
@ -28,7 +29,8 @@
|
|||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"swr": "^2.3.3",
|
"swr": "^2.3.3",
|
||||||
"ua-parser-js": "^2.0.3",
|
"ua-parser-js": "^2.0.3",
|
||||||
"yup": "^1.6.1"
|
"yup": "^1.6.1",
|
||||||
|
"yup-numeric": "^0.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
|
Loading…
Reference in New Issue
Block a user