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 { getCaptchaDifficultyHandler } from "routes/captcha/difficulty/GET.ts";
|
||||
import { loginHandler } from "@/routes/login/session/POST";
|
||||
import { logoutHandler } from "@/routes/session";
|
||||
|
||||
export function configureRoutes(app: Hono<{ Variables: Variables }>) {
|
||||
app.get("/", ...rootHandler);
|
||||
@ -17,6 +18,8 @@ export function configureRoutes(app: Hono<{ Variables: Variables }>) {
|
||||
|
||||
app.post("/login/session", ...loginHandler);
|
||||
|
||||
app.delete("/session/:id", ...logoutHandler);
|
||||
|
||||
app.post("/user", ...registerHandler);
|
||||
app.get("/user/session/:id", ...getUserByLoginSessionHandler);
|
||||
|
||||
|
@ -1,9 +1,20 @@
|
||||
import { LeftArrow } from "@/components/icons/LeftArrow";
|
||||
import { RightArrow } from "@/components/icons/RightArrow";
|
||||
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 (
|
||||
<main className="relative flex-grow pt-8 px-4 md:w-full md:h-full md:flex md:items-center md:justify-center">
|
||||
<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 { getCurrentUser } from "@/lib/userAuth";
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
import React from "react";
|
||||
|
||||
export default async function RootLayout({
|
||||
@ -7,10 +6,9 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const user = await getCurrentUser();
|
||||
return (
|
||||
<>
|
||||
<Header user={user} />
|
||||
<HeaderServer />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
@ -1,13 +1,10 @@
|
||||
import { Header } from "@/components/shell/Header";
|
||||
import { getCurrentUser } from "@/lib/userAuth";
|
||||
import { VDFtestCard } from "./TestCard";
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
|
||||
export default async function VdfBenchmarkPage() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
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">
|
||||
<VDFtestCard />
|
||||
<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 { getCurrentUser } from "@/lib/userAuth";
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
import React from "react";
|
||||
|
||||
export default async function RootLayout({
|
||||
@ -7,10 +6,9 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const user = await getCurrentUser();
|
||||
return (
|
||||
<>
|
||||
<Header user={user} />
|
||||
<HeaderServer />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
@ -15,6 +15,7 @@
|
||||
"framer-motion": "^12.15.0",
|
||||
"fumadocs-mdx": "^11.6.6",
|
||||
"i18next": "^25.2.1",
|
||||
"jotai": "^2.12.5",
|
||||
"next": "^15.3.3",
|
||||
"next-intl": "^4.1.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
@ -23,6 +24,7 @@
|
||||
"swr": "^2.3.3",
|
||||
"ua-parser-js": "^2.0.3",
|
||||
"yup": "^1.6.1",
|
||||
"yup-numeric": "^0.5.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
@ -367,6 +369,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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 { TextButton } from "@/components/ui/Buttons/TextButton";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import type { UserResponse } from "@cvsa/backend";
|
||||
import { LoginIcon } from "../icons/LoginIcon";
|
||||
import { AccountIcon } from "../icons/AccountIcon";
|
||||
import { User } from "@/lib/userAuth";
|
||||
|
||||
interface HeaderProps {
|
||||
user: UserResponse | null;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
@ -91,7 +91,7 @@ export const HeaderMobile = ({ user }: HeaderProps) => {
|
||||
</Link>
|
||||
|
||||
{user ? (
|
||||
<Link href="/my/profile">
|
||||
<Link href={`/user/${user.uid}/profile`}>
|
||||
<TextButton className="w-full h-14 flex justify-start" size="m">
|
||||
<div className="flex items-center w-72">
|
||||
<AccountIcon className="text-2xl pr-4" />
|
||||
|
@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import useRipple from "@/components/utils/useRipple";
|
||||
|
||||
interface FilledButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
|
@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import useRipple from "@/components/utils/useRipple";
|
||||
|
||||
interface TextButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
@ -5,13 +7,16 @@ interface TextButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
shape?: "round" | "square";
|
||||
children?: React.ReactNode;
|
||||
ripple?: boolean;
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export const TextButton = ({
|
||||
children,
|
||||
size = "s",
|
||||
shape = "round",
|
||||
className,
|
||||
className = "",
|
||||
disabled,
|
||||
ref,
|
||||
ripple = true,
|
||||
...rest
|
||||
}: TextButtonProps) => {
|
||||
@ -29,12 +34,19 @@ export const TextButton = ({
|
||||
<button
|
||||
className={`text-primary dark:text-dark-primary duration-150 select-none
|
||||
flex items-center justify-center relative overflow-hidden
|
||||
disabled:text-on-surface/40 disabled:dark:text-dark-on-surface/40
|
||||
${sizeClasses} ${shapeClasses} ${className}`}
|
||||
{...rest}
|
||||
onMouseDown={onMouseDown}
|
||||
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}
|
||||
</button>
|
||||
);
|
||||
|
@ -1,7 +1,12 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import React from "react";
|
||||
import React, { useRef } from "react";
|
||||
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) => {
|
||||
useEffect(() => {
|
||||
@ -23,10 +28,14 @@ type ButtonElementAttr = React.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
type DialogHeadlineProps = OptionalChidrenProps<HeadElementAttr>;
|
||||
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> {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
index?: number;
|
||||
}
|
||||
interface DialogProps extends OptionalChidrenProps<DivElementAttr> {
|
||||
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 (
|
||||
<TextButton onClick={onClick} {...rest}>
|
||||
<TextButton onClick={onClick} {...rest} ref={buttonRef}>
|
||||
{children}
|
||||
</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 (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
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) => {
|
||||
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);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{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
|
||||
className="fixed top-0 left-0 w-full h-full z-40 bg-black/20 pointer-none"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
/>
|
||||
<motion.div
|
||||
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}`}
|
||||
initial={{ opacity: 0.5, transform: "scale(1.1)" }}
|
||||
animate={{ opacity: 1, transform: "scale(1)" }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ ease: [0.31, 0.69, 0.3, 1.02], duration: 0.3 }}
|
||||
shadow-2xl shadow-shadow/15 rounded-[1.75rem] p-6 dark:bg-dark-surface-container-high mx-2
|
||||
origin-top ${className} overflow-hidden grid ${isSupported && "grid-rows-[0fr]"}`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
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"
|
||||
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>
|
||||
</div>
|
||||
)}
|
||||
|
@ -58,7 +58,7 @@ export const SearchBox: React.FC<SearchBoxProps> = ({ close = () => {} }) => {
|
||||
type="search"
|
||||
placeholder="搜索"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="bg-transparent h-full w-full focus:outline-none"
|
||||
onKeyDown={handleKeyDown}
|
||||
@ -73,7 +73,7 @@ export const SearchBox: React.FC<SearchBoxProps> = ({ close = () => {} }) => {
|
||||
type="search"
|
||||
placeholder="搜索"
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="bg-transparent h-full w-full focus:outline-none"
|
||||
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": "用户名",
|
||||
"password": "密码",
|
||||
"nickname": "昵称",
|
||||
"profile_page": {
|
||||
"role": {
|
||||
"ADMIN": "管理员",
|
||||
"USER": "普通用户",
|
||||
"OWNER": "所有者"
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"error": {
|
||||
"incorrect_password": "密码错误。",
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { UserType, sqlCred } from "@cvsa/core";
|
||||
import { UserProfile } from "../userAuth";
|
||||
|
||||
export const getUserBySession = async (sessionID: string) => {
|
||||
const users = await sqlCred<UserType[]>`
|
||||
SELECT u.*
|
||||
FROM users u
|
||||
JOIN login_sessions ls ON u.id = ls.uid
|
||||
WHERE ls.id = ${sessionID};
|
||||
SELECT user_id as id, username, nickname, "role", user_created_at as created_at
|
||||
FROM get_user_by_session_func(${sessionID});
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
return {
|
||||
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 {
|
||||
public code: number | undefined;
|
||||
@ -21,10 +21,20 @@ const httpMethods = {
|
||||
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>(
|
||||
url: string,
|
||||
init?: Omit<AxiosRequestConfig, "method"> & { method?: HttpMethod }
|
||||
): Promise<JSON> {
|
||||
): Promise<JSON | AxiosResponse<any, any>> {
|
||||
const { method = "get", data, ...config } = init || {};
|
||||
|
||||
const fullConfig: AxiosRequestConfig = {
|
||||
@ -38,6 +48,9 @@ export async function fetcher<JSON = unknown>(
|
||||
if (["post", "patch", "put"].includes(m)) {
|
||||
const response = await httpMethods[m](url, data, fullConfig);
|
||||
return response.data;
|
||||
} else if (m === "delete") {
|
||||
const response = await axios.delete(url, fullConfig);
|
||||
return response;
|
||||
} else {
|
||||
const response = await httpMethods[m](url, fullConfig);
|
||||
return response.data;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { getUserBySession } from "@/lib/db/user";
|
||||
import { getUserBySession, queryUserProfile } from "@/lib/db/user";
|
||||
|
||||
export interface User {
|
||||
uid: number;
|
||||
@ -9,6 +9,10 @@ export interface User {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserProfile extends User {
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User | null> {
|
||||
const cookieStore = await cookies();
|
||||
const sessionID = cookieStore.get("session_id");
|
||||
@ -19,6 +23,21 @@ export async function getCurrentUser(): Promise<User | null> {
|
||||
|
||||
return user ?? null;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "next",
|
||||
"version": "2.8.0",
|
||||
"version": "2.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 7400",
|
||||
@ -21,6 +21,7 @@
|
||||
"framer-motion": "^12.15.0",
|
||||
"fumadocs-mdx": "^11.6.6",
|
||||
"i18next": "^25.2.1",
|
||||
"jotai": "^2.12.5",
|
||||
"next": "^15.3.3",
|
||||
"next-intl": "^4.1.0",
|
||||
"raw-loader": "^4.0.2",
|
||||
@ -28,7 +29,8 @@
|
||||
"react-dom": "^19.1.0",
|
||||
"swr": "^2.3.3",
|
||||
"ua-parser-js": "^2.0.3",
|
||||
"yup": "^1.6.1"
|
||||
"yup": "^1.6.1",
|
||||
"yup-numeric": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
|
Loading…
Reference in New Issue
Block a user