inital commit
This commit is contained in:
commit
253bb24224
|
@ -0,0 +1,18 @@
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
|
@ -0,0 +1,54 @@
|
||||||
|
# StuVe IT Frontend
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses Repository enthält den Quellcode für die StuVe IT Frontend Webseite.
|
||||||
|
|
||||||
|
### Verwendete Technologien
|
||||||
|
|
||||||
|
- [React ❤️](https://reactjs.org/) [&]() [Typescript](https://www.typescriptlang.org/) - Code Library
|
||||||
|
- [Vite](https://vitejs.dev/) - Build Tool
|
||||||
|
- [Mantine](https://mantine.dev/) [&]() [Postcss](https://postcss.org/) - UI Library
|
||||||
|
- [Tabler Icons](https://tablericons.com/) - Icon Library
|
||||||
|
- [Pocketbase js](https://github.com/pocketbase/js-sdk) - SDK für die Pocketbase API (Backend)
|
||||||
|
- [React Router](https://reactrouter.com/) - Routing
|
||||||
|
- [React Query](https://react-query.tanstack.com/) - Data Fetching
|
||||||
|
- [Docker](https://www.docker.com/) - Containerization
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
Als Backend wird ein selbst erweitertes Pocketbase verwendet. Dieses ist in diesem [Repo](https://gitlab.uni-ulm.de/stuve-it/it-tools/backend/)
|
||||||
|
zu finden.
|
||||||
|
|
||||||
|
## Projekt Struktur
|
||||||
|
```
|
||||||
|
├── index.html # html Datei die den React Code in die Seite einbindet
|
||||||
|
├── tsconfig.json # Typescript Config Datei
|
||||||
|
├── vite.config.ts # Vite Config Datei
|
||||||
|
├── package.json # Package Datei (Node's Package Manager)
|
||||||
|
├── public/ # Öffentliche Dateien (favicon, logo, ...)
|
||||||
|
├── src/ # Source Code
|
||||||
|
│ ├── main.tsx # Main React Datei
|
||||||
|
│ ├── Router.tsx # React Router Datei
|
||||||
|
│ ├── components/ # Wiederfendbare React Komponenten
|
||||||
|
│ ├── lib/ # React Hooks etc.
|
||||||
|
│ ├── pages/ # React Seiten (der Dateibaum entspricht der URL)
|
||||||
|
│ └── models/ # Types für die Pocketbase API (Datenbank Modelle)
|
||||||
|
└── README.md # Diese Datei
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Patterns
|
||||||
|
|
||||||
|
### Pocketbase API
|
||||||
|
|
||||||
|
Um die Kommunikation mit dem Backend zu erleichtern ist in der Datei "src/lib/pocketbase.ts" ein Pocketbase Client implementiert.
|
||||||
|
|
||||||
|
Dieser Client ist ein React Hook und kann in jeder React Komponente verwendet werden.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {usePB} from "/lib/pocketbase";
|
||||||
|
|
||||||
|
const {pb} = usePB();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pocketbase Modell Types
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* @description Global configuration file for the application
|
||||||
|
*/
|
||||||
|
import {IconHome, IconInfoCircle, IconShovel, TablerIconsProps} from "@tabler/icons-react";
|
||||||
|
import {ReactNode} from "react";
|
||||||
|
|
||||||
|
// POCKETBASE
|
||||||
|
export const PB_USER_COLLECTION = "ldap_users"
|
||||||
|
export const PB_BASE_URL = "https://stuve.uni-ulm.de"
|
||||||
|
export const PB_STORAGE_KEY = "stuve-it-ldap-login"
|
||||||
|
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
export const NAV_ITEMS = [
|
||||||
|
{
|
||||||
|
section: "General",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Home",
|
||||||
|
icon: IconHome,
|
||||||
|
description: "Home",
|
||||||
|
link: "/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
section: "Events",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: "Übersicht",
|
||||||
|
icon: IconInfoCircle,
|
||||||
|
description: "Administration für StuVe Events. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,",
|
||||||
|
link: "/events"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Listen",
|
||||||
|
icon: IconShovel,
|
||||||
|
description: "Administration für StuVe Events. Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,",
|
||||||
|
link: "/events/lists"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
] as {
|
||||||
|
section: string,
|
||||||
|
items: {
|
||||||
|
title: string,
|
||||||
|
icon: (props: TablerIconsProps) => ReactNode,
|
||||||
|
description: string,
|
||||||
|
link: string
|
||||||
|
}[]
|
||||||
|
}[]
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<link rel="icon" href="/public/stuve-logo.svg"/>
|
||||||
|
<title>StuVe IT</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/fira-code": "^5.0.15",
|
||||||
|
"@fontsource/overpass": "^5.0.15",
|
||||||
|
"@mantine/core": "^7.1.5",
|
||||||
|
"@mantine/form": "^7.1.5",
|
||||||
|
"@mantine/hooks": "^7.1.5",
|
||||||
|
"@mantine/notifications": "^7.1.5",
|
||||||
|
"@tabler/icons-react": "^2.39.0",
|
||||||
|
"@tanstack/react-query": "^5.0.5",
|
||||||
|
"jwt-decode": "^3.1.2",
|
||||||
|
"ms": "^2.1.3",
|
||||||
|
"pocketbase": "^0.19.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/ms": "^0.7.33",
|
||||||
|
"@types/react": "^18.2.15",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
|
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||||
|
"eslint": "^8.45.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.3",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"postcss-preset-mantine": "^1.9.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"typescript": "^5.0.2",
|
||||||
|
"vite": "^4.4.5"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'postcss-preset-mantine': {},
|
||||||
|
'postcss-simple-vars': {
|
||||||
|
variables: {
|
||||||
|
'mantine-breakpoint-xs': '36em',
|
||||||
|
'mantine-breakpoint-sm': '48em',
|
||||||
|
'mantine-breakpoint-md': '62em',
|
||||||
|
'mantine-breakpoint-lg': '75em',
|
||||||
|
'mantine-breakpoint-xl': '88em',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<g transform="matrix(1.02041,0,0,1.02041,-197.959,0)">
|
||||||
|
<rect id="Artboard1" x="194" y="0" width="98" height="98" style="fill:none;"/>
|
||||||
|
<g id="Artboard11">
|
||||||
|
<g transform="matrix(0.129488,0,0,0.128633,216.823,22.7241)">
|
||||||
|
<g transform="matrix(7.56828,0,0,7.61857,-176.258,-188.086)">
|
||||||
|
<path d="M95.493,95.155L4.5,95.155C4.5,95.155 4.726,76.243 5.56,72.26C5.961,70.348 5.798,55.477 5.798,55.477C5.798,55.477 5.802,55.187 5.8,55.048C5.795,54.633 6.268,53.615 6.72,53.294C7.011,53.086 7.343,52.821 7.343,52.419C7.345,49.181 7.341,34.535 7.341,34.535L7.339,34.302C7.329,33.742 7.68,32.975 8.093,32.614C8.55,32.214 9.207,31.923 9.441,31.656C9.589,31.487 9.709,31.294 9.815,30.999C9.9,30.761 16.837,4.845 16.837,4.845C16.837,4.845 23.776,30.762 23.861,30.999C23.967,31.294 24.087,31.487 24.234,31.656C24.469,31.923 25.126,32.214 25.583,32.614C25.996,32.975 26.347,33.742 26.337,34.302L26.334,34.535C26.334,34.535 26.331,49.181 26.333,52.419C26.333,52.821 26.665,53.086 26.956,53.294C27.408,53.615 27.88,54.633 27.876,55.048C27.874,55.187 27.878,55.477 27.878,55.477L27.863,65.838L73.37,65.833L76.83,49.964L78.56,57.898L80.294,49.942L83.757,65.832L83.757,71.587L90.119,71.584C90.119,71.584 91.628,71.592 91.636,71.588C92.215,71.34 94.358,74.803 94.912,76.282C95.606,78.131 95.493,95.155 95.493,95.155Z" style="fill:none;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(7.56828,0,0,7.61857,-176.28,-188.086)">
|
||||||
|
<path d="M94.912,76.282C95.606,78.131 95.5,98.155 95.5,98.155L4.5,98.155C4.5,98.155 4.726,76.243 5.56,72.26C5.961,70.348 5.798,55.477 5.798,55.477C5.798,55.477 5.802,55.187 5.8,55.048C5.795,54.633 6.268,53.615 6.72,53.294C7.011,53.086 7.343,52.821 7.343,52.419C7.345,49.181 7.341,34.535 7.341,34.535L7.339,34.302C7.329,33.742 7.68,32.975 8.093,32.614C8.55,32.214 9.207,31.923 9.441,31.656C9.589,31.487 9.709,31.294 9.815,30.999C9.9,30.761 16.837,4.845 16.837,4.845C16.837,4.845 23.776,30.762 23.861,30.999C23.967,31.294 24.087,31.487 24.234,31.656C24.469,31.923 25.126,32.214 25.583,32.614C25.996,32.975 26.347,33.742 26.337,34.302L26.334,34.535C26.334,34.535 26.331,49.181 26.333,52.419C26.333,52.821 26.665,53.086 26.956,53.294C27.408,53.615 27.88,54.633 27.876,55.048C27.874,55.187 27.878,55.477 27.878,55.477L27.863,65.838L73.37,65.833L76.83,49.964L78.56,57.898L80.294,49.942L83.757,65.832L83.757,71.587L90.119,71.584C90.119,71.584 91.628,71.592 91.636,71.588C92.215,71.34 94.358,74.803 94.912,76.282Z" style="fill:rgb(34,139,230);"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(3.31017,0,0,3.33216,-123.695,-80.204)">
|
||||||
|
<path d="M47.904,125.072L82.509,185.184L13.3,185.184L47.904,125.072Z" style="fill:rgb(231,245,255);"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.1 KiB |
|
@ -0,0 +1,31 @@
|
||||||
|
import {createBrowserRouter, RouterProvider} from "react-router-dom";
|
||||||
|
import HomePage from "./pages/home";
|
||||||
|
import NotFound from "./pages/not-found";
|
||||||
|
import Layout from "./components/layout";
|
||||||
|
|
||||||
|
const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <Layout/>,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <HomePage/>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
element: <NotFound/>
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
function Router() {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RouterProvider router={router}/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Router
|
|
@ -0,0 +1,14 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: var(--padding);
|
||||||
|
overflow: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
import NavBar from "../nav";
|
||||||
|
import {Outlet} from "react-router-dom";
|
||||||
|
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return <div className={classes.container}>
|
||||||
|
<NavBar/>
|
||||||
|
|
||||||
|
<div className={`${classes.content} scrollbar`}>
|
||||||
|
<Outlet/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
import {IconExclamationCircle, IconLogin} from "@tabler/icons-react";
|
||||||
|
import {ActionIcon, Alert, Button, Modal, PasswordInput, TextInput, Title} from "@mantine/core";
|
||||||
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
import {useForm} from "@mantine/form";
|
||||||
|
import {useMutation} from "@tanstack/react-query";
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component renders a login button and a login modal.
|
||||||
|
*/
|
||||||
|
export default function Login() {
|
||||||
|
const [opened, {open, close}] = useDisclosure(false)
|
||||||
|
|
||||||
|
const {ldapLogin} = usePB()
|
||||||
|
|
||||||
|
const formValues = useForm({
|
||||||
|
initialValues: {
|
||||||
|
username: "",
|
||||||
|
password: ""
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
await ldapLogin(formValues.values.username, formValues.values.password)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Modal opened={opened} onClose={close} withCloseButton={false} size={"sm"}>
|
||||||
|
<form
|
||||||
|
className={classes.stack}
|
||||||
|
onSubmit={formValues.onSubmit(() => loginMutation.mutate())}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Title order={3}>Login mit StuVe IT Account</Title>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={"Anmeldename"}
|
||||||
|
placeholder={"vorname.nachname"}
|
||||||
|
{...formValues.getInputProps("username")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label={"Passwort"}
|
||||||
|
placeholder={"Passwort"}
|
||||||
|
{...formValues.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loginMutation.error && (
|
||||||
|
<Alert variant="transparent" color="red" title="Fehler" icon={<IconExclamationCircle/>}>
|
||||||
|
{loginMutation.error.message}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={loginMutation.isPending}
|
||||||
|
disabled={formValues.values.username === "" || formValues.values.password === ""}
|
||||||
|
type={"submit"}
|
||||||
|
>
|
||||||
|
Einloggen
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"gray"}
|
||||||
|
aria-label={"Login"}
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
<IconLogin/>
|
||||||
|
</ActionIcon>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
import {Menu, rem} from "@mantine/core";
|
||||||
|
import {NAV_ITEMS} from "../../../config.ts";
|
||||||
|
import {NavLink} from "react-router-dom";
|
||||||
|
import {Fragment} from "react";
|
||||||
|
|
||||||
|
|
||||||
|
export default function MenuItems() {
|
||||||
|
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{NAV_ITEMS.map((section, index) => (
|
||||||
|
<Fragment key={index + section.section}>
|
||||||
|
<Menu.Label>
|
||||||
|
{section.section}
|
||||||
|
</Menu.Label>
|
||||||
|
{
|
||||||
|
section.items.map((item, index) => {
|
||||||
|
return (
|
||||||
|
<Menu.Item
|
||||||
|
key={index + item.title}
|
||||||
|
leftSection={<item.icon style={{width: rem(14), height: rem(14)}}/>}
|
||||||
|
component={NavLink}
|
||||||
|
to={item.link}
|
||||||
|
aria-label={item.description}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
import {ActionIcon, Button, Divider, List, Modal, rem, Text, ThemeIcon, Title} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconBalloon,
|
||||||
|
IconCalendar,
|
||||||
|
IconId,
|
||||||
|
IconLogout,
|
||||||
|
IconServer,
|
||||||
|
IconServerOff,
|
||||||
|
IconUsersGroup
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import {useDisclosure} from "@mantine/hooks";
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This component renders a user menu button and a user menu modal with user information.
|
||||||
|
*/
|
||||||
|
export default function UserMenu() {
|
||||||
|
|
||||||
|
const [opened, {open, close}] = useDisclosure(false)
|
||||||
|
|
||||||
|
const {user, logout, apiIsHealthy} = usePB()
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Modal opened={opened} onClose={close} withCloseButton={false} size={"sm"}>
|
||||||
|
<div className={classes.stack}>
|
||||||
|
<Title order={3}>Hallo {user!.username}</Title>
|
||||||
|
|
||||||
|
<div className={classes.row}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
size={"xl"}
|
||||||
|
>
|
||||||
|
<IconId/>
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
ID {user?.uidNumber}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.row}>
|
||||||
|
<ThemeIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
size={"xl"}
|
||||||
|
>
|
||||||
|
<IconCalendar/>
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{user?.accountExpires ? (
|
||||||
|
|
||||||
|
user?.accountExpires?.getTime() > Date.now() ? (
|
||||||
|
"Account ist aktiv und läuft am " + user?.accountExpires?.toLocaleDateString() + " ab"
|
||||||
|
) : (
|
||||||
|
"Account ist abgelaufen"
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
"Dein Account läuft nicht ab"
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.row}>
|
||||||
|
{apiIsHealthy ? (
|
||||||
|
<ThemeIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"green"}
|
||||||
|
size={"xl"}
|
||||||
|
>
|
||||||
|
<IconServer/>
|
||||||
|
</ThemeIcon>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
<ThemeIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"red"}
|
||||||
|
size={"xl"}
|
||||||
|
>
|
||||||
|
<IconServerOff/>
|
||||||
|
</ThemeIcon>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
{apiIsHealthy ? "API ist erreichbar" : "API ist nicht erreichbar"}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user?.memberOf.length && <Divider/>}
|
||||||
|
|
||||||
|
{user?.memberOf.length && <Title order={5}>Deine Gruppen</Title>}
|
||||||
|
|
||||||
|
<List
|
||||||
|
spacing="xs"
|
||||||
|
size="sm"
|
||||||
|
center
|
||||||
|
icon={
|
||||||
|
<ThemeIcon color="teal" size={24} radius="xl">
|
||||||
|
<IconUsersGroup style={{width: rem(16), height: rem(16)}}/>
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(user?.expand.memberOf || []).map((group) => (
|
||||||
|
<List.Item key={group.id}>
|
||||||
|
{group.cn}
|
||||||
|
</List.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{user?.memberOf.length && <Divider/>}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
leftSection={<IconLogout/>}
|
||||||
|
color={"orange"}
|
||||||
|
onClick={logout}
|
||||||
|
>
|
||||||
|
Ausloggen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"gray"}
|
||||||
|
aria-label={"User Menu"}
|
||||||
|
onClick={open}
|
||||||
|
>
|
||||||
|
<IconBalloon/>
|
||||||
|
</ActionIcon>
|
||||||
|
</>
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
.navbar {
|
||||||
|
border-bottom: 1px solid var(--mantine-color-gray-4);
|
||||||
|
height: 56px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
padding: 0 var(--padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--mantine-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: var(--mantine-h2-font-size);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actionIcons {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: var(--gap);
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
import {usePB} from "../../lib/pocketbase.tsx";
|
||||||
|
import classes from "./index.module.css";
|
||||||
|
import {ActionIcon, Image, Menu, ThemeIcon, useMantineColorScheme} from "@mantine/core";
|
||||||
|
import {IconChevronDown, IconMoon, IconSun} from "@tabler/icons-react";
|
||||||
|
import UserMenu from "./UserMenu.tsx";
|
||||||
|
import Login from "./Login.tsx";
|
||||||
|
import MenuItems from "./MenuItems.tsx";
|
||||||
|
|
||||||
|
export default function NavBar() {
|
||||||
|
|
||||||
|
const {user} = usePB()
|
||||||
|
|
||||||
|
const {colorScheme, toggleColorScheme} = useMantineColorScheme()
|
||||||
|
|
||||||
|
return <nav className={classes.navbar}>
|
||||||
|
<Menu
|
||||||
|
trigger="hover"
|
||||||
|
closeDelay={400}
|
||||||
|
position="bottom-start"
|
||||||
|
shadow="md"
|
||||||
|
width={200}
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<div className={classes.logo}>
|
||||||
|
<Image
|
||||||
|
h={30}
|
||||||
|
w={30}
|
||||||
|
src={"/public/stuve-logo.svg"}
|
||||||
|
alt={"StuVe IT Logo"}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={classes.title}>
|
||||||
|
StuVe IT
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ThemeIcon variant={"transparent"} size={"sm"}>
|
||||||
|
<IconChevronDown/>
|
||||||
|
</ThemeIcon>
|
||||||
|
</div>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<MenuItems/>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<div className={classes.actionIcons}>
|
||||||
|
<ActionIcon
|
||||||
|
variant={"transparent"}
|
||||||
|
color={"gray"}
|
||||||
|
onClick={toggleColorScheme}
|
||||||
|
>
|
||||||
|
{colorScheme === "dark" ?
|
||||||
|
<IconSun/>
|
||||||
|
:
|
||||||
|
<IconMoon/>
|
||||||
|
}
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
{user ? <UserMenu/> : <Login/>}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
}
|
|
@ -0,0 +1,45 @@
|
||||||
|
:root {
|
||||||
|
--gap: var(--mantine-spacing-sm);
|
||||||
|
--padding: var(--mantine-spacing-md);
|
||||||
|
--border-radius: var(--mantine-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.scrollbar {
|
||||||
|
scrollbar-color: var(--mantine-color-blue-5) var(--mantine-color-body);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: block;
|
||||||
|
width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle */
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--mantine-color-gray-3);
|
||||||
|
border-radius: var(--mantine-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle on hover */
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--mantine-color-blue-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit; /* Inherits the color from the parent element */
|
||||||
|
text-decoration: none; /* Removes underline */
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover, a:active, a:visited {
|
||||||
|
color: inherit; /* Inherits the color from the parent element */
|
||||||
|
text-decoration: none; /* Removes underline */
|
||||||
|
}
|
|
@ -0,0 +1,107 @@
|
||||||
|
import {createContext, DependencyList, ReactNode, useCallback, useContext, useEffect, useMemo, useState} from "react"
|
||||||
|
import PocketBase, {LocalAuthStore, RecordAuthResponse, RecordSubscription} from 'pocketbase'
|
||||||
|
import ms from "ms";
|
||||||
|
import {useInterval} from "@mantine/hooks";
|
||||||
|
import {useQuery} from "@tanstack/react-query";
|
||||||
|
import {TypedPocketBase} from "../models";
|
||||||
|
import {PB_BASE_URL, PB_STORAGE_KEY, PB_USER_COLLECTION} from "../../config.ts";
|
||||||
|
import {LdapUser} from "../models/AuthTypes.ts";
|
||||||
|
|
||||||
|
const oneMinuteInMs = ms("1 minute");
|
||||||
|
|
||||||
|
const PocketContext = createContext({})
|
||||||
|
|
||||||
|
const PocketData = () => {
|
||||||
|
const pb = useMemo(() =>
|
||||||
|
new PocketBase(
|
||||||
|
PB_BASE_URL,
|
||||||
|
new LocalAuthStore(PB_STORAGE_KEY)
|
||||||
|
) as TypedPocketBase,
|
||||||
|
[])
|
||||||
|
|
||||||
|
const apiIsHealthyQuery = useQuery({
|
||||||
|
queryKey: ["apiHealthCheck"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await pb.health.check()
|
||||||
|
return res.code === 200
|
||||||
|
},
|
||||||
|
refetchInterval: oneMinuteInMs
|
||||||
|
})
|
||||||
|
|
||||||
|
const [user, setUser] = useState(pb.authStore.model)
|
||||||
|
|
||||||
|
pb.authStore.onChange((_, userRecord) => {
|
||||||
|
setUser(userRecord)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
await pb.collection(PB_USER_COLLECTION).authRefresh({
|
||||||
|
expand: "memberOf"
|
||||||
|
}).catch(() => {
|
||||||
|
pb.authStore.clear()
|
||||||
|
})
|
||||||
|
}, [pb])
|
||||||
|
|
||||||
|
const ldapLogin = useCallback(async (usernameOrCN: string, password: string) => {
|
||||||
|
const res = await pb.send<RecordAuthResponse>("/api/ldap/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: usernameOrCN,
|
||||||
|
password: password
|
||||||
|
})
|
||||||
|
})
|
||||||
|
pb.authStore.save(res.token, res.record)
|
||||||
|
}, [pb])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
pb.authStore.clear();
|
||||||
|
}, [pb.authStore])
|
||||||
|
|
||||||
|
const useSubscription = <T, >({idOrName, topic = "*", callback}: {
|
||||||
|
idOrName: string,
|
||||||
|
topic?: string,
|
||||||
|
callback: (data: RecordSubscription<T>) => void
|
||||||
|
}, deps?: DependencyList | undefined) => useEffect(() => {
|
||||||
|
pb.collection(idOrName).subscribe<T>(topic, callback)
|
||||||
|
return () => {
|
||||||
|
pb.collection(idOrName).unsubscribe(topic)
|
||||||
|
}
|
||||||
|
}, deps ? deps : []);
|
||||||
|
|
||||||
|
useInterval(refreshUser, oneMinuteInMs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ldapLogin,
|
||||||
|
logout,
|
||||||
|
user: user as LdapUser | null,
|
||||||
|
pb,
|
||||||
|
refreshUser,
|
||||||
|
useSubscription,
|
||||||
|
apiIsHealthy: apiIsHealthyQuery.data ?? false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PocketBaseProvider = ({children}: {
|
||||||
|
children: ReactNode,
|
||||||
|
userCollection?: string
|
||||||
|
}) => {
|
||||||
|
const data = PocketData()
|
||||||
|
return (
|
||||||
|
<PocketContext.Provider
|
||||||
|
value={data}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PocketContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePB = () => {
|
||||||
|
const pb = useContext(PocketContext)
|
||||||
|
if (!pb) {
|
||||||
|
throw new Error("usePB must be used inside PocketBaseProvider")
|
||||||
|
}
|
||||||
|
return pb as ReturnType<typeof PocketData>
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import Router from './Router.tsx'
|
||||||
|
import '@mantine/core/styles.css';
|
||||||
|
import {createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme} from "@mantine/core";
|
||||||
|
import {QueryClient, QueryClientProvider} from "@tanstack/react-query";
|
||||||
|
import {PocketBaseProvider} from "./lib/pocketbase.tsx";
|
||||||
|
import "./global.css";
|
||||||
|
|
||||||
|
// fonts
|
||||||
|
import "@fontsource/overpass"
|
||||||
|
import "@fontsource/fira-code"
|
||||||
|
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
const themeOverride = createTheme({
|
||||||
|
fontFamilyMonospace: 'Fira Code VF, monospace',
|
||||||
|
fontFamily: 'Overpass, sans-serif',
|
||||||
|
headings: {
|
||||||
|
fontFamily: 'Overpass, sans-serif'
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const theme = mergeMantineTheme(DEFAULT_THEME, themeOverride);
|
||||||
|
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<MantineProvider theme={theme} >
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<PocketBaseProvider>
|
||||||
|
<React.StrictMode>
|
||||||
|
<Router/>
|
||||||
|
</React.StrictMode>
|
||||||
|
</PocketBaseProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
)
|
|
@ -0,0 +1,42 @@
|
||||||
|
import {RecordModel} from "pocketbase";
|
||||||
|
|
||||||
|
export type LdapUser = {
|
||||||
|
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
cn: string;
|
||||||
|
dn: string;
|
||||||
|
uidNumber: string;
|
||||||
|
sn: string;
|
||||||
|
givenName: string;
|
||||||
|
accountExpires: Date | null;
|
||||||
|
memberOf: string[];
|
||||||
|
|
||||||
|
expand: {
|
||||||
|
memberOf: LdapGroup[]
|
||||||
|
}
|
||||||
|
} & RecordModel
|
||||||
|
|
||||||
|
export type LdapGroup = {
|
||||||
|
gidNumber: string;
|
||||||
|
description: string;
|
||||||
|
cn: string;
|
||||||
|
dn: string;
|
||||||
|
memberOf: string[];
|
||||||
|
|
||||||
|
expand: {
|
||||||
|
memberOf: LdapGroup[]
|
||||||
|
}
|
||||||
|
} & RecordModel
|
||||||
|
|
||||||
|
export type LdapSyncLog = {
|
||||||
|
usersFound: number;
|
||||||
|
usersSynced: number;
|
||||||
|
usersRemoved: number;
|
||||||
|
userSyncErrors: string[] | null;
|
||||||
|
|
||||||
|
groupsFound: number;
|
||||||
|
groupsSynced: number;
|
||||||
|
groupsRemoved: number;
|
||||||
|
groupSyncErrors: string[] | null;
|
||||||
|
} & RecordModel
|
|
@ -0,0 +1,12 @@
|
||||||
|
import PocketBase, {RecordService} from "pocketbase";
|
||||||
|
import {LdapGroup, LdapSyncLog, LdapUser} from "./AuthTypes.ts";
|
||||||
|
|
||||||
|
export interface TypedPocketBase extends PocketBase {
|
||||||
|
collection(idOrName: string): RecordService // default fallback for any other collection
|
||||||
|
|
||||||
|
collection(idOrName: 'ldap_users'): RecordService<LdapUser>
|
||||||
|
|
||||||
|
collection(idOrName: 'ldap_groups'): RecordService<LdapGroup>
|
||||||
|
|
||||||
|
collection(idOrName: 'ldap_sync_logs'): RecordService<LdapSyncLog>
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Home Page</h1>
|
||||||
|
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum. Quisquam, voluptatum. Quisquam,
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: var(--gap);
|
||||||
|
width: min-content;
|
||||||
|
text-align: center;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 10rem;
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-xs) {
|
||||||
|
font-size: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mantine-primary-color-filled);
|
||||||
|
margin: 0;
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
import classes from "./index.module.css"
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<h1 className={classes.status}>404</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
Oops! In deiner Timeline existiert die gesuchte Seite leider nicht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Nutze die Navigation, um zurück in vertraute Dimensionen zu gelangen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module "*.module.css";
|
||||||
|
declare module "*.css";
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react-swc'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
|
|
Loading…
Reference in New Issue