🔀 Merge pull request '♻️ 按照 Material Design + Reactjs 重构' (#1) from refactor/new-ui into master
Reviewed-on: https://code.smartsheep.studio/Hydrogen/Identity/pulls/1
This commit is contained in:
		| @@ -12,6 +12,7 @@ type Account struct { | ||||
|  | ||||
| 	Name              string                   `json:"name" gorm:"uniqueIndex"` | ||||
| 	Nick              string                   `json:"nick"` | ||||
| 	Description       string                   `json:"description"` | ||||
| 	Avatar            string                   `json:"avatar"` | ||||
| 	Profile           AccountProfile           `json:"profile"` | ||||
| 	Sessions          []AuthSession            `json:"sessions"` | ||||
|   | ||||
| @@ -6,7 +6,6 @@ type AccountProfile struct { | ||||
| 	BaseModel | ||||
|  | ||||
| 	FirstName  string     `json:"first_name"` | ||||
| 	MiddleName string     `json:"middle_name"` | ||||
| 	LastName   string     `json:"last_name"` | ||||
| 	Experience uint64     `json:"experience"` | ||||
| 	Birthday   *time.Time `json:"birthday"` | ||||
|   | ||||
| @@ -77,8 +77,8 @@ func editUserinfo(c *fiber.Ctx) error { | ||||
|  | ||||
| 	var data struct { | ||||
| 		Nick        string    `json:"nick" validate:"required,min=4,max=24"` | ||||
| 		Description string    `json:"description"` | ||||
| 		FirstName   string    `json:"first_name"` | ||||
| 		MiddleName string    `json:"middle_name"` | ||||
| 		LastName    string    `json:"last_name"` | ||||
| 		Birthday    time.Time `json:"birthday"` | ||||
| 	} | ||||
| @@ -96,8 +96,8 @@ func editUserinfo(c *fiber.Ctx) error { | ||||
| 	} | ||||
|  | ||||
| 	account.Nick = data.Nick | ||||
| 	account.Description = data.Description | ||||
| 	account.Profile.FirstName = data.FirstName | ||||
| 	account.Profile.MiddleName = data.MiddleName | ||||
| 	account.Profile.LastName = data.LastName | ||||
| 	account.Profile.Birthday = &data.Birthday | ||||
|  | ||||
|   | ||||
| @@ -14,17 +14,21 @@ func getNotifications(c *fiber.Ctx) error { | ||||
| 	take := c.QueryInt("take", 0) | ||||
| 	offset := c.QueryInt("offset", 0) | ||||
|  | ||||
| 	only_unread := c.QueryBool("only_unread", true) | ||||
|  | ||||
| 	tx := database.C.Where(&models.Notification{RecipientID: user.ID}).Model(&models.Notification{}) | ||||
| 	if only_unread { | ||||
| 		tx = tx.Where("read_at IS NULL") | ||||
| 	} | ||||
|  | ||||
| 	var count int64 | ||||
| 	var notifications []models.Notification | ||||
| 	if err := database.C. | ||||
| 		Where(&models.Notification{RecipientID: user.ID}). | ||||
| 		Model(&models.Notification{}). | ||||
| 	if err := tx. | ||||
| 		Count(&count).Error; err != nil { | ||||
| 		return fiber.NewError(fiber.StatusInternalServerError, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	if err := database.C. | ||||
| 		Where(&models.Notification{RecipientID: user.ID}). | ||||
| 	if err := tx. | ||||
| 		Limit(take). | ||||
| 		Offset(offset). | ||||
| 		Order("read_at desc"). | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| package server | ||||
|  | ||||
| import ( | ||||
| 	"code.smartsheep.studio/hydrogen/identity/pkg/view" | ||||
| 	"code.smartsheep.studio/hydrogen/identity/pkg/views" | ||||
| 	"github.com/gofiber/fiber/v2" | ||||
| 	"github.com/gofiber/fiber/v2/middleware/cache" | ||||
| 	"github.com/gofiber/fiber/v2/middleware/cors" | ||||
| @@ -92,7 +92,7 @@ func NewServer() { | ||||
| 		Expiration:   24 * time.Hour, | ||||
| 		CacheControl: true, | ||||
| 	}), filesystem.New(filesystem.Config{ | ||||
| 		Root:         http.FS(view.FS), | ||||
| 		Root:         http.FS(views.FS), | ||||
| 		PathPrefix:   "dist", | ||||
| 		Index:        "index.html", | ||||
| 		NotFoundFile: "dist/index.html", | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -1,136 +0,0 @@ | ||||
| import { | ||||
|   Slide, | ||||
|   Toolbar, | ||||
|   Typography, | ||||
|   AppBar as MuiAppBar, | ||||
|   AppBarProps as MuiAppBarProps, | ||||
|   useScrollTrigger, | ||||
|   IconButton, | ||||
|   styled, | ||||
|   Box, | ||||
|   useMediaQuery, | ||||
| } from "@mui/material"; | ||||
| import { ReactElement, ReactNode, useEffect, useState } from "react"; | ||||
| import { SITE_NAME } from "@/consts"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import NavigationDrawer, { DRAWER_WIDTH, AppNavigationHeader, isMobileQuery } from "@/components/NavigationDrawer"; | ||||
| import MenuIcon from "@mui/icons-material/Menu"; | ||||
|  | ||||
| function HideOnScroll(props: { window?: () => Window; children: ReactElement }) { | ||||
|   const { children, window } = props; | ||||
|   const trigger = useScrollTrigger({ | ||||
|     target: window ? window() : undefined, | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <Slide appear={false} direction="down" in={!trigger}> | ||||
|       {children} | ||||
|     </Slide> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| interface AppBarProps extends MuiAppBarProps { | ||||
|   open?: boolean; | ||||
| } | ||||
|  | ||||
| const ShellAppBar = styled(MuiAppBar, { | ||||
|   shouldForwardProp: (prop) => prop !== "open", | ||||
| })<AppBarProps>(({ theme, open }) => { | ||||
|   const isMobile = useMediaQuery(isMobileQuery); | ||||
|  | ||||
|   return { | ||||
|     transition: theme.transitions.create(["margin", "width"], { | ||||
|       easing: theme.transitions.easing.sharp, | ||||
|       duration: theme.transitions.duration.leavingScreen, | ||||
|     }), | ||||
|     ...(!isMobile && | ||||
|       open && { | ||||
|         width: `calc(100% - ${DRAWER_WIDTH}px)`, | ||||
|         transition: theme.transitions.create(["margin", "width"], { | ||||
|           easing: theme.transitions.easing.easeOut, | ||||
|           duration: theme.transitions.duration.enteringScreen, | ||||
|         }), | ||||
|         marginRight: DRAWER_WIDTH, | ||||
|       }), | ||||
|   }; | ||||
| }); | ||||
|  | ||||
| const AppMain = styled("main", { shouldForwardProp: (prop) => prop !== "open" })<{ | ||||
|   open?: boolean; | ||||
| }>(({ theme, open }) => { | ||||
|   const isMobile = useMediaQuery(isMobileQuery); | ||||
|  | ||||
|   return { | ||||
|     flexGrow: 1, | ||||
|     transition: theme.transitions.create("margin", { | ||||
|       easing: theme.transitions.easing.sharp, | ||||
|       duration: theme.transitions.duration.leavingScreen, | ||||
|     }), | ||||
|     marginRight: -DRAWER_WIDTH, | ||||
|     ...(!isMobile && | ||||
|       open && { | ||||
|         transition: theme.transitions.create("margin", { | ||||
|           easing: theme.transitions.easing.easeOut, | ||||
|           duration: theme.transitions.duration.enteringScreen, | ||||
|         }), | ||||
|         marginRight: 0, | ||||
|       }), | ||||
|     position: "relative", | ||||
|   }; | ||||
| }); | ||||
|  | ||||
| export default function AppShell({ children }: { children: ReactNode }) { | ||||
|   let documentWindow: Window; | ||||
|  | ||||
|   const isMobile = useMediaQuery(isMobileQuery); | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     documentWindow = window; | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <HideOnScroll window={() => documentWindow}> | ||||
|         <ShellAppBar open={open} position="fixed"> | ||||
|           <Toolbar sx={{ height: 64 }}> | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               edge="start" | ||||
|               color="inherit" | ||||
|               aria-label="menu" | ||||
|               sx={{ ml: isMobile ? 0.5 : 0, mr: 2 }} | ||||
|             > | ||||
|               <img src="/favicon.svg" alt="Logo" width={32} height={32} /> | ||||
|             </IconButton> | ||||
|  | ||||
|             <Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}> | ||||
|               <Link to="/">{SITE_NAME}</Link> | ||||
|             </Typography> | ||||
|  | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               edge="start" | ||||
|               color="inherit" | ||||
|               aria-label="menu" | ||||
|               onClick={() => setOpen(true)} | ||||
|               sx={{ width: 64, mr: 1, display: !isMobile && open ? "none" : "block" }} | ||||
|             > | ||||
|               <MenuIcon /> | ||||
|             </IconButton> | ||||
|           </Toolbar> | ||||
|         </ShellAppBar> | ||||
|       </HideOnScroll> | ||||
|  | ||||
|       <Box sx={{ display: "flex" }}> | ||||
|         <AppMain open={open}> | ||||
|           <AppNavigationHeader /> | ||||
|  | ||||
|           {children} | ||||
|         </AppMain> | ||||
|  | ||||
|         <NavigationDrawer open={open} onClose={() => setOpen(false)} /> | ||||
|       </Box> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,61 +0,0 @@ | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom/client"; | ||||
| import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; | ||||
| import { CssBaseline, ThemeProvider } from "@mui/material"; | ||||
| import { theme } from "@/theme.ts"; | ||||
|  | ||||
| import "virtual:uno.css"; | ||||
|  | ||||
| import "./index.css"; | ||||
| import "@unocss/reset/tailwind.css"; | ||||
| import "@fontsource/roboto/latin.css"; | ||||
|  | ||||
| import AppShell from "@/components/AppShell.tsx"; | ||||
| import LandingPage from "@/pages/landing.tsx"; | ||||
| import SignUpPage from "@/pages/auth/sign-up.tsx"; | ||||
| import SignInPage from "@/pages/auth/sign-in.tsx"; | ||||
| import ErrorBoundary from "@/error.tsx"; | ||||
| import AppLoader from "@/components/AppLoader.tsx"; | ||||
| import { UserinfoProvider } from "@/stores/userinfo.tsx"; | ||||
| import { WellKnownProvider } from "@/stores/wellKnown.tsx"; | ||||
|  | ||||
| declare const __GARFISH_EXPORTS__: { | ||||
|   provider: Object; | ||||
|   registerProvider?: (provider: any) => void; | ||||
| }; | ||||
|  | ||||
| declare global { | ||||
|   interface Window { | ||||
|     __LAUNCHPAD_TARGET__?: string; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const router = createBrowserRouter([ | ||||
|   { | ||||
|     path: "/", | ||||
|     element: <AppShell><Outlet /></AppShell>, | ||||
|     errorElement: <ErrorBoundary />, | ||||
|     children: [ | ||||
|       { path: "/", element: <LandingPage /> } | ||||
|     ] | ||||
|   }, | ||||
|   { path: "/auth/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> }, | ||||
|   { path: "/auth/sign-in", element: <SignInPage />, errorElement: <ErrorBoundary /> } | ||||
| ]); | ||||
|  | ||||
| const element = ( | ||||
|   <React.StrictMode> | ||||
|     <ThemeProvider theme={theme}> | ||||
|       <WellKnownProvider> | ||||
|         <UserinfoProvider> | ||||
|           <AppLoader> | ||||
|             <CssBaseline /> | ||||
|             <RouterProvider router={router} /> | ||||
|           </AppLoader> | ||||
|         </UserinfoProvider> | ||||
|       </WellKnownProvider> | ||||
|     </ThemeProvider> | ||||
|   </React.StrictMode> | ||||
| ); | ||||
|  | ||||
| ReactDOM.createRoot(document.getElementById("root")!).render(element); | ||||
							
								
								
									
										
											BIN
										
									
								
								pkg/views/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								pkg/views/bun.lockb
									
									
									
									
									
										Executable file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -1,4 +1,4 @@ | ||||
| package view | ||||
| package views | ||||
| 
 | ||||
| import "embed" | ||||
| 
 | ||||
| @@ -14,14 +14,18 @@ | ||||
|     "@emotion/styled": "^11.11.0", | ||||
|     "@fontsource/roboto": "^5.0.8", | ||||
|     "@mui/icons-material": "^5.15.10", | ||||
|     "@mui/lab": "^5.0.0-alpha.166", | ||||
|     "@mui/material": "^5.15.10", | ||||
|     "@mui/x-data-grid": "^6.19.5", | ||||
|     "@mui/x-date-pickers": "^6.19.5", | ||||
|     "@unocss/reset": "^0.58.5", | ||||
|     "dayjs": "^1.11.10", | ||||
|     "localforage": "^1.10.0", | ||||
|     "match-sorter": "^6.3.4", | ||||
|     "react": "^18.2.0", | ||||
|     "react-dom": "^18.2.0", | ||||
|     "react-router-dom": "^6.22.1", | ||||
|     "react-swipeable-views": "^0.14.0", | ||||
|     "react-transition-group": "^4.4.5", | ||||
|     "sort-by": "^1.2.0", | ||||
|     "universal-cookie": "^7.1.0", | ||||
|     "use-debounce": "^10.0.0" | ||||
| Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB | 
							
								
								
									
										95
									
								
								pkg/views/src/components/AppShell.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								pkg/views/src/components/AppShell.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| import { | ||||
|   AppBar, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   IconButton, | ||||
|   Slide, | ||||
|   Toolbar, | ||||
|   Typography, | ||||
|   useMediaQuery, | ||||
|   useScrollTrigger | ||||
| } from "@mui/material"; | ||||
| import { ReactElement, ReactNode, useEffect, useRef, useState } from "react"; | ||||
| import { SITE_NAME } from "@/consts"; | ||||
| import { Link } from "react-router-dom"; | ||||
| import NavigationMenu, { AppNavigationHeader, isMobileQuery } from "@/components/NavigationMenu.tsx"; | ||||
| import AccountCircleIcon from "@mui/icons-material/AccountCircleOutlined"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
|  | ||||
| function HideOnScroll(props: { window?: () => Window; children: ReactElement }) { | ||||
|   const { children, window } = props; | ||||
|   const trigger = useScrollTrigger({ | ||||
|     target: window ? window() : undefined | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <Slide appear={false} direction="down" in={!trigger}> | ||||
|       {children} | ||||
|     </Slide> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export default function AppShell({ children }: { children: ReactNode }) { | ||||
|   let documentWindow: Window; | ||||
|  | ||||
|   const { userinfo } = useUserinfo(); | ||||
|  | ||||
|   const isMobile = useMediaQuery(isMobileQuery); | ||||
|   const [open, setOpen] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     documentWindow = window; | ||||
|   }, []); | ||||
|  | ||||
|   const container = useRef<HTMLDivElement>(null); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <HideOnScroll window={() => documentWindow}> | ||||
|         <AppBar position="fixed"> | ||||
|           <Toolbar sx={{ height: 64 }}> | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               edge="start" | ||||
|               color="inherit" | ||||
|               aria-label="menu" | ||||
|               sx={{ ml: isMobile ? 0.5 : 0, mr: 2 }} | ||||
|             > | ||||
|               <img src="/favicon.svg" alt="Logo" width={32} height={32} /> | ||||
|             </IconButton> | ||||
|  | ||||
|             <Typography variant="h6" component="div" sx={{ flexGrow: 1, fontSize: "1.2rem" }}> | ||||
|               <Link to="/">{SITE_NAME}</Link> | ||||
|             </Typography> | ||||
|  | ||||
|             <IconButton | ||||
|               size="large" | ||||
|               edge="start" | ||||
|               color="inherit" | ||||
|               aria-label="menu" | ||||
|               onClick={() => setOpen(true)} | ||||
|               sx={{ mr: 1 }} | ||||
|             > | ||||
|               <Avatar | ||||
|                 sx={{ width: 32, height: 32, bgcolor: "transparent" }} | ||||
|                 ref={container} | ||||
|                 alt={userinfo?.displayName} | ||||
|                 src={`/api/avatar/${userinfo?.data?.avatar}`} | ||||
|               > | ||||
|                 <AccountCircleIcon /> | ||||
|               </Avatar> | ||||
|             </IconButton> | ||||
|           </Toolbar> | ||||
|         </AppBar> | ||||
|       </HideOnScroll> | ||||
|  | ||||
|       <Box component="main"> | ||||
|         <AppNavigationHeader /> | ||||
|  | ||||
|         {children} | ||||
|       </Box> | ||||
|  | ||||
|       <NavigationMenu anchorEl={container.current} open={open} onClose={() => setOpen(false)} /> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -1,27 +1,15 @@ | ||||
| import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; | ||||
| import ChevronRightIcon from "@mui/icons-material/ChevronRight"; | ||||
| import { | ||||
|   Box, | ||||
|   Collapse, | ||||
|   Divider, | ||||
|   Drawer, | ||||
|   IconButton, | ||||
|   List, | ||||
|   ListItemButton, | ||||
|   ListItemIcon, | ||||
|   ListItemText, | ||||
|   styled, | ||||
|   useMediaQuery | ||||
| } from "@mui/material"; | ||||
| import { Collapse, Divider, ListItemIcon, ListItemText, Menu, MenuItem, styled } from "@mui/material"; | ||||
| import { theme } from "@/theme"; | ||||
| import { Fragment, ReactNode, useState } from "react"; | ||||
| import HowToRegIcon from "@mui/icons-material/HowToReg"; | ||||
| import LoginIcon from "@mui/icons-material/Login"; | ||||
| import FaceIcon from "@mui/icons-material/Face"; | ||||
| import LogoutIcon from "@mui/icons-material/Logout"; | ||||
| import LogoutIcon from "@mui/icons-material/ExitToApp"; | ||||
| import ExpandLess from "@mui/icons-material/ExpandLess"; | ||||
| import ExpandMore from "@mui/icons-material/ExpandMore"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { PopoverProps } from "@mui/material/Popover"; | ||||
| import { Link } from "react-router-dom"; | ||||
| 
 | ||||
| export interface NavigationItem { | ||||
|   icon?: ReactNode; | ||||
| @@ -51,32 +39,30 @@ export function AppNavigationSection({ items, depth }: { items: NavigationItem[] | ||||
|     } else if (item.children) { | ||||
|       return ( | ||||
|         <Fragment key={idx}> | ||||
|           <ListItemButton onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2 }}> | ||||
|           <MenuItem onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}> | ||||
|             <ListItemIcon>{item.icon}</ListItemIcon> | ||||
|             <ListItemText primary={item.title} /> | ||||
|             {open ? <ExpandLess /> : <ExpandMore />} | ||||
|           </ListItemButton> | ||||
|           </MenuItem> | ||||
|           <Collapse in={open} timeout="auto" unmountOnExit> | ||||
|             <List component="div" disablePadding> | ||||
|             <AppNavigationSection items={item.children} depth={(depth ?? 0) + 1} /> | ||||
|             </List> | ||||
|           </Collapse> | ||||
|         </Fragment> | ||||
|       ); | ||||
|     } else { | ||||
|       return ( | ||||
|         <a key={idx} href={item.link ?? "/"}> | ||||
|           <ListItemButton sx={{ pl: 2 + (depth ?? 0) * 2 }}> | ||||
|         <Link key={idx} to={item.link ?? "/"}> | ||||
|           <MenuItem sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}> | ||||
|             <ListItemIcon>{item.icon}</ListItemIcon> | ||||
|             <ListItemText primary={item.title} /> | ||||
|           </ListItemButton> | ||||
|         </a> | ||||
|           </MenuItem> | ||||
|         </Link> | ||||
|       ); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onClose: () => void }) { | ||||
| export function AppNavigation() { | ||||
|   const { checkLoggedIn } = useUserinfo(); | ||||
| 
 | ||||
|   const nav: NavigationItem[] = [ | ||||
| @@ -94,61 +80,19 @@ export function AppNavigation({ showClose, onClose }: { showClose?: boolean; onC | ||||
|     ) | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|     <> | ||||
|       <AppNavigationHeader> | ||||
|         {showClose && ( | ||||
|           <IconButton onClick={onClose}> | ||||
|             {theme.direction === "rtl" ? <ChevronLeftIcon /> : <ChevronRightIcon />} | ||||
|           </IconButton> | ||||
|         )} | ||||
|       </AppNavigationHeader> | ||||
|       <Divider /> | ||||
|       <List> | ||||
|         <AppNavigationSection items={nav} /> | ||||
|       </List> | ||||
|     </> | ||||
|   ); | ||||
|   return <AppNavigationSection items={nav} />; | ||||
| } | ||||
| 
 | ||||
| export const isMobileQuery = theme.breakpoints.down("md"); | ||||
| 
 | ||||
| export default function NavigationDrawer({ open, onClose }: { open: boolean; onClose: () => void }) { | ||||
|   const isMobile = useMediaQuery(isMobileQuery); | ||||
| 
 | ||||
|   return isMobile ? ( | ||||
|     <> | ||||
|       <Box sx={{ flexShrink: 0, width: DRAWER_WIDTH }} /> | ||||
|       <Drawer | ||||
|         keepMounted | ||||
|         anchor="right" | ||||
|         variant="temporary" | ||||
|         open={open} | ||||
|         onClose={onClose} | ||||
|         sx={{ | ||||
|           "& .MuiDrawer-paper": { | ||||
|             boxSizing: "border-box", | ||||
|             width: DRAWER_WIDTH | ||||
|           } | ||||
|         }} | ||||
|       > | ||||
|         <AppNavigation onClose={onClose} /> | ||||
|       </Drawer> | ||||
|     </> | ||||
|   ) : ( | ||||
|     <Drawer | ||||
|       variant="persistent" | ||||
|       anchor="right" | ||||
|       open={open} | ||||
|       sx={{ | ||||
|         width: DRAWER_WIDTH, | ||||
|         flexShrink: 0, | ||||
|         "& .MuiDrawer-paper": { | ||||
|           width: DRAWER_WIDTH | ||||
|         } | ||||
|       }} | ||||
|     > | ||||
|       <AppNavigation showClose onClose={onClose} /> | ||||
|     </Drawer> | ||||
| export default function NavigationMenu({ anchorEl, open, onClose }: { | ||||
|   anchorEl: PopoverProps["anchorEl"]; | ||||
|   open: boolean; | ||||
|   onClose: () => void | ||||
| }) { | ||||
|   return ( | ||||
|     <Menu anchorEl={anchorEl} open={open} onClose={onClose}> | ||||
|       <AppNavigation /> | ||||
|     </Menu> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										97
									
								
								pkg/views/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								pkg/views/src/main.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| import React from "react"; | ||||
| import ReactDOM from "react-dom/client"; | ||||
| import { createBrowserRouter, Outlet, RouterProvider } from "react-router-dom"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { LocalizationProvider } from "@mui/x-date-pickers"; | ||||
| import { CssBaseline, ThemeProvider } from "@mui/material"; | ||||
| import { theme } from "@/theme.ts"; | ||||
|  | ||||
| import "virtual:uno.css"; | ||||
|  | ||||
| import "./index.css"; | ||||
| import "@unocss/reset/tailwind.css"; | ||||
| import "@fontsource/roboto/latin.css"; | ||||
|  | ||||
| import AppShell from "@/components/AppShell.tsx"; | ||||
| import LandingPage from "@/pages/landing.tsx"; | ||||
| import SignUpPage from "@/pages/auth/sign-up.tsx"; | ||||
| import SignInPage from "@/pages/auth/sign-in.tsx"; | ||||
| import OauthConnectPage from "@/pages/auth/connect.tsx"; | ||||
| import DashboardPage from "@/pages/users/dashboard.tsx"; | ||||
| import ErrorBoundary from "@/error.tsx"; | ||||
| import AppLoader from "@/components/AppLoader.tsx"; | ||||
| import UserLayout from "@/pages/users/layout.tsx"; | ||||
| import NotificationsPage from "@/pages/users/notifications.tsx"; | ||||
| import PersonalizePage from "@/pages/users/personalize.tsx"; | ||||
| import SecurityPage from "@/pages/users/security.tsx"; | ||||
| import { UserinfoProvider } from "@/stores/userinfo.tsx"; | ||||
| import { WellKnownProvider } from "@/stores/wellKnown.tsx"; | ||||
| import AuthLayout from "@/pages/auth/layout.tsx"; | ||||
| import AuthGuard from "@/pages/guard.tsx"; | ||||
|  | ||||
| declare const __GARFISH_EXPORTS__: { | ||||
|   provider: Object; | ||||
|   registerProvider?: (provider: any) => void; | ||||
| }; | ||||
|  | ||||
| declare global { | ||||
|   interface Window { | ||||
|     __LAUNCHPAD_TARGET__?: string; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const router = createBrowserRouter([ | ||||
|   { | ||||
|     path: "/", | ||||
|     element: <AppShell><Outlet /></AppShell>, | ||||
|     errorElement: <ErrorBoundary />, | ||||
|     children: [ | ||||
|       { path: "/", element: <LandingPage /> }, | ||||
|       { | ||||
|         path: "/", | ||||
|         element: <AuthGuard />, | ||||
|         children: [ | ||||
|           { | ||||
|             path: "/users", | ||||
|             element: <UserLayout />, | ||||
|             children: [ | ||||
|               { path: "/users", element: <DashboardPage /> }, | ||||
|               { path: "/users/notifications", element: <NotificationsPage /> }, | ||||
|               { path: "/users/personalize", element: <PersonalizePage /> }, | ||||
|               { path: "/users/security", element: <SecurityPage /> } | ||||
|             ] | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     ] | ||||
|   }, | ||||
|   { | ||||
|     path: "/auth", | ||||
|     element: <AuthLayout />, | ||||
|     errorElement: <ErrorBoundary />, | ||||
|     children: [ | ||||
|       { path: "/auth/sign-up", element: <SignUpPage />, errorElement: <ErrorBoundary /> }, | ||||
|       { path: "/auth/sign-in", element: <SignInPage />, errorElement: <ErrorBoundary /> }, | ||||
|       { path: "/auth/o/connect", element: <OauthConnectPage />, errorElement: <ErrorBoundary /> } | ||||
|     ] | ||||
|   } | ||||
| ]); | ||||
|  | ||||
| const element = ( | ||||
|   <React.StrictMode> | ||||
|     <LocalizationProvider dateAdapter={AdapterDayjs}> | ||||
|       <ThemeProvider theme={theme}> | ||||
|         <WellKnownProvider> | ||||
|           <UserinfoProvider> | ||||
|             <AppLoader> | ||||
|               <CssBaseline /> | ||||
|               <RouterProvider router={router} /> | ||||
|             </AppLoader> | ||||
|           </UserinfoProvider> | ||||
|         </WellKnownProvider> | ||||
|       </ThemeProvider> | ||||
|     </LocalizationProvider> | ||||
|   </React.StrictMode> | ||||
| ); | ||||
|  | ||||
| ReactDOM.createRoot(document.getElementById("root")!).render(element); | ||||
							
								
								
									
										182
									
								
								pkg/views/src/pages/auth/connect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										182
									
								
								pkg/views/src/pages/auth/connect.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,182 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { | ||||
|   Alert, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   Collapse, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { useSearchParams } from "react-router-dom"; | ||||
| import OutletIcon from "@mui/icons-material/Outlet"; | ||||
| import WhatshotIcon from "@mui/icons-material/Whatshot"; | ||||
|  | ||||
| export default function OauthConnectPage() { | ||||
|   const { getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [panel, setPanel] = useState(0); | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   const [client, setClient] = useState<any>(null); | ||||
|  | ||||
|   const [searchParams] = useSearchParams(); | ||||
|  | ||||
|   async function preconnect() { | ||||
|     const res = await request(`/api/auth/o/connect${location.search}`, { | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|  | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|  | ||||
|       if (data["session"]) { | ||||
|         setPanel(1); | ||||
|         redirect(data["session"]); | ||||
|       } else { | ||||
|         setClient(data["client"]); | ||||
|         setLoading(false); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     preconnect().then(() => console.log("Fetched metadata")); | ||||
|   }, []); | ||||
|  | ||||
|   function decline() { | ||||
|     if (window.history.length > 0) { | ||||
|       window.history.back(); | ||||
|     } else { | ||||
|       window.close(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function approve() { | ||||
|     setLoading(true); | ||||
|  | ||||
|     const res = await request("/api/auth/o/connect?" + new URLSearchParams({ | ||||
|       client_id: searchParams.get("client_id") as string, | ||||
|       redirect_uri: encodeURIComponent(searchParams.get("redirect_uri") as string), | ||||
|       response_type: "code", | ||||
|       scope: searchParams.get("scope") as string | ||||
|     }), { | ||||
|       method: "POST", | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|  | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|       setLoading(false); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setPanel(1); | ||||
|       setTimeout(() => redirect(data["session"]), 1850); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function redirect(session: any) { | ||||
|     const url = `${searchParams.get("redirect_uri")}?code=${session["grant_token"]}&state=${searchParams.get("state")}`; | ||||
|     window.open(url, "_self"); | ||||
|   } | ||||
|  | ||||
|   const elements = [ | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <OutletIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Sign in to {client?.name} | ||||
|         </Typography> | ||||
|         <Box sx={{ mt: 3, width: "100%" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12}> | ||||
|               <Typography fontWeight="bold">About this app</Typography> | ||||
|               <Typography variant="body2">{client?.description}</Typography> | ||||
|             </Grid> | ||||
|             <Grid item xs={12}> | ||||
|               <Typography fontWeight="bold">Make you trust this app</Typography> | ||||
|               <Typography variant="body2"> | ||||
|                 After you click Approve button, you will share your basic personal information to this application | ||||
|                 developer. Some of them will leak your data. Think twice. | ||||
|               </Typography> | ||||
|             </Grid> | ||||
|             <Grid item xs={12} md={6}> | ||||
|               <Button | ||||
|                 fullWidth | ||||
|                 color="info" | ||||
|                 variant="outlined" | ||||
|                 disabled={loading} | ||||
|                 sx={{ mt: 3 }} | ||||
|                 onClick={() => decline()} | ||||
|               > | ||||
|                 Decline | ||||
|               </Button> | ||||
|             </Grid> | ||||
|             <Grid item xs={12} md={6}> | ||||
|               <Button | ||||
|                 fullWidth | ||||
|                 variant="outlined" | ||||
|                 disabled={loading} | ||||
|                 sx={{ mt: 3 }} | ||||
|                 onClick={() => approve()} | ||||
|               > | ||||
|                 Approve | ||||
|               </Button> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|         </Box> | ||||
|       </> | ||||
|     ), | ||||
|     ( | ||||
|       <> | ||||
|         <Avatar sx={{ m: 1, bgcolor: "secondary.main" }}> | ||||
|           <WhatshotIcon /> | ||||
|         </Avatar> | ||||
|         <Typography component="h1" variant="h5"> | ||||
|           Authorized | ||||
|         </Typography> | ||||
|         <Box sx={{ mt: 3, width: "100%", textAlign: "center" }}> | ||||
|           <Grid container spacing={2}> | ||||
|             <Grid item xs={12} sx={{ my: 8 }}> | ||||
|               <Typography variant="h6">Now Redirecting...</Typography> | ||||
|               <Typography>Hold on a second, we are going to redirect you to the target.</Typography> | ||||
|             </Grid> | ||||
|           </Grid> | ||||
|         </Box> | ||||
|       </> | ||||
|     ) | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||
|  | ||||
|       <Card variant="outlined"> | ||||
|         <Collapse in={loading}> | ||||
|           <LinearProgress /> | ||||
|         </Collapse> | ||||
|  | ||||
|         <CardContent | ||||
|           style={{ padding: "40px 48px 36px" }} | ||||
|           sx={{ | ||||
|             display: "flex", | ||||
|             flexDirection: "column", | ||||
|             alignItems: "center" | ||||
|           }} | ||||
|         > | ||||
|           {elements[panel]} | ||||
|         </CardContent> | ||||
|       </Card> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										12
									
								
								pkg/views/src/pages/auth/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								pkg/views/src/pages/auth/layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { Box } from "@mui/material"; | ||||
| import { Outlet } from "react-router-dom"; | ||||
|  | ||||
| export default function AuthLayout() { | ||||
|   return ( | ||||
|     <Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}> | ||||
|       <Box style={{ width: "100vw", maxWidth: "450px" }}> | ||||
|         <Outlet /> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ) | ||||
| } | ||||
| @@ -277,10 +277,15 @@ export default function SignInPage() { | ||||
|   } | ||||
| 
 | ||||
|   return ( | ||||
|     <Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}> | ||||
|       <Box style={{ width: "100vw", maxWidth: "450px" }}> | ||||
|     <> | ||||
|       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||
| 
 | ||||
|       <Collapse in={searchParams.has("redirect_uri")}> | ||||
|         <Alert severity="info" sx={{ mb: 2 }}> | ||||
|           You need sign in before take an action. After that, we will take you back to your work. | ||||
|         </Alert> | ||||
|       </Collapse> | ||||
| 
 | ||||
|       <Card variant="outlined"> | ||||
|         <Collapse in={loading}> | ||||
|           <LinearProgress /> | ||||
| @@ -321,7 +326,6 @@ export default function SignInPage() { | ||||
|           </Link> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|       </Box> | ||||
|     </Box> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| @@ -166,8 +166,7 @@ export default function SignUpPage() { | ||||
|   ]; | ||||
| 
 | ||||
|   return ( | ||||
|     <Box sx={{ height: "100vh", display: "flex", alignItems: "center", justifyContent: "center" }}> | ||||
|       <Box style={{ width: "100vw", maxWidth: "450px" }}> | ||||
|     <> | ||||
|       {error && <Alert severity="error" className="capitalize" sx={{ mb: 2 }}>{error}</Alert>} | ||||
| 
 | ||||
|       <Card variant="outlined"> | ||||
| @@ -194,7 +193,6 @@ export default function SignUpPage() { | ||||
|           </Link> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|       </Box> | ||||
|     </Box> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										29
									
								
								pkg/views/src/pages/guard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								pkg/views/src/pages/guard.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| import { useEffect } from "react"; | ||||
| import { Box, CircularProgress } from "@mui/material"; | ||||
| import { Outlet, useLocation, useNavigate } from "react-router-dom"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
|  | ||||
| export default function AuthGuard() { | ||||
|   const { userinfo } = useUserinfo(); | ||||
|  | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     console.log(userinfo) | ||||
|     if (userinfo?.isReady) { | ||||
|       if (!userinfo?.isLoggedIn) { | ||||
|         const callback = location.pathname + location.search; | ||||
|         navigate({ pathname: "/auth/sign-in", search: `redirect_uri=${callback}` }); | ||||
|       } | ||||
|     } | ||||
|   }, [userinfo]); | ||||
|  | ||||
|   return !userinfo?.isReady ? ( | ||||
|     <Box sx={{ pt: 32, display: "flex", justifyContent: "center", alignItems: "center" }}> | ||||
|       <Box> | ||||
|         <CircularProgress /> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ) : <Outlet />; | ||||
| } | ||||
							
								
								
									
										35
									
								
								pkg/views/src/pages/users/dashboard.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								pkg/views/src/pages/users/dashboard.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import { Alert, Box, Card, CardContent, Container, Typography } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
|  | ||||
| export default function DashboardPage() { | ||||
|   const { userinfo } = useUserinfo(); | ||||
|  | ||||
|   return ( | ||||
|     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||
|       <Box sx={{ px: 3 }}> | ||||
|         <Typography variant="h5">Welcome, {userinfo?.displayName}</Typography> | ||||
|         <Typography variant="body2">What can I help you today?</Typography> | ||||
|       </Box> | ||||
|  | ||||
|       { | ||||
|         !userinfo?.profiles?.confirmed_at && | ||||
|         <Alert severity="warning" sx={{ mt: 3, mx: 1 }}> | ||||
|           Your account haven't confirmed yet. Go to your linked email | ||||
|           inbox and check out our registration confirm email. | ||||
|         </Alert> | ||||
|       } | ||||
|  | ||||
|       <Box sx={{ px: 1, mt: 3 }}> | ||||
|         <Typography variant="h6" sx={{ px: 2 }}>Frequently Asked Questions</Typography> | ||||
|  | ||||
|         <Card variant="outlined" sx={{ mt: 1 }}> | ||||
|           <CardContent style={{ padding: "40px" }}> | ||||
|             <Typography>没有人有问题。没有人敢有问题。鲁迅曾经说过:</Typography> | ||||
|             <Typography sx={{ pl: 4 }} fontWeight="bold">解决不了问题,就解决提问题的人。 —— 鲁迅</Typography> | ||||
|             <Typography>所以,我们的客诉率是 0% 哦~</Typography> | ||||
|           </CardContent> | ||||
|         </Card> | ||||
|       </Box> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										65
									
								
								pkg/views/src/pages/users/layout.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								pkg/views/src/pages/users/layout.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| import { Outlet, useLocation, useNavigate } from "react-router-dom"; | ||||
| import { Box, Tab, Tabs, useMediaQuery } from "@mui/material"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { theme } from "@/theme.ts"; | ||||
| import DashboardIcon from "@mui/icons-material/Dashboard"; | ||||
| import InboxIcon from "@mui/icons-material/Inbox"; | ||||
| import DrawIcon from "@mui/icons-material/Draw"; | ||||
| import SecurityIcon from "@mui/icons-material/Security"; | ||||
|  | ||||
| export default function UserLayout() { | ||||
|   const [focus, setFocus] = useState(0); | ||||
|  | ||||
|   const isMobile = useMediaQuery(theme.breakpoints.down("md")); | ||||
|  | ||||
|   const locations = ["/users", "/users/notifications", "/users/personalize", "/users/security"]; | ||||
|   const tabs = [ | ||||
|     { icon: <DashboardIcon />, label: "Dashboard" }, | ||||
|     { icon: <InboxIcon />, label: "Notifications" }, | ||||
|     { icon: <DrawIcon />, label: "Personalize" }, | ||||
|     { icon: <SecurityIcon />, label: "Security" } | ||||
|   ]; | ||||
|  | ||||
|   const location = useLocation(); | ||||
|   const navigate = useNavigate(); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const idx = locations.indexOf(location.pathname); | ||||
|     setFocus(idx); | ||||
|   }, []); | ||||
|  | ||||
|   function swap(idx: number) { | ||||
|     navigate(locations[idx]); | ||||
|     setFocus(idx); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box sx={{ display: "flex", flexDirection: isMobile ? "column" : "row", height: "calc(100vh - 64px)" }}> | ||||
|       <Box sx={{ width: isMobile ? "100%" : 280 }}> | ||||
|         <Tabs | ||||
|           orientation={isMobile ? "horizontal" : "vertical"} | ||||
|           variant="scrollable" | ||||
|           value={focus} | ||||
|           onChange={(_, val) => swap(val)} | ||||
|           sx={{ | ||||
|             borderRight: isMobile ? 0 : 1, | ||||
|             borderBottom: isMobile ? 1 : 0, | ||||
|             borderColor: "divider", | ||||
|             height: isMobile ? "fit-content" : "100%", | ||||
|             py: isMobile ? 0 : 1, | ||||
|             px: isMobile ? 1 : 0 | ||||
|           }} | ||||
|         > | ||||
|           {tabs.map((tab, idx) => ( | ||||
|             <Tab key={idx} icon={tab.icon} iconPosition={isMobile ? "top" : "start"} label={tab.label} | ||||
|                  sx={{ px: 5, justifyContent: isMobile ? "center" : "left" }} /> | ||||
|           ))} | ||||
|         </Tabs> | ||||
|       </Box> | ||||
|  | ||||
|       <Box sx={{ flexGrow: 1 }}> | ||||
|         <Outlet /> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										87
									
								
								pkg/views/src/pages/users/notifications.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								pkg/views/src/pages/users/notifications.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | ||||
| import { Alert, Box, Collapse, IconButton, LinearProgress, List, ListItem, ListItemText } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { TransitionGroup } from "react-transition-group"; | ||||
| import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead"; | ||||
|  | ||||
| export default function NotificationsPage() { | ||||
|   const { userinfo, readProfiles, getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState<null | string>(null); | ||||
|  | ||||
|   const [notifications, setNotifications] = useState<any[]>([]); | ||||
|  | ||||
|   async function readNotifications() { | ||||
|     const res = await request(`/api/notifications?take=100`, { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setNotifications(data["data"]); | ||||
|       setError(null); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function markNotifications(item: any) { | ||||
|     setLoading(true); | ||||
|     const res = await request(`/api/notifications/${item.id}/read`, { | ||||
|       method: "PUT", | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       readNotifications().then(() => readProfiles()); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readNotifications().then(() => setLoading(false)); | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <Box> | ||||
|       <Collapse in={loading}> | ||||
|         <LinearProgress color="info" /> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Collapse in={error != null}> | ||||
|         <Alert severity="error" variant="filled" square>{error}</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Collapse in={userinfo?.data?.notifications?.length <= 0}> | ||||
|         <Alert severity="success" variant="filled" square>You are done! There's no unread notifications for you.</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <List sx={{ width: "100%", bgcolor: "background.paper" }}> | ||||
|         <TransitionGroup> | ||||
|           {notifications.map((item, idx) => ( | ||||
|             <Collapse key={idx} sx={{ px: 5 }}> | ||||
|               <ListItem alignItems="flex-start" secondaryAction={ | ||||
|                 <IconButton | ||||
|                   edge="end" | ||||
|                   aria-label="delete" | ||||
|                   title="Delete" | ||||
|                   onClick={() => markNotifications(item)} | ||||
|                 > | ||||
|                   <MarkEmailReadIcon /> | ||||
|                 </IconButton> | ||||
|               }> | ||||
|                 <ListItemText | ||||
|                   primary={item.subject} | ||||
|                   secondary={item.content} | ||||
|                 /> | ||||
|               </ListItem> | ||||
|             </Collapse> | ||||
|           ))} | ||||
|         </TransitionGroup> | ||||
|       </List> | ||||
|     </Box> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										250
									
								
								pkg/views/src/pages/users/personalize.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										250
									
								
								pkg/views/src/pages/users/personalize.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,250 @@ | ||||
| import { | ||||
|   Alert, | ||||
|   Avatar, | ||||
|   Box, | ||||
|   Button, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   CircularProgress, | ||||
|   Collapse, | ||||
|   Container, | ||||
|   Divider, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Snackbar, | ||||
|   styled, | ||||
|   TextField, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { ChangeEvent, FormEvent, useState } from "react"; | ||||
| import { DatePicker } from "@mui/x-date-pickers"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import SaveIcon from "@mui/icons-material/Save"; | ||||
| import PublishIcon from "@mui/icons-material/Publish"; | ||||
| import NoAccountsIcon from "@mui/icons-material/NoAccounts"; | ||||
| import dayjs from "dayjs"; | ||||
|  | ||||
| const VisuallyHiddenInput = styled("input")({ | ||||
|   clip: "rect(0 0 0 0)", | ||||
|   clipPath: "inset(50%)", | ||||
|   height: 1, | ||||
|   overflow: "hidden", | ||||
|   position: "absolute", | ||||
|   bottom: 0, | ||||
|   left: 0, | ||||
|   whiteSpace: "nowrap", | ||||
|   width: 1 | ||||
| }); | ||||
|  | ||||
| export default function PersonalizePage() { | ||||
|   const { userinfo, readProfiles, getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [done, setDone] = useState(false); | ||||
|   const [error, setError] = useState<any>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   async function submit(evt: FormEvent<HTMLFormElement>) { | ||||
|     evt.preventDefault(); | ||||
|  | ||||
|     const data: any = Object.fromEntries(new FormData(evt.target as HTMLFormElement)); | ||||
|     if (data.birthday) data.birthday = new Date(data.birthday); | ||||
|  | ||||
|     setLoading(true); | ||||
|     const res = await request("/api/users/me", { | ||||
|       method: "PUT", | ||||
|       headers: { "Content-Type": "application/json", "Authorization": `Bearer ${getAtk()}` }, | ||||
|       body: JSON.stringify(data) | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readProfiles(); | ||||
|       setDone(true); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   async function changeAvatar(evt: ChangeEvent<HTMLInputElement>) { | ||||
|     if (!evt.target.files) return; | ||||
|  | ||||
|     const file = evt.target.files[0]; | ||||
|     const payload = new FormData(); | ||||
|     payload.set("avatar", file); | ||||
|  | ||||
|     setLoading(true); | ||||
|     const res = await request("/api/avatar", { | ||||
|       method: "PUT", | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` }, | ||||
|       body: payload | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readProfiles(); | ||||
|       setDone(true); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   function getBirthday() { | ||||
|     return userinfo?.data?.profile?.birthday ? dayjs(userinfo?.data?.profile?.birthday) : undefined; | ||||
|   } | ||||
|  | ||||
|   const basisForm = ( | ||||
|     <Box component="form" onSubmit={submit} sx={{ mt: 3 }}> | ||||
|       <Grid container spacing={2}> | ||||
|         <Grid item xs={6}> | ||||
|           <TextField | ||||
|             name="name" | ||||
|             required | ||||
|             disabled | ||||
|             fullWidth | ||||
|             label="Username" | ||||
|             autoComplete="username" | ||||
|             defaultValue={userinfo?.data?.name} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={6}> | ||||
|           <TextField | ||||
|             name="nick" | ||||
|             required | ||||
|             fullWidth | ||||
|             label="Nickname" | ||||
|             autoComplete="nickname" | ||||
|             defaultValue={userinfo?.data?.nick} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={12}> | ||||
|           <TextField | ||||
|             name="description" | ||||
|             multiline | ||||
|             fullWidth | ||||
|             label="Description" | ||||
|             autoComplete="bio" | ||||
|             defaultValue={userinfo?.data?.description} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={6} md={4}> | ||||
|           <TextField | ||||
|             name="first_name" | ||||
|             fullWidth | ||||
|             label="First Name" | ||||
|             autoComplete="given_name" | ||||
|             defaultValue={userinfo?.data?.profile?.first_name} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={6} md={4}> | ||||
|           <TextField | ||||
|             name="last_name" | ||||
|             fullWidth | ||||
|             label="Last Name" | ||||
|             autoComplete="famliy_name" | ||||
|             defaultValue={userinfo?.data?.profile?.last_name} | ||||
|             InputLabelProps={{ shrink: true }} | ||||
|           /> | ||||
|         </Grid> | ||||
|         <Grid item xs={12} md={4}> | ||||
|           <DatePicker | ||||
|             name="birthday" | ||||
|             label="Birthday" | ||||
|             defaultValue={getBirthday()} | ||||
|             sx={{ width: "100%" }} | ||||
|           /> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|  | ||||
|       <Button | ||||
|         type="submit" | ||||
|         variant="contained" | ||||
|         disabled={loading} | ||||
|         startIcon={<SaveIcon />} | ||||
|         sx={{ mt: 2, width: "180px" }} | ||||
|       > | ||||
|         Save changes | ||||
|       </Button> | ||||
|  | ||||
|       <Divider sx={{ my: 2, mx: -3 }} /> | ||||
|  | ||||
|       <Box sx={{ mt: 2.5, display: "flex", gap: 1, alignItems: "center" }}> | ||||
|         <Box> | ||||
|           <Avatar | ||||
|             sx={{ width: 32, height: 32 }} | ||||
|             alt={userinfo?.displayName} | ||||
|             src={`/api/avatar/${userinfo?.data?.avatar}`} | ||||
|           > | ||||
|             <NoAccountsIcon /> | ||||
|           </Avatar> | ||||
|         </Box> | ||||
|         <Box> | ||||
|           {/* @ts-ignore */} | ||||
|           <Button | ||||
|             type="button" | ||||
|             color="info" | ||||
|             component="label" | ||||
|             tabIndex={-1} | ||||
|             disabled={loading} | ||||
|             startIcon={<PublishIcon />} | ||||
|             sx={{ width: "180px" }} | ||||
|           > | ||||
|             Change avatar | ||||
|             <VisuallyHiddenInput type="file" accept="image/*" onChange={changeAvatar} /> | ||||
|           </Button> | ||||
|         </Box> | ||||
|       </Box> | ||||
|     </Box> | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||
|       <Box sx={{ px: 3 }}> | ||||
|         <Typography variant="h5">Personalize</Typography> | ||||
|         <Typography variant="body2"> | ||||
|           Customize your appearance and name card across all Goatworks information. | ||||
|         </Typography> | ||||
|       </Box> | ||||
|  | ||||
|       <Collapse in={error}> | ||||
|         <Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Box sx={{ mt: 2 }}> | ||||
|         <Card variant="outlined"> | ||||
|           <Collapse in={loading}> | ||||
|             <LinearProgress /> | ||||
|           </Collapse> | ||||
|  | ||||
|           <CardContent style={{ padding: "20px 24px" }}> | ||||
|             <Box sx={{ px: 1, my: 1 }}> | ||||
|               <Typography variant="h6">Information</Typography> | ||||
|               <Typography variant="subtitle2"> | ||||
|                 The information for public. Let us and others better to know who you are. | ||||
|               </Typography> | ||||
|             </Box> | ||||
|  | ||||
|             { | ||||
|               userinfo?.data != null ? basisForm : | ||||
|                 <Box sx={{ pt: 1, px: 1 }}> | ||||
|                   <CircularProgress /> | ||||
|                 </Box> | ||||
|             } | ||||
|  | ||||
|           </CardContent> | ||||
|         </Card> | ||||
|       </Box> | ||||
|  | ||||
|       <Snackbar | ||||
|         open={done} | ||||
|         autoHideDuration={1000 * 10} | ||||
|         onClose={() => setDone(false)} | ||||
|         message="Your profile has been updated. Some settings maybe need sometime to apply across site." | ||||
|       /> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										267
									
								
								pkg/views/src/pages/users/security.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										267
									
								
								pkg/views/src/pages/users/security.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,267 @@ | ||||
| import { | ||||
|   Alert, | ||||
|   Box, | ||||
|   Card, | ||||
|   CardContent, | ||||
|   Collapse, | ||||
|   Container, | ||||
|   Grid, | ||||
|   LinearProgress, | ||||
|   Tab, | ||||
|   Tabs, | ||||
|   Typography | ||||
| } from "@mui/material"; | ||||
| import { useUserinfo } from "@/stores/userinfo.tsx"; | ||||
| import { TabContext, TabPanel } from "@mui/lab"; | ||||
| import { useEffect, useState } from "react"; | ||||
| import { DataGrid, GridActionsCellItem, GridColDef, GridRowParams, GridValueGetterParams } from "@mui/x-data-grid"; | ||||
| import { request } from "@/scripts/request.ts"; | ||||
| import ExitToAppIcon from "@mui/icons-material/ExitToApp"; | ||||
|  | ||||
|  | ||||
| export default function SecurityPage() { | ||||
|   const dataDefinitions: { [id: string]: GridColDef[] } = { | ||||
|     challenges: [ | ||||
|       { field: "id", headerName: "ID", width: 64 }, | ||||
|       { field: "ip_address", headerName: "IP Address", minWidth: 128 }, | ||||
|       { field: "user_agent", headerName: "User Agent", minWidth: 320 }, | ||||
|       { | ||||
|         field: "created_at", | ||||
|         headerName: "Issued At", | ||||
|         minWidth: 160, | ||||
|         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||
|       } | ||||
|     ], | ||||
|     sessions: [ | ||||
|       { field: "id", headerName: "ID", width: 64 }, | ||||
|       { | ||||
|         field: "audiences", | ||||
|         headerName: "Audiences", | ||||
|         minWidth: 128, | ||||
|         valueGetter: (params: GridValueGetterParams) => params.row.audiences.join(", ") | ||||
|       }, | ||||
|       { | ||||
|         field: "claims", | ||||
|         headerName: "Claims", | ||||
|         minWidth: 224, | ||||
|         valueGetter: (params: GridValueGetterParams) => params.row.claims.join(", ") | ||||
|       }, | ||||
|       { | ||||
|         field: "created_at", | ||||
|         headerName: "Issued At", | ||||
|         minWidth: 160, | ||||
|         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||
|       }, | ||||
|       { | ||||
|         field: "actions", | ||||
|         type: "actions", | ||||
|         getActions: (params: GridRowParams) => [ | ||||
|           <GridActionsCellItem | ||||
|             icon={<ExitToAppIcon />} | ||||
|             onClick={() => killSession(params.row)} | ||||
|             disabled={loading} | ||||
|             label="Sign Out" | ||||
|           /> | ||||
|         ] | ||||
|       } | ||||
|     ], | ||||
|     events: [ | ||||
|       { field: "id", headerName: "ID", width: 64 }, | ||||
|       { field: "type", headerName: "Type", minWidth: 128 }, | ||||
|       { field: "target", headerName: "Affected Object", minWidth: 128 }, | ||||
|       { field: "ip_address", headerName: "IP Address", minWidth: 128 }, | ||||
|       { field: "user_agent", headerName: "User Agent", minWidth: 128 }, | ||||
|       { | ||||
|         field: "created_at", | ||||
|         headerName: "Performed At", | ||||
|         minWidth: 160, | ||||
|         valueGetter: (params: GridValueGetterParams) => new Date(params.row.created_at).toLocaleString() | ||||
|       } | ||||
|     ] | ||||
|   }; | ||||
|  | ||||
|   const { getAtk } = useUserinfo(); | ||||
|  | ||||
|   const [challenges, setChallenges] = useState<any[]>([]); | ||||
|   const [challengeCount, setChallengeCount] = useState(0); | ||||
|   const [sessions, setSessions] = useState<any[]>([]); | ||||
|   const [sessionCount, setSessionCount] = useState(0); | ||||
|   const [events, setEvents] = useState<any[]>([]); | ||||
|   const [eventCount, setEventCount] = useState(0); | ||||
|  | ||||
|   const [pagination, setPagination] = useState({ | ||||
|     challenges: { page: 0, pageSize: 5 }, | ||||
|     sessions: { page: 0, pageSize: 5 }, | ||||
|     events: { page: 0, pageSize: 5 } | ||||
|   }); | ||||
|  | ||||
|   const [error, setError] = useState<string | null>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [reverting] = useState({ | ||||
|     challenges: true, | ||||
|     sessions: true, | ||||
|     events: true | ||||
|   }); | ||||
|  | ||||
|   const [dataPane, setDataPane] = useState("challenges"); | ||||
|  | ||||
|   async function readChallenges() { | ||||
|     reverting.challenges = true; | ||||
|     const res = await request("/api/users/me/challenges?" + new URLSearchParams({ | ||||
|       take: pagination.challenges.pageSize.toString(), | ||||
|       offset: (pagination.challenges.page * pagination.challenges.pageSize).toString() | ||||
|     }), { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setChallenges(data["data"]); | ||||
|       setChallengeCount(data["count"]); | ||||
|     } | ||||
|     reverting.challenges = false; | ||||
|   } | ||||
|  | ||||
|   async function readSessions() { | ||||
|     reverting.sessions = true; | ||||
|     const res = await request("/api/users/me/sessions?" + new URLSearchParams({ | ||||
|       take: pagination.sessions.pageSize.toString(), | ||||
|       offset: (pagination.sessions.page * pagination.sessions.pageSize).toString() | ||||
|     }), { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setSessions(data["data"]); | ||||
|       setSessionCount(data["count"]); | ||||
|     } | ||||
|     reverting.sessions = false; | ||||
|   } | ||||
|  | ||||
|   async function readEvents() { | ||||
|     reverting.events = true; | ||||
|     const res = await request("/api/users/me/events?" + new URLSearchParams({ | ||||
|       take: pagination.events.pageSize.toString(), | ||||
|       offset: (pagination.events.page * pagination.events.pageSize).toString() | ||||
|     }), { | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       const data = await res.json(); | ||||
|       setEvents(data["data"]); | ||||
|       setEventCount(data["count"]); | ||||
|     } | ||||
|     reverting.events = false; | ||||
|   } | ||||
|  | ||||
|   async function killSession(item: any) { | ||||
|     setLoading(true); | ||||
|     const res = await request(`/api/users/me/sessions/${item.id}`, { | ||||
|       method: "DELETE", | ||||
|       headers: { Authorization: `Bearer ${getAtk()}` } | ||||
|     }); | ||||
|     if (res.status !== 200) { | ||||
|       setError(await res.text()); | ||||
|     } else { | ||||
|       await readSessions(); | ||||
|       setError(null); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readChallenges().then(() => console.log("Refreshed challenges list.")); | ||||
|   }, [pagination.challenges]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readSessions().then(() => console.log("Refreshed sessions list.")); | ||||
|   }, [pagination.sessions]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     readEvents().then(() => console.log("Refreshed events list.")); | ||||
|   }, [pagination.events]); | ||||
|  | ||||
|   return ( | ||||
|     <Container sx={{ pt: 5 }} maxWidth="md"> | ||||
|       <Box sx={{ px: 3 }}> | ||||
|         <Typography variant="h5">Security</Typography> | ||||
|         <Typography variant="body2"> | ||||
|           Overview and control all security details in your account. | ||||
|         </Typography> | ||||
|       </Box> | ||||
|  | ||||
|       <Collapse in={error != null}> | ||||
|         <Alert severity="error" className="capitalize" sx={{ mt: 1.5 }}>{error}</Alert> | ||||
|       </Collapse> | ||||
|  | ||||
|       <Grid container> | ||||
|         <Grid item xs={12}> | ||||
|           <Box sx={{ mt: 2 }}> | ||||
|             <Card variant="outlined"> | ||||
|               <Collapse in={loading}> | ||||
|                 <LinearProgress /> | ||||
|               </Collapse> | ||||
|  | ||||
|               <TabContext value={dataPane}> | ||||
|                 <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | ||||
|                   <Tabs centered value={dataPane} onChange={(_, val) => setDataPane(val)}> | ||||
|                     <Tab label="Challenges" value="challenges" /> | ||||
|                     <Tab label="Sessions" value="sessions" /> | ||||
|                     <Tab label="Events" value="events" /> | ||||
|                   </Tabs> | ||||
|                 </Box> | ||||
|  | ||||
|                 <CardContent style={{ padding: "20px 24px" }}> | ||||
|                   <TabPanel value={"challenges"}> | ||||
|                     <DataGrid | ||||
|                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||
|                       paginationMode="server" | ||||
|                       loading={reverting.challenges} | ||||
|                       rows={challenges} | ||||
|                       rowCount={challengeCount} | ||||
|                       columns={dataDefinitions.challenges} | ||||
|                       paginationModel={pagination.challenges} | ||||
|                       onPaginationModelChange={(val) => setPagination({ ...pagination, challenges: val })} | ||||
|                       checkboxSelection | ||||
|                     /> | ||||
|                   </TabPanel> | ||||
|                   <TabPanel value={"sessions"}> | ||||
|                     <DataGrid | ||||
|                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||
|                       paginationMode="server" | ||||
|                       loading={reverting.sessions} | ||||
|                       rows={sessions} | ||||
|                       rowCount={sessionCount} | ||||
|                       columns={dataDefinitions.sessions} | ||||
|                       paginationModel={pagination.sessions} | ||||
|                       onPaginationModelChange={(val) => setPagination({ ...pagination, sessions: val })} | ||||
|                       checkboxSelection | ||||
|                     /> | ||||
|                   </TabPanel> | ||||
|                   <TabPanel value={"events"}> | ||||
|                     <DataGrid | ||||
|                       pageSizeOptions={[5, 10, 15, 20, 25]} | ||||
|                       paginationMode="server" | ||||
|                       loading={reverting.events} | ||||
|                       rows={events} | ||||
|                       rowCount={eventCount} | ||||
|                       columns={dataDefinitions.events} | ||||
|                       paginationModel={pagination.events} | ||||
|                       onPaginationModelChange={(val) => setPagination({ ...pagination, events: val })} | ||||
|                       checkboxSelection | ||||
|                     /> | ||||
|                   </TabPanel> | ||||
|                 </CardContent> | ||||
|               </TabContext> | ||||
|             </Card> | ||||
|           </Box> | ||||
|         </Grid> | ||||
|       </Grid> | ||||
|     </Container> | ||||
|   ); | ||||
| } | ||||
| @@ -3,17 +3,17 @@ import { request } from "../scripts/request.ts"; | ||||
| import { createContext, useContext, useState } from "react"; | ||||
| 
 | ||||
| export interface Userinfo { | ||||
|   isReady: boolean, | ||||
|   isLoggedIn: boolean, | ||||
|   displayName: string, | ||||
|   profiles: any, | ||||
|   meta: any | ||||
|   data: any, | ||||
| } | ||||
| 
 | ||||
| const defaultUserinfo: Userinfo = { | ||||
|   isReady: false, | ||||
|   isLoggedIn: false, | ||||
|   displayName: "Citizen", | ||||
|   profiles: null, | ||||
|   meta: null | ||||
|   data: null | ||||
| }; | ||||
| 
 | ||||
| const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo }); | ||||
| @@ -30,10 +30,15 @@ export function UserinfoProvider(props: any) { | ||||
|   } | ||||
| 
 | ||||
|   async function readProfiles() { | ||||
|     if (!checkLoggedIn()) return; | ||||
|     if (!checkLoggedIn()) { | ||||
|       setUserinfo((data) => { | ||||
|         data.isReady = true; | ||||
|         return data; | ||||
|       }); | ||||
|     } | ||||
| 
 | ||||
|     const res = await request("/api/users/me", { | ||||
|       credentials: "include" | ||||
|       headers: { "Authorization": `Bearer ${getAtk()}` } | ||||
|     }); | ||||
| 
 | ||||
|     if (res.status !== 200) { | ||||
| @@ -44,10 +49,10 @@ export function UserinfoProvider(props: any) { | ||||
|     const data = await res.json(); | ||||
| 
 | ||||
|     setUserinfo({ | ||||
|       isReady: true, | ||||
|       isLoggedIn: true, | ||||
|       displayName: data["nick"], | ||||
|       profiles: null, | ||||
|       meta: data | ||||
|       data: data | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
		Reference in New Issue
	
	Block a user