add: logout in frontend

This commit is contained in:
alikia2x (寒寒) 2025-06-08 18:06:46 +08:00
parent 75973c72ee
commit 2cf5923b28
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
23 changed files with 570 additions and 94 deletions

View 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);
}
}
});

View File

@ -0,0 +1 @@
export * from "./[id]/DELETE";

View File

@ -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);

View File

@ -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

View 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
});
}
}
}

View File

@ -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">
&nbsp;
{format(new Date(user.createdAt), "yyyy-MM-dd HH:mm:ss", {
locale: zhCN
})}
&nbsp;
</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>
</>
);
}

View File

@ -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}
</>
);

View File

@ -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>

View File

@ -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>
</>
);
};

View 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">
&nbsp;
{format(new Date(user.createdAt), "yyyy-MM-dd HH:mm:ss", {
locale: zhCN
})}
&nbsp;
</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>
</>
);
}

View File

@ -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}
</>
);

View File

@ -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=="],

View File

@ -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" />

View File

@ -1,3 +1,5 @@
"use client";
import useRipple from "@/components/utils/useRipple";
interface FilledButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {

View File

@ -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>
);

View File

@ -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>
)}

View File

@ -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}

View 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]);
}

View File

@ -7,6 +7,13 @@
"username": "用户名",
"password": "密码",
"nickname": "昵称",
"profile_page": {
"role": {
"ADMIN": "管理员",
"USER": "普通用户",
"OWNER": "所有者"
}
},
"backend": {
"error": {
"incorrect_password": "密码错误。",

View File

@ -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
};
};

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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",