From 2cf5923b287d815871cb203614ec2dc04a2c4ca9 Mon Sep 17 00:00:00 2001 From: alikia2x Date: Sun, 8 Jun 2025 18:06:46 +0800 Subject: [PATCH] add: logout in frontend --- .../backend/routes/session/[id]/DELETE.ts | 75 ++++++++ packages/backend/routes/session/index.ts | 1 + packages/backend/src/routing.ts | 3 + packages/next/app/[locale]/login/page.tsx | 15 +- packages/next/app/[locale]/logout/route.ts | 60 ++++++ .../next/app/[locale]/my/profile/page.tsx | 46 ----- packages/next/app/[locale]/song/layout.tsx | 6 +- packages/next/app/[locale]/test-vdf/page.tsx | 7 +- .../user/[uid]/profile/LogoutButton.tsx | 46 +++++ .../app/[locale]/user/[uid]/profile/page.tsx | 65 +++++++ packages/next/app/[locale]/video/layout.tsx | 6 +- packages/next/bun.lock | 8 + packages/next/components/shell/Header.tsx | 8 +- .../components/ui/Buttons/FilledButton.tsx | 2 + .../next/components/ui/Buttons/TextButton.tsx | 16 +- packages/next/components/ui/Dialog.tsx | 171 ++++++++++++++++-- packages/next/components/ui/SearchBox.tsx | 4 +- .../components/utils/useKeyboardEvents.tsx | 31 ++++ packages/next/i18n/strings/zh.json | 7 + packages/next/lib/db/user.ts | 43 ++++- packages/next/lib/net.ts | 17 +- packages/next/lib/userAuth.ts | 21 ++- packages/next/package.json | 6 +- 23 files changed, 570 insertions(+), 94 deletions(-) create mode 100644 packages/backend/routes/session/[id]/DELETE.ts create mode 100644 packages/backend/routes/session/index.ts create mode 100644 packages/next/app/[locale]/logout/route.ts delete mode 100644 packages/next/app/[locale]/my/profile/page.tsx create mode 100644 packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx create mode 100644 packages/next/app/[locale]/user/[uid]/profile/page.tsx create mode 100644 packages/next/components/utils/useKeyboardEvents.tsx diff --git a/packages/backend/routes/session/[id]/DELETE.ts b/packages/backend/routes/session/[id]/DELETE.ts new file mode 100644 index 0000000..994034e --- /dev/null +++ b/packages/backend/routes/session/[id]/DELETE.ts @@ -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) => { + try { + const session_id = c.req.param("id"); + + const exists = loginSessionExists(session_id); + + if (!exists) { + const response: ErrorResponse = { + message: "Cannot found given session_id.", + errors: [`Session ${session_id} not found`], + code: "ENTITY_NOT_FOUND" + }; + return c.json>(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 = { + message: "Invalid registration data.", + errors: e.errors, + code: "INVALID_PAYLOAD" + }; + return c.json>(response, 400); + } else if (e instanceof SyntaxError) { + const response: ErrorResponse = { + message: "Invalid JSON payload.", + errors: [e.message], + code: "INVALID_FORMAT" + }; + return c.json>(response, 400); + } else { + const response: ErrorResponse = { + message: "Unknown error.", + errors: [(e as Error).message], + code: "UNKNOWN_ERROR" + }; + return c.json>(response, 500); + } + } +}); diff --git a/packages/backend/routes/session/index.ts b/packages/backend/routes/session/index.ts new file mode 100644 index 0000000..e116137 --- /dev/null +++ b/packages/backend/routes/session/index.ts @@ -0,0 +1 @@ +export * from "./[id]/DELETE"; diff --git a/packages/backend/src/routing.ts b/packages/backend/src/routing.ts index 23a90af..3236d1b 100644 --- a/packages/backend/src/routing.ts +++ b/packages/backend/src/routing.ts @@ -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); diff --git a/packages/next/app/[locale]/login/page.tsx b/packages/next/app/[locale]/login/page.tsx index 0d68430..f73da21 100644 --- a/packages/next/app/[locale]/login/page.tsx +++ b/packages/next/app/[locale]/login/page.tsx @@ -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 (
= { + 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 = { + message: error.message, + errors: [], + code: "SERVER_ERROR" + }; + return new Response(JSON.stringify(response), { + status: 500 + }); + } else { + const response: ErrorResponse = { + message: "Unknown error occurred", + errors: [], + code: "UNKNOWN_ERROR" + }; + return new Response(JSON.stringify(response), { + status: 500 + }); + } + } +} diff --git a/packages/next/app/[locale]/my/profile/page.tsx b/packages/next/app/[locale]/my/profile/page.tsx deleted file mode 100644 index d51aa7f..0000000 --- a/packages/next/app/[locale]/my/profile/page.tsx +++ /dev/null @@ -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 = ({ user }: SignupTimeProps) => { - return ( -

- 于  - {format(new Date(user.createdAt), "yyyy-MM-dd HH:mm:ss", { - locale: zhCN - })} -  注册。 -

- ); -}; - -export default async function ProfilePage() { - const user = await getCurrentUser(); - - if (!user) { - redirect("/login"); - } - - const displayName = user.nickname || user.username; - - return ( - <> -
-
-

- {displayName} - - UID{user.uid} - -

- -
- - ); -} diff --git a/packages/next/app/[locale]/song/layout.tsx b/packages/next/app/[locale]/song/layout.tsx index 2fcf8e2..fa02cac 100644 --- a/packages/next/app/[locale]/song/layout.tsx +++ b/packages/next/app/[locale]/song/layout.tsx @@ -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 ( <> -
+ {children} ); diff --git a/packages/next/app/[locale]/test-vdf/page.tsx b/packages/next/app/[locale]/test-vdf/page.tsx index e7ac071..007ebb9 100644 --- a/packages/next/app/[locale]/test-vdf/page.tsx +++ b/packages/next/app/[locale]/test-vdf/page.tsx @@ -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 ( <> -
+
diff --git a/packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx b/packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx new file mode 100644 index 0000000..cab7781 --- /dev/null +++ b/packages/next/app/[locale]/user/[uid]/profile/LogoutButton.tsx @@ -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 ( + <> + setShowDialog(true)} + > + 登出 + + + + 确认登出 + 确认要退出登录吗? + setShowDialog(false)}> + setShowDialog(false)}>取消 + { + try { + await fetch("/logout", { + method: "POST" + }); + router.push("/"); + } finally { + setShowDialog(false); + } + }} + > + 确认 + + + + + + ); +}; diff --git a/packages/next/app/[locale]/user/[uid]/profile/page.tsx b/packages/next/app/[locale]/user/[uid]/profile/page.tsx new file mode 100644 index 0000000..727f2f4 --- /dev/null +++ b/packages/next/app/[locale]/user/[uid]/profile/page.tsx @@ -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 = ({ user }: SignupTimeProps) => { + return ( +

+ 于  + {format(new Date(user.createdAt), "yyyy-MM-dd HH:mm:ss", { + locale: zhCN + })} +  注册。 +

+ ); +}; + +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 ( + <> + +
+

+ {displayName} + + UID{user.uid} + +

+ +

权限组:{t(`role.${user.role}`)}

+ {loggedIn && } +
+ + ); +} diff --git a/packages/next/app/[locale]/video/layout.tsx b/packages/next/app/[locale]/video/layout.tsx index 2fcf8e2..fa02cac 100644 --- a/packages/next/app/[locale]/video/layout.tsx +++ b/packages/next/app/[locale]/video/layout.tsx @@ -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 ( <> -
+ {children} ); diff --git a/packages/next/bun.lock b/packages/next/bun.lock index 9aeaa58..12ef429 100644 --- a/packages/next/bun.lock +++ b/packages/next/bun.lock @@ -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=="], diff --git a/packages/next/components/shell/Header.tsx b/packages/next/components/shell/Header.tsx index 41035a1..f1f5b58 100644 --- a/packages/next/components/shell/Header.tsx +++ b/packages/next/components/shell/Header.tsx @@ -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 ? ( - {user.nickname || user.username} + {user.nickname || user.username} ) : ( 登录 )} @@ -91,7 +91,7 @@ export const HeaderMobile = ({ user }: HeaderProps) => { {user ? ( - +
diff --git a/packages/next/components/ui/Buttons/FilledButton.tsx b/packages/next/components/ui/Buttons/FilledButton.tsx index ddb98dd..bb95630 100644 --- a/packages/next/components/ui/Buttons/FilledButton.tsx +++ b/packages/next/components/ui/Buttons/FilledButton.tsx @@ -1,3 +1,5 @@ +"use client"; + import useRipple from "@/components/utils/useRipple"; interface FilledButtonProps extends React.ButtonHTMLAttributes { diff --git a/packages/next/components/ui/Buttons/TextButton.tsx b/packages/next/components/ui/Buttons/TextButton.tsx index 50582c9..fccdd34 100644 --- a/packages/next/components/ui/Buttons/TextButton.tsx +++ b/packages/next/components/ui/Buttons/TextButton.tsx @@ -1,3 +1,5 @@ +"use client"; + import useRipple from "@/components/utils/useRipple"; interface TextButtonProps extends React.ButtonHTMLAttributes { @@ -5,13 +7,16 @@ interface TextButtonProps extends React.ButtonHTMLAttributes shape?: "round" | "square"; children?: React.ReactNode; ripple?: boolean; + ref?: React.Ref; } export const TextButton = ({ children, size = "s", shape = "round", - className, + className = "", + disabled, + ref, ripple = true, ...rest }: TextButtonProps) => { @@ -29,12 +34,19 @@ export const TextButton = ({ ); diff --git a/packages/next/components/ui/Dialog.tsx b/packages/next/components/ui/Dialog.tsx index 5bc3904..24d75d4 100644 --- a/packages/next/components/ui/Dialog.tsx +++ b/packages/next/components/ui/Dialog.tsx @@ -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; type DialogHeadlineProps = OptionalChidrenProps; type DialogSupportingTextProps = OptionalChidrenProps; -type DialogButtonGroupProps = OptionalChidrenProps; +type DialogButtonGroupProps = DivElementAttr & { + children: React.ReactElement | React.ReactElement[]; + close: () => void; +}; interface DialogButtonProps extends OptionalChidrenProps { onClick?: React.MouseEventHandler; + index?: number; } interface DialogProps extends OptionalChidrenProps { show: boolean; @@ -63,46 +72,178 @@ export const DialogSupportingText: React.FC = ({ ); }; -export const DialogButton: React.FC = ({ children, onClick, ...rest }: DialogButtonProps) => { +export const DialogButton: React.FC = ({ children, onClick, index, ...rest }: DialogButtonProps) => { + const buttonRef = useRef(null); + const focusedButton = useAtomValue(focusedButtonAtom); + + useEffect(() => { + if (!buttonRef.current) return; + if (focusedButton === index) buttonRef.current.focus(); + }, [focusedButton]); + return ( - + {children} ); }; -export const DialogButtonGroup: React.FC = ({ children, ...rest }: DialogButtonGroupProps) => { +export const DialogButtonGroup: React.FC = ({ + 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 (
- {children} + {React.Children.map(children, (child, index) => { + if (React.isValidElement(child) && child.type === DialogButton) { + return React.cloneElement(child, { + index: index + }); + } + return child; + })}
); }; +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 = ({ show, children, className }: DialogProps) => { + const dialogRef = useRef(null); + const contentRef = useRef(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 ( {show && ( -
+
)} diff --git a/packages/next/components/ui/SearchBox.tsx b/packages/next/components/ui/SearchBox.tsx index 2387a21..77a82a7 100644 --- a/packages/next/components/ui/SearchBox.tsx +++ b/packages/next/components/ui/SearchBox.tsx @@ -58,7 +58,7 @@ export const SearchBox: React.FC = ({ 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 = ({ close = () => {} }) => { type="search" placeholder="搜索" autoComplete="off" - autoCapitalize="off" + autoCapitalize="none" autoCorrect="off" className="bg-transparent h-full w-full focus:outline-none" onKeyDown={handleKeyDown} diff --git a/packages/next/components/utils/useKeyboardEvents.tsx b/packages/next/components/utils/useKeyboardEvents.tsx new file mode 100644 index 0000000..2b709d6 --- /dev/null +++ b/packages/next/components/utils/useKeyboardEvents.tsx @@ -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]); +} diff --git a/packages/next/i18n/strings/zh.json b/packages/next/i18n/strings/zh.json index 4da63c3..14e903a 100644 --- a/packages/next/i18n/strings/zh.json +++ b/packages/next/i18n/strings/zh.json @@ -7,6 +7,13 @@ "username": "用户名", "password": "密码", "nickname": "昵称", + "profile_page": { + "role": { + "ADMIN": "管理员", + "USER": "普通用户", + "OWNER": "所有者" + } + }, "backend": { "error": { "incorrect_password": "密码错误。", diff --git a/packages/next/lib/db/user.ts b/packages/next/lib/db/user.ts index 60128e1..e6396a6 100644 --- a/packages/next/lib/db/user.ts +++ b/packages/next/lib/db/user.ts @@ -1,15 +1,16 @@ import { UserType, sqlCred } from "@cvsa/core"; +import { UserProfile } from "../userAuth"; export const getUserBySession = async (sessionID: string) => { const users = await sqlCred` - 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 => { + interface Result extends UserType { + logged_in: boolean; + } + const users = await sqlCred` + 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 + }; +}; diff --git a/packages/next/lib/net.ts b/packages/next/lib/net.ts index 87cfbe5..546b887 100644 --- a/packages/next/lib/net.ts +++ b/packages/next/lib/net.ts @@ -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; +export function fetcher( + url: string, + init?: Omit & { method?: Exclude } +): Promise; +export function fetcher( + url: string, + init: Omit & { method: "DELETE" } +): Promise; + export async function fetcher( url: string, init?: Omit & { method?: HttpMethod } -): Promise { +): Promise> { const { method = "get", data, ...config } = init || {}; const fullConfig: AxiosRequestConfig = { @@ -38,6 +48,9 @@ export async function fetcher( 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; diff --git a/packages/next/lib/userAuth.ts b/packages/next/lib/userAuth.ts index c034a28..831eb90 100644 --- a/packages/next/lib/userAuth.ts +++ b/packages/next/lib/userAuth.ts @@ -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 { const cookieStore = await cookies(); const sessionID = cookieStore.get("session_id"); @@ -19,6 +23,21 @@ export async function getCurrentUser(): Promise { return user ?? null; } catch (error) { + console.log(error); + return null; + } +} + +export async function getUserProfile(uid: number): Promise { + 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; } } diff --git a/packages/next/package.json b/packages/next/package.json index e75e0a0..c1c8b6b 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -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",