Switch to Hilla for UI

This commit is contained in:
grimsi
2024-03-06 23:35:41 +01:00
parent 73457aad0b
commit e79dd7a6df
44 changed files with 12778 additions and 429 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+16 -29
View File
@@ -3,11 +3,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "3.2.2"
id("io.spring.dependency-management") version "1.1.4"
id("com.vaadin") version "24.3.3"
id("dev.hilla") version "2.5.6"
kotlin("jvm") version "1.9.22"
kotlin("plugin.spring") version "1.9.22"
kotlin("plugin.jpa") version "1.9.22"
id("io.freefair.lombok") version "8.4"
java
}
@@ -17,10 +16,9 @@ allOpen {
group = "de.grimsi"
version = "2.0.0-SNAPSHOT"
description = "gameyfin"
java {
sourceCompatibility = JavaVersion.VERSION_21
}
java.sourceCompatibility = JavaVersion.VERSION_21
configurations {
compileOnly {
@@ -29,20 +27,26 @@ configurations {
}
repositories {
mavenLocal()
mavenCentral()
maven {
name = "Vaadin Addons"
url = uri("https://maven.vaadin.com/vaadin-addons")
setUrl("https://maven.vaadin.com/vaadin-addons")
}
}
extra["vaadinVersion"] = "24.3.3"
extra["hillaVersion"] = "2.5.6"
dependencies {
// Sprint Boot
// Spring Boot & Kotlin
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Hilla
api("dev.hilla:hilla-react")
api("dev.hilla:hilla-spring-boot-starter")
// Logging
implementation("io.github.oshai:kotlin-logging-jvm:6.0.3")
@@ -51,35 +55,18 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.7")
// Frontend
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.vaadin:vaadin-spring-boot-starter")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("com.github.mvysny.karibudsl:karibu-dsl-v23:2.1.0")
implementation("com.github.mvysny.karibu-tools:karibu-tools-23:0.19")
implementation("org.jetbrains.kotlin:kotlin-reflect")
// Vaadin Add-Ons
implementation("com.flowingcode.addons:font-awesome-iron-iconset:5.2.2")
implementation("in.virit:viritin:2.7.0")
implementation("io.sunshower.aire:aire-wizard:1.0.17.Final")
// Development
developmentOnly("org.springframework.boot:spring-boot-devtools")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
runtimeOnly("com.h2database:h2")
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
// Fix compilation error
compileOnly("com.github.spotbugs:spotbugs-annotations:4.8.3")
}
dependencyManagement {
imports {
mavenBom("com.vaadin:vaadin-bom:${property("vaadinVersion")}")
mavenBom("dev.hilla:hilla-bom:${property("hillaVersion")}")
}
}
+18
View File
@@ -0,0 +1,18 @@
import router from 'Frontend/routes.js';
import {AuthProvider} from 'Frontend/util/auth.js';
import {RouterProvider} from 'react-router-dom';
import "./main.css";
import {ThemeProvider} from "@material-tailwind/react";
import React from 'react';
export default function App() {
return (
<React.StrictMode>
<ThemeProvider>
<AuthProvider>
<RouterProvider router={router}/>
</AuthProvider>
</ThemeProvider>
</React.StrictMode>
);
}
+19 -17
View File
@@ -1,23 +1,25 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body, #outlet {
height: 100vh;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
<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>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
<div id="outlet"></div>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import {createRoot} from 'react-dom/client';
import {createElement} from "react";
import App from "Frontend/App";
createRoot(document.getElementById('outlet')!).render(createElement(App));
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+21
View File
@@ -0,0 +1,21 @@
import {protectRoutes} from '@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";
export const routes = protectRoutes([
{
element: <MainLayout/>,
handle: {title: 'Main', requiresLogin: true},
children: [
{path: '/', element: <TestView/>, handle: {title: 'Gameyfin', requiresLogin: true}},
],
},
{
path: '/login',
element: <LoginView/>
}
]) as RouteObject[];
export default createBrowserRouter(routes);
-12
View File
@@ -1,12 +0,0 @@
window.applyTheme = () => {
const theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "";
document.documentElement.setAttribute("theme", theme);
};
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener('change', function () {
window.applyTheme()
});
window.applyTheme();
-6
View File
@@ -1,6 +0,0 @@
.header-logo {
width: 100%;
height: 40px;
position: absolute;
align-self: center;
}
+10
View File
@@ -0,0 +1,10 @@
import {configureAuth} from '@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;
+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;
}
+83
View File
@@ -0,0 +1,83 @@
import {useAuth} from "Frontend/util/auth";
import {useState} from "react";
import {useNavigate} from "react-router-dom";
import {Button, Card, Input, Typography} from "@material-tailwind/react";
export default function LoginView() {
const {state, login} = useAuth();
const [hasError, setError] = useState<boolean>();
const [username, setUsername] = useState<string>();
const [password, setPassword] = useState<string>();
const [url, setUrl] = useState<string>();
const navigate = useNavigate();
if (state.user && url) {
const path = new URL(url, document.baseURI).pathname;
navigate(path, {replace: true});
}
return (
<div className="flex h-screen">
<div className="fixed h-full w-full bg-gradient-to-br from-gf-primary to-gf-secondary"></div>
<Card className="m-auto p-12" shadow={true}>
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
/>
<div className="mt-8 mb-2 w-80 max-w-screen-lg sm:w-96">
<form
className="mb-1 flex flex-col gap-6"
onSubmit={async e => {
e.preventDefault();
if (typeof username === "string" && password != null) {
const {defaultUrl, error, redirectUrl} = await login(username, password);
if (error) {
setError(true);
alert("Wrong username and/or password!");
} else {
setUrl(redirectUrl ?? defaultUrl ?? '/');
}
}
}}
>
<Typography variant="h6" color="blue-gray" className="-mb-3">
Username
</Typography>
<Input
onChange={(event) => {
setUsername(event.target.value);
}}
size="lg"
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
}}
crossOrigin="" //TODO: see https://github.com/creativetimofficial/material-tailwind/issues/427
/>
<Typography variant="h6" color="blue-gray" className="-mb-3">
Password
</Typography>
<Input
onChange={(event) => {
setPassword(event.target.value);
}}
type="password"
size="lg"
className=" !border-t-blue-gray-200 focus:!border-t-gray-900"
labelProps={{
className: "before:content-none after:content-none",
}}
crossOrigin="" //TODO: see https://github.com/creativetimofficial/material-tailwind/issues/427
/>
<Button
type="submit"
size="lg"
fullWidth>
Log in
</Button>
</form>
</div>
</Card>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
import {AppLayout} from '@hilla/react-components/AppLayout.js';
import {Avatar} from '@hilla/react-components/Avatar.js';
import {Button} from '@hilla/react-components/Button.js';
import {DrawerToggle} from '@hilla/react-components/DrawerToggle.js';
import {useAuth} from 'Frontend/util/auth.js';
import {useRouteMetadata} from 'Frontend/util/routing.js';
import {useEffect} from 'react';
import {Outlet} from 'react-router-dom';
const navLinkClasses = ({isActive}: any) => {
return `block rounded-m p-s ${isActive ? 'bg-primary-10 text-primary' : 'text-body'}`;
};
export default function MainLayout() {
const currentTitle = useRouteMetadata()?.title ?? 'My App';
useEffect(() => {
document.title = currentTitle;
}, [currentTitle]);
const {state, logout} = useAuth();
return (
<AppLayout primarySection="drawer">
<div slot="drawer" className="flex flex-col justify-between h-full p-m">
<header className="flex flex-col gap-m">
<h1 className="text-l m-0">My App</h1>
<nav>
</nav>
</header>
<footer className="flex flex-col gap-s">
{state.user ? (
<>
<div className="flex items-center gap-s">
<Avatar theme="xsmall" name={state.user.name}/>
{state.user.name}
</div>
<Button onClick={async () => logout()}>Sign out</Button>
</>
) : (
<a href="/login">Sign in</a>
)}
</footer>
</div>
<DrawerToggle slot="navbar" aria-label="Menu toggle"></DrawerToggle>
<h2 slot="navbar" className="text-l m-0">
{currentTitle}
</h2>
<Outlet/>
</AppLayout>
);
}
+5
View File
@@ -0,0 +1,5 @@
export default function TestView() {
return (
<h1>Hello Gameyfin!</h1>
);
}
+26
View File
@@ -0,0 +1,26 @@
# This file was generated by the Gradle 'init' task.
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
com-h2database-h2 = "2.2.224"
dev-hilla-hilla-react = "2.5.6"
dev-hilla-hilla-spring-boot-starter = "2.5.6"
org-parttio-line-awesome = "2.0.0"
org-springframework-boot-spring-boot-devtools = "3.2.2"
org-springframework-boot-spring-boot-starter-data-jpa = "3.2.2"
org-springframework-boot-spring-boot-starter-oauth2-resource-server = "3.2.2"
org-springframework-boot-spring-boot-starter-security = "3.2.2"
org-springframework-boot-spring-boot-starter-test = "3.2.2"
org-springframework-boot-spring-boot-starter-validation = "3.2.2"
[libraries]
com-h2database-h2 = { module = "com.h2database:h2", version.ref = "com-h2database-h2" }
dev-hilla-hilla-react = { module = "dev.hilla:hilla-react", version.ref = "dev-hilla-hilla-react" }
dev-hilla-hilla-spring-boot-starter = { module = "dev.hilla:hilla-spring-boot-starter", version.ref = "dev-hilla-hilla-spring-boot-starter" }
org-parttio-line-awesome = { module = "org.parttio:line-awesome", version.ref = "org-parttio-line-awesome" }
org-springframework-boot-spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools", version.ref = "org-springframework-boot-spring-boot-devtools" }
org-springframework-boot-spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "org-springframework-boot-spring-boot-starter-data-jpa" }
org-springframework-boot-spring-boot-starter-oauth2-resource-server = { module = "org.springframework.boot:spring-boot-starter-oauth2-resource-server", version.ref = "org-springframework-boot-spring-boot-starter-oauth2-resource-server" }
org-springframework-boot-spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security", version.ref = "org-springframework-boot-spring-boot-starter-security" }
org-springframework-boot-spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "org-springframework-boot-spring-boot-starter-test" }
org-springframework-boot-spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "org-springframework-boot-spring-boot-starter-validation" }
+11301
View File
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
{
"name": "no-name",
"license": "UNLICENSED",
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/react-fontawesome": "^0.2.0",
"@hilla/form": "2.5.6",
"@hilla/frontend": "2.5.6",
"@hilla/generator-typescript-cli": "2.5.6",
"@hilla/generator-typescript-core": "2.5.6",
"@hilla/generator-typescript-plugin-backbone": "2.5.6",
"@hilla/generator-typescript-plugin-barrel": "2.5.6",
"@hilla/generator-typescript-plugin-client": "2.5.6",
"@hilla/generator-typescript-plugin-model": "2.5.6",
"@hilla/generator-typescript-plugin-push": "2.5.6",
"@hilla/generator-typescript-plugin-subtypes": "2.5.6",
"@hilla/generator-typescript-utils": "2.5.6",
"@hilla/react-auth": "2.5.6",
"@hilla/react-components": "2.3.0",
"@hilla/react-crud": "2.5.6",
"@hilla/react-form": "2.5.6",
"@material-tailwind/react": "^2.1.9",
"@polymer/polymer": "3.5.1",
"@vaadin/bundles": "24.3.0",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/router": "1.7.5",
"classnames": "^2.3.2",
"construct-style-sheets-polyfill": "3.1.0",
"lit": "3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.4.2"
},
"devDependencies": {
"@lit-labs/react": "^1.1.0",
"@rollup/plugin-replace": "5.0.4",
"@rollup/pluginutils": "5.0.5",
"@types/react": "18.2.42",
"@types/react-dom": "^18.0.8",
"@vitejs/plugin-react": "4.2.0",
"@vitejs/plugin-react-swc": "3.5.0",
"async": "3.2.4",
"autoprefixer": "^10.4.18",
"glob": "10.3.3",
"postcss": "^8.4.35",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.9.2",
"strip-css-comments": "5.0.0",
"tailwindcss": "^3.4.1",
"transform-ast": "2.4.4",
"typescript": "5.3.3",
"vite": "5.0.6",
"vite-plugin-checker": "0.6.2",
"workbox-build": "7.0.0",
"workbox-core": "7.0.0",
"workbox-precaching": "7.0.0"
},
"overrides": {
"classnames": "$classnames",
"react": "$react",
"react-dom": "$react-dom",
"react-router-dom": "$react-router-dom",
"@vaadin/bundles": "$@vaadin/bundles",
"@hilla/react-components": "$@hilla/react-components",
"@vaadin/common-frontend": "$@vaadin/common-frontend",
"construct-style-sheets-polyfill": "$construct-style-sheets-polyfill",
"lit": "$lit",
"@vaadin/router": "$@vaadin/router",
"@polymer/polymer": "$@polymer/polymer",
"@hilla/react-auth": "$@hilla/react-auth",
"@hilla/generator-typescript-plugin-model": "$@hilla/generator-typescript-plugin-model",
"@hilla/generator-typescript-plugin-barrel": "$@hilla/generator-typescript-plugin-barrel",
"@hilla/react-form": "$@hilla/react-form",
"@hilla/generator-typescript-plugin-push": "$@hilla/generator-typescript-plugin-push",
"@hilla/generator-typescript-core": "$@hilla/generator-typescript-core",
"@hilla/generator-typescript-plugin-client": "$@hilla/generator-typescript-plugin-client",
"@hilla/generator-typescript-utils": "$@hilla/generator-typescript-utils",
"@hilla/frontend": "$@hilla/frontend",
"@hilla/form": "$@hilla/form",
"@hilla/generator-typescript-plugin-subtypes": "$@hilla/generator-typescript-plugin-subtypes",
"@hilla/react-crud": "$@hilla/react-crud",
"@hilla/generator-typescript-plugin-backbone": "$@hilla/generator-typescript-plugin-backbone",
"@hilla/generator-typescript-cli": "$@hilla/generator-typescript-cli",
"@material-tailwind/react": "$@material-tailwind/react",
"@fortawesome/fontawesome-svg-core": "$@fortawesome/fontawesome-svg-core",
"@fortawesome/free-solid-svg-icons": "$@fortawesome/free-solid-svg-icons",
"@fortawesome/react-fontawesome": "$@fortawesome/react-fontawesome"
},
"vaadin": {
"dependencies": {
"@hilla/form": "2.5.6",
"@hilla/frontend": "2.5.6",
"@hilla/generator-typescript-cli": "2.5.6",
"@hilla/generator-typescript-core": "2.5.6",
"@hilla/generator-typescript-plugin-backbone": "2.5.6",
"@hilla/generator-typescript-plugin-barrel": "2.5.6",
"@hilla/generator-typescript-plugin-client": "2.5.6",
"@hilla/generator-typescript-plugin-model": "2.5.6",
"@hilla/generator-typescript-plugin-push": "2.5.6",
"@hilla/generator-typescript-plugin-subtypes": "2.5.6",
"@hilla/generator-typescript-utils": "2.5.6",
"@hilla/react-auth": "2.5.6",
"@hilla/react-components": "2.3.0",
"@hilla/react-crud": "2.5.6",
"@hilla/react-form": "2.5.6",
"@polymer/polymer": "3.5.1",
"@vaadin/bundles": "24.3.0",
"@vaadin/common-frontend": "0.0.19",
"@vaadin/router": "1.7.5",
"construct-style-sheets-polyfill": "3.1.0",
"lit": "3.1.0"
},
"devDependencies": {
"@rollup/plugin-replace": "5.0.4",
"@rollup/pluginutils": "5.0.5",
"@vitejs/plugin-react": "4.2.0",
"@vitejs/plugin-react-swc": "3.5.0",
"async": "3.2.4",
"glob": "10.3.3",
"rollup-plugin-brotli": "3.1.0",
"rollup-plugin-visualizer": "5.9.2",
"strip-css-comments": "5.0.0",
"transform-ast": "2.4.4",
"typescript": "5.3.3",
"vite": "5.0.6",
"vite-plugin-checker": "0.6.2",
"workbox-build": "7.0.0",
"workbox-core": "7.0.0",
"workbox-precaching": "7.0.0"
},
"hash": "f9e182b004ea4d86a6ca514e8d956df5851830853b0a45b9e40615d51b7d2788"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
-31
View File
@@ -1,31 +0,0 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
projectJDK: "21" #(Applied in CI/CD pipeline)
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-jvm:latest
-32
View File
@@ -1,32 +0,0 @@
This directory is automatically generated by Vaadin and contains the pre-compiled
frontend files/resources for your project (frontend development bundle).
It should be added to Version Control System and committed, so that other developers
do not have to compile it again.
Frontend development bundle is automatically updated when needed:
- an npm/pnpm package is added with @NpmPackage or directly into package.json
- CSS, JavaScript or TypeScript files are added with @CssImport, @JsModule or @JavaScript
- Vaadin add-on with front-end customizations is added
- Custom theme imports/assets added into 'theme.json' file
- Exported web component is added.
If your project development needs a hot deployment of the frontend changes,
you can switch Flow to use Vite development server (default in Vaadin 23.3 and earlier versions):
- set `vaadin.frontend.hotdeploy=true` in `application.properties`
- configure `vaadin-maven-plugin`:
```
<configuration>
<frontendHotdeploy>true</frontendHotdeploy>
</configuration>
```
- configure `jetty-maven-plugin`:
```
<configuration>
<systemProperties>
<vaadin.frontend.hotdeploy>true</vaadin.frontend.hotdeploy>
</systemProperties>
</configuration>
```
Read more [about Vaadin development mode](https://vaadin.com/docs/next/configuration/development-mode/#pre-compiled-front-end-bundle-for-faster-start-up).
Binary file not shown.
@@ -1,90 +0,0 @@
package de.grimsi.gameyfin.layouts;
import com.flowingcode.vaadin.addons.fontawesome.FontAwesome;
import com.vaadin.flow.component.ClickEvent;
import com.vaadin.flow.component.ComponentEventListener;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.avatar.Avatar;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.contextmenu.MenuItem;
import com.vaadin.flow.component.contextmenu.SubMenu;
import com.vaadin.flow.component.dependency.CssImport;
import com.vaadin.flow.component.dependency.JsModule;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.menubar.MenuBar;
import com.vaadin.flow.component.menubar.MenuBarVariant;
import com.vaadin.flow.component.notification.Notification;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import com.vaadin.flow.spring.security.AuthenticationContext;
import de.grimsi.gameyfin.resources.PublicResources;
import de.grimsi.gameyfin.services.ThemeService;
import de.grimsi.gameyfin.setup.SetupService;
import de.grimsi.gameyfin.views.SetupView;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.vaadin.firitin.util.style.LumoProps;
import static de.grimsi.gameyfin.users.util.Utils.isAdmin;
@JsModule("./scripts/prefers-color-scheme.js")
@CssImport("./styles/header.css")
public class MainLayout extends AppLayout {
public MainLayout(AuthenticationContext authContext,
@Autowired SetupService setupService,
@Autowired ThemeService themeService) {
if (!setupService.isSetupCompleted()) {
UI.getCurrent().navigate(SetupView.class);
UI.getCurrent().close();
}
UserDetails user = authContext.getAuthenticatedUser(UserDetails.class).get();
Image logo = new Image(PublicResources.GAMEYFIN_LOGO.path, "Gameyfin Logo");
logo.addClassName("header-logo");
Button toggleTheme = new Button(FontAwesome.Solid.CIRCLE_HALF_STROKE.create());
toggleTheme.addThemeVariants(ButtonVariant.LUMO_ICON);
toggleTheme.addClickListener(listener -> themeService.toggleTheme());
Avatar avatar = new Avatar(user.getUsername());
avatar.setAbbreviation(user.getUsername().substring(0, 2).toUpperCase());
avatar.setColorIndex(user.getUsername().chars().map(i -> i % 6).findFirst().getAsInt());
MenuBar menu = new MenuBar();
menu.addThemeVariants(MenuBarVariant.LUMO_ICON);
MenuItem item = menu.addItem(avatar);
SubMenu subMenu = item.getSubMenu();
subMenu.addItem(menuItem(FontAwesome.Solid.USER, "Profile", l -> Notification.show("Profile")));
if (isAdmin(user)) {
subMenu.addItem(menuItem(FontAwesome.Solid.COG, "Administration", l -> Notification.show("Administration")));
}
subMenu.addItem(menuItem(FontAwesome.Solid.QUESTION_CIRCLE, "Help", l -> Notification.show("Help")));
subMenu.addItem(menuItem(FontAwesome.Solid.SIGN_OUT, "Sign out", l -> authContext.logout()));
HorizontalLayout horizontalLayout = new HorizontalLayout();
horizontalLayout.setAlignItems(FlexComponent.Alignment.END);
horizontalLayout.add(logo, toggleTheme, menu);
addToNavbar(horizontalLayout);
}
private HorizontalLayout menuItem(FontAwesome.Solid icon, String title, ComponentEventListener<ClickEvent<HorizontalLayout>> listener) {
FontAwesome.Solid.Icon i = icon.create();
i.setSize(LumoProps.ICON_SIZE_S.var());
HorizontalLayout h = new HorizontalLayout();
h.setAlignItems(FlexComponent.Alignment.CENTER);
h.setJustifyContentMode(FlexComponent.JustifyContentMode.START);
h.add(i);
h.add(title);
h.addClickListener(listener);
return h;
}
}
@@ -1,33 +0,0 @@
package de.grimsi.gameyfin.layouts;
import com.flowingcode.vaadin.addons.fontawesome.FontAwesome;
import com.vaadin.flow.component.applayout.AppLayout;
import com.vaadin.flow.component.button.Button;
import com.vaadin.flow.component.button.ButtonVariant;
import com.vaadin.flow.component.dependency.CssImport;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.orderedlayout.FlexComponent;
import com.vaadin.flow.component.orderedlayout.HorizontalLayout;
import de.grimsi.gameyfin.resources.PublicResources;
import de.grimsi.gameyfin.services.ThemeService;
import org.springframework.beans.factory.annotation.Autowired;
@CssImport("./styles/header.css")
public class SetupLayout extends AppLayout {
public SetupLayout(@Autowired ThemeService themeService) {
Image logo = new Image(PublicResources.GAMEYFIN_LOGO.path, "Gameyfin Logo");
logo.addClassName("header-logo");
Button toggleTheme = new Button(FontAwesome.Solid.CIRCLE_HALF_STROKE.create());
toggleTheme.addThemeVariants(ButtonVariant.LUMO_ICON);
toggleTheme.addClickListener(listener -> themeService.toggleTheme());
HorizontalLayout horizontalLayout = new HorizontalLayout();
horizontalLayout.setWidthFull();
horizontalLayout.setAlignSelf(FlexComponent.Alignment.END);
horizontalLayout.add(logo, toggleTheme);
addToNavbar(horizontalLayout);
}
}
@@ -1,11 +0,0 @@
package de.grimsi.gameyfin.resources;
public enum PublicResources {
GAMEYFIN_LOGO("public/images/Logo.svg");
public final String path;
PublicResources(String path) {
this.path = path;
}
}
@@ -1,11 +0,0 @@
package de.grimsi.gameyfin.services;
import com.vaadin.flow.component.notification.Notification;
import org.springframework.stereotype.Service;
@Service
public class ThemeService {
public void toggleTheme() {
Notification.show("Not implemented");
}
}
@@ -1,48 +0,0 @@
package de.grimsi.gameyfin.views;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.html.Image;
import com.vaadin.flow.component.login.LoginForm;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.BeforeEnterEvent;
import com.vaadin.flow.router.BeforeEnterObserver;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.grimsi.gameyfin.resources.PublicResources;
import de.grimsi.gameyfin.setup.SetupService;
import org.springframework.beans.factory.annotation.Autowired;
@Route("login")
@PageTitle("Login")
@AnonymousAllowed
public class LoginView extends VerticalLayout implements BeforeEnterObserver {
private final LoginForm login = new LoginForm();
public LoginView(@Autowired SetupService setupService) {
if (!setupService.isSetupCompleted()) {
UI.getCurrent().navigate(SetupView.class);
UI.getCurrent().close();
}
Image logo = new Image(PublicResources.GAMEYFIN_LOGO.path, "Gameyfin");
logo.setHeight("100px");
login.setAction("login");
add(logo);
add(login);
}
@Override
public void beforeEnter(BeforeEnterEvent beforeEnterEvent) {
if (beforeEnterEvent.getLocation()
.getQueryParameters()
.getParameters()
.containsKey("error")
) {
login.setError(true);
}
}
}
@@ -1,21 +0,0 @@
package de.grimsi.gameyfin.views;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.html.Pre;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import de.grimsi.gameyfin.layouts.MainLayout;
import jakarta.annotation.security.PermitAll;
@Route(value = "", layout = MainLayout.class)
@PageTitle("Gameyfin")
@PermitAll
public class MainView extends VerticalLayout {
public MainView() {
add(new H1("Gameyfin main page"));
add(new Pre("Work in progress"));
}
}
@@ -1,46 +0,0 @@
package de.grimsi.gameyfin.views;
import com.vaadin.flow.component.Text;
import com.vaadin.flow.component.UI;
import com.vaadin.flow.component.formlayout.FormLayout;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.component.textfield.PasswordField;
import com.vaadin.flow.component.textfield.TextField;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import com.vaadin.flow.server.auth.AnonymousAllowed;
import de.grimsi.gameyfin.layouts.SetupLayout;
import de.grimsi.gameyfin.setup.SetupService;
import org.springframework.beans.factory.annotation.Autowired;
@Route(value = "/setup", layout = SetupLayout.class)
@PageTitle("Setup")
@AnonymousAllowed
public class SetupView extends VerticalLayout {
public SetupView(@Autowired SetupService setupService) {
if (setupService.isSetupCompleted()) {
UI.getCurrent().navigate(LoginView.class);
UI.getCurrent().close();
}
setWidthFull();
setAlignItems(Alignment.CENTER);
add(new Text("Looks like it's your first time starting Gameyfin. Let's continue setting up your very own instance 🙂"));
TextField username = new TextField("Username");
username.focus();
PasswordField passwordField = new PasswordField("Password");
PasswordField passwordFieldRepeat = new PasswordField("Password (repeated)");
FormLayout form = new FormLayout();
form.add(new Text("Let's start with creating a super admin account. This account will have full permissions."));
form.add(username, passwordField, passwordFieldRepeat);
add(form);
}
}
@@ -1,7 +1,6 @@
package de.grimsi.gameyfin.config
import com.vaadin.flow.spring.security.VaadinWebSecurity
import de.grimsi.gameyfin.views.LoginView
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
@@ -15,7 +14,8 @@ import org.springframework.security.web.util.matcher.AntPathRequestMatcher
@EnableWebSecurity
@Configuration
class SecurityConfiguration : VaadinWebSecurity() {
class SecurityConfig : VaadinWebSecurity() {
@Throws(Exception::class)
override fun configure(http: HttpSecurity) {
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher
@@ -23,18 +23,14 @@ class SecurityConfiguration : VaadinWebSecurity() {
auth.requestMatchers(AntPathRequestMatcher("/public/**")).permitAll()
}
// Configure your static resources with public access before calling
// super.configure(HttpSecurity) as it adds final anyRequest matcher
super.configure(http)
// This is important to register your login view to the navigation access control mechanism:
setLoginView(http, LoginView::class.java)
setLoginView(http, "/login")
}
@Throws(Exception::class)
public override fun configure(web: WebSecurity) {
super.configure(web)
web.ignoring().requestMatchers(AntPathRequestMatcher("/images/**"))
}
@Bean
@@ -26,17 +26,21 @@ class SetupDataLoader(
log.info { "We will now set up some data..." }
setupRoles()
//setupUser()
setupUsers()
log.info { "Setup completed..." }
}
fun setupUser() {
fun setupUsers() {
val superadmin = User("admin")
superadmin.password = "admin"
superadmin.roles = listOf(roleRepository.findByRolename(Roles.SUPERADMIN.roleName)!!)
userService.registerUser(superadmin)
val user = User("user")
user.password = "user"
user.roles = listOf(roleRepository.findByRolename(Roles.USER.roleName)!!)
userService.registerUser(user)
}
fun setupRoles() {
@@ -3,12 +3,14 @@ package de.grimsi.gameyfin.setup
import jakarta.servlet.*
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import java.io.IOException
//@Order(1)
//@Component
@Order(1)
@Component
class SetupFilter(
private val setupService: SetupService
) : Filter {
@@ -0,0 +1,19 @@
package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.users.dto.UserInfo
import dev.hilla.Endpoint
import jakarta.annotation.security.PermitAll
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
@Endpoint
class UserEndpoint {
@PermitAll
fun getUserInfo(): UserInfo {
val auth: Authentication = SecurityContextHolder.getContext().authentication
val authorities: List<String> = auth.authorities.map { g: GrantedAuthority -> g.authority }
return UserInfo(auth.name, authorities)
}
}
@@ -31,7 +31,7 @@ class UserService(
true,
true,
true,
getAuthorities(user.roles)
toAuthorities(user.roles)
)
}
@@ -40,7 +40,7 @@ class UserService(
return userRepository.save(user)
}
private fun getAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
return roles.map { r -> SimpleGrantedAuthority(r.rolename) }
}
}
@@ -0,0 +1,6 @@
package de.grimsi.gameyfin.users.dto
data class UserInfo(
val name: String,
val authorities: List<String>
)
@@ -6,24 +6,25 @@ import jakarta.validation.constraints.NotNull
@Entity
@Table(name = "users")
class User(
@NotNull
@field:NotNull
var username: String,
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@NotNull
@field:NotNull
var password: String? = null,
@Nullable
@field:Nullable
var email: String? = null,
var enabled: Boolean = true,
@Embedded
@Nullable
@field:Nullable
var avatar: Avatar? = null,
@ManyToMany(fetch = FetchType.EAGER)

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

+24 -10
View File
@@ -1,14 +1,28 @@
logging.level:
org.atmosphere: warn
server:
port: 8080
servlet:
session:
tracking-modes: cookie
spring:
# Workaround for https://github.dev/hilla/issues/842
devtools.restart.additional-exclude: dev/hilla/openapi.json
jpa:
defer-datasource-initialization: true
mustache:
check-template-location: false
sql.init.mode: always
application:
name: Gameyfin
vaadin:
launch-browser: true
# To improve the performance during development.
# For more information https://vaadin.com/docs/flow/spring/tutorial-spring-configuration.html#special-configuration-parameters
whitelisted-packages:
- com.vaadin
- org.vaadin
- dev.hilla
- com.flowingcode
frontend:
hotdeploy: false
spring:
jpa:
properties:
hibernate:
globally_quoted_identifiers: true
- dev.hilla
+9
View File
@@ -0,0 +1,9 @@
${AnsiBackground.DEFAULT}
${AnsiColor.BLUE} _____ ${AnsiColor.MAGENTA} ___ _
${AnsiColor.BLUE} / ___/ ___ _ __ _ ___ __ __${AnsiColor.MAGENTA} / _/ (_) ___
${AnsiColor.BLUE}/ (_ / / _ `/ / ' \/ -_) / // /${AnsiColor.MAGENTA} / _/ / / / _ \
${AnsiColor.BLUE}\___/ \_,_/ /_/_/_/\__/ \_, / ${AnsiColor.MAGENTA}/_/ /_/ /_//_/
${AnsiColor.BLUE} /___/
${AnsiColor.DEFAULT}
${spring.application.name} ${application.version}
@@ -1,13 +0,0 @@
package de.grimsi.gameyfin
import org.junit.jupiter.api.Test
import org.springframework.boot.test.context.SpringBootTest
@SpringBootTest
class GameyfinApplicationTests {
@Test
fun contextLoads() {
}
}
+15
View File
@@ -0,0 +1,15 @@
import withMT from "@material-tailwind/react/utils/withMT";
/** @type {import('tailwindcss').Config} */
export default withMT({
content: ["./frontend/index.html", "./frontend/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
'gf-primary': '#2332c8',
'gf-secondary': '#6441a5'
},
},
},
plugins: [],
});
+39
View File
@@ -0,0 +1,39 @@
// This TypeScript configuration file is generated by vaadin-maven-plugin.
// This is needed for TypeScript compiler to compile your TypeScript code in the project.
// It is recommended to commit this file to the VCS.
// You might want to change the configurations to fit your preferences
// For more information about the configurations, please refer to http://www.typescriptlang.org/docs/handbook/tsconfig-json.html
{
"_version": "9",
"compilerOptions": {
"sourceMap": true,
"jsx": "react-jsx",
"inlineSources": true,
"module": "esNext",
"target": "es2020",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"experimentalDecorators": true,
"useDefineForClassFields": false,
"baseUrl": "frontend",
"paths": {
"@vaadin/flow-frontend": ["generated/jar-resources"],
"@vaadin/flow-frontend/*": ["generated/jar-resources/*"],
"Frontend/*": ["*"]
}
},
"include": [
"frontend/**/*",
"types.d.ts"
],
"exclude": [
"frontend/generated/jar-resources/**"
]
}
Vendored
+61
View File
@@ -0,0 +1,61 @@
declare module '*.module.css' {
declare const styles: Record<string, string>;
export default styles;
}
declare module '*.module.sass' {
declare const styles: Record<string, string>;
export default styles;
}
declare module '*.module.scss' {
declare const styles: Record<string, string>;
export default styles;
}
declare module '*.module.less' {
declare const classes: Record<string, string>;
export default classes;
}
declare module '*.module.styl' {
declare const classes: Record<string, string>;
export default classes;
}
/* CSS FILES */
declare module '*.css';
declare module '*.sass';
declare module '*.scss';
declare module '*.less';
declare module '*.styl';
/* IMAGES */
declare module '*.svg' {
const ref: string;
export default ref;
}
declare module '*.bmp' {
const ref: string;
export default ref;
}
declare module '*.gif' {
const ref: string;
export default ref;
}
declare module '*.jpg' {
const ref: string;
export default ref;
}
declare module '*.jpeg' {
const ref: string;
export default ref;
}
declare module '*.png' {
const ref: string;
export default ref;
}
declare module '*.avif' {
const ref: string;
export default ref;
}
declare module '*.webp' {
const ref: string;
export default ref;
}
+15
View File
@@ -0,0 +1,15 @@
import reactSwc from '@vitejs/plugin-react-swc';
import type { UserConfigFn } from 'vite';
import { overrideVaadinConfig } from './vite.generated';
const customConfig: UserConfigFn = (env) => ({
// Here you can add custom Vite parameters
// https://vitejs.dev/config/
plugins: [
reactSwc({
tsDecorators: true,
}),
],
});
export default overrideVaadinConfig(customConfig);
+853
View File
@@ -0,0 +1,853 @@
/**
* NOTICE: this is an auto-generated file
*
* This file has been generated by the `flow:prepare-frontend` maven goal.
* This file will be overwritten on every run. Any custom changes should be made to vite.config.ts
*/
import path from 'path';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
import { createHash } from 'crypto';
import * as net from 'net';
import { processThemeResources } from './build/plugins/application-theme-plugin/theme-handle.js';
import { rewriteCssUrls } from './build/plugins/theme-loader/theme-loader-utils.js';
import settings from './build/vaadin-dev-server-settings.json';
import {
AssetInfo,
ChunkInfo,
defineConfig,
mergeConfig,
OutputOptions,
PluginOption,
ResolvedConfig,
UserConfigFn
} from 'vite';
import { getManifest } from 'workbox-build';
import * as rollup from 'rollup';
import brotli from 'rollup-plugin-brotli';
import replace from '@rollup/plugin-replace';
import checker from 'vite-plugin-checker';
import postcssLit from './build/plugins/rollup-plugin-postcss-lit-custom/rollup-plugin-postcss-lit.js';
import { createRequire } from 'module';
import { visualizer } from 'rollup-plugin-visualizer';
// Make `require` compatible with ES modules
const require = createRequire(import.meta.url);
const appShellUrl = '.';
const frontendFolder = path.resolve(__dirname, settings.frontendFolder);
const themeFolder = path.resolve(frontendFolder, settings.themeFolder);
const frontendBundleFolder = path.resolve(__dirname, settings.frontendBundleOutput);
const devBundleFolder = path.resolve(__dirname, settings.devBundleOutput);
const devBundle = !!process.env.devBundle;
const jarResourcesFolder = path.resolve(__dirname, settings.jarResourcesFolder);
const themeResourceFolder = path.resolve(__dirname, settings.themeResourceFolder);
const projectPackageJsonFile = path.resolve(__dirname, 'package.json');
const buildOutputFolder = devBundle ? devBundleFolder : frontendBundleFolder;
const statsFolder = path.resolve(__dirname, devBundle ? settings.devBundleStatsOutput : settings.statsOutput);
const statsFile = path.resolve(statsFolder, 'stats.json');
const bundleSizeFile = path.resolve(statsFolder, 'bundle-size.html');
const nodeModulesFolder = path.resolve(__dirname, 'node_modules');
const webComponentTags = '';
const projectIndexHtml = path.resolve(frontendFolder, 'index.html');
const projectStaticAssetsFolders = [
path.resolve(__dirname, 'src', 'main', 'resources', 'META-INF', 'resources'),
path.resolve(__dirname, 'src', 'main', 'resources', 'static'),
frontendFolder
];
// Folders in the project which can contain application themes
const themeProjectFolders = projectStaticAssetsFolders.map((folder) => path.resolve(folder, settings.themeFolder));
const themeOptions = {
devMode: false,
useDevBundle: devBundle,
// The following matches folder 'frontend/generated/themes/'
// (not 'frontend/themes') for theme in JAR that is copied there
themeResourceFolder: path.resolve(themeResourceFolder, settings.themeFolder),
themeProjectFolders: themeProjectFolders,
projectStaticAssetsOutputFolder: devBundle
? path.resolve(devBundleFolder, '../assets')
: path.resolve(__dirname, settings.staticOutput),
frontendGeneratedFolder: path.resolve(frontendFolder, settings.generatedFolder)
};
const hasExportedWebComponents = existsSync(path.resolve(frontendFolder, 'web-component.html'));
// Block debug and trace logs.
console.trace = () => {};
console.debug = () => {};
function injectManifestToSWPlugin(): rollup.Plugin {
const rewriteManifestIndexHtmlUrl = (manifest) => {
const indexEntry = manifest.find((entry) => entry.url === 'index.html');
if (indexEntry) {
indexEntry.url = appShellUrl;
}
return { manifest, warnings: [] };
};
return {
name: 'vaadin:inject-manifest-to-sw',
async transform(code, id) {
if (/sw\.(ts|js)$/.test(id)) {
const { manifestEntries } = await getManifest({
globDirectory: buildOutputFolder,
globPatterns: ['**/*'],
globIgnores: ['**/*.br'],
manifestTransforms: [rewriteManifestIndexHtmlUrl],
maximumFileSizeToCacheInBytes: 100 * 1024 * 1024 // 100mb,
});
return code.replace('self.__WB_MANIFEST', JSON.stringify(manifestEntries));
}
}
};
}
function buildSWPlugin(opts): PluginOption {
let config: ResolvedConfig;
const devMode = opts.devMode;
const swObj = {};
async function build(action: 'generate' | 'write', additionalPlugins: rollup.Plugin[] = []) {
const includedPluginNames = [
'vite:esbuild',
'rollup-plugin-dynamic-import-variables',
'vite:esbuild-transpile',
'vite:terser'
];
const plugins: rollup.Plugin[] = config.plugins.filter((p) => {
return includedPluginNames.includes(p.name);
});
const resolver = config.createResolver();
const resolvePlugin: rollup.Plugin = {
name: 'resolver',
resolveId(source, importer, _options) {
return resolver(source, importer);
}
};
plugins.unshift(resolvePlugin); // Put resolve first
plugins.push(
replace({
values: {
'process.env.NODE_ENV': JSON.stringify(config.mode),
...config.define
},
preventAssignment: true
})
);
if (additionalPlugins) {
plugins.push(...additionalPlugins);
}
const bundle = await rollup.rollup({
input: path.resolve(settings.clientServiceWorkerSource),
plugins
});
try {
return await bundle[action]({
file: path.resolve(buildOutputFolder, 'sw.js'),
format: 'es',
exports: 'none',
sourcemap: config.command === 'serve' || config.build.sourcemap,
inlineDynamicImports: true
});
} finally {
await bundle.close();
}
}
return {
name: 'vaadin:build-sw',
enforce: 'post',
async configResolved(resolvedConfig) {
config = resolvedConfig;
},
async buildStart() {
if (devMode) {
const { output } = await build('generate');
swObj.code = output[0].code;
swObj.map = output[0].map;
}
},
async load(id) {
if (id.endsWith('sw.js')) {
return '';
}
},
async transform(_code, id) {
if (id.endsWith('sw.js')) {
return swObj;
}
},
async closeBundle() {
if (!devMode) {
await build('write', [injectManifestToSWPlugin(), brotli()]);
}
}
};
}
function statsExtracterPlugin(): PluginOption {
function collectThemeJsonsInFrontend(themeJsonContents: Record<string, string>, themeName: string) {
const themeJson = path.resolve(frontendFolder, settings.themeFolder, themeName, 'theme.json');
if (existsSync(themeJson)) {
const themeJsonContent = readFileSync(themeJson, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
themeJsonContents[themeName] = themeJsonContent;
const themeJsonObject = JSON.parse(themeJsonContent);
if (themeJsonObject.parent) {
collectThemeJsonsInFrontend(themeJsonContents, themeJsonObject.parent);
}
}
}
return {
name: 'vaadin:stats',
enforce: 'post',
async writeBundle(options: OutputOptions, bundle: { [fileName: string]: AssetInfo | ChunkInfo }) {
const modules = Object.values(bundle).flatMap((b) => (b.modules ? Object.keys(b.modules) : []));
const nodeModulesFolders = modules
.map((id) => id.replace(/\\/g, '/'))
.filter((id) => id.startsWith(nodeModulesFolder.replace(/\\/g, '/')))
.map((id) => id.substring(nodeModulesFolder.length + 1));
const npmModules = nodeModulesFolders
.map((id) => id.replace(/\\/g, '/'))
.map((id) => {
const parts = id.split('/');
if (id.startsWith('@')) {
return parts[0] + '/' + parts[1];
} else {
return parts[0];
}
})
.sort()
.filter((value, index, self) => self.indexOf(value) === index);
const npmModuleAndVersion = Object.fromEntries(npmModules.map((module) => [module, getVersion(module)]));
const cvdls = Object.fromEntries(
npmModules
.filter((module) => getCvdlName(module) != null)
.map((module) => [module, { name: getCvdlName(module), version: getVersion(module) }])
);
mkdirSync(path.dirname(statsFile), { recursive: true });
const projectPackageJson = JSON.parse(readFileSync(projectPackageJsonFile, { encoding: 'utf-8' }));
const entryScripts = Object.values(bundle)
.filter((bundle) => bundle.isEntry)
.map((bundle) => bundle.fileName);
const generatedIndexHtml = path.resolve(buildOutputFolder, 'index.html');
const customIndexData: string = readFileSync(projectIndexHtml, { encoding: 'utf-8' });
const generatedIndexData: string = readFileSync(generatedIndexHtml, {
encoding: 'utf-8'
});
const customIndexRows = new Set(customIndexData.split(/[\r\n]/).filter((row) => row.trim() !== ''));
const generatedIndexRows = generatedIndexData.split(/[\r\n]/).filter((row) => row.trim() !== '');
const rowsGenerated: string[] = [];
generatedIndexRows.forEach((row) => {
if (!customIndexRows.has(row)) {
rowsGenerated.push(row);
}
});
//After dev-bundle build add used Flow frontend imports JsModule/JavaScript/CssImport
const parseImports = (filename: string, result: Set<string>): void => {
const content: string = readFileSync(filename, { encoding: 'utf-8' });
const lines = content.split('\n');
const staticImports = lines
.filter((line) => line.startsWith('import '))
.map((line) => line.substring(line.indexOf("'") + 1, line.lastIndexOf("'")))
.map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line));
const dynamicImports = lines
.filter((line) => line.includes('import('))
.map((line) => line.replace(/.*import\(/, ''))
.map((line) => line.split(/'/)[1])
.map((line) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line));
staticImports.forEach((staticImport) => result.add(staticImport));
dynamicImports.map((dynamicImport) => {
const importedFile = path.resolve(path.dirname(filename), dynamicImport);
parseImports(importedFile, result);
});
};
const generatedImportsSet = new Set<string>();
parseImports(
path.resolve(themeOptions.frontendGeneratedFolder, 'flow', 'generated-flow-imports.js'),
generatedImportsSet
);
const generatedImports = Array.from(generatedImportsSet).sort();
const frontendFiles: Record<string, string> = {};
const projectFileExtensions = ['.js', '.js.map', '.ts', '.ts.map', '.tsx', '.tsx.map', '.css', '.css.map'];
const isThemeComponentsResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
&& id.match(/.*\/jar-resources\/themes\/[^\/]+\/components\//);
const isGeneratedWebComponentResource = (id: string) =>
id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
&& id.match(/.*\/flow\/web-components\//);
const isFrontendResourceCollected = (id: string) =>
!id.startsWith(themeOptions.frontendGeneratedFolder.replace(/\\/g, '/'))
|| isThemeComponentsResource(id)
|| isGeneratedWebComponentResource(id);
// collects project's frontend resources in frontend folder, excluding
// 'generated' sub-folder, except for legacy shadow DOM stylesheets
// packaged in `theme/components/` folder
// and generated web component resources in `flow/web-components` folder.
modules
.map((id) => id.replace(/\\/g, '/'))
.filter((id) => id.startsWith(frontendFolder.replace(/\\/g, '/')))
.filter(isFrontendResourceCollected)
.map((id) => id.substring(frontendFolder.length + 1))
.map((line: string) => (line.includes('?') ? line.substring(0, line.lastIndexOf('?')) : line))
.forEach((line: string) => {
// \r\n from windows made files may be used so change to \n
const filePath = path.resolve(frontendFolder, line);
if (projectFileExtensions.includes(path.extname(filePath))) {
const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
});
// collects frontend resources from the JARs
generatedImports
.filter((line: string) => line.includes('generated/jar-resources'))
.forEach((line: string) => {
let filename = line.substring(line.indexOf('generated'));
// \r\n from windows made files may be used ro remove to be only \n
const fileBuffer = readFileSync(path.resolve(frontendFolder, filename), { encoding: 'utf-8' }).replace(
/\r\n/g,
'\n'
);
const hash = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
const fileKey = line.substring(line.indexOf('jar-resources/') + 14);
frontendFiles[fileKey] = hash;
});
// collects and hash rest of the Frontend resources excluding files in /generated/ and /themes/
// and files already in frontendFiles.
let frontendFolderAlias = "Frontend";
generatedImports
.filter((line: string) => line.startsWith(frontendFolderAlias + '/'))
.filter((line: string) => !line.startsWith(frontendFolderAlias + '/generated/'))
.filter((line: string) => !line.startsWith(frontendFolderAlias + '/themes/'))
.map((line) => line.substring(frontendFolderAlias.length + 1))
.filter((line: string) => !frontendFiles[line])
.forEach((line: string) => {
const filePath = path.resolve(frontendFolder, line);
if (projectFileExtensions.includes(path.extname(filePath)) && existsSync(filePath)) {
const fileBuffer = readFileSync(filePath, { encoding: 'utf-8' }).replace(/\r\n/g, '\n');
frontendFiles[line] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
});
// If a index.ts exists hash it to be able to see if it changes.
if (existsSync(path.resolve(frontendFolder, 'index.ts'))) {
const fileBuffer = readFileSync(path.resolve(frontendFolder, 'index.ts'), { encoding: 'utf-8' }).replace(
/\r\n/g,
'\n'
);
frontendFiles[`index.ts`] = createHash('sha256').update(fileBuffer, 'utf8').digest('hex');
}
const themeJsonContents: Record<string, string> = {};
const themesFolder = path.resolve(jarResourcesFolder, 'themes');
if (existsSync(themesFolder)) {
readdirSync(themesFolder).forEach((themeFolder) => {
const themeJson = path.resolve(themesFolder, themeFolder, 'theme.json');
if (existsSync(themeJson)) {
themeJsonContents[path.basename(themeFolder)] = readFileSync(themeJson, { encoding: 'utf-8' }).replace(
/\r\n/g,
'\n'
);
}
});
}
collectThemeJsonsInFrontend(themeJsonContents, settings.themeName);
let webComponents: string[] = [];
if (webComponentTags) {
webComponents = webComponentTags.split(';');
}
const stats = {
packageJsonDependencies: projectPackageJson.dependencies,
npmModules: npmModuleAndVersion,
bundleImports: generatedImports,
frontendHashes: frontendFiles,
themeJsonContents: themeJsonContents,
entryScripts,
webComponents,
cvdlModules: cvdls,
packageJsonHash: projectPackageJson?.vaadin?.hash,
indexHtmlGenerated: rowsGenerated
};
writeFileSync(statsFile, JSON.stringify(stats, null, 1));
}
};
}
function vaadinBundlesPlugin(): PluginOption {
type ExportInfo =
| string
| {
namespace?: string;
source: string;
};
type ExposeInfo = {
exports: ExportInfo[];
};
type PackageInfo = {
version: string;
exposes: Record<string, ExposeInfo>;
};
type BundleJson = {
packages: Record<string, PackageInfo>;
};
const disabledMessage = 'Vaadin component dependency bundles are disabled.';
const modulesDirectory = nodeModulesFolder.replace(/\\/g, '/');
let vaadinBundleJson: BundleJson;
function parseModuleId(id: string): { packageName: string; modulePath: string } {
const [scope, scopedPackageName] = id.split('/', 3);
const packageName = scope.startsWith('@') ? `${scope}/${scopedPackageName}` : scope;
const modulePath = `.${id.substring(packageName.length)}`;
return {
packageName,
modulePath
};
}
function getExports(id: string): string[] | undefined {
const { packageName, modulePath } = parseModuleId(id);
const packageInfo = vaadinBundleJson.packages[packageName];
if (!packageInfo) return;
const exposeInfo: ExposeInfo = packageInfo.exposes[modulePath];
if (!exposeInfo) return;
const exportsSet = new Set<string>();
for (const e of exposeInfo.exports) {
if (typeof e === 'string') {
exportsSet.add(e);
} else {
const { namespace, source } = e;
if (namespace) {
exportsSet.add(namespace);
} else {
const sourceExports = getExports(source);
if (sourceExports) {
sourceExports.forEach((e) => exportsSet.add(e));
}
}
}
}
return Array.from(exportsSet);
}
function getExportBinding(binding: string) {
return binding === 'default' ? '_default as default' : binding;
}
function getImportAssigment(binding: string) {
return binding === 'default' ? 'default: _default' : binding;
}
return {
name: 'vaadin:bundles',
enforce: 'pre',
apply(config, { command }) {
if (command !== 'serve') return false;
try {
const vaadinBundleJsonPath = require.resolve('@vaadin/bundles/vaadin-bundle.json');
vaadinBundleJson = JSON.parse(readFileSync(vaadinBundleJsonPath, { encoding: 'utf8' }));
} catch (e: unknown) {
if (typeof e === 'object' && (e as { code: string }).code === 'MODULE_NOT_FOUND') {
vaadinBundleJson = { packages: {} };
console.info(`@vaadin/bundles npm package is not found, ${disabledMessage}`);
return false;
} else {
throw e;
}
}
const versionMismatches: Array<{ name: string; bundledVersion: string; installedVersion: string }> = [];
for (const [name, packageInfo] of Object.entries(vaadinBundleJson.packages)) {
let installedVersion: string | undefined = undefined;
try {
const { version: bundledVersion } = packageInfo;
const installedPackageJsonFile = path.resolve(modulesDirectory, name, 'package.json');
const packageJson = JSON.parse(readFileSync(installedPackageJsonFile, { encoding: 'utf8' }));
installedVersion = packageJson.version;
if (installedVersion && installedVersion !== bundledVersion) {
versionMismatches.push({
name,
bundledVersion,
installedVersion
});
}
} catch (_) {
// ignore package not found
}
}
if (versionMismatches.length) {
console.info(`@vaadin/bundles has version mismatches with installed packages, ${disabledMessage}`);
console.info(`Packages with version mismatches: ${JSON.stringify(versionMismatches, undefined, 2)}`);
vaadinBundleJson = { packages: {} };
return false;
}
return true;
},
async config(config) {
return mergeConfig(
{
optimizeDeps: {
exclude: [
// Vaadin bundle
'@vaadin/bundles',
...Object.keys(vaadinBundleJson.packages),
'@vaadin/vaadin-material-styles'
]
}
},
config
);
},
load(rawId) {
const [path, params] = rawId.split('?');
if (!path.startsWith(modulesDirectory)) return;
const id = path.substring(modulesDirectory.length + 1);
const bindings = getExports(id);
if (bindings === undefined) return;
const cacheSuffix = params ? `?${params}` : '';
const bundlePath = `@vaadin/bundles/vaadin.js${cacheSuffix}`;
return `import { init as VaadinBundleInit, get as VaadinBundleGet } from '${bundlePath}';
await VaadinBundleInit('default');
const { ${bindings.map(getImportAssigment).join(', ')} } = (await VaadinBundleGet('./node_modules/${id}'))();
export { ${bindings.map(getExportBinding).join(', ')} };`;
}
};
}
function themePlugin(opts): PluginOption {
const fullThemeOptions = { ...themeOptions, devMode: opts.devMode };
return {
name: 'vaadin:theme',
config() {
processThemeResources(fullThemeOptions, console);
},
configureServer(server) {
function handleThemeFileCreateDelete(themeFile, stats) {
if (themeFile.startsWith(themeFolder)) {
const changed = path.relative(themeFolder, themeFile);
console.debug('Theme file ' + (!!stats ? 'created' : 'deleted'), changed);
processThemeResources(fullThemeOptions, console);
}
}
server.watcher.on('add', handleThemeFileCreateDelete);
server.watcher.on('unlink', handleThemeFileCreateDelete);
},
handleHotUpdate(context) {
const contextPath = path.resolve(context.file);
const themePath = path.resolve(themeFolder);
if (contextPath.startsWith(themePath)) {
const changed = path.relative(themePath, contextPath);
console.debug('Theme file changed', changed);
if (changed.startsWith(settings.themeName)) {
processThemeResources(fullThemeOptions, console);
}
}
},
async resolveId(id, importer) {
// force theme generation if generated theme sources does not yet exist
// this may happen for example during Java hot reload when updating
// @Theme annotation value
if (
path.resolve(themeOptions.frontendGeneratedFolder, 'theme.js') === importer &&
!existsSync(path.resolve(themeOptions.frontendGeneratedFolder, id))
) {
console.debug('Generate theme file ' + id + ' not existing. Processing theme resource');
processThemeResources(fullThemeOptions, console);
return;
}
if (!id.startsWith(settings.themeFolder)) {
return;
}
for (const location of [themeResourceFolder, frontendFolder]) {
const result = await this.resolve(path.resolve(location, id));
if (result) {
return result;
}
}
},
async transform(raw, id, options) {
// rewrite urls for the application theme css files
const [bareId, query] = id.split('?');
if (
(!bareId?.startsWith(themeFolder) && !bareId?.startsWith(themeOptions.themeResourceFolder)) ||
!bareId?.endsWith('.css')
) {
return;
}
const [themeName] = bareId.substring(themeFolder.length + 1).split('/');
return rewriteCssUrls(raw, path.dirname(bareId), path.resolve(themeFolder, themeName), console, opts);
}
};
}
function runWatchDog(watchDogPort, watchDogHost) {
const client = net.Socket();
client.setEncoding('utf8');
client.on('error', function (err) {
console.log('Watchdog connection error. Terminating vite process...', err);
client.destroy();
process.exit(0);
});
client.on('close', function () {
client.destroy();
runWatchDog(watchDogPort, watchDogHost);
});
client.connect(watchDogPort, watchDogHost || 'localhost');
}
let spaMiddlewareForceRemoved = false;
const allowedFrontendFolders = [frontendFolder, nodeModulesFolder];
function showRecompileReason(): PluginOption {
return {
name: 'vaadin:why-you-compile',
handleHotUpdate(context) {
console.log('Recompiling because', context.file, 'changed');
}
};
}
const DEV_MODE_START_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start/;
const DEV_MODE_CODE_REGEXP = /\/\*[\*!]\s+vaadin-dev-mode:start([\s\S]*)vaadin-dev-mode:end\s+\*\*\//i;
function preserveUsageStats() {
return {
name: 'vaadin:preserve-usage-stats',
transform(src: string, id: string) {
if (id.includes('vaadin-usage-statistics')) {
if (src.includes('vaadin-dev-mode:start')) {
const newSrc = src.replace(DEV_MODE_START_REGEXP, '/*! vaadin-dev-mode:start');
if (newSrc === src) {
console.error('Comment replacement failed to change anything');
} else if (!newSrc.match(DEV_MODE_CODE_REGEXP)) {
console.error('New comment fails to match original regexp');
} else {
return { code: newSrc };
}
}
}
return { code: src };
}
};
}
export const vaadinConfig: UserConfigFn = (env) => {
const devMode = env.mode === 'development';
const productionMode = !devMode && !devBundle
if (devMode && process.env.watchDogPort) {
// Open a connection with the Java dev-mode handler in order to finish
// vite when it exits or crashes.
runWatchDog(process.env.watchDogPort, process.env.watchDogHost);
}
return {
root: frontendFolder,
base: '',
publicDir: false,
resolve: {
alias: {
'@vaadin/flow-frontend': jarResourcesFolder,
Frontend: frontendFolder
},
preserveSymlinks: true
},
define: {
OFFLINE_PATH: settings.offlinePath,
VITE_ENABLED: 'true'
},
server: {
host: '127.0.0.1',
strictPort: true,
fs: {
allow: allowedFrontendFolders
}
},
build: {
outDir: buildOutputFolder,
emptyOutDir: devBundle,
assetsDir: 'VAADIN/build',
rollupOptions: {
input: {
indexhtml: projectIndexHtml,
...(hasExportedWebComponents ? { webcomponenthtml: path.resolve(frontendFolder, 'web-component.html') } : {})
},
onwarn: (warning: rollup.RollupWarning, defaultHandler: rollup.WarningHandler) => {
const ignoreEvalWarning = [
'generated/jar-resources/FlowClient.js',
'generated/jar-resources/vaadin-spreadsheet/spreadsheet-export.js',
'@vaadin/charts/src/helpers.js'
];
if (warning.code === 'EVAL' && warning.id && !!ignoreEvalWarning.find((id) => warning.id.endsWith(id))) {
return;
}
defaultHandler(warning);
}
}
},
optimizeDeps: {
entries: [
// Pre-scan entrypoints in Vite to avoid reloading on first open
'generated/vaadin.ts'
],
exclude: [
'@vaadin/router',
'@vaadin/vaadin-license-checker',
'@vaadin/vaadin-usage-statistics',
'workbox-core',
'workbox-precaching',
'workbox-routing',
'workbox-strategies'
]
},
plugins: [
productionMode && brotli(),
devMode && vaadinBundlesPlugin(),
devMode && showRecompileReason(),
settings.offlineEnabled && buildSWPlugin({ devMode }),
!devMode && statsExtracterPlugin(),
devBundle && preserveUsageStats(),
themePlugin({ devMode }),
postcssLit({
include: ['**/*.css', /.*\/.*\.css\?.*/],
exclude: [
`${themeFolder}/**/*.css`,
new RegExp(`${themeFolder}/.*/.*\\.css\\?.*`),
`${themeResourceFolder}/**/*.css`,
new RegExp(`${themeResourceFolder}/.*/.*\\.css\\?.*`),
new RegExp('.*/.*\\?html-proxy.*')
]
}),
{
name: 'vaadin:force-remove-html-middleware',
transformIndexHtml: {
order: 'pre',
handler(_html, { server }) {
if (server && !spaMiddlewareForceRemoved) {
server.middlewares.stack = server.middlewares.stack.filter((mw) => {
const handleName = '' + mw.handle;
return !handleName.includes('viteHtmlFallbackMiddleware');
});
spaMiddlewareForceRemoved = true;
}
}
}
},
hasExportedWebComponents && {
name: 'vaadin:inject-entrypoints-to-web-component-html',
transformIndexHtml: {
order: 'pre',
handler(_html, { path, server }) {
if (path !== '/web-component.html') {
return;
}
return [
{
tag: 'script',
attrs: { type: 'module', src: `/generated/vaadin-web-component.ts` },
injectTo: 'head'
}
];
}
}
},
{
name: 'vaadin:inject-entrypoints-to-index-html',
transformIndexHtml: {
order: 'pre',
handler(_html, { path, server }) {
if (path !== '/index.html') {
return;
}
const scripts = [];
if (devMode) {
scripts.push({
tag: 'script',
attrs: { type: 'module', src: `/generated/vite-devmode.ts` },
injectTo: 'head'
});
}
scripts.push({
tag: 'script',
attrs: { type: 'module', src: '/generated/vaadin.ts' },
injectTo: 'head'
});
return scripts;
}
}
},
checker({
typescript: true
}),
productionMode && visualizer({ brotliSize: true, filename: bundleSizeFile })
]
};
};
export const overrideVaadinConfig = (customConfig: UserConfigFn) => {
return defineConfig((env) => mergeConfig(vaadinConfig(env), customConfig(env)));
};
function getVersion(module: string): string {
const packageJson = path.resolve(nodeModulesFolder, module, 'package.json');
return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).version;
}
function getCvdlName(module: string): string {
const packageJson = path.resolve(nodeModulesFolder, module, 'package.json');
return JSON.parse(readFileSync(packageJson, { encoding: 'utf-8' })).cvdlName;
}