Update to Hilla 24

This commit is contained in:
grimsi
2024-08-22 10:55:22 +02:00
parent 042c326380
commit cb073c6bcf
52 changed files with 5944 additions and 4078 deletions
@@ -0,0 +1,58 @@
import * as React from "react"
import {cva, type VariantProps} from "class-variance-authority"
import {cn} from "Frontend/util/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({className, variant, ...props}, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({variant}), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({className, ...props}, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({className, ...props}, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export {Alert, AlertTitle, AlertDescription}
@@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-content1 group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }
+29
View File
@@ -0,0 +1,29 @@
import {Outlet, useNavigate} from 'react-router-dom';
import "./main.css";
import {NextUIProvider} from "@nextui-org/react";
import {ThemeProvider as NextThemesProvider} from "next-themes";
import {themeNames} from "Frontend/theming/themes";
import {AuthProvider} from "Frontend/util/auth";
import {IconContext} from "@phosphor-icons/react";
import {Toaster} from "Frontend/@/components/ui/sonner";
import client from "Frontend/generated/connect-client.default";
import {ErrorHandlingMiddleware} from "Frontend/util/middleware";
export default function App() {
const navigate = useNavigate();
client.middlewares = [ErrorHandlingMiddleware];
return (
<NextUIProvider className="size-full" navigate={navigate}>
<NextThemesProvider attribute="class" themes={themeNames()} defaultTheme="gameyfin-violet-dark">
<AuthProvider>
<IconContext.Provider value={{size: 20}}>
<Outlet/>
<Toaster/>
</IconContext.Provider>
</AuthProvider>
</NextThemesProvider>
</NextUIProvider>
);
}
+27
View File
@@ -0,0 +1,27 @@
import {useField} from "formik";
import {XCircle} from "@phosphor-icons/react";
import {Input as NextUiInput} from "@nextui-org/react";
// @ts-ignore
const Input = ({label, ...props}) => {
// @ts-ignore
const [field, meta] = useField(props);
return (
<div className="grid w-full max-w-sm items-center gap-1.5">
<NextUiInput
{...props}
{...field}
id={label}
placeholder={label}
isInvalid={meta.touched && !!meta.error}
errorMessage={
<small className="flex flex-row items-center gap-1 text-danger">
<XCircle weight="fill" size={14}/> {meta.error}
</small>}
/>
</div>
);
}
export default Input;
@@ -0,0 +1,68 @@
import {useAuth} from "Frontend/util/auth";
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
import {Avatar, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@nextui-org/react";
import {useNavigate} from "react-router-dom";
export default function ProfileMenu() {
const {state, logout} = useAuth();
const navigate = useNavigate();
const profileMenuItems = [
{
label: "My Profile",
icon: <User/>,
onClick: () => navigate('/profile')
},
{
label: "Administration",
icon: <GearFine/>,
onClick: () => alert("Administration"),
showIf: state.user?.roles?.some(a => a?.includes("ADMIN"))
},
{
label: "Help",
icon: <Question/>,
onClick: () => window.open("https://github.com/gameyfin/gameyfin/tree/v2", "_blank")
},
{
label: "Sign Out",
icon: <SignOut/>,
onClick: () => logout(),
color: "primary"
},
];
return (
<Dropdown placement="bottom-end">
<DropdownTrigger>
<Avatar showFallback
radius="full"
as="button"
className="transition-transform size-8"
classNames={{
base: "gradient-primary",
icon: "text-background/80"
}}
/>
</DropdownTrigger>
<DropdownMenu>
{/* @ts-ignore */}
{profileMenuItems.map(({label, icon, onClick, showIf, color}) => {
return (
(showIf === undefined || showIf === true) ?
<DropdownItem
key={label}
onClick={onClick}
startContent={<div color={color}>{icon}</div>}
/* @ts-ignore */
color={color ? color : ""}
className={`text-${color} hover:bg-primary/20`}
>
{label}
</DropdownItem> : null
);
})}
</DropdownMenu>
</Dropdown>
);
}
@@ -0,0 +1,16 @@
export default function GameyfinLogo({className}: {
className?: string
}) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 365.58 336.34" className={className}>
<polygon points="190.1 49.13 190.1 69.24 207.98 44.13 190.1 49.13"/>
<polygon points="365.58 0 263.22 28.66 205.64 95.97 365.58 51.18 365.58 0"/>
<polygon
points="190.1 283.11 248.6 266.73 248.6 149.74 365.58 116.99 365.58 73.12 190.1 122.25 190.1 283.11"/>
<polygon
points="58.49 144.48 155.98 117.18 175.48 89.79 175.48 53.23 0 102.36 0 336.34 58.49 254.15 58.49 144.48"/>
<polygon
points="116.99 199.59 116.99 245.09 65.81 259.42 0 336.34 175.48 287.2 175.48 170.22 131.61 182.5 116.99 199.59"/>
</svg>
);
}
@@ -0,0 +1,18 @@
import {Theme} from "Frontend/theming/theme";
import {Tooltip} from "@nextui-org/react";
export default function ThemePreview({theme, mode, isSelected}: {
theme: Theme,
mode: "light" | "dark",
isSelected?: boolean
}) {
return (
<Tooltip content={<p className="capitalize">{theme.name?.replace("-", " ")}</p>} placement="bottom">
<div className={`
${theme.name}-${mode}
bg-primary p-6 border-2 rounded-full
${isSelected ? "border-foreground" : "border-foreground-200 hover:border-focus"}`}
/>
</Tooltip>
);
}
@@ -0,0 +1,55 @@
import {useTheme} from "next-themes";
import React, {useLayoutEffect, useState} from "react";
import {Switch} from "@nextui-org/react";
import {Moon, SunDim} from "@phosphor-icons/react";
import {themes} from "Frontend/theming/themes";
import {Theme} from "Frontend/theming/theme";
import ThemePreview from "Frontend/components/theming/ThemePreview";
export function ThemeSelector() {
const {theme, setTheme} = useTheme();
const [isSelected, setIsSelected] = useState(theme ? theme.includes("light") : false);
const [currentTheme, setCurrentTheme] = useState(theme?.substring(0, theme?.lastIndexOf("-")));
useLayoutEffect(() => setTheme(`${currentTheme}-${mode()}`), [isSelected]);
function mode(): "light" | "dark" {
return isSelected ? "light" : "dark";
}
return (
<div className="flex flex-col size-full items-center">
<div className="w-full flex flex-row items-center justify-center gap-4 mb-16">
<Switch
size="lg"
startContent={<SunDim size={32}/>}
endContent={<Moon size={32}/>}
isSelected={isSelected}
onValueChange={() => {
setIsSelected(!isSelected);
}}
/>
</div>
<div className="grid grid-flow-col auto-cols-auto auto-cols-max-4 gap-8">
{
//min-w-[468px]
themes.map(((t: Theme) => (
<div
key={t.name}
onClick={() => {
setCurrentTheme(t.name);
setTheme(`${t.name}-${mode()}`);
}}>
<ThemePreview
theme={t}
mode={mode()}
isSelected={currentTheme === t.name}/>
</div>
)))
}
</div>
</div>
)
}
@@ -0,0 +1,98 @@
import React, {ReactNode, useState} from "react";
import {Form, Formik, FormikBag, FormikHelpers} from "formik";
import {ArrowLeft, ArrowRight, Check} from "@phosphor-icons/react";
import {Button} from "@nextui-org/react";
import {Step, Stepper} from "@material-tailwind/react";
const Wizard = ({children, initialValues, onSubmit}: {
children: ReactNode,
initialValues: any,
onSubmit: (values: any, bag: FormikHelpers<any> | FormikBag<any, any>) => Promise<any>
}) => {
const [stepNumber, setStepNumber] = useState(0);
const steps = React.Children.toArray(children);
const [snapshot, setSnapshot] = useState(initialValues);
const step = steps[stepNumber];
const totalSteps = steps.length;
const isFirstStep = stepNumber === 0;
const isLastStep = stepNumber === totalSteps - 1;
const next = (values: any) => {
setSnapshot(values);
setStepNumber(Math.min(stepNumber + 1, totalSteps - 1));
};
const previous = (values: any) => {
setSnapshot(values);
setStepNumber(Math.max(stepNumber - 1, 0));
};
const handleSubmit = async (values: any, bag: FormikBag<any, any> | FormikHelpers<any>) => {
/*// @ts-ignore*/
if (step.props.onSubmit) {
/*// @ts-ignore*/
await step.props.onSubmit(values, bag);
}
if (isLastStep) {
return onSubmit(values, bag);
} else {
await bag.setTouched({});
next(values);
}
};
return (
<Formik
initialValues={snapshot}
onSubmit={handleSubmit}
/*// @ts-ignore*/
validationSchema={step.props.validationSchema}
>
{(formik: { values: any; isSubmitting: any; }) => (
<Form className="flex flex-col h-full">
<div className="w-full mb-8">
{/*<p>Step {stepNumber + 1} of {steps.length}</p>*/}
<Stepper activeStep={stepNumber} activeLineClassName="bg-primary"
lineClassName="bg-foreground"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}>
{steps.map((child, index) => (
<Step key={index}
className="bg-foreground text-background"
activeClassName="bg-primary"
completedClassName="bg-primary"
placeholder={undefined}
onPointerEnterCapture={undefined}
onPointerLeaveCapture={undefined}>
{/*@ts-ignore*/}
{child.props.icon}
</Step>
))}
</Stepper>
</div>
<div className="flex grow">
{step}
</div>
<div className="left-8 right-8 absolute bottom-8 -z-1">
<div className="flex justify-between">
<Button color="primary" onClick={() => previous(formik.values)} disabled={isFirstStep}>
<ArrowLeft/>
</Button>
<Button
color="primary"
isLoading={formik.isSubmitting}
type="submit"
>
{formik.isSubmitting ? "" : isLastStep ? <Check/> : <ArrowRight/>}
</Button>
</div>
</div>
</Form>
)}
</Formik>
);
};
export default Wizard;
@@ -0,0 +1,11 @@
import {JSX, ReactNode} from "react";
import * as Yup from 'yup';
export default function WizardStep({children, icon, validationSchema}: {
children: ReactNode,
icon: JSX.Element,
validationSchema?: Yup.Schema,
onSubmit?: (...values: any) => Promise<void>
}) {
return children;
}
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Gameyfin</title>
<style>
body {
margin: 0;
width: 100vw;
height: 100vh;
}
#outlet {
height: 100%;
width: 100%;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<div id="outlet"></div>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import {createRoot} from 'react-dom/client';
import {StrictMode} from "react";
import {RouterProvider} from "react-router-dom";
import router from "./routes";
const container = document.getElementById('outlet')!;
const root = createRoot(container);
root.render(
<StrictMode>
<RouterProvider router={router}/>
</StrictMode>
);
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.gradient-primary {
@apply bg-gradient-to-br from-primary-400 to-primary-700;
}
}
+42
View File
@@ -0,0 +1,42 @@
import {protectRoutes} from '@vaadin/hilla-react-auth';
import {createBrowserRouter, RouteObject} from 'react-router-dom';
import LoginView from "Frontend/views/LoginView";
import MainLayout from "Frontend/views/MainLayout";
import TestView from "Frontend/views/TestView";
import SetupView from "Frontend/views/SetupView";
import ProfileView from "Frontend/views/ProfileView";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import App from "Frontend/App";
export const routes = protectRoutes([
{
element: <App/>,
handle: {requiresLogin: false},
children: [
{
element: <MainLayout/>,
handle: {requiresLogin: true},
children: [
{
index: true, element: <TestView/>
},
{
path: 'profile',
element: <ProfileView/>,
children: [
{path: 'appearance', element: <ThemeSelector/>}
]
}
]
},
{
path: '/login', element: <LoginView/>, handle: {requiresLogin: false}
},
{
path: '/setup', element: <SetupView/>, handle: {requiresLogin: false}
}
],
}
]) as RouteObject[];
export default createBrowserRouter(routes);
+21
View File
@@ -0,0 +1,21 @@
export type Theme = {
name?: string,
colors: {
background?: string,
foreground?: string,
primary: {
50: string,
100: string,
200: string,
300: string,
400: string,
500: string,
600: string,
700: string,
800: string,
900: string,
DEFAULT: string
},
focus?: string,
}
}
+46
View File
@@ -0,0 +1,46 @@
import {GameyfinClassic} from "./themes/gameyfin-classic";
import {GameyfinBlue} from "./themes/gameyfin-blue";
import {GameyfinViolet} from "./themes/gameyfin-violet";
import {Purple} from "./themes/purple";
import {Neutral} from "./themes/neutral";
import {Slate} from "./themes/slate";
import {Red} from "./themes/red";
import {Rose} from "./themes/rose";
import {Blue} from "./themes/blue";
import {Yellow} from "./themes/yellow";
import {Violet} from "./themes/violet";
import {Orange} from "./themes/orange";
import {Theme} from "./theme";
import {ConfigTheme, ConfigThemes} from "@nextui-org/react";
function light(c: Theme): ConfigTheme {
let t: Theme = structuredClone(c);
delete t.name;
(t as ConfigTheme).extend = "light";
return t;
}
function dark(c: Theme): ConfigTheme {
let t: Theme = structuredClone(c);
delete t.name;
(t as ConfigTheme).extend = "dark";
return t;
}
export function compileThemes(themes: Theme[]): ConfigThemes {
let compiledThemes: any = {};
themes.forEach((c: Theme) => {
compiledThemes[`${c.name}-light`] = light(c);
compiledThemes[`${c.name}-dark`] = dark(c);
})
return compiledThemes;
}
export function themeNames(): string[] {
return Object.keys(compileThemes(themes));
}
export const themes: Theme[] = [GameyfinBlue, GameyfinViolet, GameyfinClassic, Neutral, Slate, Red, Rose, Orange, Purple, Blue, Yellow, Violet];
+20
View File
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const Blue: Theme = {
name: 'blue',
colors: {
primary: {
50: '#e3eeff',
100: '#b6cdfe',
200: '#88abf7',
300: '#5b8af1',
400: '#2d69ec',
500: '#134fd2',
600: '#0b3da4',
700: '#052c77',
800: '#001a4a',
900: '#00091e',
DEFAULT: '#2563EB'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const GameyfinBlue: Theme = {
name: 'gameyfin-blue',
colors: {
primary: {
50: '#e7eaff',
100: '#bdc3f9',
200: '#919bee',
300: '#6672e5',
400: '#3c4add',
500: '#2231c3',
600: '#1a2699',
700: '#101b6f',
800: '#070f45',
900: '#02041d',
DEFAULT: '#2332c8'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const GameyfinClassic: Theme = {
name: 'gameyfin-classic',
colors: {
primary: {
50: '#e1ffec',
100: '#b8f7cf',
200: '#8ef0b2',
300: '#62ea94',
400: '#38e476',
500: '#20ca5d',
600: '#159d47',
700: '#0b7032',
800: '#02431d',
900: '#001804',
DEFAULT: '#16A34A'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const GameyfinViolet: Theme = {
name: 'gameyfin-violet',
colors: {
primary: {
50: '#f3ebff',
100: '#d5c7ed',
200: '#b7a4dd',
300: '#9a7fce',
400: '#7d5abe',
500: '#6441a5',
600: '#4e3281',
700: '#37235d',
800: '#21153a',
900: '#0d0519',
DEFAULT: '#6441a5'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const Neutral: Theme = {
name: 'neutral',
colors: {
primary: {
50: '#fceff2',
100: '#ddd7d9',
200: '#c1bfbf',
300: '#a6a6a6',
400: '#8c8c8c',
500: '#737373',
600: '#595959',
700: '#413f40',
800: '#292526',
900: '#16090d',
DEFAULT: '#525252'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const Orange: Theme = {
name: 'orange',
colors: {
primary: {
50: '#ffedde',
100: '#ffcdb2',
200: '#fbac84',
300: '#f78c54',
400: '#f46c25',
500: '#da520b',
600: '#ab3f07',
700: '#7a2d03',
800: '#4b1900',
900: '#1f0600',
DEFAULT: '#EA580C'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from "../theme";
export const Purple: Theme = {
name: 'purple',
colors: {
primary: {
50: '#ffe6ff',
100: '#f2b9f9',
200: '#e78df3',
300: '#dc5fed',
400: '#d132e6',
500: '#b91acd',
600: '#9012a0',
700: '#670b73',
800: '#3f0547',
900: '#19001b',
DEFAULT: '#DD62ED'
}
}
};
+20
View File
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const Red: Theme = {
name: 'red',
colors: {
primary: {
50: '#ffe5e5',
100: '#f9bbbb',
200: '#ef9090',
300: '#e76464',
400: '#df3939',
500: '#c62020',
600: '#9b1718',
700: '#6f0f11',
800: '#450708',
900: '#1e0000',
DEFAULT: '#DC2626'
}
}
};
+20
View File
@@ -0,0 +1,20 @@
import {Theme} from "../theme";
export const Rose: Theme = {
name: 'rose',
colors: {
primary: {
50: '#ffe4ed',
100: '#fbb9c8',
200: '#f28da4',
300: '#ec607f',
400: '#e5345b',
500: '#cb1a41',
600: '#9f1233',
700: '#730b23',
800: '#470415',
900: '#1e0006',
DEFAULT: '#E11D48'
}
}
};
+20
View File
@@ -0,0 +1,20 @@
import {Theme} from "../theme";
export const Slate: Theme = {
name: 'slate',
colors: {
primary: {
50: '#eaf3ff',
100: '#cfd7e4',
200: '#b2bdcd',
300: '#95a3b7',
400: '#7788a1',
500: '#5e6f88',
600: '#48566a',
700: '#323e4d',
800: '#1d2531',
900: '#040d17',
DEFAULT: '#475569'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const Violet: Theme = {
name: 'violet',
colors: {
primary: {
50: '#f2e7ff',
100: '#d4bdf8',
200: '#b592ee',
300: '#9867e5',
400: '#7b3cdd',
500: '#6122c3',
600: '#4b1a99',
700: '#36126e',
800: '#200944',
900: '#0d021c',
DEFAULT: '#6D28D9'
}
}
};
@@ -0,0 +1,20 @@
import {Theme} from '../theme';
export const Yellow: Theme = {
name: 'yellow',
colors: {
primary: {
50: '#fffadb',
100: '#feefae',
200: '#fce47f',
300: '#fbd94e',
400: '#face1e',
500: '#e1b505',
600: '#af8c00',
700: '#7d6400',
800: '#4c3c00',
900: '#1c1400',
DEFAULT: '#FACC15'
}
}
};
+10
View File
@@ -0,0 +1,10 @@
import {configureAuth} from '@vaadin/hilla-react-auth';
import {UserEndpoint} from 'Frontend/generated/endpoints';
// Configure auth to use `UserInfoService.getUserInfo`
const auth = configureAuth(UserEndpoint.getUserInfo);
// Export auth provider and useAuth hook, which are automatically
// typed to the result of `UserInfoService.getUserInfo`
export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;
+31
View File
@@ -0,0 +1,31 @@
import {Middleware, MiddlewareContext, MiddlewareNext} from '@vaadin/hilla-frontend';
import {toast} from "sonner";
import {getReasonPhrase} from "http-status-codes";
export const ErrorHandlingMiddleware: Middleware = async function (
context: MiddlewareContext,
next: MiddlewareNext
) {
const {endpoint, method} = context;
let originalResponse = (await next(context));
if (!originalResponse.ok) {
// .clone() is necessary because response.json() is one-time only and Hilla accesses it in its internal error handler
// @see https://developer.mozilla.org/en-US/docs/Web/API/Response/clone
let response: Response = originalResponse.clone();
//Ignore calls to UserEndpoint.getUserInfo since they are managed by Hilla and called on initial load
if (endpoint == "UserEndpoint" && method == "getUserInfo") return originalResponse;
let json: any = await response.json();
if (json.type == "dev.hilla.exception.EndpointException") {
toast.error(`${getReasonPhrase(response.status)}`, {description: `${json.message}`});
} else {
toast.error(`${getReasonPhrase(response.status)}`, {description: `${endpoint}.${method}`})
}
}
return originalResponse;
}
+15
View File
@@ -0,0 +1,15 @@
import { useMatches } from 'react-router-dom';
type RouteMetadata = {
[key: string]: any;
};
/**
* Returns the `handle` object containing the metadata for the current route,
* or undefined if the route does not have defined a handle.
*/
export function useRouteMetadata(): RouteMetadata | undefined {
const matches = useMatches();
const match = matches[matches.length - 1];
return match?.handle as RouteMetadata | undefined;
}
+20
View File
@@ -0,0 +1,20 @@
import {type ClassValue, clsx} from "clsx"
import {twMerge} from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function cssVar(variable: string) {
return getComputedStyle(document.documentElement).getPropertyValue(`--${variable}`);
}
export function hsl(hsl: string) {
return `hsl(${hsl}`;
}
export function rand(min: number, max: number) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}
+97
View File
@@ -0,0 +1,97 @@
import {useAuth} from "Frontend/util/auth";
import {useLayoutEffect, useState} from "react";
import {XCircle} from "@phosphor-icons/react";
import {Button, Card, CardBody, CardHeader, Input, Link} from "@nextui-org/react";
import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert";
import {useNavigate} from "react-router-dom";
export default function LoginView() {
const {state, login} = useAuth();
const [hasError, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState<string>();
const [password, setPassword] = useState<string>();
const [url, setUrl] = useState<string>();
const navigate = useNavigate();
useLayoutEffect(() => {
if (state.user) {
const path = url ? new URL(url, document.baseURI).pathname : '/'
navigate(path, {replace: true});
}
}, [state.user]);
return (
<div className="flex size-full gradient-primary">
<Card className="m-auto p-12">
<CardHeader>
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96">
{hasError &&
<Alert className="mb-4" variant="destructive">
<XCircle weight="fill" className="size-4"/>
<AlertTitle>Error</AlertTitle>
<AlertDescription>Wrong username and/or password</AlertDescription>
</Alert>
}
<form
className="mb-1 flex flex-col gap-6"
onSubmit={async e => {
e.preventDefault();
if (typeof username === "string" && password != null) {
setLoading(true);
const {defaultUrl, error, redirectUrl} = await login(username, password);
if (error) {
setError(true);
} else {
setUrl(redirectUrl ?? defaultUrl ?? '/');
}
setLoading(false);
}
}}
>
<label htmlFor="username">
<h6 color="blue-gray" className="-mb-3">
Username
</h6>
</label>
<Input
onChange={(event: any) => {
setUsername(event.target.value);
}}
id="username"
type="text"
autoComplete="username"
placeholder=""
/>
<label htmlFor="current-password">
<h6 color="blue-gray" className="-mb-3">
Password
</h6>
</label>
<Input
onChange={(event: any) => {
setPassword(event.target.value);
}}
id="current-password"
type="password"
autoComplete="current-password"
placeholder=""
/>
<div className="flex justify-between items-center">
<Link color="foreground" underline="always">Forgot password?</Link>
<Button color="primary" type="submit" isLoading={loading}>
{loading ? "" : "Log in"}
</Button>
</div>
</form>
</CardBody>
</Card>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import {useRouteMetadata} from 'Frontend/util/routing.js';
import {useEffect} from 'react';
import ProfileMenu from "Frontend/components/ProfileMenu";
import {Divider, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem} from "@nextui-org/react";
import GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json";
import {Outlet, useNavigate} from "react-router-dom";
export default function MainLayout() {
const currentTitle = `Gameyfin - ${useRouteMetadata()?.title}` ?? 'Gameyfin';
useEffect(() => {
document.title = currentTitle;
}, [currentTitle]);
const navigate = useNavigate();
return (
<div className="flex flex-col min-h-svh">
<Navbar maxWidth="2xl" className="shadow">
<NavbarBrand as="button" onClick={() => navigate('/')}>
<GameyfinLogo className="h-10 fill-foreground"/>
</NavbarBrand>
<NavbarContent justify="end">
<NavbarItem>
<ProfileMenu/>
</NavbarItem>
</NavbarContent>
</Navbar>
<div className="flex-1">
<div className="flex-row relative m-auto max-w-[1536px] align-self-center px-2 pt-4">
<Outlet/>
</div>
</div>
<Divider/>
<footer className="flex flex-row items-center justify-between py-4 px-12">
<p>Gameyfin {PackageJson.version}</p>
<p>
&copy; {(new Date()).getFullYear()}&ensp;
<Link href="https://github.com/gameyfin/gameyfin/graphs/contributors" target="_blank">
Gameyfin contributors
</Link>
</p>
</footer>
</div>
);
}
/**/
+47
View File
@@ -0,0 +1,47 @@
import {Listbox, ListboxItem} from "@nextui-org/react";
import {GearFine, Palette, User} from "@phosphor-icons/react";
import {Outlet, useNavigate} from "react-router-dom";
import {useState} from "react";
export default function ProfileView() {
const navigate = useNavigate();
const [selectedKeys, setSelectedKeys] = useState(new Set(["profile"]));
const menuItems = [
{
title: "My Profile",
key: "profile",
icon: <User/>,
action: () => navigate('/profile')
},
{
title: "Appearance",
key: "appearance",
icon: <Palette/>,
action: () => navigate('appearance')
},
{
title: "Manage account",
icon: <GearFine/>,
key: "account-management",
action: () => navigate('account-management')
}
]
return (
<div className="flex flex-row">
<div className="flex flex-col pr-8">
<Listbox className="min-w-60">
{menuItems.map((i) => (
<ListboxItem key={i.key} onPress={i.action} startContent={i.icon}>
{i.title}
</ListboxItem>
))}
</Listbox>
</div>
<div className="flex flex-col">
<Outlet/>
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import React from 'react';
import * as Yup from 'yup';
import Wizard from "Frontend/components/wizard/Wizard";
import WizardStep from "Frontend/components/wizard/WizardStep";
import Input from "Frontend/components/Input";
import {GearFine, HandWaving, Palette, User} from "@phosphor-icons/react";
import {Card} from "@nextui-org/react";
import {SetupEndpoint} from "Frontend/generated/endpoints";
import {ThemeSelector} from "Frontend/components/theming/ThemeSelector";
import {useNavigate} from "react-router-dom";
import {toast} from "sonner";
function WelcomeStep() {
return (
<div className="flex flex-col size-full items-center">
<div className="flex flex-col w-1/2 min-w-[468px] gap-12 items-center">
<h4>Welcome to Gameyfin 👋</h4>
<p className="place-content-center text-justify">
Gameyfin is a cutting-edge software tailored for gamers seeking efficient management of their
video
game collections. <br/><br/> With its intuitive interface and comprehensive features, Gameyfin
simplifies the organization of game libraries. Users can effortlessly add games through manual
input
or
automated recognition, categorize them based on various criteria like genre or platform, track
in-game
progress, and share achievements with friends. <br/><br/> Notably, Gameyfin stands out for its
user-friendly
design and adaptability, offering ample customization options to meet diverse user preferences.
</p>
<h5>Let's get started!</h5>
</div>
</div>
);
}
function ThemeStep() {
return (
<ThemeSelector/>
);
}
function UserStep() {
return (
<div className="flex flex-col size-full items-center">
<div className="flex flex-col w-1/2 min-w-[468px] gap-12 items-center">
<h4>Create your account</h4>
<p className="-mt-8">This will set up the initial admin user account.</p>
<div className="mb-1 flex flex-col w-full gap-6 items-center">
<Input
label="Username"
name="username"
type="text"
/>
<Input
label="E-Mail"
name="email"
type="email"
/>
<Input
label="Password"
name="password"
type="password"
/>
<Input
label="Password (repeat)"
name="passwordRepeat"
type="password"
/>
</div>
</div>
</div>
);
}
function SettingsStep() {
return (
<div className="flex flex-col size-full items-center">
<div className="flex flex-col w-1/2 min-w-[468px] gap-12 items-center">
<h4>Settings</h4>
<p>Configure your settings</p>
</div>
</div>
);
}
function SetupView() {
const navigate = useNavigate();
return (
<div className="flex size-full gradient-primary">
<Card className="w-3/4 h-3/4 min-w-[500px] m-auto p-8">
<Wizard
initialValues={{username: '', email: '', password: '', passwordRepeat: ''}}
onSubmit={
async (values: any) => {
await SetupEndpoint.registerSuperAdmin({
username: values.username,
password: values.password,
email: values.email
});
toast.success("Setup finished", {description: "Have fun with Gameyfin!"});
navigate('/login');
}
}
>
<WizardStep icon={<HandWaving/>}>
<WelcomeStep/>
</WizardStep>
<WizardStep icon={<Palette/>}>
<ThemeStep/>
</WizardStep>
<WizardStep
validationSchema={Yup.object({
username: Yup.string()
.required('Required'),
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
email: Yup.string()
.email()
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}
icon={<User/>}
>
<UserStep/>
</WizardStep>
<WizardStep icon={<GearFine/>}>
<SettingsStep/>
</WizardStep>
</Wizard>
</Card>
</div>
);
}
export default SetupView;
+44
View File
@@ -0,0 +1,44 @@
import {Link} from "react-router-dom";
import {Button} from "@nextui-org/react";
import {toast} from "sonner";
import {SystemEndpoint} from "Frontend/generated/endpoints.js";
export default function TestView() {
return (
<div className="grow justify-center mt-12">
<div className="flex flex-col items-center gap-6">
<Link to="/setup">Setup</Link>
<div className="flex flex-row gap-4">
<Button onPress={
() => toast("Normal", {
description: "Description",
action: {
label: "OK",
onClick: () => {
},
}
})}>Toast (Normal)</Button>
<Button onPress={
() => toast.success("Success", {
description: "Description",
action: {
label: "OK",
onClick: () => {
},
}
})}>Toast (Success)</Button>
<Button onPress={
() => toast.error("Error", {
description: "Description",
action: {
label: "OK",
onClick: () => {
},
}
})}>Toast (Error)</Button>
</div>
<Button onPress={() => SystemEndpoint.restart()}>Restart</Button>
</div>
</div>
);
}