Use dropdown nav

This commit is contained in:
2024-02-26 21:13:47 +08:00
parent 14e87d96ce
commit 14efa09486
30 changed files with 120 additions and 217 deletions

View File

@@ -0,0 +1,14 @@
import { ReactNode, useEffect } from "react";
import { useWellKnown } from "@/stores/wellKnown.tsx";
import { useUserinfo } from "@/stores/userinfo.tsx";
export default function AppLoader({ children }: { children: ReactNode }) {
const { readWellKnown } = useWellKnown();
const { readProfiles } = useUserinfo();
useEffect(() => {
Promise.all([readWellKnown(), readProfiles()]);
}, []);
return children;
}

View 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={userinfo?.profiles?.avatar}
>
<AccountCircleIcon />
</Avatar>
</IconButton>
</Toolbar>
</AppBar>
</HideOnScroll>
<Box component="main">
<AppNavigationHeader />
{children}
</Box>
<NavigationMenu anchorEl={container.current} open={open} onClose={() => setOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,98 @@
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/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;
title?: string;
link?: string;
divider?: boolean;
children?: NavigationItem[];
}
export const DRAWER_WIDTH = 320;
export const AppNavigationHeader = styled("div")(({ theme }) => ({
display: "flex",
alignItems: "center",
padding: theme.spacing(0, 1),
justifyContent: "flex-start",
height: 64,
...theme.mixins.toolbar
}));
export function AppNavigationSection({ items, depth }: { items: NavigationItem[], depth?: number }) {
const [open, setOpen] = useState(false);
return items.map((item, idx) => {
if (item.divider) {
return <Divider key={idx} sx={{ my: 1 }} />;
} else if (item.children) {
return (
<Fragment key={idx}>
<MenuItem onClick={() => setOpen(!open)} sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.title} />
{open ? <ExpandLess /> : <ExpandMore />}
</MenuItem>
<Collapse in={open} timeout="auto" unmountOnExit>
<AppNavigationSection items={item.children} depth={(depth ?? 0) + 1} />
</Collapse>
</Fragment>
);
} else {
return (
<Link key={idx} to={item.link ?? "/"}>
<MenuItem sx={{ pl: 2 + (depth ?? 0) * 2, width: 180 }}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.title} />
</MenuItem>
</Link>
);
}
});
}
export function AppNavigation() {
const { checkLoggedIn } = useUserinfo();
const nav: NavigationItem[] = [
...(
checkLoggedIn() ?
[
{ icon: <FaceIcon />, title: "Account", link: "/users" },
{ divider: true },
{ icon: <LogoutIcon />, title: "Sign out", link: "/auth/sign-out" }
] :
[
{ icon: <HowToRegIcon />, title: "Sign up", link: "/auth/sign-up" },
{ icon: <LoginIcon />, title: "Sign in", link: "/auth/sign-in" }
]
)
];
return <AppNavigationSection items={nav} />;
}
export const isMobileQuery = theme.breakpoints.down("md");
export default function NavigationMenu({ anchorEl, open, onClose }: {
anchorEl: PopoverProps["anchorEl"];
open: boolean;
onClose: () => void
}) {
return (
<Menu anchorEl={anchorEl} open={open} onClose={onClose}>
<AppNavigation />
</Menu>
);
}

1
pkg/views/src/consts.tsx Normal file
View File

@@ -0,0 +1 @@
export const SITE_NAME = "Goatpass";

23
pkg/views/src/error.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Link as RouterLink, useRouteError } from "react-router-dom";
import { Box, Container, Link, Typography } from "@mui/material";
export default function ErrorBoundary() {
const error = useRouteError() as any;
return (
<Container sx={{
height: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
textAlign: "center"
}}>
<Box>
<Typography variant="h1">{error.status}</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>{error?.message ?? "Something went wrong"}</Typography>
<Link component={RouterLink} to="/">Back to homepage</Link>
</Box>
</Container>
);
}

0
pkg/views/src/index.css Normal file
View File

61
pkg/views/src/main.tsx Normal file
View File

@@ -0,0 +1,61 @@
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);

View File

@@ -0,0 +1,327 @@
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
import {
Alert,
Avatar,
Box,
Button,
Card,
CardContent,
Collapse,
Grid,
LinearProgress,
Link,
Paper,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography
} from "@mui/material";
import { FormEvent, useState } from "react";
import { request } from "@/scripts/request.ts";
import { useUserinfo } from "@/stores/userinfo.tsx";
import LoginIcon from "@mui/icons-material/Login";
import SecurityIcon from "@mui/icons-material/Security";
import KeyIcon from "@mui/icons-material/Key";
import PasswordIcon from "@mui/icons-material/Password";
import EmailIcon from "@mui/icons-material/Email";
export default function SignInPage() {
const [panel, setPanel] = useState(0);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [factor, setFactor] = useState<number>();
const [factorType, setFactorType] = useState<any>();
const [factors, setFactors] = useState<any>(null);
const [challenge, setChallenge] = useState<any>(null);
const { readProfiles } = useUserinfo();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const handlers: any[] = [
async (evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.id) return;
setLoading(true);
const res = await request("/api/auth", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setFactors(data["factors"]);
setChallenge(data["challenge"]);
setPanel(1);
setError(null);
}
setLoading(false);
},
async (evt: FormEvent<HTMLFormElement>) => {
evt.preventDefault();
if (!factor) return;
setLoading(true);
const res = await request(`/api/auth/factors/${factor}`, {
method: "POST"
});
if (res.status !== 200 && res.status !== 204) {
setError(await res.text());
} else {
const item = factors.find((item: any) => item.id === factor).type;
setError(null);
setPanel(2);
setFactorType(factorTypes[item]);
}
setLoading(false);
},
async (evt: SubmitEvent) => {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.credentials) return;
setLoading(true);
const res = await request(`/api/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge_id: challenge?.id,
factor_id: factor,
secret: data.credentials
})
});
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
if (data["is_finished"]) {
await grantToken(data["session"]["grant_token"]);
await readProfiles();
callback();
} else {
setError(null);
setPanel(1);
setFactor(undefined);
setFactorType(undefined);
setChallenge(data["challenge"]);
}
}
setLoading(false);
}
];
function callback() {
if (searchParams.has("closable")) {
window.close();
} else if (searchParams.has("redirect_uri")) {
window.open(searchParams.get("redirect_uri") ?? "/", "_self");
} else {
navigate("/users");
}
}
function getFactorAvailable(factor: any) {
const blacklist: number[] = challenge?.blacklist_factors ?? [];
return blacklist.includes(factor.id);
}
const factorTypes = [
{ icon: <PasswordIcon />, label: "Password Verification", autoComplete: "password" },
{ icon: <EmailIcon />, label: "Email One Time Password", autoComplete: "one-time-code" }
];
const elements = [
(
<>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LoginIcon />
</Avatar>
<Typography component="h1" variant="h5">
Welcome back
</Typography>
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
autoComplete="username"
name="id"
required
fullWidth
label="Account ID"
helperText={"Use your username, email or phone number."}
autoFocus
/>
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? "Processing..." : "Next"}
</Button>
</Box>
</>
),
(
<>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<SecurityIcon />
</Avatar>
<Typography component="h1" variant="h5">
Verify that's you
</Typography>
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<ToggleButtonGroup
exclusive
orientation="vertical"
color="info"
value={factor}
sx={{ width: "100%" }}
onChange={(_, val) => setFactor(val)}
>
{factors?.map((item: any, idx: number) => (
<ToggleButton key={idx} value={item.id} disabled={getFactorAvailable(item)}>
<Grid container>
<Grid item xs={2}>
{factorTypes[item.type]?.icon}
</Grid>
<Grid item xs="auto">
{factorTypes[item.type]?.label}
</Grid>
</Grid>
</ToggleButton>
))}
</ToggleButtonGroup>
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? "Processing..." : "Next"}
</Button>
</Box>
</>
),
(
<>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<KeyIcon />
</Avatar>
<Typography component="h1" variant="h5">
Enter the credentials
</Typography>
<Box component="form" onSubmit={handlers[panel]} sx={{ mt: 3, width: "100%" }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
autoComplete={factorType?.autoComplete ?? "password"}
name="credentials"
type="password"
required
fullWidth
label="Credentials"
autoFocus
/>
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? "Processing..." : "Next"}
</Button>
</Box>
</>
)
];
async function grantToken(tk: string) {
const res = await request("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: tk,
grant_type: "grant_token"
})
});
if (res.status !== 200) {
const err = await res.text();
setError(err);
throw new Error(err);
} else {
setError(null);
}
}
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">
<Collapse in={loading}>
<LinearProgress />
</Collapse>
<CardContent
style={{ padding: "40px 48px 36px" }}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{elements[panel]}
</CardContent>
<Collapse in={challenge != null} unmountOnExit>
<Box>
<Paper square sx={{ pt: 3, px: 5, textAlign: "center" }}>
<Typography sx={{ mb: 2 }}>
Risk <b className="font-mono">{challenge?.risk_level}</b>&nbsp;
Progress <b className="font-mono">{challenge?.progress}/{challenge?.requirements}</b>
</Typography>
<LinearProgress
variant="determinate"
value={challenge?.progress / challenge?.requirements * 100}
sx={{ width: "calc(100%+5rem)", mt: 1, mx: -5 }}
/>
</Paper>
</Box>
</Collapse>
</Card>
<Grid container justifyContent="center" sx={{ mt: 2 }}>
<Grid item>
<Link component={RouterLink} to="/auth/sign-up" variant="body2">
Haven't an account? Sign up!
</Link>
</Grid>
</Grid>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,200 @@
import UserIcon from "@mui/icons-material/PersonAddAlt1";
import HowToRegIcon from "@mui/icons-material/HowToReg";
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
import {
Alert,
Avatar,
Box,
Button,
Card,
CardContent,
Checkbox,
Collapse,
FormControlLabel,
Grid,
LinearProgress,
Link,
TextField,
Typography
} from "@mui/material";
import { FormEvent, useState } from "react";
import { request } from "@/scripts/request.ts";
import { useWellKnown } from "@/stores/wellKnown.tsx";
export default function SignUpPage() {
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const { wellKnown } = useWellKnown();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
async function submit(evt: FormEvent<HTMLFormElement>) {
evt.preventDefault();
const data = Object.fromEntries(new FormData(evt.target as HTMLFormElement));
if (!data.human_verification) return;
if (!data.name || !data.nick || !data.email || !data.password) return;
setLoading(true);
const res = await request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setDone(true);
}
setLoading(false);
}
function callback() {
if (searchParams.has("closable")) {
window.close();
} else {
navigate("/auth/sign-in");
}
}
const elements = [
(
<>
<Avatar sx={{ mb: 1, bgcolor: "secondary.main" }}>
<UserIcon />
</Avatar>
<Typography component="h1" variant="h5">
Create an account
</Typography>
<Box component="form" onSubmit={submit} sx={{ mt: 3, width: "100%" }}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
name="name"
required
fullWidth
label="Username"
autoComplete="username"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
name="nick"
required
fullWidth
label="Nickname"
autoComplete="nickname"
/>
</Grid>
<Grid item xs={12}>
<TextField
autoComplete="email"
name="email"
required
fullWidth
label="Email Address"
/>
</Grid>
<Grid item xs={12}>
<TextField
label="Password"
name="password"
required
fullWidth
type="password"
autoComplete="new-password"
/>
</Grid>
{
!wellKnown?.open_registration && <Grid item xs={12}>
<TextField
label="Magic Token"
name="magic_token"
required
fullWidth
type="password"
autoComplete="magic-token"
helperText={"This server uses invitations only."}
/>
</Grid>
}
<Grid item xs={12}>
<FormControlLabel
name="human_verification"
control={<Checkbox value="allowExtraEmails" color="primary" />}
label={"I'm not a robot."}
/>
</Grid>
</Grid>
<Button
type="submit"
fullWidth
variant="contained"
disabled={loading}
sx={{ mt: 3, mb: 2 }}
>
{loading ? "Signing Now..." : "Sign Up"}
</Button>
</Box>
</>
),
(
<>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<HowToRegIcon />
</Avatar>
<Typography gutterBottom variant="h5" component="h1">Congratulations!</Typography>
<Typography variant="body1">
Your account has been created and activation email has sent to your inbox!
</Typography>
<Typography sx={{ my: 2 }}>
<Link onClick={() => callback()} className="cursor-pointer">Go login</Link>
</Typography>
<Typography variant="body2">
After you login, then you can take part in the entire smartsheep community.
</Typography>
</>
)
];
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">
<Collapse in={loading}>
<LinearProgress />
</Collapse>
<CardContent
style={{ padding: "40px 48px 36px" }}
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center"
}}
>
{!done ? elements[0] : elements[1]}
</CardContent>
</Card>
<Grid container justifyContent="center" sx={{ mt: 2 }}>
<Grid item>
<Link component={RouterLink} to="/auth/sign-in" variant="body2">
Already have an account? Sign in!
</Link>
</Grid>
</Grid>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,22 @@
import { Button, Container, Grid, Typography } from "@mui/material";
import { Link as RouterLink } from "react-router-dom";
export default function LandingPage() {
return (
<Container sx={{ height: "calc(100vh - 64px)", display: "flex", alignItems: "center", textAlign: "center" }}>
<Grid padding={5} spacing={8} container>
<Grid item xs={12} md={6}>
<Typography variant="h3">All Goatworks<sup>®</sup> Services</Typography>
<Typography variant="h3">In a single account</Typography>
<Typography variant="body2" sx={{ mt: 8 }}>That's</Typography>
<Typography variant="h1">Goatpass</Typography>
<Button component={RouterLink} to="/auth/sign-up" variant="contained" sx={{ mt: 2 }}>Getting Start</Button>
</Grid>
<Grid item xs={12} md={6} sx={{ order: { xs: -100, md: 0 } }}>
<img src="/favicon.svg" alt="Logo" width={256} height={256} className="block mx-auto" />
</Grid>
</Grid>
</Container>
);
}

View File

@@ -0,0 +1,4 @@
export async function request(input: string, init?: RequestInit) {
const prefix = window.__LAUNCHPAD_TARGET__ ?? "";
return await fetch(prefix + input, init)
}

View File

@@ -0,0 +1,75 @@
import Cookie from "universal-cookie";
import { request } from "../scripts/request.ts";
import { createContext, useContext, useState } from "react";
export interface Userinfo {
isLoggedIn: boolean,
displayName: string,
profiles: any,
meta: any
}
const defaultUserinfo: Userinfo = {
isLoggedIn: false,
displayName: "Citizen",
profiles: null,
meta: null
};
const UserinfoContext = createContext<any>({ userinfo: defaultUserinfo });
export function UserinfoProvider(props: any) {
const [userinfo, setUserinfo] = useState<Userinfo>(structuredClone(defaultUserinfo));
function getAtk(): string {
return new Cookie().get("identity_auth_key");
}
function checkLoggedIn(): boolean {
return new Cookie().get("identity_auth_key");
}
async function readProfiles() {
if (!checkLoggedIn()) return;
const res = await request("/api/users/me", {
credentials: "include"
});
if (res.status !== 200) {
clearUserinfo();
window.location.reload();
}
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["nick"],
profiles: null,
meta: data
});
}
function clearUserinfo() {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i];
const eqPos = cookie.indexOf("=");
const name = eqPos > -1 ? cookie.substring(0, eqPos) : cookie;
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT";
}
setUserinfo(defaultUserinfo);
}
return (
<UserinfoContext.Provider value={{ userinfo, readProfiles, checkLoggedIn, getAtk, clearUserinfo }}>
{props.children}
</UserinfoContext.Provider>
);
}
export function useUserinfo() {
return useContext(UserinfoContext);
}

View File

@@ -0,0 +1,23 @@
import { createContext, useContext, useState } from "react";
import { request } from "../scripts/request.ts";
const WellKnownContext = createContext<any>(null);
export function WellKnownProvider(props: any) {
const [wellKnown, setWellKnown] = useState<any>(null);
async function readWellKnown() {
const res = await request("/.well-known");
setWellKnown(await res.json());
}
return (
<WellKnownContext.Provider value={{ wellKnown, readWellKnown }}>
{props.children}
</WellKnownContext.Provider>
);
}
export function useWellKnown() {
return useContext(WellKnownContext);
}

20
pkg/views/src/theme.ts Normal file
View File

@@ -0,0 +1,20 @@
import { createTheme } from "@mui/material/styles";
export const theme = createTheme({
palette: {
primary: {
main: "#49509e",
},
secondary: {
main: "#d43630",
},
},
typography: {
h1: { fontSize: "2.5rem" },
h2: { fontSize: "2rem" },
h3: { fontSize: "1.75rem" },
h4: { fontSize: "1.5rem" },
h5: { fontSize: "1.25rem" },
h6: { fontSize: "1.15rem" },
},
});

1
pkg/views/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />